avr-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.
- avr_cli-0.1.0.dist-info/METADATA +298 -0
- avr_cli-0.1.0.dist-info/RECORD +42 -0
- avr_cli-0.1.0.dist-info/WHEEL +5 -0
- avr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- avr_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- avr_cli-0.1.0.dist-info/top_level.txt +1 -0
- avrea_cli/__init__.py +9 -0
- avrea_cli/_build_info.py +2 -0
- avrea_cli/api_client.py +111 -0
- avrea_cli/assets/logo_mark.svg +25 -0
- avrea_cli/audit.py +153 -0
- avrea_cli/auth.py +457 -0
- avrea_cli/billing.py +614 -0
- avrea_cli/click_ext.py +261 -0
- avrea_cli/commands/__init__.py +1 -0
- avrea_cli/commands/auth_cmd.py +296 -0
- avrea_cli/commands/cache.py +312 -0
- avrea_cli/commands/config_cmd.py +180 -0
- avrea_cli/commands/health.py +76 -0
- avrea_cli/commands/internal.py +18 -0
- avrea_cli/commands/job.py +959 -0
- avrea_cli/commands/log.py +146 -0
- avrea_cli/commands/org.py +404 -0
- avrea_cli/commands/repo.py +152 -0
- avrea_cli/commands/run.py +1424 -0
- avrea_cli/commands/settings.py +271 -0
- avrea_cli/commands/status.py +405 -0
- avrea_cli/commands/workflow.py +883 -0
- avrea_cli/config.py +49 -0
- avrea_cli/display.py +431 -0
- avrea_cli/docs_gen.py +859 -0
- avrea_cli/firewall.py +342 -0
- avrea_cli/helpers.py +218 -0
- avrea_cli/json_output.py +169 -0
- avrea_cli/log_display.py +509 -0
- avrea_cli/main.py +210 -0
- avrea_cli/metrics_display.py +347 -0
- avrea_cli/output.py +101 -0
- avrea_cli/paths.py +45 -0
- avrea_cli/repo_context.py +473 -0
- avrea_cli/transport.py +71 -0
- avrea_cli/version.py +64 -0
avrea_cli/click_ext.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Click extensions for sectioned help formatting and command aliases."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import click
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
SECTION_ORDER = ["Core Commands", "Setup & Config", "Additional Commands"]
|
|
8
|
+
|
|
9
|
+
LEARN_MORE = 'Use "avr <command> <subcommand> --help" for more information about a command.\nRead the docs at https://docs.avrea.com/cli'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _heading(text: str) -> str:
|
|
13
|
+
"""Bold-uppercase section heading. ANSI codes are stripped automatically
|
|
14
|
+
by click.echo when stdout is not a TTY (e.g., piped to less)."""
|
|
15
|
+
return click.style(text.upper(), bold=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_section(formatter: click.HelpFormatter, heading: str, body_fn) -> None:
|
|
19
|
+
"""Write a section with a bold uppercase heading (no trailing colon)."""
|
|
20
|
+
formatter.write(f"\n{_heading(heading)}\n")
|
|
21
|
+
formatter.indent()
|
|
22
|
+
body_fn()
|
|
23
|
+
formatter.dedent()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_text_section(formatter: click.HelpFormatter, heading: str, text: str) -> None:
|
|
27
|
+
"""Write a section with plain text, properly indented."""
|
|
28
|
+
indent = " " * (formatter.current_indent + 2)
|
|
29
|
+
formatter.write(f"\n{_heading(heading)}\n")
|
|
30
|
+
for line in text.splitlines():
|
|
31
|
+
formatter.write(f"{indent}{line}\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LazyGroup(click.Group):
|
|
35
|
+
"""``click.Group`` with lazy command registration.
|
|
36
|
+
|
|
37
|
+
``add_lazy_command`` records ``name -> (import_path, attr, short_help)`` so
|
|
38
|
+
``--help`` can render the row from cached metadata without importing the
|
|
39
|
+
implementation module. The module is imported on first ``get_command``
|
|
40
|
+
(i.e. when a user actually invokes the command), at which point the lazy
|
|
41
|
+
spec is consumed and the resolved Click command is registered eagerly.
|
|
42
|
+
|
|
43
|
+
Aliases (``AliasGroup``) sit above this layer and rewrite argv to the
|
|
44
|
+
canonical name before lookup, so they work transparently with lazy entries.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *args, **kwargs):
|
|
48
|
+
super().__init__(*args, **kwargs)
|
|
49
|
+
# name -> (import_path, attr, short_help_or_None).
|
|
50
|
+
self._lazy_specs: dict[str, tuple[str, str, str | None]] = {}
|
|
51
|
+
# name -> section_or_None. Hidden commands carry None.
|
|
52
|
+
self._lazy_section: dict[str, str | None] = {}
|
|
53
|
+
|
|
54
|
+
def add_lazy_command(
|
|
55
|
+
self,
|
|
56
|
+
name: str,
|
|
57
|
+
import_path: str,
|
|
58
|
+
attr: str,
|
|
59
|
+
short_help: str | None,
|
|
60
|
+
section: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Register a command without importing its module.
|
|
63
|
+
|
|
64
|
+
``short_help`` is the row text shown in ``--help``; pass ``None`` for
|
|
65
|
+
hidden commands (they're listed for resolution but skipped from help).
|
|
66
|
+
``section`` defaults to the group's active ``with section(...)`` block,
|
|
67
|
+
matching ``add_command``'s auto-tagging.
|
|
68
|
+
"""
|
|
69
|
+
# An eager registration with the same name takes precedence — drop it
|
|
70
|
+
# so this lazy registration wins. Mirrors ``add_command``'s
|
|
71
|
+
# replace-by-name semantics.
|
|
72
|
+
self.commands.pop(name, None)
|
|
73
|
+
self._lazy_specs[name] = (import_path, attr, short_help)
|
|
74
|
+
active = section if section is not None else getattr(self, "_current_section", None)
|
|
75
|
+
self._lazy_section[name] = active
|
|
76
|
+
|
|
77
|
+
def add_command(self, cmd, name=None):
|
|
78
|
+
# Eager registration wins over any pending lazy registration with the
|
|
79
|
+
# same name; drop the lazy entry so help/dispatch see only the new one.
|
|
80
|
+
resolved = name or cmd.name
|
|
81
|
+
self._lazy_specs.pop(resolved, None)
|
|
82
|
+
self._lazy_section.pop(resolved, None)
|
|
83
|
+
super().add_command(cmd, name)
|
|
84
|
+
|
|
85
|
+
def list_commands(self, ctx):
|
|
86
|
+
return sorted({*super().list_commands(ctx), *self._lazy_specs})
|
|
87
|
+
|
|
88
|
+
def get_command(self, ctx, cmd_name):
|
|
89
|
+
spec = self._lazy_specs.pop(cmd_name, None)
|
|
90
|
+
if spec is None:
|
|
91
|
+
return super().get_command(ctx, cmd_name)
|
|
92
|
+
import_path, attr, _ = spec
|
|
93
|
+
section = self._lazy_section.pop(cmd_name, None)
|
|
94
|
+
module = importlib.import_module(import_path)
|
|
95
|
+
cmd = getattr(module, attr)
|
|
96
|
+
# Skip our own ``add_command`` (it would just no-op the lazy popping
|
|
97
|
+
# we already did) and bypass any section auto-tagging by going
|
|
98
|
+
# straight to ``click.Group`` — we apply the cached section
|
|
99
|
+
# explicitly so it survives the load.
|
|
100
|
+
click.Group.add_command(self, cmd, cmd_name)
|
|
101
|
+
if section is not None:
|
|
102
|
+
cmd.help_section = section
|
|
103
|
+
return cmd
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GhHelpMixin:
|
|
107
|
+
"""Mixin that reformats Click help output with bold uppercase sections,
|
|
108
|
+
a USAGE/CORE COMMANDS/SETUP & CONFIG/ADDITIONAL COMMANDS layout, and a
|
|
109
|
+
LEARN MORE footer.
|
|
110
|
+
|
|
111
|
+
Must be mixed with click.BaseCommand or a subclass (Group, Command).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# The mixin accesses attributes from click.BaseCommand / click.Group.
|
|
115
|
+
# Type checkers can't see these through the mixin, so we annotate access
|
|
116
|
+
# points with type: ignore[attr-defined].
|
|
117
|
+
|
|
118
|
+
def format_help(self, ctx, formatter):
|
|
119
|
+
help_text = self.help # type: ignore[attr-defined]
|
|
120
|
+
if help_text:
|
|
121
|
+
formatter.write(f"{help_text}\n")
|
|
122
|
+
|
|
123
|
+
pieces = self.collect_usage_pieces(ctx) # type: ignore[attr-defined]
|
|
124
|
+
usage_line = f"{ctx.command_path} {' '.join(pieces)}"
|
|
125
|
+
_write_text_section(formatter, "USAGE", usage_line)
|
|
126
|
+
|
|
127
|
+
self.format_commands(ctx, formatter) # type: ignore[attr-defined]
|
|
128
|
+
self.format_options(ctx, formatter)
|
|
129
|
+
|
|
130
|
+
_write_text_section(formatter, "LEARN MORE", LEARN_MORE)
|
|
131
|
+
|
|
132
|
+
def format_options(self, ctx, formatter):
|
|
133
|
+
"""Render options as FLAGS / INHERITED FLAGS."""
|
|
134
|
+
opts = []
|
|
135
|
+
for param in self.get_params(ctx): # type: ignore[attr-defined]
|
|
136
|
+
rv = param.get_help_record(ctx)
|
|
137
|
+
if rv is not None:
|
|
138
|
+
opts.append(rv)
|
|
139
|
+
|
|
140
|
+
if opts:
|
|
141
|
+
inherited = [(n, h) for n, h in opts if n.strip().startswith("--help")]
|
|
142
|
+
own = [(n, h) for n, h in opts if not n.strip().startswith("--help")]
|
|
143
|
+
|
|
144
|
+
if own:
|
|
145
|
+
_write_section(formatter, "FLAGS", lambda: formatter.write_dl(own))
|
|
146
|
+
|
|
147
|
+
if inherited:
|
|
148
|
+
_write_section(formatter, "INHERITED FLAGS", lambda: formatter.write_dl(inherited))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AliasGroup(GhHelpMixin, LazyGroup):
|
|
152
|
+
"""Root CLI group with sectioned help, plural-to-singular aliases, and grouped commands.
|
|
153
|
+
|
|
154
|
+
``section_order`` is the list of section headings rendered, top to bottom.
|
|
155
|
+
Commands tag themselves with ``cmd.help_section = "<heading>"``; commands
|
|
156
|
+
without a tag are silently omitted from --help (so registering a new
|
|
157
|
+
command without picking a section is loud — you notice immediately).
|
|
158
|
+
Defaults to the public-CLI section list; downstream CLIs (e.g. avr-admin)
|
|
159
|
+
can pass their own.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, *args, aliases=None, section_order=None, **kwargs):
|
|
163
|
+
super().__init__(*args, **kwargs)
|
|
164
|
+
self._aliases = aliases or {}
|
|
165
|
+
self._section_order = list(section_order) if section_order is not None else SECTION_ORDER
|
|
166
|
+
self._current_section: str | None = None
|
|
167
|
+
|
|
168
|
+
@contextmanager
|
|
169
|
+
def section(self, name: str):
|
|
170
|
+
"""Tag every command registered inside the ``with`` block with ``name``.
|
|
171
|
+
|
|
172
|
+
Lets the caller register commands in batches without re-listing the
|
|
173
|
+
names afterwards just to assign ``help_section``::
|
|
174
|
+
|
|
175
|
+
with cli.section("Public Commands"):
|
|
176
|
+
cli.add_command(auth_group, name="auth")
|
|
177
|
+
cli.add_command(jobs)
|
|
178
|
+
|
|
179
|
+
with cli.section("Admin Commands"):
|
|
180
|
+
cli.add_command(ai)
|
|
181
|
+
|
|
182
|
+
Nests cleanly — outer section is restored on exit.
|
|
183
|
+
"""
|
|
184
|
+
prev = self._current_section
|
|
185
|
+
self._current_section = name
|
|
186
|
+
try:
|
|
187
|
+
yield
|
|
188
|
+
finally:
|
|
189
|
+
self._current_section = prev
|
|
190
|
+
|
|
191
|
+
def add_command(self, cmd, name=None):
|
|
192
|
+
super().add_command(cmd, name)
|
|
193
|
+
if self._current_section is not None:
|
|
194
|
+
# Auto-tag with the active section. We always overwrite — when
|
|
195
|
+
# the same command object is registered to two CLIs (avr-admin
|
|
196
|
+
# inheriting from avr) each registration's section context wins
|
|
197
|
+
# for its own group. Different processes anyway, so cross-CLI
|
|
198
|
+
# state collisions aren't a real concern.
|
|
199
|
+
cmd.help_section = self._current_section
|
|
200
|
+
|
|
201
|
+
def get_command(self, ctx, cmd_name):
|
|
202
|
+
rv = super().get_command(ctx, cmd_name)
|
|
203
|
+
if rv is not None:
|
|
204
|
+
return rv
|
|
205
|
+
canonical = self._aliases.get(cmd_name)
|
|
206
|
+
if canonical:
|
|
207
|
+
return super().get_command(ctx, canonical)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def resolve_command(self, ctx, args):
|
|
211
|
+
cmd_name = args[0] if args else None
|
|
212
|
+
if cmd_name in self._aliases:
|
|
213
|
+
args = [self._aliases[cmd_name], *args[1:]]
|
|
214
|
+
return super().resolve_command(ctx, args)
|
|
215
|
+
|
|
216
|
+
def format_commands(self, ctx, formatter):
|
|
217
|
+
commands_by_section: dict[str, list[tuple[str, str]]] = {}
|
|
218
|
+
for name in self.list_commands(ctx):
|
|
219
|
+
# Lazy entries render straight from cached metadata — never import.
|
|
220
|
+
if name in self._lazy_specs:
|
|
221
|
+
short = self._lazy_specs[name][2]
|
|
222
|
+
section = self._lazy_section.get(name)
|
|
223
|
+
if short is None or section is None:
|
|
224
|
+
continue
|
|
225
|
+
else:
|
|
226
|
+
cmd = self.get_command(ctx, name)
|
|
227
|
+
if cmd is None or cmd.hidden:
|
|
228
|
+
continue
|
|
229
|
+
section = getattr(cmd, "help_section", None)
|
|
230
|
+
if section is None:
|
|
231
|
+
continue
|
|
232
|
+
short = cmd.get_short_help_str(limit=formatter.width)
|
|
233
|
+
commands_by_section.setdefault(section, []).append((name, short))
|
|
234
|
+
|
|
235
|
+
for section in self._section_order:
|
|
236
|
+
cmds = commands_by_section.get(section)
|
|
237
|
+
if not cmds:
|
|
238
|
+
continue
|
|
239
|
+
rows = [(f"{name}:", short) for name, short in cmds]
|
|
240
|
+
_write_section(formatter, section, lambda r=rows: formatter.write_dl(r))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class GhGroup(GhHelpMixin, LazyGroup):
|
|
244
|
+
"""Command group with sectioned help (no aliases, no command-grouping)."""
|
|
245
|
+
|
|
246
|
+
def format_commands(self, ctx, formatter):
|
|
247
|
+
rows: list[tuple[str, str]] = []
|
|
248
|
+
for name in self.list_commands(ctx):
|
|
249
|
+
if name in self._lazy_specs:
|
|
250
|
+
short = self._lazy_specs[name][2]
|
|
251
|
+
if short is None:
|
|
252
|
+
continue
|
|
253
|
+
rows.append((f"{name}:", short))
|
|
254
|
+
continue
|
|
255
|
+
cmd = self.get_command(ctx, name)
|
|
256
|
+
if cmd is None or cmd.hidden:
|
|
257
|
+
continue
|
|
258
|
+
rows.append((f"{name}:", cmd.get_short_help_str(limit=formatter.width)))
|
|
259
|
+
|
|
260
|
+
if rows:
|
|
261
|
+
_write_section(formatter, "AVAILABLE COMMANDS", lambda: formatter.write_dl(rows))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Authentication CLI commands (avr auth login/logout/status)."""
|
|
2
|
+
|
|
3
|
+
from avrea_cli import auth
|
|
4
|
+
from avrea_cli.api_client import ApiClient
|
|
5
|
+
from avrea_cli.click_ext import GhGroup
|
|
6
|
+
from avrea_cli.config import CliConfig
|
|
7
|
+
from avrea_cli.helpers import ensure_authenticated
|
|
8
|
+
from avrea_cli.helpers import ensure_ctx
|
|
9
|
+
from avrea_cli.helpers import handle_http_error
|
|
10
|
+
from avrea_cli.json_output import emit_json_record
|
|
11
|
+
from avrea_cli.json_output import handle_json_meta
|
|
12
|
+
from avrea_cli.json_output import json_options
|
|
13
|
+
from avrea_cli.json_output import make_schema
|
|
14
|
+
from avrea_cli.json_output import split_fields
|
|
15
|
+
from avrea_cli.output import format_key_value
|
|
16
|
+
import click
|
|
17
|
+
import httpx
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
# `host`/`default_org`/`token` come from local config, not the API response.
|
|
21
|
+
# The handler injects them under `_local:*` keys before projection so the
|
|
22
|
+
# schema stays a flat wire-name → path map (matching every other command).
|
|
23
|
+
_AUTH_STATUS_FIELDS = make_schema(
|
|
24
|
+
"email",
|
|
25
|
+
"name",
|
|
26
|
+
"created_at",
|
|
27
|
+
user_id="id",
|
|
28
|
+
host="_local:host",
|
|
29
|
+
default_org="_local:default_org",
|
|
30
|
+
token="_local:token",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fetch_email(api_url: str, api_key: str) -> str | None:
|
|
35
|
+
"""Best-effort: read the just-issued key's owner so we can show
|
|
36
|
+
'Logged in as <email>'. Failures are silent — the key is already stored."""
|
|
37
|
+
try:
|
|
38
|
+
with httpx.Client(timeout=5.0) as c:
|
|
39
|
+
r = c.get(f"{api_url}/users/me", headers={"Authorization": f"Bearer {api_key}"})
|
|
40
|
+
r.raise_for_status()
|
|
41
|
+
return r.json().get("email")
|
|
42
|
+
except httpx.HTTPError, ValueError, KeyError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _maybe_auto_pin_default_org(api_url: str, api_key: str) -> tuple[str, str] | None:
|
|
47
|
+
"""If the user belongs to exactly one organization, store it as the
|
|
48
|
+
default for this host so subsequent commands don't have to re-resolve.
|
|
49
|
+
|
|
50
|
+
Returns (slug, org_id) when an org was pinned, None otherwise (zero
|
|
51
|
+
orgs, multiple orgs, transport failure). Multi-org users still pick
|
|
52
|
+
explicitly via ``avr config set org`` — auto-picking would silently
|
|
53
|
+
bias every command toward an arbitrary org."""
|
|
54
|
+
try:
|
|
55
|
+
with httpx.Client(timeout=5.0) as c:
|
|
56
|
+
r = c.get(f"{api_url}/users/me/organizations", headers={"Authorization": f"Bearer {api_key}"})
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
data = r.json().get("data") or []
|
|
59
|
+
except httpx.HTTPError, ValueError, KeyError:
|
|
60
|
+
return None
|
|
61
|
+
if len(data) != 1:
|
|
62
|
+
return None
|
|
63
|
+
org_id = data[0].get("organization_id")
|
|
64
|
+
slug = data[0].get("slug") or org_id
|
|
65
|
+
if not org_id:
|
|
66
|
+
return None
|
|
67
|
+
return slug, org_id
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _do_login(ctx, provider):
|
|
71
|
+
config: CliConfig = ctx.obj["config"]
|
|
72
|
+
try:
|
|
73
|
+
api_key = auth.login(config.public_api_url, provider=provider)
|
|
74
|
+
except click.ClickException as exc:
|
|
75
|
+
# Click's exception carries a useful `.message`; surface it under a
|
|
76
|
+
# consistent "Login failed:" prefix and exit 1.
|
|
77
|
+
click.echo(f"Login failed: {exc.format_message()}", err=True)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
# click.Abort is intentionally not caught here — auth.login already
|
|
80
|
+
# printed a context-specific error to stderr before raising it. Letting
|
|
81
|
+
# it propagate hands control back to Click's runner, which exits cleanly
|
|
82
|
+
# without our generic "Login failed:" prefix duplicating the message.
|
|
83
|
+
|
|
84
|
+
auth.store_token(api_key, host=config.public_api_url)
|
|
85
|
+
|
|
86
|
+
check = click.style("✓", fg="green")
|
|
87
|
+
email = _fetch_email(config.public_api_url, api_key)
|
|
88
|
+
if email:
|
|
89
|
+
click.echo(f"{check} Logged in as {click.style(email, bold=True)}")
|
|
90
|
+
else:
|
|
91
|
+
click.echo(f"{check} Authentication complete.")
|
|
92
|
+
click.echo(f" Credentials saved to {auth.HOSTS_FILE}")
|
|
93
|
+
|
|
94
|
+
# Auto-pin only fires for single-org users — see _maybe_auto_pin_default_org.
|
|
95
|
+
if not auth.load_default_org(host=config.public_api_url):
|
|
96
|
+
pinned = _maybe_auto_pin_default_org(config.public_api_url, api_key)
|
|
97
|
+
if pinned:
|
|
98
|
+
slug, org_id = pinned
|
|
99
|
+
auth.store_default_org(org_id, host=config.public_api_url)
|
|
100
|
+
click.echo(f" Default organization: {click.style(slug, bold=True)} ({org_id})")
|
|
101
|
+
|
|
102
|
+
click.echo()
|
|
103
|
+
click.echo("Try: avr status")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _do_logout(ctx):
|
|
107
|
+
client: ApiClient = ctx.obj["client"]
|
|
108
|
+
config: CliConfig = ctx.obj["config"]
|
|
109
|
+
|
|
110
|
+
if config.auth_token:
|
|
111
|
+
try:
|
|
112
|
+
client.public_delete("/users/me/api-keys/current")
|
|
113
|
+
except httpx.HTTPStatusError:
|
|
114
|
+
click.echo("Warning: Could not revoke API key on server.", err=True)
|
|
115
|
+
except httpx.ConnectError, httpx.TimeoutException:
|
|
116
|
+
click.echo("Warning: Could not reach server to revoke API key.", err=True)
|
|
117
|
+
|
|
118
|
+
cleared = auth.clear(host=config.public_api_url)
|
|
119
|
+
check = click.style("✓", fg="green")
|
|
120
|
+
if cleared:
|
|
121
|
+
click.echo(f"{check} Logged out.")
|
|
122
|
+
else:
|
|
123
|
+
click.echo("No stored credentials found.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@click.group(cls=GhGroup)
|
|
127
|
+
@click.pass_context
|
|
128
|
+
def auth_group(ctx):
|
|
129
|
+
"""Authenticate and manage credentials."""
|
|
130
|
+
ensure_ctx(ctx)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@auth_group.command("login")
|
|
134
|
+
@click.option(
|
|
135
|
+
"--provider",
|
|
136
|
+
type=click.Choice(["google", "github"], case_sensitive=False),
|
|
137
|
+
default="github",
|
|
138
|
+
show_default=True,
|
|
139
|
+
help="OAuth provider to use for CLI login.",
|
|
140
|
+
)
|
|
141
|
+
@click.pass_context
|
|
142
|
+
def auth_login(ctx, provider: str):
|
|
143
|
+
"""Authenticate via browser and store credentials."""
|
|
144
|
+
_do_login(ctx, provider)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@auth_group.command("logout")
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def auth_logout(ctx):
|
|
150
|
+
"""Revoke the current API key and remove stored credentials."""
|
|
151
|
+
_do_logout(ctx)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@auth_group.command("switch")
|
|
155
|
+
@click.argument("host", required=False)
|
|
156
|
+
@click.pass_context
|
|
157
|
+
def auth_switch(ctx, host: str | None):
|
|
158
|
+
"""Switch the default host used when AVR_HOST isn't set.
|
|
159
|
+
|
|
160
|
+
\b
|
|
161
|
+
Examples:
|
|
162
|
+
avr auth switch # show current default + all hosts
|
|
163
|
+
avr auth switch https://api.avrea.com
|
|
164
|
+
"""
|
|
165
|
+
ensure_ctx(ctx)
|
|
166
|
+
hosts = auth.list_hosts()
|
|
167
|
+
if not hosts:
|
|
168
|
+
click.echo("No stored credentials. Run `avr auth login` first.", err=True)
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
current = auth.load_default_host()
|
|
172
|
+
if host is None:
|
|
173
|
+
# Bare `auth switch` lists what's available so the user can pick.
|
|
174
|
+
# Stdout gets bare hostnames so `avr auth switch | fzf` works; the
|
|
175
|
+
# header, default marker, and pick hint go to stderr.
|
|
176
|
+
if sys.stdout.isatty():
|
|
177
|
+
click.echo("Stored hosts:", err=True)
|
|
178
|
+
for h in hosts:
|
|
179
|
+
click.echo(h)
|
|
180
|
+
if h == current and sys.stdout.isatty():
|
|
181
|
+
click.echo(click.style(f" (default: {h})", fg="green"), err=True)
|
|
182
|
+
click.echo(f"\nPass one of these to switch, e.g. `avr auth switch {hosts[0]}`.", err=True)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
target = host.rstrip("/")
|
|
186
|
+
if target not in hosts:
|
|
187
|
+
click.echo(f"Error: no stored credentials for host {target!r}.", err=True)
|
|
188
|
+
click.echo("Available hosts:", err=True)
|
|
189
|
+
for h in hosts:
|
|
190
|
+
click.echo(f" {h}", err=True)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
|
|
193
|
+
auth.set_default_host(target)
|
|
194
|
+
check = click.style("✓", fg="green")
|
|
195
|
+
click.echo(f"{check} Default host set to {click.style(target, bold=True)}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@auth_group.command("status")
|
|
199
|
+
@click.option("--show-token", is_flag=True, help="Display the auth token in plain text.")
|
|
200
|
+
@json_options
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def auth_status(ctx, show_token: bool, json_fields, jq_expr):
|
|
203
|
+
"""Display the authenticated user and connection state."""
|
|
204
|
+
if handle_json_meta(json_fields, jq_expr, _AUTH_STATUS_FIELDS):
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
client: ApiClient = ctx.obj["client"]
|
|
208
|
+
config: CliConfig = ctx.obj["config"]
|
|
209
|
+
|
|
210
|
+
ensure_authenticated(config)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
result = client.public_get("/users/me")
|
|
214
|
+
except httpx.HTTPStatusError as exc:
|
|
215
|
+
# handle_http_error short-circuits 401 to the same auth hint, and
|
|
216
|
+
# surfaces other statuses with detail-aware framing.
|
|
217
|
+
handle_http_error(exc, "fetch user info")
|
|
218
|
+
|
|
219
|
+
# `.get()` for every field — a partial /users/me response (schema drift,
|
|
220
|
+
# degraded backend) should yield a missing-field message, not a KeyError
|
|
221
|
+
# traceback dumped at the user.
|
|
222
|
+
|
|
223
|
+
if json_fields is not None:
|
|
224
|
+
# Build a synthetic record so the schema can mix server fields with
|
|
225
|
+
# local config (host, default_org, token). The token field is removed
|
|
226
|
+
# from the schema entirely unless --show-token is set — `--json '*'`
|
|
227
|
+
# then returns only the fields that exist, and `--json token` errors
|
|
228
|
+
# with the available-fields hint, prompting the user to add the flag.
|
|
229
|
+
record: dict[str, object] = dict(result)
|
|
230
|
+
record["_local:host"] = config.public_api_url
|
|
231
|
+
record["_local:default_org"] = config.default_org
|
|
232
|
+
if show_token:
|
|
233
|
+
record["_local:token"] = config.auth_token
|
|
234
|
+
schema = _AUTH_STATUS_FIELDS
|
|
235
|
+
else:
|
|
236
|
+
schema = {k: v for k, v in _AUTH_STATUS_FIELDS.items() if k != "token"}
|
|
237
|
+
emit_json_record(record, split_fields(json_fields, schema), schema, jq_expr)
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
click.echo(
|
|
241
|
+
format_key_value(
|
|
242
|
+
{
|
|
243
|
+
"User ID": result.get("id") or "-",
|
|
244
|
+
"Email": result.get("email") or "-",
|
|
245
|
+
"Name": result.get("name", "N/A"),
|
|
246
|
+
"Host": config.public_api_url,
|
|
247
|
+
"Default org": config.default_org or "(not set)",
|
|
248
|
+
"Created": result.get("created_at") or "-",
|
|
249
|
+
"Token": _format_token(config.auth_token, show_token),
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
click.echo()
|
|
254
|
+
click.echo(click.style(" Switch host: avr auth switch <host>", dim=True))
|
|
255
|
+
click.echo(click.style(" Switch default org: avr config set org <slug>", dim=True))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _format_token(token: str | None, show: bool) -> str:
|
|
259
|
+
"""Render the API token for `auth status`. Masks by default; reveals the
|
|
260
|
+
full token only when --show-token is passed."""
|
|
261
|
+
if not token:
|
|
262
|
+
return "(none)"
|
|
263
|
+
if show:
|
|
264
|
+
return token
|
|
265
|
+
# Show enough of the prefix to identify the key family without leaking it.
|
|
266
|
+
# Tokens ≤ 8 chars are out-of-spec (real Avrea API keys are much longer);
|
|
267
|
+
# collapsing all such inputs to a featureless 32-asterisk string is
|
|
268
|
+
# intentional, not a bug — the test suite pins this for safety.
|
|
269
|
+
visible = token[:4] if len(token) > 8 else ""
|
|
270
|
+
return f"{visible}{'*' * 32}" if visible else "*" * 32
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Top-level aliases (hidden, for backward compat)
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@click.command("login", hidden=True)
|
|
279
|
+
@click.option(
|
|
280
|
+
"--provider",
|
|
281
|
+
type=click.Choice(["google", "github"], case_sensitive=False),
|
|
282
|
+
default="github",
|
|
283
|
+
show_default=True,
|
|
284
|
+
help="OAuth provider to use for CLI login.",
|
|
285
|
+
)
|
|
286
|
+
@click.pass_context
|
|
287
|
+
def login_alias(ctx, provider: str):
|
|
288
|
+
"""Authenticate via browser and store credentials."""
|
|
289
|
+
_do_login(ctx, provider)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@click.command("logout", hidden=True)
|
|
293
|
+
@click.pass_context
|
|
294
|
+
def logout_alias(ctx):
|
|
295
|
+
"""Revoke the current API key and remove stored credentials."""
|
|
296
|
+
_do_logout(ctx)
|