observal-cli 0.2.0__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 (44) hide show
  1. observal_cli/README.md +150 -0
  2. observal_cli/__init__.py +0 -0
  3. observal_cli/analyzer.py +565 -0
  4. observal_cli/branding.py +19 -0
  5. observal_cli/client.py +264 -0
  6. observal_cli/cmd_agent.py +783 -0
  7. observal_cli/cmd_auth.py +823 -0
  8. observal_cli/cmd_doctor.py +674 -0
  9. observal_cli/cmd_hook.py +246 -0
  10. observal_cli/cmd_mcp.py +1044 -0
  11. observal_cli/cmd_migrate.py +764 -0
  12. observal_cli/cmd_ops.py +1250 -0
  13. observal_cli/cmd_profile.py +308 -0
  14. observal_cli/cmd_prompt.py +200 -0
  15. observal_cli/cmd_pull.py +324 -0
  16. observal_cli/cmd_sandbox.py +178 -0
  17. observal_cli/cmd_scan.py +1056 -0
  18. observal_cli/cmd_skill.py +202 -0
  19. observal_cli/cmd_uninstall.py +340 -0
  20. observal_cli/config.py +160 -0
  21. observal_cli/constants.py +151 -0
  22. observal_cli/hooks/__init__.py +0 -0
  23. observal_cli/hooks/buffer_event.py +97 -0
  24. observal_cli/hooks/flush_buffer.py +141 -0
  25. observal_cli/hooks/kiro_hook.py +210 -0
  26. observal_cli/hooks/kiro_stop_hook.py +220 -0
  27. observal_cli/hooks/observal-hook.sh +31 -0
  28. observal_cli/hooks/observal-stop-hook.sh +134 -0
  29. observal_cli/hooks/payload_crypto.py +78 -0
  30. observal_cli/hooks_spec.py +154 -0
  31. observal_cli/main.py +105 -0
  32. observal_cli/prompts.py +92 -0
  33. observal_cli/proxy.py +205 -0
  34. observal_cli/render.py +139 -0
  35. observal_cli/requirements.txt +3 -0
  36. observal_cli/sandbox_runner.py +217 -0
  37. observal_cli/settings_reconciler.py +188 -0
  38. observal_cli/shim.py +459 -0
  39. observal_cli/telemetry_buffer.py +163 -0
  40. observal_cli-0.2.0.dist-info/METADATA +528 -0
  41. observal_cli-0.2.0.dist-info/RECORD +44 -0
  42. observal_cli-0.2.0.dist-info/WHEEL +4 -0
  43. observal_cli-0.2.0.dist-info/entry_points.txt +5 -0
  44. observal_cli-0.2.0.dist-info/licenses/LICENSE +108 -0
@@ -0,0 +1,565 @@
1
+ """Local repository analysis for MCP server submissions.
2
+
3
+ Clones the repo using the system git (which inherits the user's credential
4
+ helpers, SSO sessions, SSH keys, etc.) and runs the same analysis that the
5
+ server performs: MCP pattern detection, AST parsing, env-var scanning.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import json
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+ _CLONE_TIMEOUT = 120 # seconds
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Patterns (mirrored from observal-server/services/mcp_validator.py)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _PYTHON_MCP_PATTERN = re.compile(
26
+ r"FastMCP\("
27
+ r"|@mcp\.server"
28
+ r"|from\s+mcp\.server\s+import\s+Server"
29
+ r"|from\s+mcp\s+import"
30
+ r"|import\s+mcp\b"
31
+ r"|McpServer\("
32
+ r"|MCPServer\("
33
+ r"|@app\.tool\b"
34
+ r"|@server\.tool\b"
35
+ r"|Server\(\s*name\s*="
36
+ )
37
+
38
+ _ENV_VAR_PATTERN_PYTHON = re.compile(
39
+ r"""os\.environ\s*(?:\.get\s*\(\s*|\.?\[?\s*\[?\s*)["']([A-Z][A-Z0-9_]+)["']"""
40
+ r"""|os\.getenv\s*\(\s*["']([A-Z][A-Z0-9_]+)["']"""
41
+ )
42
+
43
+ _ENV_VAR_PATTERN_GO = re.compile(r"""os\.Getenv\(\s*"([A-Z][A-Z0-9_]+)"\s*\)""")
44
+
45
+ # README patterns: docker -e flags, export statements, JSON config keys
46
+ _README_PATTERNS = [
47
+ re.compile(r"""-e\s+([A-Z][A-Z0-9_]+)"""),
48
+ re.compile(r"""export\s+([A-Z][A-Z0-9_]+)="""),
49
+ re.compile(r""""([A-Z][A-Z0-9_]+)"\s*:\s*\""""),
50
+ ]
51
+
52
+ _ENV_VAR_PATTERN_TS = re.compile(
53
+ r"""process\.env\.([A-Z][A-Z0-9_]+)"""
54
+ r"""|process\.env\[\s*["']([A-Z][A-Z0-9_]+)["']\s*\]"""
55
+ )
56
+
57
+ _INTERNAL_ENV_VARS = frozenset(
58
+ {
59
+ "PATH",
60
+ "HOME",
61
+ "USER",
62
+ "SHELL",
63
+ "LANG",
64
+ "TERM",
65
+ "PWD",
66
+ "TMPDIR",
67
+ "PYTHONPATH",
68
+ "PYTHONDONTWRITEBYTECODE",
69
+ "PYTHONUSERBASE",
70
+ "PYTHONHOME",
71
+ "PYTHONUNBUFFERED",
72
+ "VIRTUAL_ENV",
73
+ "NODE_ENV",
74
+ "NODE_PATH",
75
+ "NODE_OPTIONS",
76
+ "PORT",
77
+ "HOST",
78
+ "DEBUG",
79
+ "APP",
80
+ "LOG_LEVEL",
81
+ "LOGGING_LEVEL",
82
+ "HOSTNAME",
83
+ "DISPLAY",
84
+ "EDITOR",
85
+ "PAGER",
86
+ "TZ",
87
+ "LC_ALL",
88
+ "LC_CTYPE",
89
+ }
90
+ )
91
+
92
+ # User-facing env vars that match a filtered prefix but should still be detected
93
+ _ALLOWED_ENV_VARS = frozenset(
94
+ {
95
+ "GITHUB_TOKEN",
96
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
97
+ "DOCKER_HOST",
98
+ }
99
+ )
100
+
101
+ # Prefix patterns for build/CI/infrastructure env vars that are never user-facing
102
+ _FILTERED_PREFIXES = (
103
+ "CI_",
104
+ "GITHUB_",
105
+ "GITLAB_",
106
+ "CIRCLECI_",
107
+ "TRAVIS_",
108
+ "JENKINS_",
109
+ "BUILDKITE_",
110
+ "DOCKER_",
111
+ "BUILDKIT_",
112
+ "COMPOSE_",
113
+ "NPM_",
114
+ "PIP_",
115
+ "UV_",
116
+ "OTEL_",
117
+ "MCP_LOG_",
118
+ )
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Helpers
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _clone_repo(git_url: str, dest: str) -> str | None:
126
+ """Shallow-clone a repo using system git. Returns error string or None on success."""
127
+ try:
128
+ result = subprocess.run(
129
+ ["git", "clone", "--depth", "1", git_url, dest],
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=_CLONE_TIMEOUT,
133
+ )
134
+ except FileNotFoundError:
135
+ return "git is not installed or not on PATH"
136
+ except subprocess.TimeoutExpired:
137
+ return f"Clone timed out after {_CLONE_TIMEOUT}s"
138
+
139
+ if result.returncode != 0:
140
+ stderr = result.stderr.strip().lower()
141
+ auth_hints = ("authentication", "403", "404", "could not read username", "terminal prompts disabled")
142
+ if any(h in stderr for h in auth_hints):
143
+ return "Repository is private or not accessible."
144
+ if "not found" in stderr or "does not exist" in stderr:
145
+ return "Repository not found. Check the URL."
146
+ return f"git clone failed: {result.stderr.strip()}"
147
+ return None
148
+
149
+
150
+ def _is_filtered_env_var(name: str) -> bool:
151
+ """Return True if the env var is internal/infrastructure and should not be prompted."""
152
+ if name in _ALLOWED_ENV_VARS:
153
+ return False
154
+ if name in _INTERNAL_ENV_VARS:
155
+ return True
156
+ return any(name.startswith(prefix) for prefix in _FILTERED_PREFIXES)
157
+
158
+
159
+ # Directories that contain test / internal / build code — not user-facing config
160
+ _SKIP_DIRS = frozenset(
161
+ {
162
+ "test",
163
+ "tests",
164
+ "e2e",
165
+ "internal",
166
+ "testdata",
167
+ "vendor",
168
+ "node_modules",
169
+ "__pycache__",
170
+ ".git",
171
+ }
172
+ )
173
+
174
+
175
+ def _is_test_file(path: Path) -> bool:
176
+ """Return True if the file is in a test/internal directory or is a test file."""
177
+ if any(part in _SKIP_DIRS for part in path.parts):
178
+ return True
179
+ name = path.name
180
+ return name.endswith("_test.go") or name.startswith("test_") or name.endswith("_test.py")
181
+
182
+
183
+ def _scan_files_for_env_vars(root: Path, glob: str, pattern: re.Pattern, found: dict[str, str]) -> None:
184
+ """Scan files matching *glob* for env var references using *pattern*."""
185
+ for path in root.rglob(glob):
186
+ if _is_test_file(path.relative_to(root)):
187
+ continue
188
+ try:
189
+ content = path.read_text(errors="ignore")
190
+ for m in pattern.finditer(content):
191
+ name = next((g for g in m.groups() if g), None)
192
+ if name and not _is_filtered_env_var(name):
193
+ found.setdefault(name, "")
194
+ except Exception:
195
+ continue
196
+
197
+
198
+ def _scan_readme_for_env_vars(root: Path, found: dict[str, str]) -> None:
199
+ """Extract env vars from README files (docker -e, export, JSON config)."""
200
+ for name in ("README.md", "README.rst", "README.txt", "README"):
201
+ readme = root / name
202
+ if not readme.exists():
203
+ continue
204
+ try:
205
+ content = readme.read_text(errors="ignore")
206
+ except Exception:
207
+ continue
208
+ for pattern in _README_PATTERNS:
209
+ for m in pattern.finditer(content):
210
+ var = m.group(1)
211
+ if var and not _is_filtered_env_var(var):
212
+ found.setdefault(var, "")
213
+ break # only scan the first README found
214
+
215
+
216
+ def _extract_manifest_env_vars(root: Path, found: dict[str, str]) -> bool:
217
+ """Extract env vars from a server.json MCP manifest (authoritative source).
218
+
219
+ The manifest is the standard MCP server descriptor. Env vars declared here
220
+ are always included — they bypass the prefix filter since the author
221
+ explicitly listed them as required.
222
+
223
+ Returns True if a valid server.json was found (even if it declares no env vars).
224
+ """
225
+ manifest = root / "server.json"
226
+ if not manifest.exists():
227
+ return False
228
+ try:
229
+ data = json.loads(manifest.read_text(errors="ignore"))
230
+ except Exception:
231
+ return False
232
+ # packages[].runtimeArguments — Docker -e flags (e.g. GitHub MCP server)
233
+ for pkg in data.get("packages", []):
234
+ for arg in pkg.get("runtimeArguments", []):
235
+ value = arg.get("value", "")
236
+ # Pattern: "ENV_VAR={placeholder}" — extract the var name before '='
237
+ if "=" in value:
238
+ var_name = value.split("=", 1)[0]
239
+ if var_name and var_name == var_name.upper():
240
+ desc = arg.get("description", "")
241
+ found.setdefault(var_name, desc)
242
+
243
+ # remotes[].variables — URL-interpolated secrets (e.g. ?api_key={key})
244
+ for remote in data.get("remotes", []):
245
+ for var_key, var_meta in (remote.get("variables") or {}).items():
246
+ desc = var_meta.get("description", "") if isinstance(var_meta, dict) else ""
247
+ found.setdefault(var_key, desc)
248
+ return True
249
+
250
+
251
+ def _scan_env_example(root: Path, found: dict[str, str]) -> None:
252
+ """Scan .env.example / .env.sample files for documented env vars."""
253
+ for env_file in root.glob(".env*"):
254
+ if env_file.name in (".env", ".env.local"):
255
+ continue # skip actual secrets
256
+ try:
257
+ for line in env_file.read_text(errors="ignore").splitlines():
258
+ line = line.strip()
259
+ if not line or line.startswith("#"):
260
+ continue
261
+ key = line.split("=", 1)[0].strip()
262
+ if key and key == key.upper() and not _is_filtered_env_var(key):
263
+ found.setdefault(key, "")
264
+ except Exception:
265
+ continue
266
+
267
+
268
+ def _detect_env_vars(tmp_dir: str) -> list[dict]:
269
+ """Scan repo files for required environment variables.
270
+
271
+ Tiered detection (stops at first tier that finds results):
272
+ 1. server.json manifest (authoritative — author's explicit declaration)
273
+ 2. README + .env.example (author's documentation)
274
+ 3. Source code scanning (last resort — catches os.Getenv / process.env / etc.)
275
+ """
276
+ root = Path(tmp_dir)
277
+ found: dict[str, str] = {}
278
+
279
+ # Tier 1: MCP server manifest — authoritative, skip everything else
280
+ if _extract_manifest_env_vars(root, found):
281
+ return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
282
+
283
+ # Tier 2: README — author's documented config (export, docker -e, JSON examples)
284
+ _scan_readme_for_env_vars(root, found)
285
+ if found:
286
+ return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
287
+
288
+ # Tier 3: .env.example — explicit config template
289
+ _scan_env_example(root, found)
290
+ if found:
291
+ return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
292
+
293
+ # Tier 4: Source code scanning — last resort
294
+ _scan_files_for_env_vars(root, "*.py", _ENV_VAR_PATTERN_PYTHON, found)
295
+ _scan_files_for_env_vars(root, "*.go", _ENV_VAR_PATTERN_GO, found)
296
+ for ext in ("*.ts", "*.js", "*.mts", "*.mjs"):
297
+ _scan_files_for_env_vars(root, ext, _ENV_VAR_PATTERN_TS, found)
298
+
299
+ return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
300
+
301
+
302
+ # Regex for Docker registry image references in README
303
+ _DOCKER_IMAGE_PATTERN = re.compile(
304
+ r"((?:ghcr\.io|docker\.io|registry\.[a-z0-9.-]+\.[a-z]{2,}|[a-z0-9.-]+\.azurecr\.io|[a-z0-9.-]+\.gcr\.io)"
305
+ r"/[a-z0-9_./-]+"
306
+ r"(?::[a-z0-9._-]+)?)"
307
+ )
308
+
309
+
310
+ def _detect_docker_image(root: Path, git_url: str) -> tuple[str | None, bool]:
311
+ """Detect Docker image from repo artifacts.
312
+
313
+ Returns (image, is_suggested). is_suggested=True for GHCR inference from git URL.
314
+
315
+ Priority: compose image > README reference > GHCR inference from git URL.
316
+ Dockerfile FROM is not returned (it's the build base, not the published image).
317
+ """
318
+ # 1. docker-compose / compose files — most authoritative for pre-built images
319
+ for compose_name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
320
+ compose_file = root / compose_name
321
+ if compose_file.exists():
322
+ try:
323
+ import yaml
324
+
325
+ data = yaml.safe_load(compose_file.read_text(errors="ignore"))
326
+ for svc in (data.get("services") or {}).values():
327
+ img = svc.get("image")
328
+ if img and isinstance(img, str):
329
+ return (img, False)
330
+ except Exception:
331
+ pass
332
+
333
+ # 2. README — look for registry image references
334
+ for readme_name in ("README.md", "README.rst", "README.txt", "README"):
335
+ readme = root / readme_name
336
+ if not readme.exists():
337
+ continue
338
+ try:
339
+ content = readme.read_text(errors="ignore")
340
+ m = _DOCKER_IMAGE_PATTERN.search(content)
341
+ if m:
342
+ return (m.group(1), False)
343
+ except Exception:
344
+ pass
345
+ break
346
+
347
+ # 3. Infer GHCR from GitHub URL
348
+ _safe_name = re.compile(r"^[a-zA-Z0-9._-]+$")
349
+ try:
350
+ url_parts = urlparse(git_url)
351
+ if url_parts.hostname and "github.com" in url_parts.hostname:
352
+ path = url_parts.path.strip("/")
353
+ if path.endswith(".git"):
354
+ path = path[:-4]
355
+ parts = path.split("/")
356
+ if len(parts) >= 2 and _safe_name.match(parts[0]) and _safe_name.match(parts[1]):
357
+ return (f"ghcr.io/{parts[0]}/{parts[1]}", True)
358
+ except Exception:
359
+ pass
360
+
361
+ return (None, False)
362
+
363
+
364
+ def _infer_command_args(
365
+ framework: str | None,
366
+ docker_image: str | None,
367
+ name: str,
368
+ entry_point: str | None = None,
369
+ ) -> tuple[str | None, list[str] | None]:
370
+ """Infer the startup command and args from framework + docker image.
371
+
372
+ Returns (command, args) or (None, None) if nothing can be inferred.
373
+ """
374
+ if docker_image:
375
+ return ("docker", ["run", "-i", "--rm", docker_image])
376
+
377
+ fw = (framework or "").lower()
378
+ if "typescript" in fw or "ts" in fw:
379
+ return ("npx", ["-y", name])
380
+ if "go" in fw:
381
+ return (name, [])
382
+ if "python" in fw or entry_point:
383
+ return ("python", ["-m", name])
384
+
385
+ return (None, None)
386
+
387
+
388
+ def _detect_non_python_mcp(tmp_dir: str) -> str | None:
389
+ """Check for non-Python MCP frameworks. Returns framework name or None."""
390
+ root = Path(tmp_dir)
391
+
392
+ pkg_json = root / "package.json"
393
+ if pkg_json.exists():
394
+ try:
395
+ data = json.loads(pkg_json.read_text(errors="ignore"))
396
+ all_deps = {}
397
+ all_deps.update(data.get("dependencies", {}))
398
+ all_deps.update(data.get("devDependencies", {}))
399
+ if "@modelcontextprotocol/sdk" in all_deps:
400
+ return "typescript-mcp-sdk"
401
+ except Exception:
402
+ pass
403
+
404
+ for go_file in root.rglob("*.go"):
405
+ try:
406
+ content = go_file.read_text(errors="ignore")
407
+ if "mcp-go" in content or "mcp_go" in content:
408
+ return "go-mcp-sdk"
409
+ except Exception:
410
+ continue
411
+
412
+ return None
413
+
414
+
415
+ def _extract_repo_name(git_url: str, tmp_dir: str) -> str:
416
+ """Extract a usable name from the git URL or directory name as fallback."""
417
+ try:
418
+ parsed = urlparse(git_url)
419
+ path = parsed.path.rstrip("/")
420
+ if path.endswith(".git"):
421
+ path = path[:-4]
422
+ name = path.rsplit("/", 1)[-1]
423
+ if name:
424
+ return name
425
+ except Exception:
426
+ pass
427
+ return Path(tmp_dir).name or "unknown"
428
+
429
+
430
+ def _analyze_python_entry(tree: ast.AST, git_url: str, tmp_dir: str) -> tuple[str, str, list[dict], list[str]]:
431
+ """Extract server name, description, tools, and issues from an AST.
432
+
433
+ Returns (server_name, server_desc, tools, issues).
434
+ """
435
+ server_name = ""
436
+ server_desc = ""
437
+ for node in ast.walk(tree):
438
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
439
+ continue
440
+ if node.func.id == "FastMCP":
441
+ if node.args and isinstance(node.args[0], ast.Constant):
442
+ server_name = str(node.args[0].value)
443
+ for kw in node.keywords:
444
+ if kw.arg == "description" and isinstance(kw.value, ast.Constant):
445
+ server_desc = str(kw.value.value)
446
+ if server_name:
447
+ break
448
+ if node.func.id == "Server":
449
+ for kw in node.keywords:
450
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant):
451
+ server_name = str(kw.value.value)
452
+ if kw.arg == "description" and isinstance(kw.value, ast.Constant):
453
+ server_desc = str(kw.value.value)
454
+ if not server_name and node.args and isinstance(node.args[0], ast.Constant):
455
+ server_name = str(node.args[0].value)
456
+ if server_name:
457
+ break
458
+
459
+ if not server_name:
460
+ server_name = _extract_repo_name(git_url, tmp_dir)
461
+
462
+ tools: list[dict] = []
463
+ issues: list[str] = []
464
+ for node in ast.walk(tree):
465
+ if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
466
+ continue
467
+ is_tool = any(
468
+ (isinstance(d, ast.Attribute) and d.attr == "tool")
469
+ or (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute) and d.func.attr == "tool")
470
+ for d in node.decorator_list
471
+ )
472
+ if is_tool:
473
+ docstring = ast.get_docstring(node) or ""
474
+ untyped = [a.arg for a in node.args.args if a.arg != "self" and a.annotation is None]
475
+ tools.append({"name": node.name, "docstring": docstring})
476
+ if len(docstring) < 20:
477
+ issues.append(f"Tool '{node.name}': docstring too short ({len(docstring)} chars, need 20+)")
478
+ if untyped:
479
+ issues.append(f"Tool '{node.name}': untyped params: {', '.join(untyped)}")
480
+
481
+ if not tools:
482
+ issues.append("No @tool decorated functions found")
483
+
484
+ return server_name, server_desc, tools, issues
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Public API
489
+ # ---------------------------------------------------------------------------
490
+
491
+
492
+ def analyze_local(git_url: str) -> dict:
493
+ """Clone a repo locally and analyze it for MCP metadata.
494
+
495
+ Returns a dict matching the McpAnalyzeResponse shape:
496
+ {name, description, version, tools, environment_variables, issues, error}
497
+ """
498
+ _empty: dict = {"name": "", "description": "", "version": "0.1.0", "tools": []}
499
+
500
+ tmp_dir = tempfile.mkdtemp(prefix="observal_cli_analyze_")
501
+ try:
502
+ clone_err = _clone_repo(git_url, tmp_dir)
503
+ if clone_err:
504
+ return {**_empty, "error": clone_err}
505
+
506
+ # Find Python MCP entry point
507
+ entry_point = None
508
+ for py_file in Path(tmp_dir).rglob("*.py"):
509
+ try:
510
+ if _PYTHON_MCP_PATTERN.search(py_file.read_text(errors="ignore")):
511
+ entry_point = py_file
512
+ break
513
+ except Exception:
514
+ continue
515
+
516
+ env_vars = _detect_env_vars(tmp_dir)
517
+
518
+ if not entry_point:
519
+ non_python = _detect_non_python_mcp(tmp_dir)
520
+ name = _extract_repo_name(git_url, tmp_dir)
521
+ docker_image, docker_suggested = _detect_docker_image(Path(tmp_dir), git_url)
522
+ cmd, cmd_args = _infer_command_args(non_python, docker_image, name)
523
+ base: dict = {
524
+ "name": name,
525
+ "description": "",
526
+ "version": "0.1.0",
527
+ "tools": [],
528
+ "environment_variables": env_vars,
529
+ }
530
+ if non_python:
531
+ base["framework"] = non_python
532
+ if docker_image:
533
+ base["docker_image"] = docker_image
534
+ base["docker_image_suggested"] = docker_suggested
535
+ if cmd:
536
+ base["command"] = cmd
537
+ base["args"] = cmd_args
538
+ return base
539
+
540
+ tree = ast.parse(entry_point.read_text(errors="ignore"))
541
+ server_name, server_desc, tools, issues = _analyze_python_entry(tree, git_url, tmp_dir)
542
+ relative_entry = str(entry_point.relative_to(tmp_dir))
543
+
544
+ docker_image, docker_suggested = _detect_docker_image(Path(tmp_dir), git_url)
545
+ cmd, cmd_args = _infer_command_args("python", docker_image, server_name, relative_entry)
546
+ result: dict = {
547
+ "name": server_name,
548
+ "description": server_desc,
549
+ "version": "0.1.0",
550
+ "tools": tools,
551
+ "issues": issues,
552
+ "environment_variables": env_vars,
553
+ "entry_point": relative_entry,
554
+ }
555
+ if docker_image:
556
+ result["docker_image"] = docker_image
557
+ result["docker_image_suggested"] = docker_suggested
558
+ if cmd:
559
+ result["command"] = cmd
560
+ result["args"] = cmd_args
561
+ return result
562
+ except Exception:
563
+ return {**_empty, "error": "Local analysis failed unexpectedly."}
564
+ finally:
565
+ shutil.rmtree(tmp_dir, ignore_errors=True)
@@ -0,0 +1,19 @@
1
+ """Observal CLI branding — ASCII banner and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich import print as rprint
6
+
7
+ BANNER = r"""
8
+ ██████╗ ██████╗ ███████╗███████╗██████╗ ██╗ ██╗ █████╗ ██╗
9
+ ██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔══██╗██║
10
+ ██║ ██║██████╔╝███████╗█████╗ ██████╔╝██║ ██║███████║██║
11
+ ██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══██║██║
12
+ ╚██████╔╝██████╔╝███████║███████╗██║ ██║ ╚████╔╝ ██║ ██║███████╗
13
+ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝
14
+ """
15
+
16
+
17
+ def welcome_banner() -> None:
18
+ """Print the Observal welcome banner."""
19
+ rprint(f"[bold cyan]{BANNER}[/bold cyan]")