unitysvc-services 0.1.24__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 (37) hide show
  1. unitysvc_services/__init__.py +4 -0
  2. unitysvc_services/api.py +421 -0
  3. unitysvc_services/cli.py +23 -0
  4. unitysvc_services/format_data.py +140 -0
  5. unitysvc_services/interactive_prompt.py +1132 -0
  6. unitysvc_services/list.py +216 -0
  7. unitysvc_services/models/__init__.py +71 -0
  8. unitysvc_services/models/base.py +1375 -0
  9. unitysvc_services/models/listing_data.py +118 -0
  10. unitysvc_services/models/listing_v1.py +56 -0
  11. unitysvc_services/models/provider_data.py +79 -0
  12. unitysvc_services/models/provider_v1.py +54 -0
  13. unitysvc_services/models/seller_data.py +120 -0
  14. unitysvc_services/models/seller_v1.py +42 -0
  15. unitysvc_services/models/service_data.py +114 -0
  16. unitysvc_services/models/service_v1.py +81 -0
  17. unitysvc_services/populate.py +207 -0
  18. unitysvc_services/publisher.py +1628 -0
  19. unitysvc_services/py.typed +0 -0
  20. unitysvc_services/query.py +688 -0
  21. unitysvc_services/scaffold.py +1103 -0
  22. unitysvc_services/schema/base.json +777 -0
  23. unitysvc_services/schema/listing_v1.json +1286 -0
  24. unitysvc_services/schema/provider_v1.json +952 -0
  25. unitysvc_services/schema/seller_v1.json +379 -0
  26. unitysvc_services/schema/service_v1.json +1306 -0
  27. unitysvc_services/test.py +965 -0
  28. unitysvc_services/unpublisher.py +505 -0
  29. unitysvc_services/update.py +287 -0
  30. unitysvc_services/utils.py +533 -0
  31. unitysvc_services/validator.py +731 -0
  32. unitysvc_services-0.1.24.dist-info/METADATA +184 -0
  33. unitysvc_services-0.1.24.dist-info/RECORD +37 -0
  34. unitysvc_services-0.1.24.dist-info/WHEEL +5 -0
  35. unitysvc_services-0.1.24.dist-info/entry_points.txt +3 -0
  36. unitysvc_services-0.1.24.dist-info/licenses/LICENSE +21 -0
  37. unitysvc_services-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1132 @@
1
+ """Interactive prompt system for creating data files.
2
+
3
+ This module provides a systematic way to prompt users for field values
4
+ using a field registry approach, making it easy to add/remove/modify fields.
5
+ """
6
+
7
+ import json
8
+ import tomllib
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.prompt import Confirm, IntPrompt, Prompt
18
+
19
+ console = Console()
20
+
21
+
22
+ # =============================================================================
23
+ # HELPER FUNCTIONS FOR AUTO-DISCOVERY
24
+ # =============================================================================
25
+
26
+
27
+ def find_seller_name(data_dir: Path | None = None) -> str | None:
28
+ """Find seller name from seller.json or seller.toml in data directory.
29
+
30
+ Args:
31
+ data_dir: Directory to search for seller file (defaults to search common locations)
32
+
33
+ Returns:
34
+ Seller name if found, None otherwise
35
+ """
36
+ # Search in multiple common locations
37
+ search_dirs = []
38
+
39
+ if data_dir is not None:
40
+ search_dirs.append(data_dir)
41
+ else:
42
+ # Common locations to search
43
+ cwd = Path.cwd()
44
+ search_dirs.extend(
45
+ [
46
+ cwd / "data", # ./data (most common)
47
+ cwd, # current directory
48
+ cwd.parent / "data", # ../data (if we're in a subdirectory)
49
+ cwd.parent, # parent directory
50
+ ]
51
+ )
52
+
53
+ # Look for seller file in each search directory
54
+ for search_dir in search_dirs:
55
+ for filename in ["seller.json", "seller.toml"]:
56
+ seller_file = search_dir / filename
57
+ if seller_file.exists():
58
+ try:
59
+ if filename.endswith(".json"):
60
+ with open(seller_file) as f:
61
+ data = json.load(f)
62
+ else: # .toml
63
+ with open(seller_file, "rb") as f:
64
+ data = tomllib.load(f)
65
+
66
+ seller_name = data.get("name")
67
+ if seller_name:
68
+ return seller_name
69
+ except Exception:
70
+ continue
71
+
72
+ return None
73
+
74
+
75
+ def prompt_for_pricing() -> dict[str, Any]:
76
+ """Interactively prompt for pricing information (for seller_price).
77
+
78
+ Returns:
79
+ Dictionary with pricing data
80
+ """
81
+ console.print("\n[bold cyan]Adding pricing information[/bold cyan]")
82
+
83
+ # Required field: pricing type (now inside price_data)
84
+ # Note: revenue_share is only valid for seller_price, which is the only context
85
+ # where this function is called
86
+ pricing_type = Prompt.ask(
87
+ "[bold blue]Pricing type[/bold blue] [red]*[/red]",
88
+ choices=["one_million_tokens", "one_second", "image", "step", "revenue_share"],
89
+ default="one_million_tokens",
90
+ )
91
+
92
+ # Optional fields
93
+ description = Prompt.ask("[bold blue]Description[/bold blue] [dim](optional)[/dim]", default="")
94
+ currency = Prompt.ask("[bold blue]Currency code[/bold blue] [dim](optional, e.g., 'USD')[/dim]", default="USD")
95
+ reference = Prompt.ask(
96
+ "[bold blue]Reference URL[/bold blue] [dim](optional, link to upstream pricing page)[/dim]", default=""
97
+ )
98
+
99
+ # Build price_data based on pricing type
100
+ price_data: dict[str, Any]
101
+
102
+ if pricing_type == "revenue_share":
103
+ # Revenue share pricing - just needs a percentage
104
+ console.print("\n[dim]Revenue share: seller receives a percentage of customer charge[/dim]")
105
+ percentage = Prompt.ask(
106
+ "[bold blue]Percentage[/bold blue] [dim](0-100, e.g., '70' for 70%)[/dim]",
107
+ default="70",
108
+ )
109
+ price_data = {"type": pricing_type, "percentage": percentage}
110
+ else:
111
+ # Other pricing types - ask for price structure
112
+ console.print("\n[dim]Price data structure options:[/dim]")
113
+ console.print('[dim] 1. Simple: {"type": "...", "price": "10.00"}[/dim]')
114
+ console.print('[dim] 2. Input/Output (LLMs): {"type": "...", "input": "5.00", "output": "15.00"}[/dim]')
115
+ console.print('[dim] 3. Custom: any JSON with "type" field included[/dim]')
116
+
117
+ structure = Prompt.ask(
118
+ "\n[bold blue]Price data structure[/bold blue]",
119
+ choices=["simple", "input_output", "custom"],
120
+ default="simple",
121
+ )
122
+
123
+ if structure == "simple":
124
+ amount = Prompt.ask(
125
+ "[bold blue]Price amount[/bold blue] [dim](e.g., '0.50')[/dim]",
126
+ default="0",
127
+ )
128
+ price_data = {"type": pricing_type, "price": amount}
129
+
130
+ elif structure == "input_output":
131
+ input_amount = Prompt.ask(
132
+ "[bold blue]Input price amount[/bold blue] [dim](e.g., '0.50')[/dim]",
133
+ default="0",
134
+ )
135
+ output_amount = Prompt.ask(
136
+ "[bold blue]Output price amount[/bold blue] [dim](e.g., '1.50')[/dim]",
137
+ default="0",
138
+ )
139
+ price_data = {"type": pricing_type, "input": input_amount, "output": output_amount}
140
+
141
+ else: # custom
142
+ console.print(
143
+ f'\n[dim]Enter additional price_data fields as JSON (type "{pricing_type}" will be added)[/dim]'
144
+ )
145
+ console.print('[dim]Example: {"price": "0.05"}[/dim]')
146
+ while True:
147
+ json_input = Prompt.ask("[bold blue]Additional price data JSON[/bold blue]", default="{}")
148
+ try:
149
+ custom_data = json.loads(json_input)
150
+ if not isinstance(custom_data, dict):
151
+ console.print("[red]Error: Price data must be a JSON object (dict)[/red]")
152
+ continue
153
+ price_data = {"type": pricing_type, **custom_data}
154
+ break
155
+ except json.JSONDecodeError as e:
156
+ console.print(f"[red]Invalid JSON: {e}[/red]")
157
+ console.print("[dim]Try again or press Ctrl+C to cancel[/dim]")
158
+
159
+ # Build pricing dict
160
+ pricing: dict[str, Any] = {
161
+ "price_data": price_data,
162
+ }
163
+
164
+ if description:
165
+ pricing["description"] = description
166
+ if currency:
167
+ pricing["currency"] = currency
168
+ if reference:
169
+ pricing["reference"] = reference
170
+
171
+ return pricing
172
+
173
+
174
+ def prompt_for_document(listing_dir: Path) -> dict[str, Any]:
175
+ """Interactively prompt for a single document.
176
+
177
+ Args:
178
+ listing_dir: Directory where the listing file will be created (for validating file paths)
179
+
180
+ Returns:
181
+ Dictionary with document data
182
+ """
183
+ console.print("\n[bold cyan]Adding a document[/bold cyan]")
184
+
185
+ # Required fields
186
+ title = Prompt.ask("[bold blue]Document title[/bold blue] [red]*[/red]")
187
+
188
+ mime_type = Prompt.ask(
189
+ "[bold blue]MIME type[/bold blue] [red]*[/red]",
190
+ choices=["markdown", "python", "javascript", "bash", "html", "text", "pdf", "jpeg", "png", "svg", "url"],
191
+ default="markdown",
192
+ )
193
+
194
+ category = Prompt.ask(
195
+ "[bold blue]Document category[/bold blue] [red]*[/red]",
196
+ choices=[
197
+ "getting_started",
198
+ "api_reference",
199
+ "tutorial",
200
+ "code_example",
201
+ "use_case",
202
+ "troubleshooting",
203
+ "changelog",
204
+ "best_practice",
205
+ "specification",
206
+ "service_level_agreement",
207
+ "terms_of_service",
208
+ "logo",
209
+ "other",
210
+ ],
211
+ default="getting_started",
212
+ )
213
+
214
+ # Optional fields
215
+ description = Prompt.ask("[bold blue]Description[/bold blue] [dim](optional)[/dim]", default="")
216
+
217
+ # At least one of file_path or external_url must be specified
218
+ file_path = ""
219
+ external_url = ""
220
+
221
+ while not file_path and not external_url:
222
+ console.print("[dim]At least one of file path or external URL must be specified[/dim]")
223
+
224
+ file_path = Prompt.ask(
225
+ "[bold blue]File path[/bold blue] [dim](relative to listing dir, e.g., 'docs/guide.md')[/dim]",
226
+ default="",
227
+ )
228
+
229
+ # If file_path is provided, validate it exists
230
+ if file_path:
231
+ file_full_path = listing_dir / file_path
232
+ if not file_full_path.exists():
233
+ console.print(f"[yellow]Warning: File not found at {file_full_path}[/yellow]")
234
+ if not Confirm.ask("[bold blue]Use this path anyway?[/bold blue]", default=False):
235
+ file_path = ""
236
+ continue
237
+
238
+ # If no file_path, must provide external_url
239
+ if not file_path:
240
+ external_url = Prompt.ask(
241
+ "[bold blue]External URL[/bold blue] [dim](required if no file path)[/dim]",
242
+ default="",
243
+ )
244
+ if not external_url:
245
+ console.print("[red]Either file path or external URL must be provided[/red]")
246
+ continue
247
+
248
+ is_public = Confirm.ask("[bold blue]Is public?[/bold blue]", default=False)
249
+
250
+ # Build document dict
251
+ doc: dict[str, Any] = {
252
+ "title": title,
253
+ "mime_type": mime_type,
254
+ "category": category,
255
+ }
256
+
257
+ if description:
258
+ doc["description"] = description
259
+ if file_path:
260
+ doc["file_path"] = file_path
261
+ if external_url:
262
+ doc["external_url"] = external_url
263
+ if is_public:
264
+ doc["is_public"] = is_public
265
+
266
+ return doc
267
+
268
+
269
+ def find_service_name(service_dir: Path | None = None) -> str | None:
270
+ """Find service name from service.json or service.toml in service directory.
271
+
272
+ Args:
273
+ service_dir: Directory to search for service file (defaults to search common locations)
274
+
275
+ Returns:
276
+ Service name if found, None otherwise
277
+ """
278
+ # Search in multiple common locations
279
+ search_dirs = []
280
+
281
+ if service_dir is not None:
282
+ search_dirs.append(service_dir)
283
+ else:
284
+ # Common locations to search
285
+ cwd = Path.cwd()
286
+ search_dirs.extend(
287
+ [
288
+ cwd, # current directory (most common for services)
289
+ cwd.parent, # parent directory (if we're in a subdirectory)
290
+ ]
291
+ )
292
+
293
+ # Look for service file in each search directory
294
+ for search_dir in search_dirs:
295
+ for filename in ["service.json", "service.toml"]:
296
+ service_file = search_dir / filename
297
+ if service_file.exists():
298
+ try:
299
+ if filename.endswith(".json"):
300
+ with open(service_file) as f:
301
+ data = json.load(f)
302
+ else: # .toml
303
+ with open(service_file, "rb") as f:
304
+ data = tomllib.load(f)
305
+
306
+ service_name = data.get("name")
307
+ if service_name:
308
+ return service_name
309
+ except Exception:
310
+ continue
311
+
312
+ return None
313
+
314
+
315
+ @dataclass
316
+ class FieldDef:
317
+ """Definition for a single field to prompt.
318
+
319
+ Attributes:
320
+ name: Field name in the schema
321
+ prompt_text: Text to display to the user
322
+ field_type: Type of field (string, email, choice, boolean, integer)
323
+ required: Whether the field is required
324
+ default: Default value (can be callable that takes context)
325
+ choices: List of choices for choice-type fields
326
+ description: Help text for the field
327
+ skip_if: Callable that returns True if field should be skipped
328
+ validate: Optional validation function
329
+ group: Logical grouping for related fields
330
+ """
331
+
332
+ name: str
333
+ prompt_text: str
334
+ field_type: str = "string" # string, email, uri, choice, boolean, integer
335
+ required: bool = False
336
+ default: Any = None
337
+ choices: list[str] | None = None
338
+ description: str | None = None
339
+ skip_if: Callable[[dict], bool] | None = None
340
+ validate: Callable[[Any], Any] | None = None
341
+ group: str = "general"
342
+
343
+
344
+ @dataclass
345
+ class FieldGroup:
346
+ """Group of related fields."""
347
+
348
+ name: str
349
+ title: str
350
+ fields: list[FieldDef] = field(default_factory=list)
351
+
352
+
353
+ class PromptEngine:
354
+ """Engine for prompting users based on field definitions."""
355
+
356
+ def __init__(self, groups: list[FieldGroup]):
357
+ """Initialize prompt engine with field groups.
358
+
359
+ Args:
360
+ groups: List of field groups to prompt for
361
+ """
362
+ self.groups = groups
363
+
364
+ def prompt_all(self, context: dict[str, Any] | None = None) -> dict[str, Any]:
365
+ """Prompt for all fields in all groups.
366
+
367
+ Args:
368
+ context: Initial context/data (e.g., name provided via CLI arg)
369
+
370
+ Returns:
371
+ Dictionary with all prompted values
372
+ """
373
+ if context is None:
374
+ context = {}
375
+
376
+ data = {}
377
+
378
+ for group in self.groups:
379
+ if group.fields:
380
+ console.print(f"\n[bold cyan]{group.title}[/bold cyan]")
381
+
382
+ for field_def in group.fields:
383
+ # Skip if condition met
384
+ if field_def.skip_if and field_def.skip_if(context):
385
+ continue
386
+
387
+ # Skip if already in context (provided via CLI)
388
+ if field_def.name in context:
389
+ value = context[field_def.name]
390
+ console.print(f"[dim]{field_def.prompt_text}: {value} (from CLI)[/dim]")
391
+ data[field_def.name] = value
392
+ continue
393
+
394
+ # Get default value (can be callable)
395
+ default_value = field_def.default
396
+ if callable(default_value):
397
+ default_value = default_value(context, data)
398
+
399
+ # Prompt based on field type
400
+ value = self._prompt_field(field_def, default_value, data)
401
+
402
+ # Only add non-None values (unless required)
403
+ if value is not None or field_def.required:
404
+ data[field_def.name] = value
405
+
406
+ # Add to context for subsequent fields
407
+ context[field_def.name] = value
408
+
409
+ return data
410
+
411
+ def _prompt_field(self, field_def: FieldDef, default_value: Any, current_data: dict[str, Any]) -> Any:
412
+ """Prompt for a single field.
413
+
414
+ Args:
415
+ field_def: Field definition
416
+ default_value: Default value to suggest
417
+ current_data: Currently collected data (for validation)
418
+
419
+ Returns:
420
+ User input value
421
+ """
422
+ # Indicate if field is optional
423
+ required_marker = " [red]*[/red]" if field_def.required else " [dim](optional)[/dim]"
424
+ prompt_label = f"[bold blue]{field_def.prompt_text}{required_marker}[/bold blue]"
425
+
426
+ if field_def.description:
427
+ console.print(f"[dim]{field_def.description}[/dim]")
428
+
429
+ try:
430
+ if field_def.field_type == "boolean":
431
+ return Confirm.ask(prompt_label, default=default_value if default_value is not None else False)
432
+
433
+ elif field_def.field_type == "integer":
434
+ while True:
435
+ try:
436
+ if not field_def.required:
437
+ # Optional integer - allow empty input
438
+ default_str = str(default_value) if default_value is not None else ""
439
+ result = Prompt.ask(prompt_label, default=default_str)
440
+ if result == "":
441
+ return None
442
+ value = int(result)
443
+ elif default_value is not None:
444
+ value = IntPrompt.ask(prompt_label, default=default_value)
445
+ else:
446
+ value = IntPrompt.ask(prompt_label)
447
+
448
+ # Apply custom validation if provided
449
+ if field_def.validate:
450
+ value = field_def.validate(value)
451
+ return value
452
+ except ValueError:
453
+ console.print("[red]Please enter a valid integer[/red]")
454
+
455
+ elif field_def.field_type == "choice":
456
+ if not field_def.choices:
457
+ raise ValueError(f"Field {field_def.name} is type 'choice' but has no choices defined")
458
+
459
+ return Prompt.ask(
460
+ prompt_label,
461
+ choices=field_def.choices,
462
+ default=default_value if default_value else field_def.choices[0],
463
+ )
464
+
465
+ else: # string, email, uri
466
+ while True:
467
+ if not field_def.required and default_value is None:
468
+ # Optional field with no default - allow empty
469
+ result = Prompt.ask(prompt_label, default="")
470
+ if result == "":
471
+ return None
472
+ elif default_value is not None:
473
+ result = Prompt.ask(prompt_label, default=str(default_value))
474
+ else:
475
+ result = Prompt.ask(prompt_label)
476
+
477
+ # Validate input
478
+ if self._validate_field_value(field_def, result):
479
+ # Apply custom validation if provided
480
+ if field_def.validate:
481
+ try:
482
+ result = field_def.validate(result)
483
+ except Exception as e:
484
+ console.print(f"[red]Validation error: {e}[/red]")
485
+ continue
486
+ return result
487
+ else:
488
+ # Validation failed, loop to retry
489
+ pass
490
+
491
+ except KeyboardInterrupt:
492
+ console.print("\n[yellow]Cancelled by user[/yellow]")
493
+ raise typer.Abort()
494
+ except Exception as e:
495
+ console.print(f"[red]Error prompting for {field_def.name}: {e}[/red]")
496
+ raise
497
+
498
+ def _validate_field_value(self, field_def: FieldDef, value: Any) -> bool:
499
+ """Validate field value based on field type.
500
+
501
+ Args:
502
+ field_def: Field definition
503
+ value: Value to validate
504
+
505
+ Returns:
506
+ True if valid, False otherwise (with error message printed)
507
+ """
508
+ if value is None or value == "":
509
+ if field_def.required:
510
+ console.print("[red]This field is required[/red]")
511
+ return False
512
+ return True
513
+
514
+ # Type-specific validation
515
+ if field_def.field_type == "email":
516
+ if "@" not in value or "." not in value.split("@")[-1]:
517
+ console.print("[red]Please enter a valid email address[/red]")
518
+ return False
519
+
520
+ elif field_def.field_type == "uri":
521
+ if not value.startswith(("http://", "https://")):
522
+ console.print("[red]URL must start with http:// or https://[/red]")
523
+ return False
524
+
525
+ return True
526
+
527
+
528
+ # =============================================================================
529
+ # SELLER FIELD REGISTRY
530
+ # =============================================================================
531
+
532
+ SELLER_GROUPS = [
533
+ FieldGroup(
534
+ name="basic",
535
+ title="Basic Information",
536
+ fields=[
537
+ FieldDef(
538
+ name="name",
539
+ prompt_text="Seller ID (URL-friendly)",
540
+ field_type="string",
541
+ required=True,
542
+ description="Unique identifier (e.g., 'acme-corp', 'john-doe')",
543
+ ),
544
+ FieldDef(
545
+ name="display_name",
546
+ prompt_text="Display name",
547
+ field_type="string",
548
+ required=False,
549
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").replace("_", " ").title(),
550
+ description="Human-readable name (e.g., 'ACME Corporation')",
551
+ ),
552
+ FieldDef(
553
+ name="seller_type",
554
+ prompt_text="Seller type",
555
+ field_type="choice",
556
+ choices=["individual", "organization", "partnership", "corporation"],
557
+ default="individual",
558
+ description="Type of seller entity",
559
+ ),
560
+ ],
561
+ ),
562
+ FieldGroup(
563
+ name="contact",
564
+ title="Contact Information",
565
+ fields=[
566
+ FieldDef(
567
+ name="contact_email",
568
+ prompt_text="Primary contact email",
569
+ field_type="email",
570
+ required=True,
571
+ ),
572
+ FieldDef(
573
+ name="secondary_contact_email",
574
+ prompt_text="Secondary contact email",
575
+ field_type="email",
576
+ required=False,
577
+ ),
578
+ FieldDef(
579
+ name="homepage",
580
+ prompt_text="Homepage URL",
581
+ field_type="uri",
582
+ required=False,
583
+ ),
584
+ ],
585
+ ),
586
+ FieldGroup(
587
+ name="details",
588
+ title="Additional Details",
589
+ fields=[
590
+ FieldDef(
591
+ name="description",
592
+ prompt_text="Description",
593
+ field_type="string",
594
+ required=False,
595
+ default=lambda ctx, data: f"{ctx.get('name', 'seller')} - {data.get('seller_type', 'seller')}",
596
+ ),
597
+ FieldDef(
598
+ name="account_manager",
599
+ prompt_text="Account manager email",
600
+ field_type="email",
601
+ required=False,
602
+ description="Email of the user managing this seller account (must be a registered user)",
603
+ ),
604
+ FieldDef(
605
+ name="business_registration",
606
+ prompt_text="Business registration number",
607
+ field_type="string",
608
+ required=False,
609
+ skip_if=lambda ctx: ctx.get("seller_type") == "individual",
610
+ description="Required for organizations",
611
+ ),
612
+ FieldDef(
613
+ name="tax_id",
614
+ prompt_text="Tax ID (EIN, VAT, etc.)",
615
+ field_type="string",
616
+ required=False,
617
+ skip_if=lambda ctx: ctx.get("seller_type") == "individual",
618
+ ),
619
+ ],
620
+ ),
621
+ FieldGroup(
622
+ name="status",
623
+ title="Status & Verification",
624
+ fields=[
625
+ FieldDef(
626
+ name="status",
627
+ prompt_text="Status",
628
+ field_type="choice",
629
+ choices=["active", "pending", "disabled", "incomplete"],
630
+ default="active",
631
+ ),
632
+ FieldDef(
633
+ name="is_verified",
634
+ prompt_text="Is verified (KYC complete)?",
635
+ field_type="boolean",
636
+ default=False,
637
+ ),
638
+ ],
639
+ ),
640
+ ]
641
+
642
+
643
+ # =============================================================================
644
+ # PROVIDER FIELD REGISTRY
645
+ # =============================================================================
646
+
647
+ PROVIDER_GROUPS = [
648
+ FieldGroup(
649
+ name="basic",
650
+ title="Basic Information",
651
+ fields=[
652
+ FieldDef(
653
+ name="name",
654
+ prompt_text="Provider ID (URL-friendly)",
655
+ field_type="string",
656
+ required=True,
657
+ description="Unique identifier (e.g., 'openai', 'fireworks')",
658
+ ),
659
+ FieldDef(
660
+ name="display_name",
661
+ prompt_text="Display name",
662
+ field_type="string",
663
+ required=False,
664
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").replace("_", " ").title(),
665
+ description="Human-readable name (e.g., 'OpenAI', 'Fireworks.ai')",
666
+ ),
667
+ FieldDef(
668
+ name="description",
669
+ prompt_text="Description",
670
+ field_type="string",
671
+ required=False,
672
+ ),
673
+ ],
674
+ ),
675
+ FieldGroup(
676
+ name="contact",
677
+ title="Contact & Web",
678
+ fields=[
679
+ FieldDef(
680
+ name="contact_email",
681
+ prompt_text="Contact email",
682
+ field_type="email",
683
+ required=True,
684
+ ),
685
+ FieldDef(
686
+ name="secondary_contact_email",
687
+ prompt_text="Secondary contact email",
688
+ field_type="email",
689
+ required=False,
690
+ ),
691
+ FieldDef(
692
+ name="homepage",
693
+ prompt_text="Homepage URL",
694
+ field_type="uri",
695
+ required=True,
696
+ ),
697
+ ],
698
+ ),
699
+ FieldGroup(
700
+ name="access",
701
+ title="Provider Access (API Credentials)",
702
+ fields=[
703
+ FieldDef(
704
+ name="base_url",
705
+ prompt_text="API endpoint URL",
706
+ field_type="uri",
707
+ required=True,
708
+ description="Base URL for API access (e.g., 'https://api.openai.com/v1')",
709
+ ),
710
+ FieldDef(
711
+ name="api_key",
712
+ prompt_text="API key (optional, can be set later)",
713
+ field_type="string",
714
+ required=False,
715
+ description="Leave empty if you'll set it later or use env var",
716
+ ),
717
+ FieldDef(
718
+ name="access_method",
719
+ prompt_text="Access method",
720
+ field_type="choice",
721
+ choices=["http", "websocket", "grpc"],
722
+ default="http",
723
+ ),
724
+ ],
725
+ ),
726
+ FieldGroup(
727
+ name="status",
728
+ title="Status",
729
+ fields=[
730
+ FieldDef(
731
+ name="status",
732
+ prompt_text="Provider status",
733
+ field_type="choice",
734
+ choices=["active", "pending", "disabled", "incomplete"],
735
+ default="active",
736
+ ),
737
+ ],
738
+ ),
739
+ FieldGroup(
740
+ name="automation",
741
+ title="Service Population (Optional)",
742
+ fields=[
743
+ FieldDef(
744
+ name="enable_services_populator",
745
+ prompt_text="Enable automated service population?",
746
+ field_type="boolean",
747
+ required=False,
748
+ default=False,
749
+ description="Use a script to automatically populate service offerings and listings",
750
+ ),
751
+ FieldDef(
752
+ name="populator_command",
753
+ prompt_text="Populator script command",
754
+ field_type="string",
755
+ required=False,
756
+ skip_if=lambda ctx: not ctx.get("enable_services_populator", False),
757
+ description="Command to execute (e.g., 'python scripts/populate.py'). Run by 'usvc populate'",
758
+ ),
759
+ ],
760
+ ),
761
+ ]
762
+
763
+
764
+ # =============================================================================
765
+ # SERVICE OFFERING FIELD REGISTRY
766
+ # =============================================================================
767
+
768
+ OFFERING_GROUPS = [
769
+ FieldGroup(
770
+ name="basic",
771
+ title="Basic Information",
772
+ fields=[
773
+ FieldDef(
774
+ name="name",
775
+ prompt_text="Service name (e.g., 'gpt-4', 'llama-3-1-405b')",
776
+ field_type="string",
777
+ required=True,
778
+ description="Usually the model name or service identifier",
779
+ ),
780
+ FieldDef(
781
+ name="display_name",
782
+ prompt_text="Display name",
783
+ field_type="string",
784
+ required=True,
785
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").title(),
786
+ ),
787
+ FieldDef(
788
+ name="version",
789
+ prompt_text="Version",
790
+ field_type="string",
791
+ required=False,
792
+ default=None,
793
+ ),
794
+ FieldDef(
795
+ name="description",
796
+ prompt_text="Description",
797
+ field_type="string",
798
+ required=True,
799
+ description="Brief description of the service",
800
+ ),
801
+ ],
802
+ ),
803
+ FieldGroup(
804
+ name="classification",
805
+ title="Service Classification",
806
+ fields=[
807
+ FieldDef(
808
+ name="service_type",
809
+ prompt_text="Service type",
810
+ field_type="choice",
811
+ choices=["llm", "embedding", "vision", "audio", "image", "video", "other"],
812
+ default="llm",
813
+ required=True,
814
+ ),
815
+ FieldDef(
816
+ name="upstream_status",
817
+ prompt_text="Upstream status",
818
+ field_type="choice",
819
+ choices=["uploading", "ready", "deprecated"],
820
+ default="ready",
821
+ ),
822
+ ],
823
+ ),
824
+ FieldGroup(
825
+ name="access",
826
+ title="Upstream Access Interface",
827
+ fields=[
828
+ FieldDef(
829
+ name="upstream_base_url",
830
+ prompt_text="Upstream API endpoint URL",
831
+ field_type="uri",
832
+ required=True,
833
+ description="Base URL for accessing this service upstream",
834
+ ),
835
+ FieldDef(
836
+ name="upstream_api_key",
837
+ prompt_text="Upstream API key (optional)",
838
+ field_type="string",
839
+ required=False,
840
+ description="Leave empty if using provider's API key",
841
+ ),
842
+ FieldDef(
843
+ name="add_upstream_documents",
844
+ prompt_text="Add documents to upstream access interface?",
845
+ field_type="boolean",
846
+ default=False,
847
+ description="API docs, code examples, etc. for accessing the upstream service",
848
+ ),
849
+ ],
850
+ ),
851
+ FieldGroup(
852
+ name="pricing",
853
+ title="Seller Pricing (Optional)",
854
+ fields=[
855
+ FieldDef(
856
+ name="add_seller_pricing",
857
+ prompt_text="Add seller pricing information?",
858
+ field_type="boolean",
859
+ default=False,
860
+ description="The agreed rate between seller and UnitySVC",
861
+ ),
862
+ ],
863
+ ),
864
+ FieldGroup(
865
+ name="additional",
866
+ title="Additional Information (Optional)",
867
+ fields=[
868
+ FieldDef(
869
+ name="tagline",
870
+ prompt_text="Tagline",
871
+ field_type="string",
872
+ required=False,
873
+ description="Short elevator pitch for the service",
874
+ ),
875
+ ],
876
+ ),
877
+ ]
878
+
879
+
880
+ # =============================================================================
881
+ # SERVICE LISTING FIELD REGISTRY
882
+ # =============================================================================
883
+
884
+ LISTING_GROUPS = [
885
+ FieldGroup(
886
+ name="basic",
887
+ title="Basic Information",
888
+ fields=[
889
+ FieldDef(
890
+ name="service_name",
891
+ prompt_text="Service name (must match service.json)",
892
+ field_type="string",
893
+ required=False,
894
+ default=lambda ctx, data: find_service_name(),
895
+ description="Auto-detected from service.json in current directory",
896
+ ),
897
+ FieldDef(
898
+ name="name",
899
+ prompt_text="Listing identifier",
900
+ field_type="string",
901
+ required=False,
902
+ description="Name identifier for the service listing (defaults to filename)",
903
+ ),
904
+ FieldDef(
905
+ name="display_name",
906
+ prompt_text="Display name",
907
+ field_type="string",
908
+ required=False,
909
+ description="Human-readable listing name (e.g., 'Premium GPT-4 Access')",
910
+ ),
911
+ ],
912
+ ),
913
+ FieldGroup(
914
+ name="seller",
915
+ title="Seller Information",
916
+ fields=[
917
+ FieldDef(
918
+ name="seller_name",
919
+ prompt_text="Seller name (must match seller.json)",
920
+ field_type="string",
921
+ required=False,
922
+ default=lambda ctx, data: find_seller_name(),
923
+ description="Auto-detected from seller.json in data directory",
924
+ ),
925
+ ],
926
+ ),
927
+ FieldGroup(
928
+ name="status",
929
+ title="Status",
930
+ fields=[
931
+ FieldDef(
932
+ name="listing_status",
933
+ prompt_text="Listing status",
934
+ field_type="choice",
935
+ choices=["draft", "ready", "deprecated"],
936
+ default="draft",
937
+ ),
938
+ ],
939
+ ),
940
+ FieldGroup(
941
+ name="documents",
942
+ title="Documents (Optional)",
943
+ fields=[
944
+ FieldDef(
945
+ name="add_documents",
946
+ prompt_text="Add documents (SLA, guides, etc.)?",
947
+ field_type="boolean",
948
+ default=False,
949
+ description="Documents provide additional information about the listing",
950
+ ),
951
+ ],
952
+ ),
953
+ ]
954
+
955
+
956
+ # =============================================================================
957
+ # HELPER FUNCTIONS
958
+ # =============================================================================
959
+
960
+
961
+ def create_seller_data(user_input: dict[str, Any]) -> dict[str, Any]:
962
+ """Create seller data structure from user input.
963
+
964
+ Args:
965
+ user_input: User-provided field values
966
+
967
+ Returns:
968
+ Complete seller data dictionary
969
+ """
970
+ data = {
971
+ "schema": "seller_v1",
972
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
973
+ }
974
+
975
+ # Add all non-None user input
976
+ for key, value in user_input.items():
977
+ if value is not None:
978
+ data[key] = value
979
+
980
+ return data
981
+
982
+
983
+ def create_provider_data(user_input: dict[str, Any]) -> dict[str, Any]:
984
+ """Create provider data structure from user input.
985
+
986
+ Args:
987
+ user_input: User-provided field values
988
+
989
+ Returns:
990
+ Complete provider data dictionary
991
+ """
992
+ # Extract access interface fields
993
+ access_fields = ["base_url", "api_key", "access_method"]
994
+ provider_access_info: dict[str, Any] = {}
995
+
996
+ for key in access_fields:
997
+ if key in user_input and user_input[key] is not None:
998
+ provider_access_info[key] = user_input[key]
999
+
1000
+ # Extract services_populator fields
1001
+ populator_fields = ["enable_services_populator", "populator_command"]
1002
+ services_populator: dict[str, Any] | None = None
1003
+
1004
+ if user_input.get("enable_services_populator") and user_input.get("populator_command"):
1005
+ services_populator = {"command": user_input["populator_command"]}
1006
+
1007
+ # Create base data
1008
+ data = {
1009
+ "schema": "provider_v1",
1010
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1011
+ "provider_access_info": provider_access_info,
1012
+ }
1013
+
1014
+ # Add services_populator if configured
1015
+ if services_populator:
1016
+ data["services_populator"] = services_populator
1017
+
1018
+ # Add non-access, non-populator fields
1019
+ excluded_fields = access_fields + populator_fields
1020
+ for key, value in user_input.items():
1021
+ if key not in excluded_fields and value is not None:
1022
+ data[key] = value
1023
+
1024
+ return data
1025
+
1026
+
1027
+ def create_offering_data(user_input: dict[str, Any], offering_dir: Path | None = None) -> dict[str, Any]:
1028
+ """Create service offering data structure from user input.
1029
+
1030
+ Args:
1031
+ user_input: User-provided field values
1032
+ offering_dir: Directory where offering file will be created (for validating document file paths)
1033
+
1034
+ Returns:
1035
+ Complete service offering data dictionary
1036
+ """
1037
+ # Extract upstream access interface fields
1038
+ upstream_fields = ["upstream_base_url", "upstream_api_key", "add_upstream_documents"]
1039
+ upstream_access_interface: dict[str, Any] = {}
1040
+
1041
+ for key in upstream_fields:
1042
+ # Map to the actual field names in AccessInterface
1043
+ if key == "upstream_base_url" and user_input.get(key):
1044
+ upstream_access_interface["base_url"] = user_input[key]
1045
+ elif key == "upstream_api_key" and user_input.get(key):
1046
+ upstream_access_interface["api_key"] = user_input[key]
1047
+
1048
+ # Handle documents for upstream access interface if user wants to add them
1049
+ if user_input.get("add_upstream_documents"):
1050
+ if offering_dir is None:
1051
+ console.print("[yellow]Warning: Cannot validate file paths without offering directory[/yellow]")
1052
+ offering_dir = Path.cwd()
1053
+
1054
+ console.print("\n[bold cyan]Add documents to upstream access interface[/bold cyan]")
1055
+ documents = []
1056
+ while True:
1057
+ doc = prompt_for_document(offering_dir)
1058
+ documents.append(doc)
1059
+
1060
+ if not Confirm.ask("\n[bold blue]Add another document?[/bold blue]", default=False):
1061
+ break
1062
+
1063
+ if documents:
1064
+ upstream_access_interface["documents"] = documents
1065
+
1066
+ # Handle seller pricing if user wants to add it
1067
+ seller_price = None
1068
+ if user_input.get("add_seller_pricing"):
1069
+ seller_price = prompt_for_pricing()
1070
+
1071
+ # Create base data
1072
+ data = {
1073
+ "schema": "service_v1",
1074
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1075
+ "upstream_access_interface": upstream_access_interface,
1076
+ "details": {}, # Required field, user can add details manually later
1077
+ }
1078
+
1079
+ # Add seller price if provided
1080
+ if seller_price:
1081
+ data["seller_price"] = seller_price
1082
+
1083
+ # Add non-upstream fields (exclude add_upstream_documents and add_seller_pricing which are just flags)
1084
+ excluded_fields = upstream_fields + ["add_seller_pricing"]
1085
+ for key, value in user_input.items():
1086
+ if key not in excluded_fields and value is not None:
1087
+ data[key] = value
1088
+
1089
+ return data
1090
+
1091
+
1092
+ def create_listing_data(user_input: dict[str, Any], listing_dir: Path | None = None) -> dict[str, Any]:
1093
+ """Create service listing data structure from user input.
1094
+
1095
+ Args:
1096
+ user_input: User-provided field values
1097
+ listing_dir: Directory where listing file will be created (for validating document file paths)
1098
+
1099
+ Returns:
1100
+ Complete service listing data dictionary
1101
+ """
1102
+ data: dict[str, Any] = {
1103
+ "schema": "listing_v1",
1104
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1105
+ "user_access_interfaces": [], # Required field, user must add interfaces manually
1106
+ "customer_price": None, # Optional, can be added later
1107
+ }
1108
+
1109
+ # Handle documents if user wants to add them
1110
+ documents = []
1111
+ if user_input.get("add_documents"):
1112
+ if listing_dir is None:
1113
+ console.print("[yellow]Warning: Cannot validate file paths without listing directory[/yellow]")
1114
+ listing_dir = Path.cwd()
1115
+
1116
+ console.print("\n[bold cyan]Add documents to listing[/bold cyan]")
1117
+ while True:
1118
+ doc = prompt_for_document(listing_dir)
1119
+ documents.append(doc)
1120
+
1121
+ if not Confirm.ask("\n[bold blue]Add another document?[/bold blue]", default=False):
1122
+ break
1123
+
1124
+ if documents:
1125
+ data["documents"] = documents
1126
+
1127
+ # Add all non-None user input (except add_documents which is just a flag)
1128
+ for key, value in user_input.items():
1129
+ if key != "add_documents" and value is not None:
1130
+ data[key] = value
1131
+
1132
+ return data