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/commands/wiki.py ADDED
@@ -0,0 +1,49 @@
1
+ """Wiki commands (read-only metadata).
2
+
3
+ OpenProject API v3 exposes only ``GET /api/v3/wiki_pages/{id}`` and the page's
4
+ attachments. The WikiPage model is a stub (id + title only) — there is NO
5
+ list/create/update/delete and NO way to read or write the page body over the
6
+ API. These commands surface what's available and are explicit about the limits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from .. import serialize
14
+ from ._shared import ctx_obj
15
+
16
+ app = typer.Typer(no_args_is_help=True)
17
+
18
+
19
+ @app.command()
20
+ def get(
21
+ ctx: typer.Context,
22
+ page_id: int = typer.Argument(..., help="Wiki page id."),
23
+ raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
24
+ ) -> None:
25
+ """Show wiki page metadata (id + title only; body is not exposed by the API)."""
26
+ obj = ctx_obj(ctx)
27
+ doc = obj.client().get(f"wiki_pages/{page_id}")
28
+ if raw:
29
+ obj.emitter.emit(doc)
30
+ return
31
+ obj.emitter.emit(
32
+ {
33
+ "id": doc.get("id"),
34
+ "title": doc.get("title"),
35
+ "project": (doc.get("_embedded") or {}).get("project", {}).get("identifier"),
36
+ "note": "OpenProject API v3 does not expose wiki page body text.",
37
+ }
38
+ )
39
+
40
+
41
+ @app.command()
42
+ def attachments(
43
+ ctx: typer.Context,
44
+ page_id: int = typer.Argument(..., help="Wiki page id."),
45
+ ) -> None:
46
+ """List attachments on a wiki page."""
47
+ obj = ctx_obj(ctx)
48
+ rows = [serialize.attachment(a) for a in obj.client().collect(f"wiki_pages/{page_id}/attachments")]
49
+ obj.emitter.emit(rows, columns=[("ID", "id"), ("File", "fileName"), ("Size", "fileSize"), ("Type", "contentType")])
@@ -0,0 +1,338 @@
1
+ """Work-package commands: CRUD, move, assign, watchers, schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from .. import hal, resolve, serialize, wpfilters
10
+ from ..duration import parse_hours_input
11
+ from ..errors import OpError
12
+ from ._shared import apply_custom_fields, ctx_obj, parse_json_option, set_link
13
+
14
+ app = typer.Typer(no_args_is_help=True)
15
+
16
+ _COLUMNS = [
17
+ ("ID", "id"),
18
+ ("Type", "type"),
19
+ ("Subject", "subject"),
20
+ ("Status", "status"),
21
+ ("Assignee", lambda r: (r.get("assignee") or {}).get("name")),
22
+ ("Project", lambda r: (r.get("project") or {}).get("name")),
23
+ ]
24
+
25
+
26
+ # --------------------------------------------------------------------- list
27
+ @app.command("list")
28
+ def list_wps(
29
+ ctx: typer.Context,
30
+ project: str = typer.Option(None, "--project", "-P", help="Project id/identifier."),
31
+ status: str = typer.Option(None, "--status", "-s", help="'open', 'closed', or a status name."),
32
+ type_: str = typer.Option(None, "--type", "-t", help="Work-package type name."),
33
+ assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee (login/name/id or 'me')."),
34
+ mine: bool = typer.Option(False, "--mine", help="Assigned to me."),
35
+ unassigned: bool = typer.Option(False, "--unassigned", help="No assignee."),
36
+ author: str = typer.Option(None, "--author", "--created-by", help="Author (login/name/id or 'me')."),
37
+ priority: str = typer.Option(None, "--priority", help="Priority name."),
38
+ version: str = typer.Option(None, "--version", help="Target version (name/id)."),
39
+ overdue: bool = typer.Option(False, "--overdue", help="Past due and still open."),
40
+ updated_since: str = typer.Option(None, "--updated-since", help="Updated since (date or 7d/today)."),
41
+ due_before: str = typer.Option(None, "--due-before", help="Due on/before (date or spec)."),
42
+ query: str = typer.Option(None, "--query", "-q", help="Full-text search (subject/description/comments)."),
43
+ where: list[str] = typer.Option(None, "--where", "-w", help='Expression filter, e.g. "status = open" (repeatable).'),
44
+ all_statuses: bool = typer.Option(False, "--all", help="Include closed (disable default open filter)."),
45
+ sort: str = typer.Option("id", "--sort", help="Sort field, e.g. id, updatedAt, dueDate."),
46
+ desc: bool = typer.Option(False, "--desc", help="Sort descending."),
47
+ limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows (0 = all)."),
48
+ ) -> None:
49
+ """List / filter work packages (global, scoped by filters). See `search wp` for the full set."""
50
+ obj = ctx_obj(ctx)
51
+ client = obj.client()
52
+ filters = wpfilters.build(
53
+ client, project=project, status=status, type_=type_, assignee=assignee,
54
+ mine=mine, unassigned=unassigned, author=author, priority=priority, version=version,
55
+ overdue=overdue, updated_since=updated_since, due_before=due_before,
56
+ query=query, where=where, all_statuses=all_statuses,
57
+ )
58
+ params = {
59
+ "filters": wpfilters.encode(filters),
60
+ "sortBy": json.dumps([[sort, "desc" if desc else "asc"]]),
61
+ }
62
+ rows = [
63
+ serialize.work_package(w, include_description=False)
64
+ for w in client.collect("work_packages", params=params, limit=limit or None)
65
+ ]
66
+ obj.emitter.emit(rows, columns=_COLUMNS, empty="(no work packages)")
67
+
68
+
69
+ # ---------------------------------------------------------------------- get
70
+ @app.command()
71
+ def get(
72
+ ctx: typer.Context,
73
+ wp_id: int = typer.Argument(..., help="Work package id."),
74
+ raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
75
+ ) -> None:
76
+ """Show one work package."""
77
+ obj = ctx_obj(ctx)
78
+ doc = obj.client().get(f"work_packages/{wp_id}")
79
+ obj.emitter.emit(doc if raw else serialize.work_package(doc))
80
+
81
+
82
+ # ------------------------------------------------------------------- create
83
+ @app.command()
84
+ def create(
85
+ ctx: typer.Context,
86
+ subject: str = typer.Argument(..., help="Work package subject/title."),
87
+ project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
88
+ type_: str = typer.Option("Task", "--type", "-t", help="Type name (default Task)."),
89
+ description: str = typer.Option(None, "--description", "-d", help="Description (markdown)."),
90
+ status: str = typer.Option(None, "--status", "-s", help="Initial status name."),
91
+ priority: str = typer.Option(None, "--priority", help="Priority name."),
92
+ assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee (login/name/id or 'me')."),
93
+ responsible: str = typer.Option(None, "--responsible", help="Accountable user."),
94
+ parent: int = typer.Option(None, "--parent", help="Parent work package id."),
95
+ start_date: str = typer.Option(None, "--start-date", help="YYYY-MM-DD."),
96
+ due_date: str = typer.Option(None, "--due-date", help="YYYY-MM-DD."),
97
+ estimated: str = typer.Option(None, "--estimated", help="Estimated time (hours or ISO8601)."),
98
+ custom_fields: str = typer.Option(None, "--custom-fields", help="JSON of customFieldN values."),
99
+ set_: str = typer.Option(None, "--set", help="Raw JSON merged into the create body."),
100
+ notify: bool = typer.Option(False, "--notify", help="Send notifications for this create."),
101
+ ) -> None:
102
+ """Create a work package. Pass names (type/status/priority/assignee) — they're resolved.
103
+
104
+ Example: openproject wp create "Fix login" --project webshop --type Bug --assignee me --priority High
105
+ Custom fields: --custom-fields '{"customField1":"INV-1"}' (discover them with `cf wp`).
106
+ Note: the assignee must be a member of the project (see `member add`).
107
+ """
108
+ obj = ctx_obj(ctx)
109
+ client = obj.client()
110
+ pid = resolve.project_id(client, project)
111
+
112
+ body: dict = {"subject": subject, "_links": {}}
113
+ set_link(body, "project", f"/api/v3/projects/{pid}")
114
+ set_link(body, "type", resolve.wp_type(client, type_))
115
+ if description is not None:
116
+ body["description"] = {"raw": description}
117
+ if status:
118
+ set_link(body, "status", resolve.status(client, status))
119
+ if priority:
120
+ set_link(body, "priority", resolve.priority(client, priority))
121
+ if assignee:
122
+ set_link(body, "assignee", resolve.user(client, assignee, project_ref=project))
123
+ if responsible:
124
+ set_link(body, "responsible", resolve.user(client, responsible, project_ref=project))
125
+ if parent:
126
+ set_link(body, "parent", f"/api/v3/work_packages/{parent}")
127
+ if start_date:
128
+ body["startDate"] = start_date
129
+ if due_date:
130
+ body["dueDate"] = due_date
131
+ if estimated:
132
+ body["estimatedTime"] = parse_hours_input(estimated)
133
+ apply_custom_fields(body, custom_fields)
134
+ if set_:
135
+ extra = parse_json_option(set_, what="--set")
136
+ if isinstance(extra, dict):
137
+ body.update({k: v for k, v in extra.items() if k != "_links"})
138
+ body["_links"].update(extra.get("_links", {}))
139
+
140
+ doc = client.post("work_packages", json=body, params={"notify": str(notify).lower()})
141
+ obj.emitter.emit(serialize.work_package(doc))
142
+
143
+
144
+ # ------------------------------------------------------------------- update
145
+ @app.command()
146
+ def update(
147
+ ctx: typer.Context,
148
+ wp_id: int = typer.Argument(..., help="Work package id."),
149
+ subject: str = typer.Option(None, "--subject", help="New subject."),
150
+ description: str = typer.Option(None, "--description", "-d", help="New description."),
151
+ status: str = typer.Option(None, "--status", "-s", help="New status name."),
152
+ type_: str = typer.Option(None, "--type", "-t", help="New type name."),
153
+ priority: str = typer.Option(None, "--priority", help="New priority."),
154
+ assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee ('none' to clear)."),
155
+ responsible: str = typer.Option(None, "--responsible", help="Accountable ('none' to clear)."),
156
+ parent: str = typer.Option(None, "--parent", help="Parent id ('none' to detach)."),
157
+ start_date: str = typer.Option(None, "--start-date", help="YYYY-MM-DD."),
158
+ due_date: str = typer.Option(None, "--due-date", help="YYYY-MM-DD."),
159
+ estimated: str = typer.Option(None, "--estimated", help="Estimated time (hours or ISO8601)."),
160
+ done_ratio: int = typer.Option(None, "--done-ratio", help="Percentage done 0-100."),
161
+ custom_fields: str = typer.Option(None, "--custom-fields", help="JSON of customFieldN values."),
162
+ set_: str = typer.Option(None, "--set", help="Raw JSON merged into the patch body."),
163
+ notify: bool = typer.Option(False, "--notify", help="Send notifications for this change."),
164
+ ) -> None:
165
+ """Update a work package. lockVersion is fetched and retried automatically.
166
+
167
+ Example: openproject wp update 42 --status "In progress" --assignee jane.doe --done-ratio 50
168
+ Clear a link with the value 'none' (e.g. --assignee none). Use --set '{...}' for arbitrary fields.
169
+ """
170
+ obj = ctx_obj(ctx)
171
+ client = obj.client()
172
+
173
+ body: dict = {"_links": {}}
174
+ if subject is not None:
175
+ body["subject"] = subject
176
+ if description is not None:
177
+ body["description"] = {"raw": description}
178
+ if start_date is not None:
179
+ body["startDate"] = start_date
180
+ if due_date is not None:
181
+ body["dueDate"] = due_date
182
+ if estimated is not None:
183
+ body["estimatedTime"] = parse_hours_input(estimated)
184
+ if done_ratio is not None:
185
+ body["percentageDone"] = done_ratio
186
+ if status:
187
+ set_link(body, "status", resolve.status(client, status))
188
+ if type_:
189
+ set_link(body, "type", resolve.wp_type(client, type_))
190
+ if priority:
191
+ set_link(body, "priority", resolve.priority(client, priority))
192
+ if assignee is not None:
193
+ set_link(body, "assignee", None if assignee.lower() == "none" else resolve.user(client, assignee))
194
+ if responsible is not None:
195
+ set_link(body, "responsible", None if responsible.lower() == "none" else resolve.user(client, responsible))
196
+ if parent is not None:
197
+ set_link(body, "parent", None if parent.lower() == "none" else f"/api/v3/work_packages/{parent}")
198
+ apply_custom_fields(body, custom_fields)
199
+ if set_:
200
+ extra = parse_json_option(set_, what="--set")
201
+ if isinstance(extra, dict):
202
+ body.update({k: v for k, v in extra.items() if k != "_links"})
203
+ body["_links"].update(extra.get("_links", {}))
204
+ if not body.get("_links"):
205
+ body.pop("_links")
206
+ if not body:
207
+ raise OpError("nothing to update — pass at least one field")
208
+
209
+ doc = client.update_locked(f"work_packages/{wp_id}", body, params={"notify": str(notify).lower()})
210
+ obj.emitter.emit(serialize.work_package(doc))
211
+
212
+
213
+ # --------------------------------------------------------------------- move
214
+ @app.command()
215
+ def move(
216
+ ctx: typer.Context,
217
+ wp_id: int = typer.Argument(..., help="Work package id."),
218
+ project: str = typer.Argument(..., help="Destination project id/identifier."),
219
+ type_: str = typer.Option(None, "--type", "-t", help="New type (if not valid in target project)."),
220
+ ) -> None:
221
+ """Move a work package to another project."""
222
+ obj = ctx_obj(ctx)
223
+ client = obj.client()
224
+ pid = resolve.project_id(client, project)
225
+ body: dict = {"_links": {"project": {"href": f"/api/v3/projects/{pid}"}}}
226
+ if type_:
227
+ body["_links"]["type"] = {"href": resolve.wp_type(client, type_)}
228
+ doc = client.update_locked(f"work_packages/{wp_id}", body)
229
+ obj.emitter.emit(serialize.work_package(doc))
230
+
231
+
232
+ # ------------------------------------------------------------------- delete
233
+ @app.command()
234
+ def delete(
235
+ ctx: typer.Context,
236
+ wp_id: int = typer.Argument(..., help="Work package id."),
237
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
238
+ ) -> None:
239
+ """Delete a work package."""
240
+ obj = ctx_obj(ctx)
241
+ if not yes:
242
+ typer.confirm(f"Delete work package {wp_id}?", abort=True)
243
+ obj.client().delete(f"work_packages/{wp_id}")
244
+ obj.emitter.emit({"status": "deleted", "workPackage": wp_id})
245
+
246
+
247
+ # ------------------------------------------------------- assignee shortcuts
248
+ @app.command()
249
+ def assign(
250
+ ctx: typer.Context,
251
+ wp_id: int = typer.Argument(..., help="Work package id."),
252
+ user: str = typer.Argument(..., help="Assignee (login/name/id or 'me'; 'none' to unassign)."),
253
+ ) -> None:
254
+ """Set (or clear with 'none') the assignee."""
255
+ obj = ctx_obj(ctx)
256
+ client = obj.client()
257
+ href = None if user.lower() == "none" else resolve.user(client, user)
258
+ doc = client.update_locked(f"work_packages/{wp_id}", {"_links": {"assignee": {"href": href}}})
259
+ obj.emitter.emit(serialize.work_package(doc))
260
+
261
+
262
+ @app.command()
263
+ def unassign(ctx: typer.Context, wp_id: int = typer.Argument(..., help="Work package id.")) -> None:
264
+ """Remove the assignee."""
265
+ obj = ctx_obj(ctx)
266
+ doc = obj.client().update_locked(f"work_packages/{wp_id}", {"_links": {"assignee": {"href": None}}})
267
+ obj.emitter.emit(serialize.work_package(doc))
268
+
269
+
270
+ @app.command()
271
+ def assignees(
272
+ ctx: typer.Context,
273
+ wp_id: int = typer.Argument(..., help="Work package id."),
274
+ ) -> None:
275
+ """List users assignable to this work package."""
276
+ obj = ctx_obj(ctx)
277
+ rows = [serialize.principal(p) for p in obj.client().collect(f"work_packages/{wp_id}/available_assignees")]
278
+ obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Login", "login"), ("Type", "type")])
279
+
280
+
281
+ # ----------------------------------------------------------------- watchers
282
+ @app.command()
283
+ def watchers(ctx: typer.Context, wp_id: int = typer.Argument(..., help="Work package id.")) -> None:
284
+ """List watchers of a work package."""
285
+ obj = ctx_obj(ctx)
286
+ rows = [serialize.principal(p) for p in obj.client().collect(f"work_packages/{wp_id}/watchers")]
287
+ obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Login", "login")])
288
+
289
+
290
+ @app.command()
291
+ def watch(
292
+ ctx: typer.Context,
293
+ wp_id: int = typer.Argument(..., help="Work package id."),
294
+ user: str = typer.Argument("me", help="User to add as watcher (default: me)."),
295
+ ) -> None:
296
+ """Add a watcher (note: body key is 'user', not under _links)."""
297
+ obj = ctx_obj(ctx)
298
+ client = obj.client()
299
+ href = resolve.user(client, user)
300
+ client.post(f"work_packages/{wp_id}/watchers", json={"user": {"href": href}})
301
+ obj.emitter.emit({"status": "watching", "workPackage": wp_id, "user": href})
302
+
303
+
304
+ @app.command()
305
+ def unwatch(
306
+ ctx: typer.Context,
307
+ wp_id: int = typer.Argument(..., help="Work package id."),
308
+ user: str = typer.Argument("me", help="User to remove (default: me)."),
309
+ ) -> None:
310
+ """Remove a watcher."""
311
+ obj = ctx_obj(ctx)
312
+ client = obj.client()
313
+ href = resolve.user(client, user)
314
+ uid = hal.id_from_href(href)
315
+ client.delete(f"work_packages/{wp_id}/watchers/{uid}")
316
+ obj.emitter.emit({"status": "unwatched", "workPackage": wp_id, "user": uid})
317
+
318
+
319
+ # ------------------------------------------------------------------- schema
320
+ @app.command()
321
+ def schema(
322
+ ctx: typer.Context,
323
+ project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
324
+ type_: str = typer.Option("Task", "--type", "-t", help="Type name."),
325
+ ) -> None:
326
+ """Discover the create schema (fields, required flags, custom fields) via the form."""
327
+ obj = ctx_obj(ctx)
328
+ client = obj.client()
329
+ pid = resolve.project_id(client, project)
330
+ body = {"_links": {"project": {"href": f"/api/v3/projects/{pid}"}, "type": {"href": resolve.wp_type(client, type_)}}}
331
+ form = client.post(f"projects/{pid}/work_packages/form", json=body)
332
+ sch = (form.get("_embedded") or {}).get("schema") or {}
333
+ fields = [
334
+ serialize.custom_field_schema(name, spec)
335
+ for name, spec in sch.items()
336
+ if isinstance(spec, dict) and spec.get("type")
337
+ ]
338
+ obj.emitter.emit(fields, columns=[("Key", "key"), ("Name", "name"), ("Type", "type"), ("Required", "required"), ("Writable", "writable")])
opcli/config.py ADDED
@@ -0,0 +1,134 @@
1
+ """Non-secret configuration: connection profiles.
2
+
3
+ Config lives in a plain JSON file (``~/.config/op-cli/config.json`` by
4
+ default). It never contains the API token — that is kept in the OS keyring by
5
+ :mod:`opcli.credentials`. Multiple named *profiles* let one operator point the
6
+ CLI at several OpenProject instances (e.g. ``prod`` and ``staging``).
7
+
8
+ Environment overrides (useful for CI and the test-suite):
9
+
10
+ * ``OPCLI_BASE_URL`` — overrides the active profile's base URL.
11
+ * ``OPCLI_PROFILE`` — selects the active profile.
12
+ * ``OPCLI_TOKEN`` — supplies the token directly (see :mod:`credentials`).
13
+ * ``OPCLI_CONFIG_DIR`` / ``XDG_CONFIG_HOME`` — relocate the config directory.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .errors import ConfigError
25
+
26
+ DEFAULT_PROFILE = "default"
27
+
28
+
29
+ def config_dir() -> Path:
30
+ base = os.environ.get("OPCLI_CONFIG_DIR")
31
+ if base:
32
+ return Path(base)
33
+ xdg = os.environ.get("XDG_CONFIG_HOME")
34
+ root = Path(xdg) if xdg else Path.home() / ".config"
35
+ return root / "op-cli"
36
+
37
+
38
+ def config_path() -> Path:
39
+ return config_dir() / "config.json"
40
+
41
+
42
+ @dataclass
43
+ class Profile:
44
+ name: str
45
+ base_url: str
46
+ username: str | None = None # informational; the login of the API user
47
+ verify_ssl: bool = True
48
+
49
+ def api_root(self) -> str:
50
+ return self.base_url.rstrip("/") + "/api/v3"
51
+
52
+
53
+ @dataclass
54
+ class Config:
55
+ current_profile: str = DEFAULT_PROFILE
56
+ profiles: dict[str, Profile] = field(default_factory=dict)
57
+ default_format: str | None = None # json | table | markdown; None = not yet chosen
58
+
59
+ # ---- persistence -------------------------------------------------
60
+ @classmethod
61
+ def load(cls) -> "Config":
62
+ path = config_path()
63
+ if not path.exists():
64
+ return cls()
65
+ try:
66
+ raw = json.loads(path.read_text())
67
+ profiles = {
68
+ name: Profile(
69
+ name=name,
70
+ base_url=p["base_url"],
71
+ username=p.get("username"),
72
+ verify_ssl=p.get("verify_ssl", True),
73
+ )
74
+ for name, p in raw.get("profiles", {}).items()
75
+ }
76
+ return cls(
77
+ current_profile=raw.get("current_profile", DEFAULT_PROFILE),
78
+ profiles=profiles,
79
+ default_format=raw.get("default_format"),
80
+ )
81
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
82
+ raise ConfigError(f"malformed config at {path}: {exc}") from exc
83
+
84
+ def save(self) -> None:
85
+ path = config_path()
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ data: dict[str, Any] = {
88
+ "current_profile": self.current_profile,
89
+ "default_format": self.default_format,
90
+ "profiles": {
91
+ name: {
92
+ "base_url": p.base_url,
93
+ "username": p.username,
94
+ "verify_ssl": p.verify_ssl,
95
+ }
96
+ for name, p in self.profiles.items()
97
+ },
98
+ }
99
+ path.write_text(json.dumps(data, indent=2) + "\n")
100
+
101
+ # ---- resolution --------------------------------------------------
102
+ def active_profile_name(self) -> str:
103
+ return os.environ.get("OPCLI_PROFILE") or self.current_profile
104
+
105
+ def resolve(self) -> Profile:
106
+ """Return the effective profile, applying env overrides.
107
+
108
+ A profile can be fully synthesised from the environment even with no
109
+ config file on disk, so ``OPCLI_BASE_URL`` + ``OPCLI_TOKEN`` are enough
110
+ to run the CLI headless.
111
+ """
112
+ name = self.active_profile_name()
113
+ env_url = os.environ.get("OPCLI_BASE_URL")
114
+
115
+ prof = self.profiles.get(name)
116
+ if prof is None:
117
+ if env_url:
118
+ return Profile(name=name, base_url=env_url)
119
+ raise ConfigError(
120
+ f"no profile '{name}' configured. Run `openproject auth login` or set OPCLI_BASE_URL."
121
+ )
122
+ if env_url:
123
+ return Profile(
124
+ name=prof.name,
125
+ base_url=env_url,
126
+ username=prof.username,
127
+ verify_ssl=prof.verify_ssl,
128
+ )
129
+ return prof
130
+
131
+ def upsert_profile(self, prof: Profile, make_current: bool = True) -> None:
132
+ self.profiles[prof.name] = prof
133
+ if make_current:
134
+ self.current_profile = prof.name
opcli/context.py ADDED
@@ -0,0 +1,117 @@
1
+ """The shared application context stored on Typer's ``ctx.obj``.
2
+
3
+ Wires together configuration, stored credentials, the HTTP client, and the
4
+ output emitter so every command can reach them without re-plumbing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from typing import Optional
12
+
13
+ from . import credentials
14
+ from .client import Client
15
+ from .config import Config, Profile
16
+ from .errors import AuthError
17
+ from .output import Emitter, OutputFormat
18
+
19
+
20
+ class AppContext:
21
+ def __init__(
22
+ self,
23
+ output: Optional[OutputFormat] = None,
24
+ color: bool = True,
25
+ *,
26
+ interactive: Optional[bool] = None,
27
+ ):
28
+ self.config = Config.load()
29
+ fmt = self._resolve_format(output, interactive)
30
+ env_fields = os.environ.get("OPCLI_CLI_FIELDS")
31
+ fields = env_fields.split(",") if env_fields else None
32
+ self.emitter = Emitter(fmt, color=color, fields=fields)
33
+ self._client: Optional[Client] = None
34
+
35
+ # ---- output-format resolution ------------------------------------
36
+ def _resolve_format(self, output: Optional[OutputFormat], interactive: Optional[bool]) -> OutputFormat:
37
+ """Precedence: --format anywhere > -o/--output > $OPCLI_FORMAT >
38
+ saved default > first-run prompt (interactive) > json."""
39
+ cli = os.environ.get("OPCLI_CLI_FORMAT") # set by `--format/-f` (main pre-parse)
40
+ if cli:
41
+ try:
42
+ return OutputFormat.coerce(cli)
43
+ except ValueError:
44
+ pass
45
+ if output is not None:
46
+ return output
47
+ env = os.environ.get("OPCLI_FORMAT")
48
+ if env:
49
+ try:
50
+ return OutputFormat.coerce(env)
51
+ except ValueError:
52
+ pass
53
+ if self.config.default_format:
54
+ try:
55
+ return OutputFormat.coerce(self.config.default_format)
56
+ except ValueError:
57
+ pass
58
+ if interactive is None:
59
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
60
+ if interactive:
61
+ chosen = self._prompt_default_format()
62
+ if chosen is not None:
63
+ self.config.default_format = chosen.value
64
+ try:
65
+ self.config.save()
66
+ except Exception:
67
+ pass
68
+ return chosen
69
+ return OutputFormat.json
70
+
71
+ def _prompt_default_format(self) -> Optional[OutputFormat]:
72
+ # Prompt on stderr so stdout (the real command output) stays clean.
73
+ try:
74
+ sys.stderr.write(
75
+ "\nFirst run — choose a default output format (saved for next time):\n"
76
+ " 1) json structured, best for scripts & agents (default)\n"
77
+ " 2) table human-readable terminal tables\n"
78
+ " 3) markdown paste-into-docs tables\n"
79
+ "Enter 1/2/3 [1]: "
80
+ )
81
+ sys.stderr.flush()
82
+ ans = sys.stdin.readline().strip().lower()
83
+ except Exception:
84
+ return None
85
+ return {
86
+ "": OutputFormat.json, "1": OutputFormat.json, "json": OutputFormat.json,
87
+ "2": OutputFormat.table, "table": OutputFormat.table,
88
+ "3": OutputFormat.markdown, "markdown": OutputFormat.markdown, "md": OutputFormat.markdown,
89
+ }.get(ans, OutputFormat.json)
90
+
91
+ @property
92
+ def output(self) -> OutputFormat:
93
+ return self.emitter.fmt
94
+
95
+ def profile(self) -> Profile:
96
+ return self.config.resolve()
97
+
98
+ def token(self) -> Optional[str]:
99
+ return credentials.get_token(self.config.active_profile_name())
100
+
101
+ def client(self) -> Client:
102
+ if self._client is not None:
103
+ return self._client
104
+ prof = self.profile()
105
+ token = self.token()
106
+ if not token:
107
+ raise AuthError(
108
+ f"no API token for profile '{prof.name}'. Run `openproject auth login` "
109
+ f"(or set OPCLI_TOKEN)."
110
+ )
111
+ self._client = Client(prof.base_url, token, verify_ssl=prof.verify_ssl)
112
+ return self._client
113
+
114
+ def close(self) -> None:
115
+ if self._client is not None:
116
+ self._client.close()
117
+ self._client = None