foodforthought-cli 0.2.1__py3-none-any.whl → 0.2.3__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 (46) hide show
  1. ate/__init__.py +1 -1
  2. ate/bridge_server.py +622 -0
  3. ate/cli.py +2625 -242
  4. ate/compatibility.py +580 -0
  5. ate/generators/__init__.py +19 -0
  6. ate/generators/docker_generator.py +461 -0
  7. ate/generators/hardware_config.py +469 -0
  8. ate/generators/ros2_generator.py +617 -0
  9. ate/generators/skill_generator.py +783 -0
  10. ate/marketplace.py +524 -0
  11. ate/mcp_server.py +1341 -107
  12. ate/primitives.py +1016 -0
  13. ate/robot_setup.py +2222 -0
  14. ate/skill_schema.py +537 -0
  15. ate/telemetry/__init__.py +33 -0
  16. ate/telemetry/cli.py +455 -0
  17. ate/telemetry/collector.py +444 -0
  18. ate/telemetry/context.py +318 -0
  19. ate/telemetry/fleet_agent.py +419 -0
  20. ate/telemetry/formats/__init__.py +18 -0
  21. ate/telemetry/formats/hdf5_serializer.py +503 -0
  22. ate/telemetry/formats/mcap_serializer.py +457 -0
  23. ate/telemetry/types.py +334 -0
  24. foodforthought_cli-0.2.3.dist-info/METADATA +300 -0
  25. foodforthought_cli-0.2.3.dist-info/RECORD +44 -0
  26. foodforthought_cli-0.2.3.dist-info/top_level.txt +6 -0
  27. mechdog_labeled/__init__.py +3 -0
  28. mechdog_labeled/primitives.py +113 -0
  29. mechdog_labeled/servo_map.py +209 -0
  30. mechdog_output/__init__.py +3 -0
  31. mechdog_output/primitives.py +59 -0
  32. mechdog_output/servo_map.py +203 -0
  33. test_autodetect/__init__.py +3 -0
  34. test_autodetect/primitives.py +113 -0
  35. test_autodetect/servo_map.py +209 -0
  36. test_full_auto/__init__.py +3 -0
  37. test_full_auto/primitives.py +113 -0
  38. test_full_auto/servo_map.py +209 -0
  39. test_smart_detect/__init__.py +3 -0
  40. test_smart_detect/primitives.py +113 -0
  41. test_smart_detect/servo_map.py +209 -0
  42. foodforthought_cli-0.2.1.dist-info/METADATA +0 -151
  43. foodforthought_cli-0.2.1.dist-info/RECORD +0 -9
  44. foodforthought_cli-0.2.1.dist-info/top_level.txt +0 -1
  45. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/WHEEL +0 -0
  46. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.3.dist-info}/entry_points.txt +0 -0
ate/marketplace.py ADDED
@@ -0,0 +1,524 @@
1
+ """
2
+ Skill Marketplace commands for the FoodforThought CLI.
3
+ Provides npm-like interface for discovering, installing, and publishing robot skills.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Optional, List, Dict, Any
13
+
14
+ try:
15
+ import requests
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich.panel import Panel
19
+ from rich.progress import Progress
20
+ from rich.markdown import Markdown
21
+ except ImportError:
22
+ print("Error: Required packages not installed. Run: pip install rich requests")
23
+ sys.exit(1)
24
+
25
+ console = Console()
26
+
27
+ # API configuration
28
+ BASE_URL = os.getenv("ATE_API_URL", "https://kindly.fyi/api")
29
+ API_KEY = os.getenv("ATE_API_KEY", "")
30
+
31
+
32
+ class MarketplaceClient:
33
+ """Client for interacting with the Skill Marketplace API."""
34
+
35
+ def __init__(self, base_url: str = BASE_URL, api_key: str = API_KEY):
36
+ self.base_url = base_url
37
+ self.headers = {
38
+ "Content-Type": "application/json",
39
+ }
40
+ if api_key:
41
+ self.headers["Authorization"] = f"Bearer {api_key}"
42
+
43
+ def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
44
+ """Make HTTP request to API."""
45
+ url = f"{self.base_url}{endpoint}"
46
+ try:
47
+ if method.upper() == "GET" and "params" in kwargs:
48
+ response = requests.get(url, headers=self.headers, params=kwargs["params"])
49
+ else:
50
+ response = requests.request(method, url, headers=self.headers, **kwargs)
51
+ response.raise_for_status()
52
+ return response.json()
53
+ except requests.exceptions.HTTPError as e:
54
+ try:
55
+ error_data = e.response.json()
56
+ console.print(f"[red]Error: {error_data.get('error', str(e))}[/red]")
57
+ except Exception:
58
+ console.print(f"[red]Error: {e}[/red]")
59
+ sys.exit(1)
60
+ except requests.exceptions.RequestException as e:
61
+ console.print(f"[red]Network error: {e}[/red]")
62
+ sys.exit(1)
63
+
64
+ def get(self, endpoint: str, **kwargs) -> Dict[str, Any]:
65
+ return self._request("GET", endpoint, **kwargs)
66
+
67
+ def post(self, endpoint: str, data: Dict = None, **kwargs) -> Dict[str, Any]:
68
+ return self._request("POST", endpoint, json=data, **kwargs)
69
+
70
+ def put(self, endpoint: str, data: Dict = None, **kwargs) -> Dict[str, Any]:
71
+ return self._request("PUT", endpoint, json=data, **kwargs)
72
+
73
+
74
+ # Singleton client
75
+ _client: Optional[MarketplaceClient] = None
76
+
77
+
78
+ def get_client() -> MarketplaceClient:
79
+ global _client
80
+ if _client is None:
81
+ _client = MarketplaceClient()
82
+ return _client
83
+
84
+
85
+ def search_skills(
86
+ query: str,
87
+ category: Optional[str] = None,
88
+ robot_type: Optional[str] = None,
89
+ license_type: Optional[str] = None,
90
+ pricing: Optional[str] = None,
91
+ sort: str = "downloads",
92
+ limit: int = 20,
93
+ ) -> None:
94
+ """
95
+ Search the skill marketplace.
96
+
97
+ Examples:
98
+ ate marketplace search "pick and place"
99
+ ate marketplace search gripper --category manipulation
100
+ ate marketplace search navigation --robot ur5
101
+ """
102
+ client = get_client()
103
+
104
+ params = {"q": query, "sort": sort, "limit": limit}
105
+ if category:
106
+ params["category"] = category
107
+ if robot_type:
108
+ params["robotType"] = robot_type
109
+ if license_type:
110
+ params["license"] = license_type
111
+ if pricing:
112
+ params["pricing"] = pricing
113
+
114
+ with console.status("Searching marketplace..."):
115
+ result = client.get("/marketplace/skills", params=params)
116
+
117
+ skills = result.get("skills", [])
118
+ pagination = result.get("pagination", {})
119
+
120
+ if not skills:
121
+ console.print(f"[yellow]No skills found matching '{query}'[/yellow]")
122
+ return
123
+
124
+ table = Table(title=f"Skills matching '{query}' ({pagination.get('total', 0)} total)")
125
+ table.add_column("Name", style="cyan", no_wrap=True)
126
+ table.add_column("Author", style="dim")
127
+ table.add_column("Category")
128
+ table.add_column("Downloads", justify="right")
129
+ table.add_column("Rating", justify="right")
130
+ table.add_column("License", style="dim")
131
+
132
+ for skill in skills:
133
+ rating = skill.get("avgRating", 0)
134
+ rating_str = f"{rating:.1f}" if rating > 0 else "—"
135
+ verified = "" if not skill.get("verified") else " "
136
+
137
+ table.add_row(
138
+ f"{skill['name']}{verified}",
139
+ skill.get("author", {}).get("name", "Unknown"),
140
+ skill.get("category", "—"),
141
+ str(skill.get("downloads", 0)),
142
+ rating_str,
143
+ skill.get("license", "—").upper(),
144
+ )
145
+
146
+ console.print(table)
147
+
148
+ if pagination.get("hasMore"):
149
+ console.print(
150
+ f"\n[dim]Showing {len(skills)} of {pagination.get('total')} results. "
151
+ f"Use --limit to see more.[/dim]"
152
+ )
153
+
154
+
155
+ def show_skill(slug: str) -> None:
156
+ """
157
+ Show detailed information about a skill.
158
+
159
+ Examples:
160
+ ate marketplace show pick-and-place
161
+ """
162
+ client = get_client()
163
+
164
+ with console.status(f"Fetching skill '{slug}'..."):
165
+ result = client.get(f"/marketplace/skills/{slug}")
166
+
167
+ skill = result.get("skill", {})
168
+
169
+ if not skill:
170
+ console.print(f"[red]Skill '{slug}' not found[/red]")
171
+ return
172
+
173
+ # Header
174
+ verified = "" if not skill.get("verified") else ""
175
+ console.print(Panel(
176
+ f"[bold cyan]{skill['name']}[/bold cyan] {verified}\n"
177
+ f"[dim]by {skill.get('author', {}).get('name', 'Unknown')}[/dim]\n\n"
178
+ f"{skill.get('description', 'No description')}",
179
+ title=f"v{skill.get('version', '?')}",
180
+ subtitle=skill.get('category', ''),
181
+ ))
182
+
183
+ # Stats table
184
+ stats_table = Table(show_header=False, box=None)
185
+ stats_table.add_column("Key", style="dim")
186
+ stats_table.add_column("Value")
187
+
188
+ rating = skill.get("avgRating", 0)
189
+ stats_table.add_row("Downloads", str(skill.get("downloads", 0)))
190
+ stats_table.add_row("Installs", str(skill.get("installs", 0)))
191
+ stats_table.add_row("Executions", str(skill.get("executions", 0)))
192
+ stats_table.add_row("Success Rate", f"{skill.get('successRate', 0) * 100:.1f}%")
193
+ stats_table.add_row("Rating", f"{rating:.1f}" if rating > 0 else "No ratings")
194
+ stats_table.add_row("License", skill.get("license", "—").upper())
195
+
196
+ # Pricing
197
+ pricing = skill.get("pricing", {})
198
+ if pricing.get("type") == "free":
199
+ stats_table.add_row("Price", "[green]Free[/green]")
200
+ else:
201
+ price = pricing.get("price", 0)
202
+ currency = pricing.get("currency", "usd").upper()
203
+ stats_table.add_row("Price", f"${price:.2f} {currency}")
204
+
205
+ console.print(stats_table)
206
+
207
+ # Compatibility
208
+ compatibility = skill.get("compatibility", [])
209
+ if compatibility:
210
+ console.print("\n[bold]Compatible Robots:[/bold]")
211
+ for compat in compatibility[:5]:
212
+ robot = compat.get("robot", {})
213
+ success = compat.get("successRate", 0)
214
+ console.print(
215
+ f" • {robot.get('name', 'Unknown')} "
216
+ f"({robot.get('manufacturer', '')}) - "
217
+ f"[green]{success * 100:.0f}% success[/green]"
218
+ )
219
+
220
+ # Links
221
+ console.print("\n[bold]Links:[/bold]")
222
+ if skill.get("sourceUrl"):
223
+ console.print(f" Source: {skill['sourceUrl']}")
224
+ if skill.get("documentationUrl"):
225
+ console.print(f" Docs: {skill['documentationUrl']}")
226
+ console.print(f" Web: https://foodforthought.kindly.fyi/marketplace/{slug}")
227
+
228
+
229
+ def install_skill(
230
+ skill_name: str,
231
+ version: Optional[str] = None,
232
+ robot: Optional[str] = None,
233
+ output_dir: Optional[str] = None,
234
+ ) -> None:
235
+ """
236
+ Install a skill from the marketplace.
237
+
238
+ Examples:
239
+ ate marketplace install pick-and-place
240
+ ate marketplace install pick-and-place --version 1.2.0
241
+ ate marketplace install pick-and-place --robot my-arm
242
+ """
243
+ client = get_client()
244
+
245
+ # Check compatibility first if robot specified
246
+ if robot:
247
+ console.print(f"Checking compatibility with {robot}...")
248
+ compat = client.post("/marketplace/compatibility", {
249
+ "skillSlug": skill_name,
250
+ "robotId": robot,
251
+ })
252
+
253
+ if not compat.get("compatible"):
254
+ console.print(f"[red]Skill is not compatible with robot {robot}[/red]")
255
+ for issue in compat.get("issues", []):
256
+ console.print(f" • {issue}")
257
+
258
+ adaptations = compat.get("adaptations", [])
259
+ if adaptations:
260
+ console.print("\n[yellow]Possible adaptations:[/yellow]")
261
+ for adapt in adaptations:
262
+ console.print(f" • {adapt.get('description')}")
263
+
264
+ if not console.input("\nInstall anyway? (y/N): ").lower() == "y":
265
+ return
266
+
267
+ # Install
268
+ with console.status("Installing skill..."):
269
+ result = client.post(f"/marketplace/skills/{skill_name}/install", {
270
+ "version": version,
271
+ "robotId": robot,
272
+ })
273
+
274
+ if result.get("requiresPayment"):
275
+ console.print("[yellow]This is a paid skill.[/yellow]")
276
+ console.print(f"Price: ${result['pricing'].get('price', 0):.2f}")
277
+ console.print(f"Checkout: {result.get('checkoutUrl')}")
278
+ return
279
+
280
+ download_url = result.get("downloadUrl")
281
+ installed_version = result.get("version")
282
+
283
+ if not download_url:
284
+ console.print("[red]Failed to get download URL[/red]")
285
+ return
286
+
287
+ # Download package
288
+ output_path = Path(output_dir or f"./{skill_name}")
289
+ output_path.mkdir(parents=True, exist_ok=True)
290
+
291
+ with Progress() as progress:
292
+ task = progress.add_task("Downloading...", total=100)
293
+
294
+ try:
295
+ response = requests.get(download_url, stream=True)
296
+ response.raise_for_status()
297
+
298
+ # Save as zip and extract
299
+ zip_path = output_path / f"{skill_name}.zip"
300
+ with open(zip_path, "wb") as f:
301
+ for chunk in response.iter_content(chunk_size=8192):
302
+ f.write(chunk)
303
+ progress.update(task, completed=50)
304
+
305
+ # Extract
306
+ with zipfile.ZipFile(zip_path, "r") as z:
307
+ z.extractall(output_path)
308
+ progress.update(task, completed=100)
309
+
310
+ # Clean up zip
311
+ zip_path.unlink()
312
+
313
+ except Exception as e:
314
+ console.print(f"[red]Download failed: {e}[/red]")
315
+ # Still mark as installed since the installation record was created
316
+ console.print("[yellow]Skill recorded as installed but download failed.[/yellow]")
317
+ return
318
+
319
+ console.print(f"[green] Installed {skill_name} v{installed_version}[/green]")
320
+ console.print(f"Location: {output_path.absolute()}")
321
+
322
+ # Show compatibility info
323
+ compat_info = result.get("compatibility")
324
+ if compat_info:
325
+ if compat_info.get("status") == "untested":
326
+ console.print(
327
+ "[yellow]Note: This skill hasn't been tested on your robot. "
328
+ "Please report compatibility after testing.[/yellow]"
329
+ )
330
+
331
+
332
+ def publish_skill(
333
+ path: str,
334
+ public: bool = True,
335
+ ) -> None:
336
+ """
337
+ Publish a skill to the marketplace.
338
+
339
+ The skill directory must contain a skill.yaml file with metadata.
340
+
341
+ Examples:
342
+ ate marketplace publish ./my-skill
343
+ ate marketplace publish ./my-skill --no-public
344
+ """
345
+ skill_path = Path(path)
346
+
347
+ if not skill_path.exists():
348
+ console.print(f"[red]Path not found: {path}[/red]")
349
+ return
350
+
351
+ # Look for skill.yaml
352
+ config_path = skill_path / "skill.yaml"
353
+ if not config_path.exists():
354
+ config_path = skill_path / "skill.yml"
355
+ if not config_path.exists():
356
+ console.print("[red]skill.yaml not found in skill directory[/red]")
357
+ console.print("\nCreate a skill.yaml with:")
358
+ console.print("""
359
+ name: my-skill
360
+ version: 1.0.0
361
+ description: A brief description
362
+ category: manipulation
363
+ tags:
364
+ - gripper
365
+ - pick-and-place
366
+ license: mit
367
+ """)
368
+ return
369
+
370
+ # Parse config
371
+ try:
372
+ import yaml
373
+ with open(config_path) as f:
374
+ config = yaml.safe_load(f)
375
+ except ImportError:
376
+ console.print("[yellow]PyYAML not installed, trying JSON fallback...[/yellow]")
377
+ # Try JSON fallback
378
+ json_path = skill_path / "skill.json"
379
+ if json_path.exists():
380
+ with open(json_path) as f:
381
+ config = json.load(f)
382
+ else:
383
+ console.print("[red]Install PyYAML or use skill.json[/red]")
384
+ return
385
+ except Exception as e:
386
+ console.print(f"[red]Failed to parse config: {e}[/red]")
387
+ return
388
+
389
+ # Validate required fields
390
+ required = ["name", "version", "description", "category"]
391
+ missing = [f for f in required if not config.get(f)]
392
+ if missing:
393
+ console.print(f"[red]Missing required fields: {', '.join(missing)}[/red]")
394
+ return
395
+
396
+ console.print(f"Publishing [cyan]{config['name']}[/cyan] v{config['version']}...")
397
+
398
+ # Create package zip
399
+ console.print("Creating package...")
400
+ zip_path = skill_path.parent / f"{config['name']}-{config['version']}.zip"
401
+
402
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
403
+ for file in skill_path.rglob("*"):
404
+ if file.is_file():
405
+ # Skip common non-essential files
406
+ if file.name.startswith(".") or file.suffix in [".pyc", ".pyo"]:
407
+ continue
408
+ if "__pycache__" in str(file):
409
+ continue
410
+ arcname = file.relative_to(skill_path)
411
+ z.write(file, arcname)
412
+
413
+ # Upload package (placeholder - would upload to Vercel Blob or S3)
414
+ console.print("Uploading package...")
415
+ # In production, this would upload to cloud storage
416
+ # For now, we'll just use a placeholder URL
417
+ package_url = f"https://storage.kindly.fyi/skills/{config['name']}/{config['version']}/package.zip"
418
+
419
+ # Clean up local zip
420
+ zip_path.unlink()
421
+
422
+ # Read README if exists
423
+ readme = None
424
+ readme_path = skill_path / "README.md"
425
+ if readme_path.exists():
426
+ readme = readme_path.read_text()
427
+
428
+ # Publish to API
429
+ client = get_client()
430
+
431
+ try:
432
+ result = client.post("/marketplace/skills", {
433
+ "name": config["name"],
434
+ "version": config["version"],
435
+ "description": config["description"],
436
+ "readme": readme,
437
+ "category": config.get("category", "other"),
438
+ "tags": config.get("tags", []),
439
+ "robotTypes": config.get("robotTypes", []),
440
+ "hardwareRequirements": config.get("hardwareRequirements", []),
441
+ "softwareRequirements": config.get("softwareRequirements", []),
442
+ "packageUrl": package_url,
443
+ "sourceUrl": config.get("sourceUrl"),
444
+ "documentationUrl": config.get("documentationUrl"),
445
+ "license": config.get("license", "mit"),
446
+ "pricing": config.get("pricing", {"type": "free"}),
447
+ })
448
+
449
+ skill = result.get("skill", {})
450
+ console.print(f"[green] Published {skill['name']} v{skill['version']}[/green]")
451
+ console.print(f"Status: {skill.get('status', 'pending_review')}")
452
+ console.print(f"URL: https://foodforthought.kindly.fyi/marketplace/{skill['slug']}")
453
+
454
+ if skill.get("status") == "pending_review":
455
+ console.print(
456
+ "\n[yellow]Your skill is pending review. "
457
+ "It will be published once approved.[/yellow]"
458
+ )
459
+ except Exception as e:
460
+ console.print(f"[red]Failed to publish: {e}[/red]")
461
+
462
+
463
+ def report_compatibility(
464
+ skill_name: str,
465
+ robot: str,
466
+ works: bool,
467
+ notes: Optional[str] = None,
468
+ version: Optional[str] = None,
469
+ ) -> None:
470
+ """
471
+ Report skill compatibility with a robot.
472
+
473
+ Examples:
474
+ ate marketplace report pick-and-place my-arm --works
475
+ ate marketplace report pick-and-place my-arm --no-works --notes "Gripper too weak"
476
+ """
477
+ client = get_client()
478
+
479
+ result = client.put("/marketplace/compatibility", {
480
+ "skillSlug": skill_name,
481
+ "robotId": robot,
482
+ "works": works,
483
+ "notes": notes,
484
+ "version": version,
485
+ })
486
+
487
+ stats = result.get("communityStats", {})
488
+ console.print("[green] Compatibility report submitted. Thank you![/green]")
489
+ console.print(
490
+ f"\nCommunity stats for this robot: "
491
+ f"{stats.get('workingReports', 0)}/{stats.get('totalReports', 0)} working "
492
+ f"({stats.get('successRate', 0) * 100:.0f}% success rate)"
493
+ )
494
+
495
+
496
+ def list_installed() -> None:
497
+ """List all installed skills."""
498
+ client = get_client()
499
+
500
+ # Get user's installations
501
+ result = client.get("/marketplace/skills", params={"installed": "true"})
502
+
503
+ skills = result.get("skills", [])
504
+
505
+ if not skills:
506
+ console.print("[yellow]No skills installed[/yellow]")
507
+ console.print("Use 'ate marketplace search' to find skills to install.")
508
+ return
509
+
510
+ table = Table(title="Installed Skills")
511
+ table.add_column("Name", style="cyan")
512
+ table.add_column("Version")
513
+ table.add_column("Category")
514
+ table.add_column("Installed")
515
+
516
+ for skill in skills:
517
+ table.add_row(
518
+ skill["name"],
519
+ skill["version"],
520
+ skill.get("category", "—"),
521
+ skill.get("installedAt", "—"),
522
+ )
523
+
524
+ console.print(table)