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.
Files changed (83) hide show
  1. caraer_cli/__init__.py +4 -0
  2. caraer_cli/api/__init__.py +1 -0
  3. caraer_cli/api/apps.py +78 -0
  4. caraer_cli/api/auth.py +53 -0
  5. caraer_cli/api/client.py +179 -0
  6. caraer_cli/api/functions.py +72 -0
  7. caraer_cli/api/installation.py +93 -0
  8. caraer_cli/api/integration_runtime.py +77 -0
  9. caraer_cli/api/projects.py +213 -0
  10. caraer_cli/api/sandboxes.py +29 -0
  11. caraer_cli/api/webhooks.py +59 -0
  12. caraer_cli/app_sync.py +697 -0
  13. caraer_cli/apps_local.py +274 -0
  14. caraer_cli/commands/__init__.py +1 -0
  15. caraer_cli/commands/apps.py +2025 -0
  16. caraer_cli/commands/auth.py +173 -0
  17. caraer_cli/commands/company.py +68 -0
  18. caraer_cli/commands/installation.py +182 -0
  19. caraer_cli/commands/profile.py +239 -0
  20. caraer_cli/commands/publish.py +40 -0
  21. caraer_cli/commands/sandbox.py +92 -0
  22. caraer_cli/commands/skill.py +165 -0
  23. caraer_cli/commands/webhooks.py +68 -0
  24. caraer_cli/completion.py +77 -0
  25. caraer_cli/completion_callbacks.py +162 -0
  26. caraer_cli/context.py +41 -0
  27. caraer_cli/errors.py +84 -0
  28. caraer_cli/formatters/__init__.py +1 -0
  29. caraer_cli/formatters/output.py +381 -0
  30. caraer_cli/formatters/timestamps.py +98 -0
  31. caraer_cli/install_completion.py +38 -0
  32. caraer_cli/local_app.py +135 -0
  33. caraer_cli/main.py +104 -0
  34. caraer_cli/models/__init__.py +1 -0
  35. caraer_cli/models/api.py +52 -0
  36. caraer_cli/project/__init__.py +17 -0
  37. caraer_cli/project/app_bars_sync.py +154 -0
  38. caraer_cli/project/app_manifest_template.py +68 -0
  39. caraer_cli/project/inbound_sync.py +192 -0
  40. caraer_cli/project/json_schemas.py +66 -0
  41. caraer_cli/project/lifecycle_sync.py +119 -0
  42. caraer_cli/project/local_dev.py +825 -0
  43. caraer_cli/project/manifest_edit.py +51 -0
  44. caraer_cli/project/marketplace_assemble.py +165 -0
  45. caraer_cli/project/oauth_providers_sync.py +180 -0
  46. caraer_cli/project/paths.py +102 -0
  47. caraer_cli/project/pricing_sync.py +81 -0
  48. caraer_cli/project/push_plan.py +590 -0
  49. caraer_cli/project/release.py +128 -0
  50. caraer_cli/project/scaffold.py +537 -0
  51. caraer_cli/project/schedules_sync.py +170 -0
  52. caraer_cli/project/schema.py +160 -0
  53. caraer_cli/project/settings_sync.py +77 -0
  54. caraer_cli/project/state.py +51 -0
  55. caraer_cli/project/sync.py +308 -0
  56. caraer_cli/project/typegen.py +278 -0
  57. caraer_cli/project/validate_app.py +1215 -0
  58. caraer_cli/project/webhooks_sync.py +233 -0
  59. caraer_cli/resolve.py +55 -0
  60. caraer_cli/schemas/app.caraer.schema.json +175 -0
  61. caraer_cli/schemas/function.caraer.schema.json +29 -0
  62. caraer_cli/schemas/inbound.caraer.schema.json +49 -0
  63. caraer_cli/schemas/lifecycle.caraer.schema.json +50 -0
  64. caraer_cli/schemas/schedule.caraer.schema.json +39 -0
  65. caraer_cli/schemas/webhook.caraer.schema.json +50 -0
  66. caraer_cli/skills/.gitkeep +2 -0
  67. caraer_cli/skills/caraer-apps/SKILL.md +172 -0
  68. caraer_cli/skills/caraer-apps/reference.md +163 -0
  69. caraer_cli/state/__init__.py +1 -0
  70. caraer_cli/state/config.py +148 -0
  71. caraer_cli/state/session.py +98 -0
  72. caraer_cli/utils.py +40 -0
  73. caraer_cli/wizard/__init__.py +9 -0
  74. caraer_cli/wizard/catalog.py +170 -0
  75. caraer_cli/wizard/marketplace.py +350 -0
  76. caraer_cli/wizard/prompts.py +99 -0
  77. caraer_cli/wizard/public_app.py +519 -0
  78. caraer_cli/wizard/scope_macros.py +214 -0
  79. caraer_cli-0.1.2.dist-info/METADATA +266 -0
  80. caraer_cli-0.1.2.dist-info/RECORD +83 -0
  81. caraer_cli-0.1.2.dist-info/WHEEL +4 -0
  82. caraer_cli-0.1.2.dist-info/entry_points.txt +2 -0
  83. 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())