oaken-cli 0.1.0__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.
oaken_cli/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The Oaken command-line client.
3
+
4
+ The CLI is a *thin* client: it holds no business logic. Every command is a call to a registry
5
+ operation, reached through an :class:`~oaken_cli.client.ApiClient`. In normal use that client
6
+ speaks HTTP to the local core daemon (prompt 16); in tests it wraps the in-process
7
+ ``CoreClient`` directly, so the whole CLI is exercised without a socket.
8
+
9
+ The entry point is :func:`oaken_cli.main.main`, wired as the ``oaken`` console script.
10
+ """
11
+
12
+ from oaken_cli.main import main
13
+
14
+ __all__ = ["main"]
oaken_cli/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Run the CLI as ``python -m oaken_cli``. The console script ``oaken`` is the normal entry point;
3
+ this mirror is handy for ``python -m`` invocation and for measuring startup without the wrapper."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from oaken_cli.main import _console_main
8
+
9
+ if __name__ == "__main__":
10
+ _console_main()
oaken_cli/client.py ADDED
@@ -0,0 +1,144 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The seam between the CLI and core: a small, transport-neutral client.
3
+
4
+ The CLI never imports core's HTTP wiring or its in-process ``CoreClient`` directly. It programs
5
+ against :class:`ApiClient`, an abstract interface with exactly the operations a thin client needs:
6
+
7
+ * :meth:`ApiClient.call` — run one registry operation (``POST /v1/ops/{name}`` over HTTP);
8
+ * :meth:`ApiClient.list_ops` — the operation catalogue (``GET /v1/ops``), which drives both the
9
+ generic ``oaken op NAME`` fallback and ``--help`` text;
10
+ * :meth:`ApiClient.stream_events` — the SSE event stream (``GET /v1/events``), for ``oaken watch``;
11
+ * :meth:`ApiClient.health` — a liveness probe (``GET /v1/health``) used by ``oaken status``.
12
+
13
+ Two implementations exist (or will):
14
+
15
+ * :class:`~oaken_cli.inprocess.InProcessApiClient` wraps the in-process ``CoreClient``. It needs no
16
+ daemon and no socket, so the CLI's whole command surface is testable today (prompt 17).
17
+ * ``HttpApiClient`` speaks to the running daemon. **It is a stub until prompt 16** builds the
18
+ server; see :mod:`oaken_cli.http`.
19
+
20
+ The two results a mutation can have — it ran, or it needs confirming first — are modelled by
21
+ :class:`CallResult`, which mirrors core's own ``CallResult`` without importing it, so the HTTP
22
+ decoder and the in-process adapter produce the same shape.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from abc import ABC, abstractmethod
28
+ from collections.abc import AsyncIterator, Mapping
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OpInfo:
35
+ """One row of the operation catalogue, as a thin client needs it.
36
+
37
+ This is the decoded form of a ``/v1/ops`` entry. It is deliberately a plain dataclass, not a
38
+ pydantic model: the CLI must not import the registry to describe an operation, so that a
39
+ registry change never forces a CLI release (the generic ``oaken op NAME`` fallback is built
40
+ from exactly these fields).
41
+ """
42
+
43
+ name: str
44
+ summary: str
45
+ permission: str # "read" | "mutate" | "send"
46
+ marks_read: bool = False
47
+ streaming: bool = False
48
+ input_schema: Mapping[str, Any] = field(default_factory=dict)
49
+ output_schema: Mapping[str, Any] = field(default_factory=dict)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Proposal:
54
+ """What a mutation *would* change, returned when it has not been confirmed.
55
+
56
+ The CLI shows this to the person and, for an explicit ``--yes`` or an interactive confirm,
57
+ calls again with ``confirmation_token``. Mirrors core's gate ``Proposal``; ``changes`` may be a
58
+ prefix of the whole, and ``total_changes`` is always the real count.
59
+ """
60
+
61
+ operation: str
62
+ summary: str
63
+ changes: list[dict[str, Any]] = field(default_factory=list)
64
+ total_changes: int = 0
65
+ confirmation_token: str = ""
66
+ expires_at: str = ""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CallResult:
71
+ """The outcome of :meth:`ApiClient.call`: either ``output`` (it ran) or ``proposal`` (confirm
72
+ first). ``request_id`` ties the call to an audit row and to the daemon's logs."""
73
+
74
+ request_id: str
75
+ output: Any | None = None
76
+ proposal: Proposal | None = None
77
+
78
+ @property
79
+ def executed(self) -> bool:
80
+ return self.proposal is None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Event:
85
+ """One line of the SSE stream: a typed event name, its JSON data, and the stream id used to
86
+ resume with ``Last-Event-ID`` after a reconnect."""
87
+
88
+ id: str
89
+ event: str
90
+ data: Mapping[str, Any]
91
+
92
+
93
+ class ApiClient(ABC):
94
+ """The thin-client contract. An implementation decides *how* it reaches core (HTTP to the
95
+ daemon, or the in-process registry); the CLI only ever uses these methods.
96
+
97
+ Lifetime: an ``ApiClient`` is an async context manager so an HTTP implementation can hold a
98
+ connection pool and close it cleanly. The in-process implementation needs no teardown but
99
+ honours the same protocol.
100
+ """
101
+
102
+ async def __aenter__(self) -> ApiClient:
103
+ return self
104
+
105
+ async def __aexit__(self, *exc: object) -> None:
106
+ await self.aclose()
107
+
108
+ async def aclose(self) -> None: # noqa: B027 — an optional concrete hook, not abstract
109
+ """Release any resources (HTTP pool, etc.). The default does nothing."""
110
+
111
+ @abstractmethod
112
+ async def health(self) -> Mapping[str, Any]:
113
+ """Liveness only. Never requires a working operation; used by ``oaken status`` to tell a
114
+ running-but-degraded core from one that is down. Raises :class:`CoreUnavailable` when core
115
+ cannot be reached at all."""
116
+
117
+ @abstractmethod
118
+ async def list_ops(self) -> list[OpInfo]:
119
+ """The operations this caller may invoke, each with its schemas. Drives the generic
120
+ ``oaken op NAME`` fallback and the auto-derived ``--help``."""
121
+
122
+ @abstractmethod
123
+ async def call(
124
+ self,
125
+ name: str,
126
+ payload: Mapping[str, Any] | None = None,
127
+ *,
128
+ confirm: bool = False,
129
+ confirmation_token: str | None = None,
130
+ timeout: float | None = None, # noqa: ASYNC109
131
+ ) -> CallResult:
132
+ """Run one operation. ``timeout`` (seconds) replaces the transport's default wait for the
133
+ answer, for the few operations that are slow by nature, such as ``ask`` on a local model.
134
+
135
+ ``confirm=True`` is the CLI saying *the person at the terminal asked for this exact
136
+ action*; it is how a human-class caller pre-confirms a mutation. A mutation that is neither
137
+ confirmed nor accompanied by a ``confirmation_token`` comes back as a :class:`Proposal` and
138
+ changes nothing.
139
+ """
140
+
141
+ @abstractmethod
142
+ def stream_events(self, last_event_id: str | None = None) -> AsyncIterator[Event]:
143
+ """Yield events as they arrive, resuming after ``last_event_id`` when given. Returns an
144
+ async iterator (not a coroutine) so ``oaken watch`` can ``async for`` over it."""
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CLI command handlers. Each module holds thin handlers that call registry operations through an
3
+ :class:`~oaken_cli.client.ApiClient`. Imported lazily by :mod:`oaken_cli.main`."""
@@ -0,0 +1,349 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``oaken admin account list|show|add|set|remove`` and ``oaken auth gmail|smtp|imap``.
3
+
4
+ The account wizard asks the questions and sends the answers to core, which stores them, so nobody
5
+ edits a file by hand. Only non-secret settings are asked for or stored in the account. A credential
6
+ is handled by ``auth``: a password is read without echo (or from stdin for scripts), sent once to
7
+ core over loopback, and kept by the provider in the OS keyring. The CLI never prints it, never
8
+ takes it as an argument, and never writes it to disk. Gmail's consent is core's: the CLI shows the
9
+ address, opens a browser if asked, and waits for core to report the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import getpass
17
+ import json
18
+ import sys
19
+ from typing import Any
20
+
21
+ from oaken_cli.client import ApiClient
22
+ from oaken_cli.commands.runtime import emit, run
23
+ from oaken_cli.exits import CliError, ExitCode
24
+
25
+ SECRETISH = ("password", "passwd", "secret", "token", "credential", "api_key", "apikey")
26
+ PROVIDERS = ("gmail", "smtp", "imap")
27
+
28
+
29
+ # --- admin account --------------------------------------------------------------------------
30
+
31
+
32
+ def cmd_admin(args: argparse.Namespace) -> int:
33
+ if args.action == "add":
34
+ _require_answers_or_tty(args)
35
+ return run(lambda client: _admin(client, args), args)
36
+
37
+
38
+ def _require_answers_or_tty(args: argparse.Namespace) -> None:
39
+ if (args.provider and args.account_id and args.name) or sys.stdin.isatty():
40
+ return
41
+ raise CliError(
42
+ "no terminal to ask on: give --provider, --id and --name (and --set KEY=VALUE)",
43
+ ExitCode.USAGE,
44
+ )
45
+
46
+
47
+ async def _admin(client: ApiClient, args: argparse.Namespace) -> int:
48
+ if args.action == "list":
49
+ result = await client.call("list_accounts", {})
50
+ rows = result.output.get("accounts", []) if isinstance(result.output, dict) else []
51
+
52
+ def listed() -> None:
53
+ from oaken_cli.output import print_table, print_text
54
+
55
+ if not rows:
56
+ print_text('no accounts; add one with "oaken admin account add"')
57
+ return
58
+ for r in rows:
59
+ r["send"] = "no (receive-only)" if r.get("receive_only") else "yes"
60
+ print_table(rows, ["account_id", "provider", "display_name", "send"], title="accounts")
61
+
62
+ return emit(args, data=result.output, text=listed)
63
+ if args.action == "show":
64
+ result = await client.call("get_account", {"account_id": args.account_id})
65
+ return emit(args, data=result.output, text=lambda: _show(result.output))
66
+ return await _add(client, args)
67
+
68
+
69
+ def _show(out: Any) -> None:
70
+ from oaken_cli.output import print_text
71
+
72
+ print_text(f"{out['account_id']}: {out['provider']} ({out['display_name']})")
73
+ if out.get("receive_only"):
74
+ print_text(" receive-only (listen but not send)")
75
+ caps = out.get("capabilities") or []
76
+ if caps:
77
+ print_text(f" capabilities: {', '.join(caps)}")
78
+ for key, value in out.get("config", {}).items():
79
+ print_text(f" {key} = {json.dumps(value)}")
80
+ if not out.get("config"):
81
+ print_text(" no settings")
82
+ if out.get("problem"):
83
+ print_text(f"problem: {out['problem']}")
84
+
85
+
86
+ def cmd_set(args: argparse.Namespace) -> int:
87
+ return run(lambda client: _set(client, args), args)
88
+
89
+
90
+ async def _set(client: ApiClient, args: argparse.Namespace) -> int:
91
+ # Read the current config, merge the changes, and write the whole thing back (the op replaces).
92
+ current = await client.call("get_account", {"account_id": args.account_id})
93
+ out = current.output if isinstance(current.output, dict) else {}
94
+ config = dict(out.get("config", {}))
95
+ config.update(_parse_settings(args.set))
96
+ receive_only = getattr(args, "receive_only", None)
97
+ if receive_only is True:
98
+ config["receive_only"] = True
99
+ elif receive_only is False:
100
+ config.pop("receive_only", None)
101
+ result = await client.call(
102
+ "set_account_config", {"account_id": args.account_id, "config": config}, confirm=True
103
+ )
104
+ body = result.output if isinstance(result.output, dict) else {}
105
+
106
+ def done() -> None:
107
+ from oaken_cli.output import print_text
108
+
109
+ account = body.get("account", {})
110
+ _show(account)
111
+ if body.get("restart_needed"):
112
+ print_text("(the running provider keeps its old settings until core restarts)")
113
+
114
+ return emit(args, data=result.output, text=done)
115
+
116
+
117
+ def cmd_remove(args: argparse.Namespace) -> int:
118
+ return run(lambda client: _remove(client, args), args)
119
+
120
+
121
+ async def _remove(client: ApiClient, args: argparse.Namespace) -> int:
122
+ result = await client.call("remove_account", {"account_id": args.account_id}, confirm=True)
123
+ out = result.output if isinstance(result.output, dict) else {}
124
+
125
+ def done() -> None:
126
+ from oaken_cli.output import print_text
127
+
128
+ print_text(
129
+ f"account {args.account_id} removed ({out.get('schedules_removed', 0)} schedules)"
130
+ )
131
+ if out.get("mail_kept"):
132
+ print_text("stored mail is kept; purge is a separate operation")
133
+ if out.get("restart_needed"):
134
+ print_text("(a started provider keeps serving until core restarts)")
135
+
136
+ return emit(args, data=result.output, text=done)
137
+
138
+
139
+ async def _add(client: ApiClient, args: argparse.Namespace) -> int:
140
+ providers = await _providers(client)
141
+ interactive = sys.stdin.isatty() and not (args.provider and args.account_id and args.name)
142
+ provider = args.provider or _ask(f"provider ({', '.join(sorted(providers))})", "", interactive)
143
+ if provider not in providers:
144
+ raise CliError(
145
+ f"no provider named {provider!r}; installed: {', '.join(sorted(providers))}",
146
+ ExitCode.USAGE,
147
+ )
148
+ account_id = args.account_id or _ask("account id (a-z, 0-9, - and _)", provider, interactive)
149
+ name = args.name or _ask("display name", account_id, interactive)
150
+ config = _parse_settings(args.set)
151
+ schema = providers[provider].get("config_schema") or {}
152
+ if interactive:
153
+ config = _wizard(schema, config)
154
+ # Receive-only is the "listen but not send" switch (prompt 24). A new Gmail account defaults
155
+ # to receive-only until the user turns send on; other providers default to able-to-send.
156
+ receive_only = getattr(args, "receive_only", None)
157
+ if receive_only is None and interactive and provider == "gmail":
158
+ receive_only = _ask("receive only (listen but not send)? [Y/n]", "y", True).lower() in (
159
+ "",
160
+ "y",
161
+ "yes",
162
+ )
163
+ elif receive_only is None and provider == "gmail":
164
+ receive_only = True # the honest default for a non-interactive Gmail add
165
+ if receive_only:
166
+ config["receive_only"] = True
167
+ result = await client.call(
168
+ "create_account",
169
+ {"account_id": account_id, "provider": provider, "display_name": name, "config": config},
170
+ confirm=True,
171
+ )
172
+
173
+ def done() -> None:
174
+ from oaken_cli.output import print_text
175
+
176
+ print_text(f"account {account_id} added")
177
+ print_text(f'next: "oaken auth {provider} --account {account_id}" to store its credential')
178
+
179
+ return emit(args, data=result.output, text=done)
180
+
181
+
182
+ async def _providers(client: ApiClient) -> dict[str, dict[str, Any]]:
183
+ result = await client.call("list_plugins", {})
184
+ plugins = result.output.get("plugins", []) if isinstance(result.output, dict) else []
185
+ return {p["name"]: p for p in plugins if p.get("kind") == "provider" and "@" not in p["name"]}
186
+
187
+
188
+ def _ask(question: str, default: str, interactive: bool) -> str:
189
+ if not interactive:
190
+ raise CliError(f"missing {question}", ExitCode.USAGE)
191
+ shown = f" [{default}]" if default else ""
192
+ answer = input(f"{question}{shown}: ").strip()
193
+ return answer or default
194
+
195
+
196
+ def _parse_settings(pairs: list[str] | None) -> dict[str, Any]:
197
+ out: dict[str, Any] = {}
198
+ for pair in pairs or []:
199
+ key, sep, value = pair.partition("=")
200
+ if not sep or not key:
201
+ raise CliError(f"--set takes KEY=VALUE, not {pair!r}", ExitCode.USAGE)
202
+ out[key] = _coerce(value)
203
+ return out
204
+
205
+
206
+ def _coerce(text: str) -> Any:
207
+ try:
208
+ return json.loads(text)
209
+ except ValueError:
210
+ return text
211
+
212
+
213
+ def _wizard(schema: dict[str, Any], given: dict[str, Any]) -> dict[str, Any]:
214
+ """Ask for each non-secret setting the provider declares, showing its default."""
215
+ config = dict(given)
216
+ for key, spec in schema.get("properties", {}).items():
217
+ if key in config or any(s in key.lower() for s in SECRETISH):
218
+ continue
219
+ default = spec.get("default")
220
+ hint = spec.get("description") or spec.get("title") or ""
221
+ shown = "" if default is None else f" [{json.dumps(default)}]"
222
+ answer = input(f"{key}{shown}{' - ' + hint if hint else ''}: ").strip()
223
+ if answer:
224
+ config[key] = _coerce(answer)
225
+ return config
226
+
227
+
228
+ # --- auth -----------------------------------------------------------------------------------
229
+
230
+
231
+ def cmd_auth(args: argparse.Namespace) -> int:
232
+ password = _read_password(args) if _needs_password(args) else None
233
+ args.password_value = password
234
+ return run(lambda client: _auth(client, args), args)
235
+
236
+
237
+ def _needs_password(args: argparse.Namespace) -> bool:
238
+ return args.provider in ("smtp", "imap") and not args.check
239
+
240
+
241
+ def _read_password(args: argparse.Namespace) -> str:
242
+ """Without echo from a terminal, or one line from stdin; never from an argument."""
243
+ if args.password_stdin:
244
+ password = sys.stdin.readline().rstrip("\r\n")
245
+ elif sys.stdin.isatty():
246
+ password = getpass.getpass(f"{args.provider} password (not shown): ")
247
+ else:
248
+ raise CliError(
249
+ "no terminal to ask on; pipe the password with --password-stdin", ExitCode.USAGE
250
+ )
251
+ if not password:
252
+ raise CliError("no password given", ExitCode.USAGE)
253
+ return password
254
+
255
+
256
+ async def _account_for(client: ApiClient, args: argparse.Namespace) -> str:
257
+ if args.account:
258
+ return str(args.account)
259
+ result = await client.call("list_accounts", {})
260
+ rows = result.output.get("accounts", []) if isinstance(result.output, dict) else []
261
+ mine = [a["account_id"] for a in rows if a.get("provider") == args.provider]
262
+ if len(mine) == 1:
263
+ return str(mine[0])
264
+ if not mine:
265
+ raise CliError(
266
+ f'no {args.provider} account; add one with "oaken admin account add"', ExitCode.USAGE
267
+ )
268
+ raise CliError(
269
+ f"several {args.provider} accounts ({', '.join(mine)}); use --account", ExitCode.USAGE
270
+ )
271
+
272
+
273
+ async def _auth(client: ApiClient, args: argparse.Namespace) -> int:
274
+ account = await _account_for(client, args)
275
+ if args.check:
276
+ return await _check(client, args, account)
277
+ if args.provider == "gmail":
278
+ return await _consent(client, args, account)
279
+ await client.call(
280
+ "set_account_password",
281
+ {"account_id": account, "password": args.password_value},
282
+ confirm=True,
283
+ )
284
+ args.password_value = None
285
+ return await _check(client, args, account, stored=True)
286
+
287
+
288
+ async def _check(
289
+ client: ApiClient, args: argparse.Namespace, account: str, *, stored: bool = False
290
+ ) -> int:
291
+ result = await client.call("check_account", {"account_id": account})
292
+ out = result.output if isinstance(result.output, dict) else {}
293
+
294
+ def text() -> None:
295
+ from oaken_cli.output import print_text
296
+
297
+ if stored:
298
+ print_text(f"password for {account} stored in the OS keyring")
299
+ print_text(
300
+ f"{account}: login works"
301
+ if out.get("ok")
302
+ else f"{account}: {out.get('detail', 'failed')}"
303
+ )
304
+
305
+ emit(args, data={"account_id": account, "stored": stored, **out}, text=text)
306
+ return int(ExitCode.OK if out.get("ok") else ExitCode.AUTH)
307
+
308
+
309
+ async def _consent(client: ApiClient, args: argparse.Namespace, account: str) -> int:
310
+ started = await client.call("start_authorization", {"account_id": account}, confirm=True)
311
+ url = str((started.output or {}).get("url", "")) if isinstance(started.output, dict) else ""
312
+ if not args.json:
313
+ from oaken_cli.output import print_text
314
+
315
+ print_text(f"open this address to authorize {account}:\n{url}")
316
+ if not args.no_browser and sys.stdout.isatty():
317
+ import webbrowser
318
+
319
+ webbrowser.open(url)
320
+ print_text("waiting for Google to redirect back to this machine...")
321
+ state, detail = await _wait_authorized(client, account, args.timeout)
322
+
323
+ def text() -> None:
324
+ from oaken_cli.output import print_text
325
+
326
+ if state == "done":
327
+ print_text(f"{account} authorized; the refresh token is in the OS keyring")
328
+ else:
329
+ print_text(f"{account}: {detail or 'authorization did not finish'}")
330
+
331
+ emit(
332
+ args, data={"account_id": account, "state": state, "detail": detail, "url": url}, text=text
333
+ )
334
+ return int(ExitCode.OK if state == "done" else ExitCode.AUTH)
335
+
336
+
337
+ async def _wait_authorized(client: ApiClient, account: str, timeout: float) -> tuple[str, str]: # noqa: ASYNC109
338
+ async def poll() -> tuple[str, str]:
339
+ while True:
340
+ result = await client.call("authorization_status", {"account_id": account})
341
+ out = result.output if isinstance(result.output, dict) else {}
342
+ if out.get("state") in ("done", "failed"):
343
+ return str(out["state"]), str(out.get("detail", ""))
344
+ await asyncio.sleep(0.5)
345
+
346
+ try:
347
+ return await asyncio.wait_for(poll(), timeout)
348
+ except TimeoutError:
349
+ return "failed", f"timed out after {timeout:.0f}s; nothing was changed"