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.
@@ -0,0 +1,312 @@
1
+ """Cache management CLI commands."""
2
+
3
+ from avrea_cli.api_client import ApiClient
4
+ from avrea_cli.click_ext import GhGroup
5
+ from avrea_cli.config import CliConfig
6
+ from avrea_cli.display import get_console_url
7
+ from avrea_cli.display import is_piped
8
+ from avrea_cli.display import open_or_print_url
9
+ from avrea_cli.display import print_piped_header
10
+ from avrea_cli.display import print_piped_row
11
+ from avrea_cli.helpers import ensure_authenticated
12
+ from avrea_cli.helpers import ensure_ctx
13
+ from avrea_cli.helpers import ensure_prompts_allowed
14
+ from avrea_cli.helpers import format_size
15
+ from avrea_cli.helpers import get_org_id
16
+ from avrea_cli.helpers import get_org_slug
17
+ from avrea_cli.helpers import handle_http_error
18
+ from avrea_cli.json_output import emit_json
19
+ from avrea_cli.json_output import emit_json_record
20
+ from avrea_cli.json_output import handle_json_meta
21
+ from avrea_cli.json_output import make_schema
22
+ from avrea_cli.json_output import reject_web_with_json
23
+ from avrea_cli.json_output import split_fields
24
+ from avrea_cli.output import format_key_value
25
+ from avrea_cli.output import format_timestamp
26
+ from avrea_cli.output import output_list
27
+ from avrea_cli.repo_context import resolve_repo_named
28
+ from avrea_cli.repo_context import resolve_repo_or_detect
29
+ from typing import Any
30
+ import click
31
+ import httpx
32
+
33
+
34
+ @click.group(cls=GhGroup)
35
+ @click.pass_context
36
+ def cache(ctx):
37
+ """Inspect and manage the Avrea build cache."""
38
+ ensure_ctx(ctx)
39
+
40
+
41
+ _CACHE_LIST_FIELDS = make_schema(
42
+ "cache_type", "key", "ref", "size_bytes", "created_at", "last_accessed_at", "version", "hit_count"
43
+ )
44
+
45
+
46
+ @cache.command("list")
47
+ @click.option("--repo", "repo_id", help="Repository (org/repo or rep-xxx). Auto-detected from git remote if omitted.")
48
+ @click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
49
+ @click.option(
50
+ "--type",
51
+ "cache_type",
52
+ type=str,
53
+ help="Filter by cache type (e.g. gha, bazel, turbo, rclone).",
54
+ )
55
+ @click.option("--key", help="Filter by key prefix.")
56
+ @click.option("--ref", help="Filter by exact ref match.")
57
+ @click.option(
58
+ "-L", "--limit", type=click.IntRange(1, 1000), default=100, show_default=True, help="Max entries to return."
59
+ )
60
+ @click.option("--offset", type=click.IntRange(min=0), default=0, show_default=True, help="Number of entries to skip.")
61
+ @click.option(
62
+ "--order",
63
+ type=click.Choice(["created_at.desc", "created_at.asc"]),
64
+ default="created_at.desc",
65
+ show_default=True,
66
+ help="Sort order.",
67
+ )
68
+ @click.option(
69
+ "--json",
70
+ "json_fields",
71
+ default=None,
72
+ help='Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.',
73
+ )
74
+ @click.option("-q", "--jq", "jq_expr", default=None, help="Filter --json output through a jq expression.")
75
+ @click.option("--web", is_flag=True, help="Open in browser.")
76
+ @click.pass_context
77
+ def cache_list(ctx, repo_id, org_id, cache_type, key, ref, limit, offset, order, json_fields, jq_expr, web: bool):
78
+ """List cache entries for a repository.
79
+
80
+ \b
81
+ Examples:
82
+ avr cache list --repo rep-abc123
83
+ avr cache list --repo rep-abc123 --type gha --limit 50
84
+ avr cache list --repo rep-abc123 --key "node_modules" --ref refs/heads/main
85
+ avr cache list --repo rep-abc123 --json key,size_bytes,created_at
86
+
87
+ \b
88
+ JSON FIELDS
89
+ cache_type, created_at, hit_count, key, last_accessed_at, ref, size_bytes, version
90
+ """
91
+ reject_web_with_json(json_fields, web)
92
+ if handle_json_meta(json_fields, jq_expr, _CACHE_LIST_FIELDS):
93
+ return
94
+
95
+ client: ApiClient = ctx.obj["client"]
96
+ config: CliConfig = ctx.obj["config"]
97
+ ensure_authenticated(config)
98
+
99
+ org_id = get_org_id(config, org_id, client=client)
100
+ repo_id = resolve_repo_or_detect(client, config, org_id, repo_id, required=True)
101
+
102
+ if web:
103
+ slug = get_org_slug(client, org_id)
104
+ console_url = get_console_url(config.public_api_url)
105
+ url = f"{console_url}/org/{slug}/caches/{repo_id}"
106
+ open_or_print_url(url)
107
+ return
108
+
109
+ params: dict[str, Any] = {"limit": limit, "order": order}
110
+ if cache_type:
111
+ params["cache_type"] = cache_type.lower()
112
+ if key:
113
+ params["key"] = key
114
+ if ref:
115
+ params["ref"] = ref
116
+ if offset:
117
+ params["offset"] = offset
118
+
119
+ try:
120
+ response = client.public_get(f"/orgs/{org_id}/repos/{repo_id}/cache/entries", params=params)
121
+ except httpx.HTTPStatusError as exc:
122
+ handle_http_error(exc, "list cache entries")
123
+
124
+ entries = response["data"]
125
+
126
+ if json_fields is not None:
127
+ emit_json(entries, split_fields(json_fields, _CACHE_LIST_FIELDS), _CACHE_LIST_FIELDS, jq_expr)
128
+ return
129
+
130
+ if is_piped():
131
+ print_piped_header(["cache_type", "key", "ref", "size_bytes", "created_at"])
132
+ for e in entries:
133
+ print_piped_row(
134
+ [
135
+ e.get("cache_type", ""),
136
+ e.get("key", ""),
137
+ e.get("ref", ""),
138
+ e.get("size_bytes", 0),
139
+ e.get("created_at", ""),
140
+ ]
141
+ )
142
+ return
143
+
144
+ for entry in entries:
145
+ entry["size_display"] = format_size(entry.get("size_bytes", 0))
146
+ entry["key_display"] = entry.get("key") or "-"
147
+ entry["ref_display"] = entry.get("ref") or "-"
148
+ entry["created_display"] = format_timestamp(entry.get("created_at"))
149
+
150
+ output_list(
151
+ entries,
152
+ columns=["cache_type", "key_display", "ref_display", "size_display", "created_display"],
153
+ column_labels=["Type", "Key", "Ref", "Size", "Created"],
154
+ )
155
+
156
+ total = response.get("total", len(entries))
157
+ shown = offset + len(entries)
158
+ if shown < total:
159
+ click.echo(f"\nShowing {shown}/{total}. Next page: --offset {shown}", err=True)
160
+
161
+
162
+ _CACHE_USAGE_FIELDS = make_schema(
163
+ total_size_bytes="data.total_size_bytes",
164
+ quota_bytes="data.quota_bytes",
165
+ over_quota="data.over_quota",
166
+ by_type="data.by_type",
167
+ )
168
+
169
+
170
+ @cache.command("usage")
171
+ @click.option("--repo", "repo_id", help="Repository (org/repo or rep-xxx). Auto-detected from git remote if omitted.")
172
+ @click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
173
+ @click.option(
174
+ "--json",
175
+ "json_fields",
176
+ default=None,
177
+ help='Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.',
178
+ )
179
+ @click.option("-q", "--jq", "jq_expr", default=None, help="Filter --json output through a jq expression.")
180
+ @click.pass_context
181
+ def cache_usage(ctx, repo_id, org_id, json_fields, jq_expr):
182
+ """Show cache usage summary for a repository.
183
+
184
+ \b
185
+ Examples:
186
+ avr cache usage --repo rep-abc123
187
+ avr cache usage --repo rep-abc123 --json '*'
188
+
189
+ \b
190
+ JSON FIELDS
191
+ by_type, over_quota, quota_bytes, total_size_bytes
192
+ """
193
+ if handle_json_meta(json_fields, jq_expr, _CACHE_USAGE_FIELDS):
194
+ return
195
+
196
+ client: ApiClient = ctx.obj["client"]
197
+ config: CliConfig = ctx.obj["config"]
198
+ ensure_authenticated(config)
199
+
200
+ org_id = get_org_id(config, org_id, client=client)
201
+ repo_id = resolve_repo_or_detect(client, config, org_id, repo_id, required=True)
202
+
203
+ try:
204
+ response = client.public_get(f"/orgs/{org_id}/repos/{repo_id}/cache")
205
+ except httpx.HTTPStatusError as exc:
206
+ handle_http_error(exc, "get cache usage")
207
+
208
+ if json_fields is not None:
209
+ emit_json_record(response, split_fields(json_fields, _CACHE_USAGE_FIELDS), _CACHE_USAGE_FIELDS, jq_expr)
210
+ return
211
+
212
+ data = response["data"]
213
+ total = data.get("total_size_bytes", 0)
214
+ quota = data.get("quota_bytes", 0)
215
+ pct = (total / quota * 100) if quota > 0 else 0
216
+
217
+ click.echo(
218
+ format_key_value(
219
+ {
220
+ "Total Size": format_size(total),
221
+ "Quota": format_size(quota),
222
+ "Usage": f"{pct:.1f}%",
223
+ "Over Quota": "yes" if data.get("over_quota") else "no",
224
+ }
225
+ )
226
+ )
227
+
228
+ by_type = data.get("by_type", [])
229
+ if by_type:
230
+ click.echo()
231
+ for bt in by_type:
232
+ bt["size_display"] = format_size(bt.get("size_bytes", 0))
233
+
234
+ output_list(
235
+ by_type,
236
+ columns=["cache_type", "size_display", "entry_count"],
237
+ column_labels=["Type", "Size", "Count"],
238
+ )
239
+
240
+
241
+ @cache.command("delete")
242
+ @click.option("--repo", "repo_id", help="Repository (org/repo or rep-xxx). Auto-detected from git remote if omitted.")
243
+ @click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
244
+ @click.option(
245
+ "--type",
246
+ "cache_type",
247
+ type=str,
248
+ default=None,
249
+ help="Cache type (required with --key, e.g. gha, bazel, sccache).",
250
+ )
251
+ @click.option("--key", default=None, help="Delete entries matching this cache key name.")
252
+ @click.option("--ref", default=None, help="Ref to narrow deletion scope (used by gha).")
253
+ @click.option("--all", "delete_all", is_flag=True, help="Delete ALL cache entries for the repository.")
254
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
255
+ @click.pass_context
256
+ def cache_delete(ctx, repo_id, org_id, cache_type, key, ref, delete_all, yes):
257
+ """Delete cache entries by key name or all entries.
258
+
259
+ Exactly one of --key or --all must be provided.
260
+ When using --key, --type is required to scope the deletion.
261
+
262
+ \b
263
+ Examples:
264
+ avr cache delete --repo rep-abc123 --type gha --key "node_modules" --ref refs/heads/main --yes
265
+ avr cache delete --repo rep-abc123 --all --yes
266
+ """
267
+ client: ApiClient = ctx.obj["client"]
268
+ config: CliConfig = ctx.obj["config"]
269
+ ensure_authenticated(config)
270
+
271
+ provided = sum(bool(x) for x in (key, delete_all))
272
+ if provided != 1:
273
+ click.echo("Error: Provide exactly one of --key or --all.", err=True)
274
+ raise click.Abort()
275
+
276
+ if key and not cache_type:
277
+ click.echo("Error: --type is required when using --key.", err=True)
278
+ raise click.Abort()
279
+
280
+ org_id = get_org_id(config, org_id, client=client)
281
+ repo_id, repo_label = resolve_repo_named(client, config, org_id, repo_id)
282
+
283
+ if key:
284
+ if not yes:
285
+ ensure_prompts_allowed("cache delete needs confirmation")
286
+ ref_scope = f"ref '{ref}'" if ref else "across all refs"
287
+ click.confirm(
288
+ f"Delete entries in {repo_label} matching type '{cache_type}', key '{key}' ({ref_scope})?",
289
+ abort=True,
290
+ )
291
+ try:
292
+ params: dict[str, str] = {"cache_type": cache_type, "key": key}
293
+ if ref:
294
+ params["ref"] = ref
295
+ response = client.public_delete(
296
+ f"/orgs/{org_id}/repos/{repo_id}/cache/entries",
297
+ params=params,
298
+ )
299
+ count = response.get("deleted_count", 0) if response else 0
300
+ click.echo(f"Deleted {count} cache {'entry' if count == 1 else 'entries'}.")
301
+ except httpx.HTTPStatusError as exc:
302
+ handle_http_error(exc, "delete cache entries")
303
+ else:
304
+ if not yes:
305
+ ensure_prompts_allowed("cache purge needs confirmation")
306
+ click.confirm(f"Delete ALL cache entries for {repo_label}?", abort=True)
307
+ try:
308
+ response = client.public_delete(f"/orgs/{org_id}/repos/{repo_id}/cache")
309
+ count = response.get("deleted_count", 0) if response else 0
310
+ click.echo(f"Purged {count} cache {'entry' if count == 1 else 'entries'} from {repo_label}.")
311
+ except httpx.HTTPStatusError as exc:
312
+ handle_http_error(exc, "purge cache")
@@ -0,0 +1,180 @@
1
+ """CLI configuration commands (avr config)."""
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 get_org_slug
10
+ from avrea_cli.helpers import handle_http_error
11
+ from avrea_cli.repo_context import detect_repo_from_git
12
+ from urllib.parse import urlparse
13
+ import click
14
+ import httpx
15
+ import os
16
+
17
+
18
+ def _fetch_me(client: ApiClient) -> dict | None:
19
+ """Best-effort fetch of /users/me; returns None on any error."""
20
+ try:
21
+ response = client.public_get("/users/me")
22
+ return response.get("data") or response
23
+ except httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException:
24
+ return None
25
+
26
+
27
+ def _src(label: str) -> str:
28
+ return click.style(f"(from {label})", dim=True)
29
+
30
+
31
+ def _print_config_status(ctx) -> None:
32
+ cfg: CliConfig = ctx.obj["config"]
33
+ client: ApiClient = ctx.obj["client"]
34
+
35
+ # Header: API host with source annotation
36
+ host = urlparse(cfg.public_api_url).hostname or cfg.public_api_url
37
+ if os.getenv("AVR_HOST"):
38
+ host_src = _src("AVR_HOST")
39
+ elif auth.load_default_host():
40
+ host_src = _src("hosts.json")
41
+ else:
42
+ host_src = _src("default")
43
+ click.echo(f"{click.style(host, bold=True)} {host_src}")
44
+
45
+ # Auth status line
46
+ if cfg.auth_token:
47
+ me = _fetch_me(client)
48
+ token_src = _src("AVR_TOKEN" if os.getenv("AVR_TOKEN") else "hosts.json")
49
+ if me and me.get("email"):
50
+ click.echo(f" {click.style('✓', fg='green')} authenticated as {me['email']} {token_src}")
51
+ elif me:
52
+ click.echo(f" {click.style('✓', fg='green')} authenticated {token_src}")
53
+ else:
54
+ click.echo(f" {click.style('!', fg='yellow')} token set but /users/me failed {token_src}")
55
+ else:
56
+ click.echo(f" {click.style('✗', fg='red')} not authenticated (run: avr auth login)")
57
+
58
+ # Active org — show slug if we can resolve it
59
+ if cfg.default_org:
60
+ if cfg.auth_token:
61
+ slug = get_org_slug(client, cfg.default_org)
62
+ if slug != cfg.default_org:
63
+ active_org = f"{click.style(slug, bold=True)} ({cfg.default_org})"
64
+ else:
65
+ active_org = click.style(cfg.default_org, bold=True)
66
+ else:
67
+ active_org = click.style(cfg.default_org, bold=True)
68
+ org_src = _src("AVR_ORG" if os.getenv("AVR_ORG") else "hosts.json")
69
+ active_org = f"{active_org} {org_src}"
70
+ else:
71
+ active_org = click.style("(not set)", dim=True)
72
+
73
+ # Default repo — explicit override or auto-detected from git remote
74
+ if cfg.repo_override:
75
+ default_repo = f"{click.style(cfg.repo_override, bold=True)} {_src('AVR_REPO')}"
76
+ else:
77
+ detected = detect_repo_from_git()
78
+ if detected:
79
+ default_repo = f"{click.style(detected, bold=True)} {_src('git remote')}"
80
+ else:
81
+ default_repo = click.style("(no git remote; pass --repo explicitly)", dim=True)
82
+
83
+ click.echo(f" - Active org: {active_org}")
84
+ click.echo(f" - Default repo: {default_repo}")
85
+
86
+
87
+ @click.group(cls=GhGroup, invoke_without_command=True)
88
+ @click.pass_context
89
+ def config(ctx):
90
+ """View and manage CLI configuration."""
91
+ ensure_ctx(ctx)
92
+ if ctx.invoked_subcommand is None:
93
+ _print_config_status(ctx)
94
+
95
+
96
+ @config.command("set")
97
+ @click.argument("key", type=click.Choice(["org"]))
98
+ @click.argument("value")
99
+ @click.pass_context
100
+ def config_set(ctx, key: str, value: str):
101
+ """Set a CLI configuration value.
102
+
103
+ \b
104
+ Available keys:
105
+ org Active organization ID
106
+
107
+ \b
108
+ Examples:
109
+ avr config set org org-abc123
110
+ """
111
+ client: ApiClient = ctx.obj["client"]
112
+ cfg: CliConfig = ctx.obj["config"]
113
+ ensure_authenticated(cfg)
114
+
115
+ if key == "org":
116
+ # Verify the org exists and user has access
117
+ try:
118
+ response = client.public_get("/users/me/organizations")
119
+ organizations = response["data"]
120
+ except httpx.HTTPStatusError as exc:
121
+ handle_http_error(exc, "fetch organizations")
122
+
123
+ org_ids = [o["organization_id"] for o in organizations]
124
+ if value not in org_ids:
125
+ click.echo(f"Error: Organization '{value}' not found or you don't have access.", err=True)
126
+ click.echo("Available organizations:", err=True)
127
+ for o in organizations:
128
+ click.echo(f" {o['organization_id']} ({o['name']})", err=True)
129
+ raise click.Abort()
130
+
131
+ auth.store_default_org(value, host=cfg.public_api_url)
132
+ org_name = next((o["name"] for o in organizations if o["organization_id"] == value), value)
133
+ click.echo(f"Default organization set to: {org_name} ({value})")
134
+
135
+
136
+ @config.command("get")
137
+ @click.argument("key", type=click.Choice(["org"]))
138
+ @click.pass_context
139
+ def config_get(ctx, key: str):
140
+ """Print the value of a configuration key.
141
+
142
+ \b
143
+ Available keys:
144
+ org Active organization ID
145
+ """
146
+ cfg: CliConfig = ctx.obj["config"]
147
+ values = {"org": cfg.default_org or ""}
148
+ click.echo(values[key])
149
+
150
+
151
+ @config.command("list")
152
+ @click.pass_context
153
+ def config_list(ctx):
154
+ """Show the active CLI configuration (host, auth, org, default repo)."""
155
+ _print_config_status(ctx)
156
+
157
+
158
+ @config.command("unset")
159
+ @click.argument("key", type=click.Choice(["org"]))
160
+ @click.pass_context
161
+ def config_unset(ctx, key: str):
162
+ """Remove a configuration override.
163
+
164
+ \b
165
+ Available keys:
166
+ org Drop the stored default organization for the active host
167
+
168
+ \b
169
+ Examples:
170
+ avr config unset org
171
+ """
172
+ cfg: CliConfig = ctx.obj["config"]
173
+ if key == "org":
174
+ # Match the brevity of `config set`: the host is implied by AVR_HOST
175
+ # / the active default. Showing it would be redundant in the common
176
+ # single-host install.
177
+ if auth.clear_default_org(host=cfg.public_api_url):
178
+ click.echo("Cleared default organization.")
179
+ else:
180
+ click.echo("No default organization was set.")
@@ -0,0 +1,76 @@
1
+ """Health check command."""
2
+
3
+ from avrea_cli.api_client import ApiClient
4
+ from avrea_cli.config import CliConfig
5
+ from avrea_cli.json_output import emit_json_record
6
+ from avrea_cli.json_output import handle_json_meta
7
+ from avrea_cli.json_output import json_options
8
+ from avrea_cli.json_output import make_schema
9
+ from avrea_cli.json_output import split_fields
10
+ from urllib.parse import urlparse
11
+ import click
12
+ import httpx
13
+
14
+ # Single-key schema for now; add explicit entries as the endpoint grows new
15
+ # keys (build hash, etc.) so `--json '*'` stays in sync with the typed surface.
16
+ _HEALTH_FIELDS = make_schema("status")
17
+
18
+
19
+ @click.command("health")
20
+ @json_options
21
+ @click.pass_context
22
+ def health(ctx, json_fields, jq_expr):
23
+ """Check Avrea platform status.
24
+
25
+ \b
26
+ Examples:
27
+ avr health
28
+ avr health --json status
29
+ avr health --json '*' -q '.status'
30
+ """
31
+ if handle_json_meta(json_fields, jq_expr, _HEALTH_FIELDS):
32
+ return
33
+
34
+ client: ApiClient = ctx.obj["client"]
35
+ config: CliConfig = ctx.obj["config"]
36
+ host = urlparse(config.public_api_url).hostname or config.public_api_url
37
+ is_json = json_fields is not None
38
+
39
+ if not is_json:
40
+ click.echo("Checking API health...")
41
+
42
+ try:
43
+ result = client.public_get("/health")
44
+ except (httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException) as exc:
45
+ reason = _humanize_health_failure(exc, host)
46
+ if is_json:
47
+ # Same JSON shape as success — schema-projected, no extra keys.
48
+ # Humans get the friendly reason on stderr; consumers get the
49
+ # stable {"status": "unreachable"} record.
50
+ click.echo(f"API unreachable: {reason}", err=True)
51
+ emit_json_record(
52
+ {"status": "unreachable"},
53
+ split_fields(json_fields, _HEALTH_FIELDS),
54
+ _HEALTH_FIELDS,
55
+ jq_expr,
56
+ )
57
+ else:
58
+ click.echo(f"✗ API: {reason}", err=True)
59
+ raise click.Abort() from None
60
+
61
+ if is_json:
62
+ emit_json_record(result, split_fields(json_fields, _HEALTH_FIELDS), _HEALTH_FIELDS, jq_expr)
63
+ return
64
+
65
+ status = result.get("status", "unknown")
66
+ click.echo(f"✓ API: {status}")
67
+
68
+
69
+ def _humanize_health_failure(exc: Exception, host: str) -> str:
70
+ if isinstance(exc, httpx.ConnectError):
71
+ return f"couldn't reach {host} — check your network connection."
72
+ if isinstance(exc, httpx.TimeoutException):
73
+ return f"{host} took too long to respond."
74
+ if isinstance(exc, httpx.HTTPStatusError):
75
+ return f"HTTP {exc.response.status_code} from {host}."
76
+ return f"{host} unreachable."
@@ -0,0 +1,18 @@
1
+ """Hidden ``avr internal`` command group.
2
+
3
+ Houses maintenance commands that ship with the CLI but aren't part of the
4
+ public surface — listed here so the published ``avr`` binary can dogfood
5
+ its own docs generator (``avr internal docs``) without polluting the
6
+ top-level help text.
7
+ """
8
+
9
+ from avrea_cli.docs_gen import docs_command
10
+ import click
11
+
12
+
13
+ @click.group("internal", hidden=True)
14
+ def internal() -> None:
15
+ """Maintenance commands (hidden)."""
16
+
17
+
18
+ internal.add_command(docs_command)