caraer-cli 0.1.2__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.
- caraer_cli/__init__.py +4 -0
- caraer_cli/api/__init__.py +1 -0
- caraer_cli/api/apps.py +78 -0
- caraer_cli/api/auth.py +53 -0
- caraer_cli/api/client.py +179 -0
- caraer_cli/api/functions.py +72 -0
- caraer_cli/api/installation.py +93 -0
- caraer_cli/api/integration_runtime.py +77 -0
- caraer_cli/api/projects.py +213 -0
- caraer_cli/api/sandboxes.py +29 -0
- caraer_cli/api/webhooks.py +59 -0
- caraer_cli/app_sync.py +697 -0
- caraer_cli/apps_local.py +274 -0
- caraer_cli/commands/__init__.py +1 -0
- caraer_cli/commands/apps.py +2025 -0
- caraer_cli/commands/auth.py +173 -0
- caraer_cli/commands/company.py +68 -0
- caraer_cli/commands/installation.py +182 -0
- caraer_cli/commands/profile.py +239 -0
- caraer_cli/commands/publish.py +40 -0
- caraer_cli/commands/sandbox.py +92 -0
- caraer_cli/commands/skill.py +165 -0
- caraer_cli/commands/webhooks.py +68 -0
- caraer_cli/completion.py +77 -0
- caraer_cli/completion_callbacks.py +162 -0
- caraer_cli/context.py +41 -0
- caraer_cli/errors.py +84 -0
- caraer_cli/formatters/__init__.py +1 -0
- caraer_cli/formatters/output.py +381 -0
- caraer_cli/formatters/timestamps.py +98 -0
- caraer_cli/install_completion.py +38 -0
- caraer_cli/local_app.py +135 -0
- caraer_cli/main.py +104 -0
- caraer_cli/models/__init__.py +1 -0
- caraer_cli/models/api.py +52 -0
- caraer_cli/project/__init__.py +17 -0
- caraer_cli/project/app_bars_sync.py +154 -0
- caraer_cli/project/app_manifest_template.py +68 -0
- caraer_cli/project/inbound_sync.py +192 -0
- caraer_cli/project/json_schemas.py +66 -0
- caraer_cli/project/lifecycle_sync.py +119 -0
- caraer_cli/project/local_dev.py +825 -0
- caraer_cli/project/manifest_edit.py +51 -0
- caraer_cli/project/marketplace_assemble.py +165 -0
- caraer_cli/project/oauth_providers_sync.py +180 -0
- caraer_cli/project/paths.py +102 -0
- caraer_cli/project/pricing_sync.py +81 -0
- caraer_cli/project/push_plan.py +590 -0
- caraer_cli/project/release.py +128 -0
- caraer_cli/project/scaffold.py +537 -0
- caraer_cli/project/schedules_sync.py +170 -0
- caraer_cli/project/schema.py +160 -0
- caraer_cli/project/settings_sync.py +77 -0
- caraer_cli/project/state.py +51 -0
- caraer_cli/project/sync.py +308 -0
- caraer_cli/project/typegen.py +278 -0
- caraer_cli/project/validate_app.py +1215 -0
- caraer_cli/project/webhooks_sync.py +233 -0
- caraer_cli/resolve.py +55 -0
- caraer_cli/schemas/app.caraer.schema.json +175 -0
- caraer_cli/schemas/function.caraer.schema.json +29 -0
- caraer_cli/schemas/inbound.caraer.schema.json +49 -0
- caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
- caraer_cli/schemas/schedule.caraer.schema.json +39 -0
- caraer_cli/schemas/webhook.caraer.schema.json +50 -0
- caraer_cli/skills/.gitkeep +2 -0
- caraer_cli/skills/caraer-apps/SKILL.md +172 -0
- caraer_cli/skills/caraer-apps/reference.md +163 -0
- caraer_cli/state/__init__.py +1 -0
- caraer_cli/state/config.py +148 -0
- caraer_cli/state/session.py +98 -0
- caraer_cli/utils.py +40 -0
- caraer_cli/wizard/__init__.py +9 -0
- caraer_cli/wizard/catalog.py +170 -0
- caraer_cli/wizard/marketplace.py +350 -0
- caraer_cli/wizard/prompts.py +99 -0
- caraer_cli/wizard/public_app.py +519 -0
- caraer_cli/wizard/scope_macros.py +214 -0
- caraer_cli-0.1.2.dist-info/METADATA +266 -0
- caraer_cli-0.1.2.dist-info/RECORD +83 -0
- caraer_cli-0.1.2.dist-info/WHEEL +4 -0
- caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
- caraer_cli-0.1.2.dist-info/licenses/LICENSE +8 -0
caraer_cli/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .models.api import ErrorEnvelope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CliError(Exception):
|
|
11
|
+
message: str
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
return self.message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ApiError(CliError):
|
|
19
|
+
status: int = 500
|
|
20
|
+
payload: dict[str, Any] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthError(ApiError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ValidationError(ApiError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScopeError(ApiError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RoleError(ApiError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(ApiError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BackendError(ApiError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_api_error(status: int, payload: dict[str, Any] | None) -> ApiError:
|
|
48
|
+
envelope = ErrorEnvelope.model_validate(payload or {})
|
|
49
|
+
message = envelope.message or "Request failed."
|
|
50
|
+
detail_lines: list[str] = []
|
|
51
|
+
|
|
52
|
+
if envelope.errors:
|
|
53
|
+
for item in envelope.errors:
|
|
54
|
+
fragment = item.message or "Validation error"
|
|
55
|
+
if item.field:
|
|
56
|
+
fragment = f"{item.field}: {fragment}"
|
|
57
|
+
if item.correctionSuggestion:
|
|
58
|
+
fragment = f"{fragment} ({item.correctionSuggestion})"
|
|
59
|
+
detail_lines.append(fragment)
|
|
60
|
+
|
|
61
|
+
if envelope.scopes:
|
|
62
|
+
detail_lines.append("Missing scopes: " + ", ".join(envelope.scopes))
|
|
63
|
+
if envelope.roles:
|
|
64
|
+
detail_lines.append("Missing roles: " + ", ".join(envelope.roles))
|
|
65
|
+
|
|
66
|
+
request_id = envelope.requestId or envelope.correlationId
|
|
67
|
+
if request_id:
|
|
68
|
+
detail_lines.append(f"Request ID: {request_id}")
|
|
69
|
+
|
|
70
|
+
if detail_lines:
|
|
71
|
+
message = f"{message}\n" + "\n".join(detail_lines)
|
|
72
|
+
|
|
73
|
+
kwargs = {"message": message, "status": status, "payload": payload}
|
|
74
|
+
if status in (401, 423):
|
|
75
|
+
return AuthError(**kwargs)
|
|
76
|
+
if status == 403 and envelope.scopes:
|
|
77
|
+
return ScopeError(**kwargs)
|
|
78
|
+
if status == 403 and envelope.roles:
|
|
79
|
+
return RoleError(**kwargs)
|
|
80
|
+
if status == 404:
|
|
81
|
+
return NotFoundError(**kwargs)
|
|
82
|
+
if status == 400:
|
|
83
|
+
return ValidationError(**kwargs)
|
|
84
|
+
return BackendError(**kwargs)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output formatting helpers."""
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from caraer_cli.formatters.timestamps import normalize_payload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Prefer these columns first when rendering list tables.
|
|
18
|
+
PREFERRED_COLUMNS = (
|
|
19
|
+
"version",
|
|
20
|
+
"live",
|
|
21
|
+
"name",
|
|
22
|
+
"active",
|
|
23
|
+
"uuid",
|
|
24
|
+
"label",
|
|
25
|
+
"title",
|
|
26
|
+
"status",
|
|
27
|
+
"target",
|
|
28
|
+
"releaseNotes",
|
|
29
|
+
"platformVersion",
|
|
30
|
+
"privateApp",
|
|
31
|
+
"installed",
|
|
32
|
+
"runtime",
|
|
33
|
+
"description",
|
|
34
|
+
"base_url",
|
|
35
|
+
"createdAt",
|
|
36
|
+
"updatedAt",
|
|
37
|
+
"activatedAt",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_data(data: Any, output: str = "table") -> None:
|
|
42
|
+
data = normalize_payload(data)
|
|
43
|
+
mode = (output or "table").lower()
|
|
44
|
+
if mode == "json":
|
|
45
|
+
console.print_json(json.dumps(data, default=str))
|
|
46
|
+
return
|
|
47
|
+
if mode == "yaml":
|
|
48
|
+
console.print(yaml.safe_dump(data, sort_keys=False))
|
|
49
|
+
return
|
|
50
|
+
print_table(data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_logs(
|
|
54
|
+
data: Any,
|
|
55
|
+
*,
|
|
56
|
+
seen: set[str] | None = None,
|
|
57
|
+
show_header: bool = True,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Render Cloud Logging payloads: summary table, then one line per entry.
|
|
60
|
+
|
|
61
|
+
When ``seen`` is provided (follow mode), only newly observed entries are
|
|
62
|
+
printed so polls don't reprint the same window.
|
|
63
|
+
"""
|
|
64
|
+
data = normalize_payload(data)
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
print_data(data, "table")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
entries = data.get("entries")
|
|
70
|
+
if not isinstance(entries, list):
|
|
71
|
+
entries = []
|
|
72
|
+
|
|
73
|
+
readable_entries = [
|
|
74
|
+
entry for entry in entries if isinstance(entry, dict) and _log_message(entry)
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
if show_header:
|
|
78
|
+
summary = {key: value for key, value in data.items() if key != "entries"}
|
|
79
|
+
summary["entries"] = len(readable_entries)
|
|
80
|
+
# Hide empty / error-only message noise in the summary.
|
|
81
|
+
if not summary.get("message"):
|
|
82
|
+
summary.pop("message", None)
|
|
83
|
+
_print_kv(summary)
|
|
84
|
+
if readable_entries:
|
|
85
|
+
console.print(Text("Logs", style="bold magenta"))
|
|
86
|
+
|
|
87
|
+
if not readable_entries:
|
|
88
|
+
if show_header:
|
|
89
|
+
console.print(Text("(no log entries)", style="dim"))
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
ordered = list(reversed(readable_entries))
|
|
93
|
+
printed = 0
|
|
94
|
+
for entry in ordered:
|
|
95
|
+
ts = _format_log_timestamp(entry.get("timestamp"))
|
|
96
|
+
severity = str(entry.get("severity") or "")
|
|
97
|
+
message = _log_message(entry) or ""
|
|
98
|
+
line = f"{ts} {severity:<7} {message}".rstrip()
|
|
99
|
+
key = f"{entry.get('timestamp')}|{severity}|{message}"
|
|
100
|
+
|
|
101
|
+
if seen is not None:
|
|
102
|
+
if key in seen:
|
|
103
|
+
continue
|
|
104
|
+
seen.add(key)
|
|
105
|
+
|
|
106
|
+
severity_upper = severity.upper()
|
|
107
|
+
style = None
|
|
108
|
+
if severity_upper in {"ERROR", "CRITICAL", "ALERT", "EMERGENCY"}:
|
|
109
|
+
style = "red"
|
|
110
|
+
elif severity_upper == "WARNING":
|
|
111
|
+
style = "yellow"
|
|
112
|
+
console.print(line, style=style, soft_wrap=True)
|
|
113
|
+
printed += 1
|
|
114
|
+
|
|
115
|
+
if show_header and printed == 0 and seen is not None:
|
|
116
|
+
console.print(Text("(no new log entries)", style="dim"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _log_message(entry: dict[str, Any]) -> str | None:
|
|
120
|
+
raw = entry.get("message")
|
|
121
|
+
if raw is None or raw == "":
|
|
122
|
+
raw = entry.get("textPayload")
|
|
123
|
+
if raw is None:
|
|
124
|
+
return None
|
|
125
|
+
text = str(raw).strip()
|
|
126
|
+
if not text:
|
|
127
|
+
return None
|
|
128
|
+
# Drop protobuf Any dumps that older backends still return.
|
|
129
|
+
if text.startswith("type_url:") or '\nvalue: "' in text or "\\nvalue:" in text:
|
|
130
|
+
return None
|
|
131
|
+
if len(text) > 2000:
|
|
132
|
+
return text[:1999] + "…"
|
|
133
|
+
return text
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_log_timestamp(value: Any) -> str:
|
|
137
|
+
if value is None:
|
|
138
|
+
return ""
|
|
139
|
+
text = str(value).strip()
|
|
140
|
+
if not text:
|
|
141
|
+
return ""
|
|
142
|
+
# 2026-07-21T21:16:34.931628Z → 21:16:34
|
|
143
|
+
if "T" in text:
|
|
144
|
+
try:
|
|
145
|
+
time_part = text.split("T", 1)[1]
|
|
146
|
+
return time_part[:8]
|
|
147
|
+
except Exception:
|
|
148
|
+
return text[:19]
|
|
149
|
+
return text[:19]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_success(message: str) -> None:
|
|
153
|
+
console.print(f"[green]{message}[/green]")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def print_warning(message: str) -> None:
|
|
157
|
+
console.print(f"[yellow]{message}[/yellow]")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_error(message: str) -> None:
|
|
161
|
+
console.print(f"[red]{message}[/red]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def print_table(data: Any) -> None:
|
|
165
|
+
if isinstance(data, list):
|
|
166
|
+
_print_rows(data)
|
|
167
|
+
return
|
|
168
|
+
if isinstance(data, dict):
|
|
169
|
+
_print_kv(data)
|
|
170
|
+
return
|
|
171
|
+
console.print(str(data))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def project_rows(rows: list[Any], columns: list[str]) -> list[dict[str, Any]]:
|
|
175
|
+
projected: list[dict[str, Any]] = []
|
|
176
|
+
for row in rows:
|
|
177
|
+
if not isinstance(row, dict):
|
|
178
|
+
continue
|
|
179
|
+
projected.append({column: row.get(column) for column in columns})
|
|
180
|
+
return projected
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def print_app_detail(app: dict[str, Any], *, full: bool = False) -> None:
|
|
184
|
+
"""Human-friendly app detail view (table mode)."""
|
|
185
|
+
if full:
|
|
186
|
+
print_table(normalize_payload(app))
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
details = app.get("details") if isinstance(app.get("details"), dict) else {}
|
|
190
|
+
publish = app.get("appPublish") if isinstance(app.get("appPublish"), dict) else {}
|
|
191
|
+
|
|
192
|
+
overview = normalize_payload(
|
|
193
|
+
{
|
|
194
|
+
"uuid": app.get("uuid"),
|
|
195
|
+
"name": app.get("name"),
|
|
196
|
+
"label": app.get("label"),
|
|
197
|
+
"description": app.get("description") or details.get("description"),
|
|
198
|
+
"category": app.get("category") or details.get("category"),
|
|
199
|
+
"url": app.get("url") or details.get("url"),
|
|
200
|
+
"privateApp": app.get("privateApp"),
|
|
201
|
+
"installed": app.get("installed"),
|
|
202
|
+
"authMethod": app.get("authMethod"),
|
|
203
|
+
"platformVersion": app.get("platformVersion"),
|
|
204
|
+
"runtime": app.get("runtime"),
|
|
205
|
+
"runtimeStatus": app.get("runtimeStatus"),
|
|
206
|
+
"runtimeBaseUrl": app.get("runtimeBaseUrl"),
|
|
207
|
+
"publishState": publish.get("publishState"),
|
|
208
|
+
"webhookRateLimitPerMinute": app.get("webhookRateLimitPerMinute"),
|
|
209
|
+
"createdAt": app.get("createdAt"),
|
|
210
|
+
"updatedAt": app.get("updatedAt"),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
_print_section("Overview", overview)
|
|
214
|
+
|
|
215
|
+
branding = {
|
|
216
|
+
"brandColor": details.get("brandColor"),
|
|
217
|
+
"textColor": details.get("textColor"),
|
|
218
|
+
"image": details.get("image") or app.get("image"),
|
|
219
|
+
"brandmark": app.get("brandmark"),
|
|
220
|
+
"title": details.get("title"),
|
|
221
|
+
"subcategories": details.get("subcategories"),
|
|
222
|
+
}
|
|
223
|
+
_print_section("Branding / details", branding)
|
|
224
|
+
|
|
225
|
+
resources = {
|
|
226
|
+
"appBars": _count_label(app.get("appBars"), item_label=_app_bar_label),
|
|
227
|
+
"pricingPlans": _count_label(app.get("pricingPlans"), item_label=_pricing_label),
|
|
228
|
+
"serverlessFunctions": _count_label(
|
|
229
|
+
app.get("serverlessFunctions"), item_label=_function_label
|
|
230
|
+
),
|
|
231
|
+
"requiredScopes": _count_label(app.get("requiredScopes")),
|
|
232
|
+
"settingsSchema": _count_label(app.get("settingsSchema"), item_label=_schema_label),
|
|
233
|
+
"oauthRedirectUris": _count_label(app.get("oauthRedirectUris")),
|
|
234
|
+
}
|
|
235
|
+
_print_section("Resources", resources)
|
|
236
|
+
console.print(
|
|
237
|
+
Text("Tip: use --full for the complete payload, or --output json.", style="dim")
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _print_section(title: str, values: dict[str, Any]) -> None:
|
|
242
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
243
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
244
|
+
table.add_column("Value", overflow="fold")
|
|
245
|
+
for key, value in values.items():
|
|
246
|
+
if value is None or value == "" or value == []:
|
|
247
|
+
continue
|
|
248
|
+
table.add_row(key, _format_value(value))
|
|
249
|
+
console.print(Panel(table, title=title, border_style="magenta"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _count_label(
|
|
253
|
+
value: Any,
|
|
254
|
+
*,
|
|
255
|
+
item_label: Any | None = None,
|
|
256
|
+
) -> str | None:
|
|
257
|
+
if not isinstance(value, list):
|
|
258
|
+
return None if value in (None, "") else str(value)
|
|
259
|
+
if not value:
|
|
260
|
+
return "0"
|
|
261
|
+
labels: list[str] = []
|
|
262
|
+
for item in value[:5]:
|
|
263
|
+
if item_label:
|
|
264
|
+
labels.append(item_label(item))
|
|
265
|
+
elif isinstance(item, dict):
|
|
266
|
+
labels.append(str(item.get("name") or item.get("label") or item.get("uuid") or "item"))
|
|
267
|
+
else:
|
|
268
|
+
labels.append(str(item))
|
|
269
|
+
suffix = ", ".join(labels)
|
|
270
|
+
if len(value) > 5:
|
|
271
|
+
suffix += ", …"
|
|
272
|
+
return f"{len(value)} — {suffix}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _app_bar_label(item: Any) -> str:
|
|
276
|
+
if not isinstance(item, dict):
|
|
277
|
+
return str(item)
|
|
278
|
+
return str(item.get("label") or item.get("location") or item.get("uuid") or "appBar")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _pricing_label(item: Any) -> str:
|
|
282
|
+
if not isinstance(item, dict):
|
|
283
|
+
return str(item)
|
|
284
|
+
return str(item.get("title") or item.get("name") or item.get("uuid") or "plan")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _function_label(item: Any) -> str:
|
|
288
|
+
if not isinstance(item, dict):
|
|
289
|
+
return str(item)
|
|
290
|
+
runtime = item.get("runtime") or "?"
|
|
291
|
+
name = item.get("name") or item.get("description") or item.get("uuid") or "function"
|
|
292
|
+
return f"{name} ({runtime})"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _schema_label(item: Any) -> str:
|
|
296
|
+
if not isinstance(item, dict):
|
|
297
|
+
return str(item)
|
|
298
|
+
return str(item.get("name") or item.get("label") or "field")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _format_value(value: Any, *, max_len: int = 120) -> str:
|
|
302
|
+
if value is None:
|
|
303
|
+
return ""
|
|
304
|
+
if isinstance(value, bool):
|
|
305
|
+
return "true" if value else "false"
|
|
306
|
+
if isinstance(value, list):
|
|
307
|
+
if not value:
|
|
308
|
+
return "[]"
|
|
309
|
+
if all(not isinstance(v, (dict, list)) for v in value):
|
|
310
|
+
text = ", ".join(str(v) for v in value)
|
|
311
|
+
else:
|
|
312
|
+
text = _count_label(value) or "[]"
|
|
313
|
+
return _truncate(text, max_len)
|
|
314
|
+
if isinstance(value, dict):
|
|
315
|
+
# Compact nested object: show a few scalar keys.
|
|
316
|
+
parts: list[str] = []
|
|
317
|
+
for key, nested in value.items():
|
|
318
|
+
if isinstance(nested, (dict, list)) or nested in (None, ""):
|
|
319
|
+
continue
|
|
320
|
+
parts.append(f"{key}={nested}")
|
|
321
|
+
if len(parts) >= 4:
|
|
322
|
+
break
|
|
323
|
+
text = ", ".join(parts) if parts else f"{{{len(value)} fields}}"
|
|
324
|
+
return _truncate(text, max_len)
|
|
325
|
+
return _truncate(str(value), max_len)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _truncate(text: str, max_len: int) -> str:
|
|
329
|
+
if len(text) <= max_len:
|
|
330
|
+
return text
|
|
331
|
+
return text[: max_len - 1] + "…"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _cell_value(value: Any, *, max_len: int = 80) -> str:
|
|
335
|
+
return _format_value(value, max_len=max_len)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _print_rows(rows: list[Any]) -> None:
|
|
339
|
+
if not rows:
|
|
340
|
+
console.print("No results.")
|
|
341
|
+
return
|
|
342
|
+
if not isinstance(rows[0], dict):
|
|
343
|
+
for row in rows:
|
|
344
|
+
console.print(str(row))
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
# Skip nested objects/lists and blank keys — they blow up terminal tables.
|
|
348
|
+
scalar_keys: set[str] = set()
|
|
349
|
+
for row in rows:
|
|
350
|
+
for key, value in row.items():
|
|
351
|
+
if not key or not isinstance(key, str):
|
|
352
|
+
continue
|
|
353
|
+
if isinstance(value, (dict, list)):
|
|
354
|
+
continue
|
|
355
|
+
scalar_keys.add(key)
|
|
356
|
+
|
|
357
|
+
preferred = [key for key in PREFERRED_COLUMNS if key in scalar_keys]
|
|
358
|
+
remaining = sorted(key for key in scalar_keys if key not in preferred)
|
|
359
|
+
# Keep tables readable: preferred fields + a few extras.
|
|
360
|
+
headers = preferred + remaining[: max(0, 8 - len(preferred))]
|
|
361
|
+
if not headers:
|
|
362
|
+
console.print("No tabular fields to display. Use --output json.")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
366
|
+
for header in headers:
|
|
367
|
+
table.add_column(header, overflow="fold")
|
|
368
|
+
for row in rows:
|
|
369
|
+
table.add_row(*[_cell_value(row.get(h)) for h in headers])
|
|
370
|
+
console.print(table)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _print_kv(item: dict[str, Any]) -> None:
|
|
374
|
+
table = Table(show_header=False)
|
|
375
|
+
table.add_column("Field", style="cyan", no_wrap=True)
|
|
376
|
+
table.add_column("Value", overflow="fold")
|
|
377
|
+
for key, value in item.items():
|
|
378
|
+
if not key:
|
|
379
|
+
continue
|
|
380
|
+
table.add_row(str(key), _format_value(value, max_len=160))
|
|
381
|
+
console.print(table)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Normalize API timestamps for CLI display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
TIMESTAMP_KEYS = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"createdAt",
|
|
11
|
+
"updatedAt",
|
|
12
|
+
"deletedAt",
|
|
13
|
+
"activatedAt",
|
|
14
|
+
"publishedAt",
|
|
15
|
+
"submittedAt",
|
|
16
|
+
"reviewedAt",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def epoch_ms_to_iso(value: Any) -> str | None:
|
|
22
|
+
"""Convert epoch milliseconds (or seconds) to an ISO-8601 UTC string."""
|
|
23
|
+
if value is None or value == "":
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
text = value.strip()
|
|
27
|
+
if not text:
|
|
28
|
+
return None
|
|
29
|
+
if text.endswith("Z") or "+" in text[10:] or text.count("-") >= 2 and "T" in text:
|
|
30
|
+
return text
|
|
31
|
+
try:
|
|
32
|
+
numeric = float(text)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return text
|
|
35
|
+
value = numeric
|
|
36
|
+
if isinstance(value, bool):
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, (int, float)):
|
|
39
|
+
seconds = float(value)
|
|
40
|
+
# Heuristic: values before year ~2001 in ms are treated as seconds.
|
|
41
|
+
if seconds > 1e12:
|
|
42
|
+
seconds = seconds / 1000.0
|
|
43
|
+
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
44
|
+
return dt.isoformat().replace("+00:00", "Z")
|
|
45
|
+
return str(value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sort_key_for_timestamp(value: Any) -> float:
|
|
49
|
+
"""Sort key: newer first when used with reverse=True."""
|
|
50
|
+
if value is None or value == "":
|
|
51
|
+
return float("-inf")
|
|
52
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
53
|
+
number = float(value)
|
|
54
|
+
return number / 1000.0 if number > 1e12 else number
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
text = value.strip()
|
|
57
|
+
try:
|
|
58
|
+
if text.endswith("Z"):
|
|
59
|
+
text = text[:-1] + "+00:00"
|
|
60
|
+
return datetime.fromisoformat(text).timestamp()
|
|
61
|
+
except ValueError:
|
|
62
|
+
try:
|
|
63
|
+
return sort_key_for_timestamp(float(text))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return float("-inf")
|
|
66
|
+
return float("-inf")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_payload(data: Any) -> Any:
|
|
70
|
+
"""Recursively convert timestamp fields to ISO and sort list rows by createdAt."""
|
|
71
|
+
if isinstance(data, list):
|
|
72
|
+
normalized = [normalize_payload(item) for item in data]
|
|
73
|
+
if normalized and all(isinstance(item, dict) for item in normalized):
|
|
74
|
+
if any("createdAt" in item for item in normalized):
|
|
75
|
+
normalized.sort(
|
|
76
|
+
key=lambda item: sort_key_for_timestamp(item.get("createdAt")),
|
|
77
|
+
reverse=True,
|
|
78
|
+
)
|
|
79
|
+
elif any("activatedAt" in item for item in normalized):
|
|
80
|
+
normalized.sort(
|
|
81
|
+
key=lambda item: sort_key_for_timestamp(item.get("activatedAt")),
|
|
82
|
+
reverse=True,
|
|
83
|
+
)
|
|
84
|
+
elif any("updatedAt" in item for item in normalized):
|
|
85
|
+
normalized.sort(
|
|
86
|
+
key=lambda item: sort_key_for_timestamp(item.get("updatedAt")),
|
|
87
|
+
reverse=True,
|
|
88
|
+
)
|
|
89
|
+
return normalized
|
|
90
|
+
if isinstance(data, dict):
|
|
91
|
+
out: dict[str, Any] = {}
|
|
92
|
+
for key, value in data.items():
|
|
93
|
+
if key in TIMESTAMP_KEYS:
|
|
94
|
+
out[key] = epoch_ms_to_iso(value)
|
|
95
|
+
else:
|
|
96
|
+
out[key] = normalize_payload(value)
|
|
97
|
+
return out
|
|
98
|
+
return data
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Install shell tab completion (used by scripts/install.sh)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from caraer_cli.completion import install_completion
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(description="Install Caraer CLI shell completion.")
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--shell",
|
|
16
|
+
"-s",
|
|
17
|
+
help="Shell to install for (zsh|bash|fish). Auto-detected when omitted.",
|
|
18
|
+
)
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
try:
|
|
21
|
+
installed_shell, path = install_completion(shell=args.shell, prog_name="caraer")
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
print(str(exc), file=sys.stderr)
|
|
24
|
+
return 2
|
|
25
|
+
|
|
26
|
+
print(f"{installed_shell} completion installed in {path}")
|
|
27
|
+
if installed_shell == "zsh":
|
|
28
|
+
fpath_hint = Path.home() / ".zfunc"
|
|
29
|
+
print(
|
|
30
|
+
"If completion still does not work, ensure your ~/.zshrc contains:\n"
|
|
31
|
+
f' fpath=("{fpath_hint}" $fpath)\n'
|
|
32
|
+
" autoload -Uz compinit && compinit"
|
|
33
|
+
)
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|