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 +14 -0
- oaken_cli/__main__.py +10 -0
- oaken_cli/client.py +144 -0
- oaken_cli/commands/__init__.py +3 -0
- oaken_cli/commands/account_cmd.py +349 -0
- oaken_cli/commands/agent_cmd.py +207 -0
- oaken_cli/commands/completion.py +64 -0
- oaken_cli/commands/content_cmd.py +266 -0
- oaken_cli/commands/daemon_cmd.py +99 -0
- oaken_cli/commands/mail_cmd.py +349 -0
- oaken_cli/commands/mcp_cmd.py +263 -0
- oaken_cli/commands/operation_cmd.py +162 -0
- oaken_cli/commands/runtime.py +147 -0
- oaken_cli/commands/schedule_cmd.py +138 -0
- oaken_cli/commands/system_cmd.py +408 -0
- oaken_cli/daemon.py +99 -0
- oaken_cli/discovery.py +184 -0
- oaken_cli/exits.py +74 -0
- oaken_cli/http.py +252 -0
- oaken_cli/inprocess.py +119 -0
- oaken_cli/main.py +840 -0
- oaken_cli/output.py +114 -0
- oaken_cli/sanitize.py +77 -0
- oaken_cli-0.1.0.dist-info/METADATA +28 -0
- oaken_cli-0.1.0.dist-info/RECORD +27 -0
- oaken_cli-0.1.0.dist-info/WHEEL +4 -0
- oaken_cli-0.1.0.dist-info/entry_points.txt +2 -0
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,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"
|