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
@@ -0,0 +1,170 @@
1
+ """Static marketplace catalogs mirrored from the Caraer backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Subcategory:
10
+ key: str
11
+ label: str
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MainCategory:
16
+ key: str
17
+ label: str
18
+ subcategories: tuple[Subcategory, ...]
19
+
20
+
21
+ def _slugify(label: str) -> str:
22
+ normalized = label.strip().lower().replace("&", "and")
23
+ chars: list[str] = []
24
+ prev_underscore = False
25
+ for ch in normalized:
26
+ if ch.isalnum():
27
+ chars.append(ch)
28
+ prev_underscore = False
29
+ elif not prev_underscore:
30
+ chars.append("_")
31
+ prev_underscore = True
32
+ return "".join(chars).strip("_")
33
+
34
+
35
+ def _category(label: str, sub_labels: list[str]) -> MainCategory:
36
+ return MainCategory(
37
+ key=_slugify(label),
38
+ label=label,
39
+ subcategories=tuple(Subcategory(key=_slugify(s), label=s) for s in sub_labels),
40
+ )
41
+
42
+
43
+ MAIN_CATEGORIES: tuple[MainCategory, ...] = (
44
+ _category(
45
+ "Productivity",
46
+ ["Agenda", "Tasks", "Notes", "Documents", "Files", "Automation", "Forms"],
47
+ ),
48
+ _category(
49
+ "Communication",
50
+ [
51
+ "Email",
52
+ "Chat",
53
+ "Meetings",
54
+ "Calling",
55
+ "Notifications",
56
+ "Customer Conversations",
57
+ "Social Messaging",
58
+ ],
59
+ ),
60
+ _category(
61
+ "Recruitment",
62
+ [
63
+ "ATS",
64
+ "Job Posting",
65
+ "Sourcing",
66
+ "Candidate Communication",
67
+ "Career Sites",
68
+ "Screening",
69
+ "Interview Scheduling",
70
+ "Analytics",
71
+ ],
72
+ ),
73
+ _category(
74
+ "HR",
75
+ ["HRIS", "Onboarding", "Payroll", "Contracts", "Learning", "Engagement", "Compliance"],
76
+ ),
77
+ _category(
78
+ "Marketing",
79
+ [
80
+ "Email Marketing",
81
+ "Social Media",
82
+ "Campaigns",
83
+ "Content",
84
+ "SEO",
85
+ "Advertising",
86
+ "Automation",
87
+ "Branding",
88
+ ],
89
+ ),
90
+ _category(
91
+ "Sales",
92
+ [
93
+ "CRM",
94
+ "Lead Generation",
95
+ "Enrichment",
96
+ "Outreach",
97
+ "Proposals",
98
+ "Account Management",
99
+ "Analytics",
100
+ ],
101
+ ),
102
+ _category(
103
+ "Data",
104
+ [
105
+ "Dashboards",
106
+ "Reporting",
107
+ "Spreadsheets",
108
+ "Visualization",
109
+ "Data Management",
110
+ "Predictions",
111
+ ],
112
+ ),
113
+ _category(
114
+ "Finance",
115
+ ["Invoicing", "Payments", "Bookkeeping", "Expenses", "Budgeting", "Reporting"],
116
+ ),
117
+ _category(
118
+ "Customer Support",
119
+ ["Helpdesk", "Ticketing", "Live Chat", "Knowledge Base", "Feedback", "Analytics"],
120
+ ),
121
+ _category(
122
+ "Developer Tools",
123
+ [
124
+ "Code",
125
+ "Repositories",
126
+ "APIs",
127
+ "Webhooks",
128
+ "DevOps",
129
+ "Monitoring",
130
+ "Testing",
131
+ "No-code",
132
+ ],
133
+ ),
134
+ _category(
135
+ "Education & Learning",
136
+ [
137
+ "Courses",
138
+ "Training",
139
+ "Onboarding",
140
+ "Coaching",
141
+ "Assessments",
142
+ "Knowledge Sharing",
143
+ ],
144
+ ),
145
+ )
146
+
147
+ APP_BAR_LOCATIONS: tuple[tuple[str, str], ...] = (
148
+ ("RECORD_PREVIEW", "Record preview (action)"),
149
+ ("RECORD_OVERVIEW", "Record overview (action)"),
150
+ ("RECORD_DETAIL", "Record detail (iframe)"),
151
+ ("TOOL_BAR", "Tool bar (iframe)"),
152
+ ("TRAIT_BAR", "Trait bar (iframe)"),
153
+ )
154
+
155
+ ACTION_BASED_LOCATIONS = {"RECORD_PREVIEW", "RECORD_OVERVIEW"}
156
+
157
+ SETTING_FIELD_TYPES: tuple[tuple[str, str], ...] = (
158
+ ("SINGLE_LINE", "Single line text"),
159
+ ("MULTI_LINE", "Multi-line text"),
160
+ ("SWITCH", "Switch / boolean"),
161
+ ("SINGLE_SELECT", "Single select"),
162
+ ("MULTI_SELECT", "Multi select"),
163
+ )
164
+
165
+
166
+ def find_main_category(key: str) -> MainCategory | None:
167
+ for category in MAIN_CATEGORIES:
168
+ if category.key == key:
169
+ return category
170
+ return None
@@ -0,0 +1,350 @@
1
+ """Interactive prompts for marketplace fields (settings, pricing, app bars, lifecycle)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from questionary import Choice
8
+ from rich.console import Console
9
+
10
+ from caraer_cli.project.lifecycle_sync import LIFECYCLE_HOOKS
11
+ from caraer_cli.wizard.catalog import (
12
+ ACTION_BASED_LOCATIONS,
13
+ APP_BAR_LOCATIONS,
14
+ SETTING_FIELD_TYPES,
15
+ )
16
+ from caraer_cli.wizard.prompts import ask_confirm, ask_select, ask_text
17
+
18
+ console = Console()
19
+
20
+
21
+ def _optional(value: str | None) -> str | None:
22
+ text = (value or "").strip()
23
+ return text or None
24
+
25
+
26
+ def _coerce_number(raw: str) -> int | float | str:
27
+ text = raw.strip()
28
+ try:
29
+ if "." in text:
30
+ return float(text)
31
+ return int(text)
32
+ except ValueError:
33
+ return text
34
+
35
+
36
+ def prompt_setting_field(
37
+ *,
38
+ name: str | None = None,
39
+ label: str | None = None,
40
+ field_type: str | None = None,
41
+ required: bool | None = None,
42
+ help_text: str | None = None,
43
+ default_value: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ """Prompt for one settingsSchema field (wizard-style)."""
46
+ resolved_name = (name or "").strip() or ask_text("Field name", required=True)
47
+ resolved_label = (
48
+ label
49
+ if label is not None and str(label).strip()
50
+ else ask_text("Field label", default=resolved_name)
51
+ )
52
+ resolved_type = (
53
+ (field_type or "").strip().upper()
54
+ or ask_select(
55
+ "Field type",
56
+ [Choice(title=lbl, value=key) for key, lbl in SETTING_FIELD_TYPES],
57
+ default="SINGLE_LINE",
58
+ )
59
+ )
60
+ resolved_required = (
61
+ required
62
+ if required is not None
63
+ else ask_confirm("Required?", default=False)
64
+ )
65
+ resolved_help = (
66
+ help_text
67
+ if help_text is not None
68
+ else ask_text("Help text (optional)", default="")
69
+ )
70
+ resolved_default = (
71
+ default_value
72
+ if default_value is not None
73
+ else ask_text("Default value (optional)", default="")
74
+ )
75
+
76
+ field: dict[str, Any] = {
77
+ "name": resolved_name.strip(),
78
+ "label": (resolved_label or resolved_name).strip(),
79
+ "type": resolved_type,
80
+ "required": bool(resolved_required),
81
+ }
82
+ if _optional(resolved_help):
83
+ field["helpText"] = resolved_help.strip()
84
+ if _optional(resolved_default):
85
+ if resolved_type == "SWITCH":
86
+ field["defaultValue"] = resolved_default.strip().lower() in {
87
+ "1",
88
+ "true",
89
+ "yes",
90
+ "y",
91
+ }
92
+ else:
93
+ field["defaultValue"] = resolved_default.strip()
94
+
95
+ if resolved_type in {"SINGLE_SELECT", "MULTI_SELECT"}:
96
+ options_raw = ask_text(
97
+ "Options (comma-separated label=value or label)",
98
+ required=True,
99
+ )
100
+ options: list[dict[str, str]] = []
101
+ for part in options_raw.split(","):
102
+ item = part.strip()
103
+ if not item:
104
+ continue
105
+ if "=" in item:
106
+ option_label, option_value = item.split("=", 1)
107
+ else:
108
+ option_label, option_value = item, item
109
+ options.append(
110
+ {"label": option_label.strip(), "value": option_value.strip()}
111
+ )
112
+ field["options"] = options
113
+ return field
114
+
115
+
116
+ def prompt_pricing_tier() -> dict[str, Any]:
117
+ tier: dict[str, Any] = {
118
+ "startUnits": _coerce_number(
119
+ ask_text("Start units", default="0", required=True)
120
+ ),
121
+ }
122
+ end_units = ask_text("End units (blank = unlimited)", default="")
123
+ if _optional(end_units):
124
+ tier["endUnits"] = _coerce_number(end_units)
125
+ price_month = ask_text("Price per month (optional)", default="")
126
+ price_year = ask_text("Price per year (optional)", default="")
127
+ price_extra = ask_text("Price per extra unit (optional)", default="")
128
+ if _optional(price_month):
129
+ tier["pricePerMonth"] = _coerce_number(price_month)
130
+ if _optional(price_year):
131
+ tier["pricePerYear"] = _coerce_number(price_year)
132
+ if _optional(price_extra):
133
+ tier["pricePerExtraUnit"] = _coerce_number(price_extra)
134
+ return tier
135
+
136
+
137
+ def prompt_pricing_plan(
138
+ *,
139
+ title: str | None = None,
140
+ pricing_type: str | None = None,
141
+ price_per_unit: float | None = None,
142
+ unit: str | None = None,
143
+ description: str | None = None,
144
+ ) -> dict[str, Any]:
145
+ """Prompt for one pricing plan (wizard-style)."""
146
+ resolved_type = (
147
+ (pricing_type or "").strip().upper()
148
+ or ask_select(
149
+ "Pricing type",
150
+ [
151
+ Choice(title="FLAT — fixed price per unit", value="FLAT"),
152
+ Choice(title="TIERED — volume tiers", value="TIERED"),
153
+ ],
154
+ default="FLAT",
155
+ )
156
+ )
157
+ resolved_title = (title or "").strip() or ask_text("Plan title", required=True)
158
+ plan: dict[str, Any] = {
159
+ "title": resolved_title,
160
+ "pricingType": resolved_type,
161
+ }
162
+ resolved_description = (
163
+ description
164
+ if description is not None
165
+ else ask_text("Plan description (optional)", default="")
166
+ )
167
+ if _optional(resolved_description):
168
+ plan["description"] = resolved_description.strip()
169
+
170
+ if resolved_type == "FLAT":
171
+ if price_per_unit is not None and title is not None and unit is not None:
172
+ # Non-interactive-ish path with CLI flags.
173
+ plan["pricePerUnit"] = price_per_unit
174
+ plan["unit"] = (unit or "installations").strip() or "installations"
175
+ else:
176
+ price = ask_text(
177
+ "Price per unit (e.g. 0 or 10.00)",
178
+ default="" if price_per_unit is None else str(price_per_unit),
179
+ )
180
+ resolved_unit = ask_text(
181
+ "Unit (e.g. installations, seats)",
182
+ default=(unit or "installations"),
183
+ )
184
+ free_units = ask_text("Free units (optional)", default="")
185
+ free_period = ask_text("Free units period (optional)", default="")
186
+ if _optional(price):
187
+ plan["pricePerUnit"] = _coerce_number(price)
188
+ elif price_per_unit is not None:
189
+ plan["pricePerUnit"] = price_per_unit
190
+ else:
191
+ plan["pricePerUnit"] = 0
192
+ plan["unit"] = (resolved_unit or "installations").strip()
193
+ if _optional(free_units):
194
+ plan["freeUnits"] = _coerce_number(free_units)
195
+ if _optional(free_period):
196
+ plan["freeUnitsPeriod"] = free_period.strip()
197
+ else:
198
+ tiers: list[dict[str, Any]] = []
199
+ console.print("[dim]Add at least one tier.[/dim]")
200
+ while True:
201
+ tiers.append(prompt_pricing_tier())
202
+ if not ask_confirm("Add another tier?", default=False):
203
+ break
204
+ plan["tiers"] = tiers
205
+ return plan
206
+
207
+
208
+ def prompt_app_bar(
209
+ *,
210
+ label: str | None = None,
211
+ location: str | None = None,
212
+ function_name: str | None = None,
213
+ iframe_url: str | None = None,
214
+ function_choices: list[str] | None = None,
215
+ ) -> dict[str, Any]:
216
+ """Prompt for one app bar (wizard-style)."""
217
+ resolved_location = (
218
+ (location or "").strip().upper()
219
+ or ask_select(
220
+ "Location",
221
+ [Choice(title=lbl, value=key) for key, lbl in APP_BAR_LOCATIONS],
222
+ default="RECORD_PREVIEW",
223
+ )
224
+ )
225
+ resolved_label = (label or "").strip() or ask_text("Label", required=True)
226
+ bar: dict[str, Any] = {
227
+ "location": resolved_location,
228
+ "label": resolved_label,
229
+ }
230
+ tooltip = ask_text("Tooltip label (optional)", default="")
231
+ if _optional(tooltip):
232
+ bar["tooltipLabel"] = tooltip.strip()
233
+
234
+ if resolved_location in ACTION_BASED_LOCATIONS:
235
+ description = ask_text("Description (optional)", default="")
236
+ action_label = ask_text(
237
+ "Action button label (optional)", default=resolved_label
238
+ )
239
+ if _optional(description):
240
+ bar["description"] = description.strip()
241
+ if _optional(action_label):
242
+ bar["actionLabel"] = action_label.strip()
243
+
244
+ delivery = ask_select(
245
+ "Action delivery",
246
+ [
247
+ Choice(title="SERVERLESS — local function", value="SERVERLESS"),
248
+ Choice(title="HTTP — external URL", value="HTTP"),
249
+ ],
250
+ default="SERVERLESS",
251
+ )
252
+ if delivery == "HTTP":
253
+ url = ask_text("Webhook URL", required=True)
254
+ bar["webhook"] = {
255
+ "topic": "app.bar.triggered",
256
+ "deliveryMode": "HTTP",
257
+ "enabled": True,
258
+ "url": url.strip(),
259
+ }
260
+ else:
261
+ names = function_choices or []
262
+ if function_name and function_name.strip():
263
+ fn = function_name.strip()
264
+ elif names:
265
+ fn = ask_select(
266
+ "Function",
267
+ [Choice(title=n, value=n) for n in names],
268
+ default=names[0],
269
+ )
270
+ else:
271
+ fn = ask_text(
272
+ "Function name",
273
+ default="on-app-bar",
274
+ required=True,
275
+ )
276
+ bar["webhook"] = {
277
+ "topic": "app.bar.triggered",
278
+ "deliveryMode": "SERVERLESS",
279
+ "enabled": True,
280
+ "serverlessFunction": {"name": fn},
281
+ }
282
+ else:
283
+ url = (iframe_url or "").strip() or ask_text("Iframe URL", required=True)
284
+ icon = ask_text("Icon (optional)", default="")
285
+ bar["iframeUrl"] = url
286
+ if _optional(icon):
287
+ bar["icon"] = icon.strip()
288
+ return bar
289
+
290
+
291
+ def prompt_lifecycle_hook(
292
+ *,
293
+ event: str | None = None,
294
+ function_name: str | None = None,
295
+ create_function: bool | None = None,
296
+ ) -> dict[str, Any]:
297
+ """Prompt for lifecycle hook options. Returns keys used by the scaffold."""
298
+ events = sorted(LIFECYCLE_HOOKS.keys())
299
+ resolved_event = (
300
+ (event or "").strip().lower()
301
+ or ask_select(
302
+ "Lifecycle event",
303
+ [
304
+ Choice(title=f"{stem} ({LIFECYCLE_HOOKS[stem][1]})", value=stem)
305
+ for stem in events
306
+ ],
307
+ default="install",
308
+ )
309
+ )
310
+ if resolved_event not in LIFECYCLE_HOOKS:
311
+ raise ValueError(
312
+ f"Unknown lifecycle event '{resolved_event}'. "
313
+ f"Expected one of: {', '.join(events)}."
314
+ )
315
+ _manifest_key, topic = LIFECYCLE_HOOKS[resolved_event]
316
+ delivery = ask_select(
317
+ "Delivery mode",
318
+ [
319
+ Choice(title="SERVERLESS — local function", value="SERVERLESS"),
320
+ Choice(title="HTTP — external URL", value="HTTP"),
321
+ ],
322
+ default="SERVERLESS",
323
+ )
324
+ enabled = ask_confirm("Enabled?", default=True)
325
+ result: dict[str, Any] = {
326
+ "event": resolved_event,
327
+ "topic": topic,
328
+ "deliveryMode": delivery,
329
+ "enabled": enabled,
330
+ "create_function": False,
331
+ "function_name": None,
332
+ "url": None,
333
+ }
334
+ if delivery == "HTTP":
335
+ result["url"] = ask_text("Webhook URL", required=True).strip()
336
+ else:
337
+ default_fn = f"on-{resolved_event}"
338
+ result["function_name"] = (
339
+ (function_name or "").strip()
340
+ or ask_text("Function name", default=default_fn, required=True)
341
+ )
342
+ result["create_function"] = (
343
+ create_function
344
+ if create_function is not None
345
+ else ask_confirm(
346
+ f"Scaffold function folder functions/{result['function_name']}/?",
347
+ default=True,
348
+ )
349
+ )
350
+ return result
@@ -0,0 +1,99 @@
1
+ """Thin wrappers around questionary with cancel handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, Sequence
7
+
8
+ import questionary
9
+ import typer
10
+ from questionary import Choice, Style
11
+
12
+ PROMPT_STYLE = Style(
13
+ [
14
+ ("qmark", "fg:#E74363 bold"),
15
+ ("question", "bold"),
16
+ ("answer", "fg:#E74363"),
17
+ ("pointer", "fg:#E74363 bold"),
18
+ ("highlighted", "fg:#E74363 bold"),
19
+ ("selected", "fg:#66bb6a"),
20
+ ]
21
+ )
22
+
23
+
24
+ class WizardCancelled(typer.Exit):
25
+ """Raised when the user cancels an interactive prompt."""
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__(code=1)
29
+
30
+
31
+ def _or_cancel(value: Any) -> Any:
32
+ if value is None:
33
+ raise WizardCancelled()
34
+ return value
35
+
36
+
37
+ def ask_text(message: str, *, default: str = "", required: bool = False) -> str:
38
+ while True:
39
+ value = _or_cancel(
40
+ questionary.text(message, default=default, style=PROMPT_STYLE).ask()
41
+ )
42
+ text = str(value).strip()
43
+ if text or not required:
44
+ return text
45
+ print("This field is required.")
46
+
47
+
48
+ def require_text(
49
+ value: str | None,
50
+ message: str,
51
+ *,
52
+ default: str = "",
53
+ flag: str | None = None,
54
+ ) -> str:
55
+ """Return a stripped value, or interactively ask when missing.
56
+
57
+ In non-interactive environments (no TTY), raises ``ValueError`` instead of
58
+ prompting so CI/scripts still fail clearly.
59
+ """
60
+ if value is not None and str(value).strip():
61
+ return str(value).strip()
62
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
63
+ hint = flag or message
64
+ raise ValueError(f"Missing required value: {hint}")
65
+ return ask_text(message, default=default, required=True)
66
+
67
+
68
+ def ask_confirm(message: str, *, default: bool = False) -> bool:
69
+ return bool(
70
+ _or_cancel(questionary.confirm(message, default=default, style=PROMPT_STYLE).ask())
71
+ )
72
+
73
+
74
+ def ask_select(
75
+ message: str,
76
+ choices: Sequence[str | Choice],
77
+ *,
78
+ default: str | None = None,
79
+ ) -> str:
80
+ kwargs: dict[str, Any] = {"style": PROMPT_STYLE}
81
+ if default is not None:
82
+ kwargs["default"] = default
83
+ return str(_or_cancel(questionary.select(message, choices=list(choices), **kwargs).ask()))
84
+
85
+
86
+ def ask_checkbox(
87
+ message: str,
88
+ choices: Sequence[str | Choice],
89
+ *,
90
+ min_selected: int = 0,
91
+ ) -> list[str]:
92
+ while True:
93
+ selected = _or_cancel(
94
+ questionary.checkbox(message, choices=list(choices), style=PROMPT_STYLE).ask()
95
+ )
96
+ values = [str(item) for item in selected]
97
+ if len(values) >= min_selected:
98
+ return values
99
+ print(f"Select at least {min_selected}.")