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,1044 @@
1
+ """MCP server CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich import print as rprint
12
+ from rich.table import Table
13
+
14
+ from observal_cli import client, config
15
+ from observal_cli.analyzer import analyze_local
16
+ from observal_cli.constants import VALID_IDES, VALID_MCP_CATEGORIES
17
+ from observal_cli.prompts import fuzzy_select, select_one
18
+ from observal_cli.render import (
19
+ console,
20
+ ide_tags,
21
+ kv_panel,
22
+ output_json,
23
+ relative_time,
24
+ spinner,
25
+ status_badge,
26
+ )
27
+
28
+ mcp_app = typer.Typer(help="MCP server registry commands")
29
+
30
+
31
+ # ── Env var configuration helpers ────────────────────────────
32
+
33
+
34
+ def _parse_env_file(file_path: str) -> list[dict]:
35
+ """Parse a .env-style file and return env var dicts."""
36
+ path = Path(file_path).expanduser().resolve()
37
+ if not path.exists():
38
+ rprint(f"[red]File not found:[/red] {path}")
39
+ return []
40
+
41
+ env_vars: list[dict] = []
42
+ for line in path.read_text(errors="ignore").splitlines():
43
+ line = line.strip()
44
+ if not line or line.startswith("#"):
45
+ continue
46
+ key = line.split("=", 1)[0].strip()
47
+ if key and key == key.upper():
48
+ env_vars.append({"name": key, "description": "", "required": True})
49
+ return env_vars
50
+
51
+
52
+ def _configure_env_vars_interactive(detected: list[dict]) -> list[dict]:
53
+ """Interactive env var configuration at submit time.
54
+
55
+ Offers three paths:
56
+ 1. Review and edit auto-detected vars
57
+ 2. Load from an env file path
58
+ 3. Enter manually
59
+ """
60
+ is_tty = sys.stdin.isatty()
61
+
62
+ if detected:
63
+ rprint(f"\n[bold]Auto-detected {len(detected)} env var(s):[/bold]")
64
+ for ev in detected:
65
+ rprint(f" [cyan]*[/cyan] {ev['name']}")
66
+
67
+ rprint("\n[bold]How would you like to configure environment variables?[/bold]")
68
+
69
+ if is_tty:
70
+ choices = []
71
+ if detected:
72
+ choices.append("Review auto-detected vars")
73
+ choices.extend(["Load from .env file", "Enter manually", "Skip (no env vars)"])
74
+ choice = select_one("Env var configuration", choices)
75
+ else:
76
+ if detected:
77
+ rprint(" 1. Review auto-detected vars")
78
+ rprint(" 2. Load from .env file")
79
+ rprint(" 3. Enter manually")
80
+ rprint(" 4. Skip (no env vars)")
81
+ raw = typer.prompt("Choose", default="1")
82
+ else:
83
+ rprint(" 1. Load from .env file")
84
+ rprint(" 2. Enter manually")
85
+ rprint(" 3. Skip (no env vars)")
86
+ raw = typer.prompt("Choose", default="3")
87
+ choice_map = {
88
+ "1": "Review auto-detected vars" if detected else "Load from .env file",
89
+ "2": "Load from .env file" if detected else "Enter manually",
90
+ "3": "Enter manually" if detected else "Skip (no env vars)",
91
+ "4": "Skip (no env vars)",
92
+ }
93
+ choice = choice_map.get(raw, "Skip (no env vars)")
94
+
95
+ if choice == "Skip (no env vars)":
96
+ return []
97
+
98
+ if choice == "Load from .env file":
99
+ file_path = typer.prompt("Path to .env file (e.g. .env.example)")
100
+ env_vars = _parse_env_file(file_path)
101
+ if not env_vars:
102
+ rprint("[yellow]No variables found in file.[/yellow]")
103
+ return []
104
+ rprint(f"\n[green]Loaded {len(env_vars)} var(s) from file.[/green]")
105
+ return _review_env_vars(env_vars)
106
+
107
+ if choice == "Enter manually":
108
+ return _enter_env_vars_manually()
109
+
110
+ # Review auto-detected
111
+ return _review_env_vars(detected)
112
+
113
+
114
+ def _review_env_vars(env_vars: list[dict]) -> list[dict]:
115
+ """Let the developer review, remove, and annotate each env var."""
116
+ reviewed: list[dict] = []
117
+
118
+ rprint("\n[bold]Review each variable[/bold]\n")
119
+
120
+ for ev in env_vars:
121
+ action = typer.prompt(
122
+ f" {ev['name']} — keep? [Enter=keep / r=remove / o=optional]",
123
+ default="",
124
+ show_default=False,
125
+ )
126
+ action = action.strip().lower()
127
+
128
+ if action == "r":
129
+ rprint(" [dim]removed[/dim]")
130
+ continue
131
+
132
+ required = action != "o"
133
+ desc = ev.get("description", "")
134
+ if not desc:
135
+ desc = typer.prompt(f" Description for {ev['name']} (optional)", default="")
136
+
137
+ reviewed.append({"name": ev["name"], "description": desc, "required": required})
138
+ status = "[green]required[/green]" if required else "[yellow]optional[/yellow]"
139
+ rprint(f" {status}")
140
+
141
+ # Offer to add more
142
+ while True:
143
+ add_more = typer.prompt("\n Add another env var? (name or Enter to finish)", default="")
144
+ if not add_more:
145
+ break
146
+ desc = typer.prompt(f" Description for {add_more} (optional)", default="")
147
+ req = typer.confirm(" Required?", default=True)
148
+ reviewed.append({"name": add_more.strip().upper(), "description": desc, "required": req})
149
+
150
+ return reviewed
151
+
152
+
153
+ def _enter_env_vars_manually() -> list[dict]:
154
+ """Prompt the developer to enter env vars one by one."""
155
+ env_vars: list[dict] = []
156
+ rprint("\n[bold]Enter env vars one at a time[/bold] [dim](empty name to finish)[/dim]\n")
157
+
158
+ while True:
159
+ name = typer.prompt(" Variable name (or Enter to finish)", default="")
160
+ if not name:
161
+ break
162
+ name = name.strip().upper()
163
+ desc = typer.prompt(f" Description for {name} (optional)", default="")
164
+ req = typer.confirm(" Required?", default=True)
165
+ env_vars.append({"name": name, "description": desc, "required": req})
166
+
167
+ return env_vars
168
+
169
+
170
+ # ── Dollar-sign variable detection ──────────────────────────
171
+
172
+ _DOLLAR_VAR_RE = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
173
+
174
+
175
+ def _extract_dollar_vars(args: list[str], env: dict[str, str]) -> list[str]:
176
+ """Extract unique $VAR / ${VAR} references from args and env values.
177
+
178
+ Returns a sorted list of uppercase variable names found in the args list
179
+ and the *values* (not keys) of the env dict, filtered to exclude
180
+ system/infrastructure vars (PATH, HOME, CI_*, etc.).
181
+ """
182
+ from observal_cli.analyzer import _is_filtered_env_var
183
+
184
+ found: set[str] = set()
185
+ for arg in args:
186
+ found.update(_DOLLAR_VAR_RE.findall(arg))
187
+ for value in env.values():
188
+ if isinstance(value, str):
189
+ found.update(_DOLLAR_VAR_RE.findall(value))
190
+ return sorted(name for name in found if not _is_filtered_env_var(name))
191
+
192
+
193
+ # ── Direct config helpers ────────────────────────────────────
194
+
195
+
196
+ def _unwrap_mcp_config(cfg: dict) -> tuple[dict, str | None]:
197
+ """Unwrap nested mcpServers / named-server wrappers.
198
+
199
+ Accepts three shapes:
200
+ 1. {"mcpServers": {"name": {config}}}
201
+ 2. {"name": {config}} (single key whose value has command/url/type)
202
+ 3. {config} (bare config with command/args or url)
203
+
204
+ Returns (inner_config, server_name | None).
205
+ """
206
+ # Shape 1: wrapped under mcpServers
207
+ if "mcpServers" in cfg and isinstance(cfg["mcpServers"], dict):
208
+ servers = cfg["mcpServers"]
209
+ if len(servers) == 1:
210
+ server_name, inner = next(iter(servers.items()))
211
+ if isinstance(inner, dict):
212
+ return inner, server_name
213
+ return cfg, None
214
+
215
+ # Shape 3: bare config — has a direct config key
216
+ if cfg.get("command") or cfg.get("url") or cfg.get("type"):
217
+ return cfg, None
218
+
219
+ # Shape 2: single named key wrapping a config dict
220
+ if len(cfg) == 1:
221
+ server_name, inner = next(iter(cfg.items()))
222
+ if isinstance(inner, dict) and (inner.get("command") or inner.get("url") or inner.get("type")):
223
+ return inner, server_name
224
+
225
+ return cfg, None
226
+
227
+
228
+ def _parse_direct_config(cfg: dict) -> dict:
229
+ """Normalize a JSON config dict (mcp.json style) into submit-ready fields.
230
+
231
+ Accepts wrapped (mcpServers) or bare configs.
232
+ Handles two transport shapes:
233
+ - stdio: {command, args, env}
234
+ - SSE/HTTP: {url, type, headers, autoApprove}
235
+ """
236
+ inner, server_name = _unwrap_mcp_config(cfg)
237
+ parsed: dict = {}
238
+ if server_name:
239
+ parsed["_server_name"] = server_name
240
+
241
+ if inner.get("url") and not inner.get("command"):
242
+ # SSE / streamable-http transport
243
+ transport = inner.get("type", "sse")
244
+ parsed["transport"] = transport
245
+ parsed["url"] = inner["url"]
246
+
247
+ # Convert headers dict {name: value} → list of {name, description, required}
248
+ raw_headers = inner.get("headers") or {}
249
+ if isinstance(raw_headers, dict):
250
+ parsed["headers"] = [{"name": k, "description": "", "required": True} for k in raw_headers]
251
+ elif isinstance(raw_headers, list):
252
+ parsed["headers"] = raw_headers
253
+
254
+ if inner.get("autoApprove"):
255
+ parsed["auto_approve"] = inner["autoApprove"]
256
+
257
+ # env as environment_variables
258
+ raw_env = inner.get("env") or {}
259
+ if isinstance(raw_env, dict):
260
+ parsed["environment_variables"] = [{"name": k, "description": "", "required": True} for k in raw_env]
261
+
262
+ # Detect $VAR references in env values
263
+ dollar_vars = _extract_dollar_vars([], raw_env)
264
+ existing_names = {ev["name"] for ev in parsed.get("environment_variables", [])}
265
+ for var_name in dollar_vars:
266
+ if var_name not in existing_names:
267
+ parsed.setdefault("environment_variables", []).append(
268
+ {"name": var_name, "description": "", "required": True}
269
+ )
270
+ existing_names.add(var_name)
271
+ if dollar_vars:
272
+ parsed["_dollar_vars_detected"] = dollar_vars
273
+
274
+ elif inner.get("command"):
275
+ # stdio transport
276
+ parsed["transport"] = "stdio"
277
+ parsed["command"] = inner["command"]
278
+ parsed["args"] = inner.get("args") or []
279
+
280
+ # Derive framework from command
281
+ cmd = inner["command"]
282
+ if cmd == "docker":
283
+ parsed["framework"] = "docker"
284
+ # Extract docker_image: last non-flag arg
285
+ args = parsed["args"]
286
+ for arg in reversed(args):
287
+ if not arg.startswith("-"):
288
+ parsed["docker_image"] = arg
289
+ break
290
+ elif cmd in ("python", "python3"):
291
+ parsed["framework"] = "python"
292
+ elif cmd in ("npx", "node"):
293
+ parsed["framework"] = "typescript"
294
+ else:
295
+ parsed["framework"] = None
296
+
297
+ # env as environment_variables
298
+ raw_env = inner.get("env") or {}
299
+ if isinstance(raw_env, dict):
300
+ parsed["environment_variables"] = [{"name": k, "description": "", "required": True} for k in raw_env]
301
+
302
+ # Detect $VAR references in args and env values
303
+ dollar_vars = _extract_dollar_vars(parsed["args"], raw_env)
304
+ existing_names = {ev["name"] for ev in parsed.get("environment_variables", [])}
305
+ for var_name in dollar_vars:
306
+ if var_name not in existing_names:
307
+ parsed.setdefault("environment_variables", []).append(
308
+ {"name": var_name, "description": "", "required": True}
309
+ )
310
+ existing_names.add(var_name)
311
+ if dollar_vars:
312
+ parsed["_dollar_vars_detected"] = dollar_vars
313
+
314
+ if inner.get("autoApprove"):
315
+ parsed["auto_approve"] = inner["autoApprove"]
316
+
317
+ return parsed
318
+
319
+
320
+ def _build_config_preview(server_name: str, parsed: dict) -> dict:
321
+ """Build a mcp.json-style preview dict for display during submit."""
322
+ preview: dict = {}
323
+
324
+ if parsed.get("url"):
325
+ # SSE / streamable-http preview
326
+ preview["type"] = parsed.get("transport", "sse")
327
+ preview["url"] = parsed["url"]
328
+ if parsed.get("headers"):
329
+ preview["headers"] = {h["name"]: f"<{h['name']}>" for h in parsed["headers"]}
330
+ env_vars = parsed.get("environment_variables") or []
331
+ if env_vars:
332
+ preview["env"] = {ev["name"]: f"<{ev['name']}>" for ev in env_vars}
333
+ if parsed.get("auto_approve"):
334
+ preview["autoApprove"] = parsed["auto_approve"]
335
+ preview["disabled"] = False
336
+ else:
337
+ # stdio preview
338
+ command = parsed.get("command", "")
339
+ args = list(parsed.get("args") or [])
340
+
341
+ # Inject -e flags for docker env vars
342
+ env_vars = parsed.get("environment_variables") or []
343
+ if command == "docker" and env_vars:
344
+ # Find the image position (last non-flag arg) and inject -e before it
345
+ insert_idx = len(args)
346
+ for i in range(len(args) - 1, -1, -1):
347
+ if not args[i].startswith("-"):
348
+ insert_idx = i
349
+ break
350
+ for ev in reversed(env_vars):
351
+ args.insert(insert_idx, f"{ev['name']}=<{ev['name']}>")
352
+ args.insert(insert_idx, "-e")
353
+
354
+ preview["command"] = command
355
+ preview["args"] = args
356
+ if env_vars:
357
+ preview["env"] = {ev["name"]: f"<{ev['name']}>" for ev in env_vars}
358
+
359
+ return {server_name: preview}
360
+
361
+
362
+ # ── Implementation functions (shared by canonical + deprecated) ──
363
+
364
+
365
+ def _submit_impl(git_url, name, category, yes, direct_config=False, draft=False):
366
+ # ── Path B/C: Direct JSON config (no git URL needed) ─────
367
+ if direct_config:
368
+ rprint("[bold]Paste your MCP server JSON config below.[/bold]")
369
+ rprint("[dim]Press Enter on an empty line when done.[/dim]\n")
370
+ lines: list[str] = []
371
+ has_content = False
372
+ while True:
373
+ try:
374
+ line = input()
375
+ except EOFError:
376
+ break
377
+ if line.strip() == "":
378
+ if has_content:
379
+ break
380
+ else:
381
+ has_content = True
382
+ lines.append(line)
383
+ raw_text = "\n".join(lines).strip()
384
+ if not raw_text:
385
+ rprint("[red]No input received.[/red]")
386
+ raise typer.Exit(1)
387
+ try:
388
+ cfg = json.loads(raw_text)
389
+ except json.JSONDecodeError:
390
+ # Long single-line pastes can get split by the terminal — retry without newlines
391
+ try:
392
+ cfg = json.loads("".join(part.strip() for part in lines))
393
+ except json.JSONDecodeError as e:
394
+ rprint(f"[red]Invalid JSON:[/red] {e}")
395
+ raise typer.Exit(1)
396
+
397
+ parsed = _parse_direct_config(cfg)
398
+ _name = name or parsed.pop("_server_name", None) or "my-mcp-server"
399
+
400
+ # Notify about dollar-sign input variables
401
+ dollar_vars = parsed.pop("_dollar_vars_detected", None)
402
+ if dollar_vars:
403
+ rprint("\n[bold yellow]Input variables detected:[/bold yellow]")
404
+ rprint(
405
+ "[dim]Dollar-sign variables in args/env will become install-time"
406
+ " dependencies — users will be prompted for these values.[/dim]\n"
407
+ )
408
+ for var in dollar_vars:
409
+ rprint(f" [cyan]$[/cyan]{var}")
410
+ rprint()
411
+
412
+ rprint("\n[bold]Config preview:[/bold]")
413
+ console.print_json(json.dumps(_build_config_preview(_name, parsed), indent=2))
414
+
415
+ if not yes:
416
+ if not typer.confirm("\nSubmit this config?", default=True):
417
+ raise typer.Abort()
418
+
419
+ # Let creator review/confirm input dependencies
420
+ if dollar_vars:
421
+ rprint("\n[bold]Confirm input dependencies:[/bold]")
422
+ parsed["environment_variables"] = _review_env_vars(parsed.get("environment_variables", []))
423
+
424
+ _name = name or typer.prompt("Server name", default=_name)
425
+ _desc = typer.prompt("Description (what does this server do?)", default="")
426
+ _owner = typer.prompt("Owner / Team (e.g. your GitHub username)", default="default")
427
+ _category = category or select_one("Category", VALID_MCP_CATEGORIES, default="general")
428
+ else:
429
+ if dollar_vars:
430
+ rprint(f"\n[dim]Auto-detected {len(dollar_vars)} input variable(s) from $VAR patterns.[/dim]")
431
+ _desc = ""
432
+ _owner = "default"
433
+ _category = category or "general"
434
+
435
+ supported_ides = list(VALID_IDES)
436
+ submit_payload: dict = {
437
+ "name": _name,
438
+ "version": "0.1.0",
439
+ "category": _category,
440
+ "description": _desc,
441
+ "owner": _owner,
442
+ "supported_ides": supported_ides,
443
+ "environment_variables": parsed.get("environment_variables", []),
444
+ }
445
+ if parsed.get("command"):
446
+ submit_payload["command"] = parsed["command"]
447
+ if parsed.get("args") is not None:
448
+ submit_payload["args"] = parsed["args"]
449
+ if parsed.get("url"):
450
+ submit_payload["url"] = parsed["url"]
451
+ if parsed.get("headers"):
452
+ submit_payload["headers"] = parsed["headers"]
453
+ if parsed.get("auto_approve"):
454
+ submit_payload["auto_approve"] = parsed["auto_approve"]
455
+ if parsed.get("transport"):
456
+ submit_payload["transport"] = parsed["transport"]
457
+ if parsed.get("framework"):
458
+ submit_payload["framework"] = parsed["framework"]
459
+ if parsed.get("docker_image"):
460
+ submit_payload["docker_image"] = parsed["docker_image"]
461
+
462
+ endpoint = "/api/v1/mcps/draft" if draft else "/api/v1/mcps/submit"
463
+ label = "Saving draft..." if draft else "Submitting..."
464
+ with spinner(label):
465
+ result = client.post(endpoint, submit_payload)
466
+ msg = "Draft saved!" if draft else "Submitted!"
467
+ rprint(f"\n[green]{msg}[/green] ID: [bold]{result['id']}[/bold]")
468
+ rprint(f" Status: {status_badge(result.get('status', 'pending'))}")
469
+ return
470
+
471
+ # ── Path A: Git URL analysis ─────────────────────────────
472
+ analyzed_locally = False
473
+ with spinner("Analyzing repository..."):
474
+ try:
475
+ prefill = analyze_local(git_url)
476
+ if prefill.get("error"):
477
+ rprint(f"[yellow]Local analysis issue:[/yellow] {prefill['error']}")
478
+ rprint("[dim]Falling back to server-side analysis...[/dim]")
479
+ try:
480
+ prefill = client.post("/api/v1/mcps/analyze", {"git_url": git_url})
481
+ except (Exception, SystemExit):
482
+ rprint("[yellow]Server analysis also failed. Fill in details manually.[/yellow]")
483
+ prefill = {}
484
+ else:
485
+ analyzed_locally = True
486
+ except Exception:
487
+ try:
488
+ prefill = client.post("/api/v1/mcps/analyze", {"git_url": git_url})
489
+ except (Exception, SystemExit):
490
+ rprint("[yellow]Could not analyze repo. Fill in details manually.[/yellow]")
491
+ prefill = {}
492
+
493
+ # ── Analysis summary ──────────────────────────────────────
494
+ detected_name = prefill.get("name", "")
495
+ detected_desc = prefill.get("description", "")
496
+ detected_ver = prefill.get("version", "0.1.0")
497
+ detected_framework = prefill.get("framework", "")
498
+ tools = prefill.get("tools", [])
499
+
500
+ detected_env_vars = prefill.get("environment_variables", [])
501
+ issues = prefill.get("issues", [])
502
+ error = prefill.get("error", "")
503
+
504
+ # Extract command/args/docker fields from analysis
505
+ detected_command = prefill.get("command")
506
+ detected_args = prefill.get("args")
507
+ detected_docker_image = prefill.get("docker_image")
508
+ detected_docker_suggested = prefill.get("docker_image_suggested", False)
509
+
510
+ rprint("\n[bold]--- Analysis Results ---[/bold]")
511
+
512
+ if error:
513
+ rprint(f" [bold red]Error:[/bold red] {error}")
514
+ rprint(" [dim]You can still submit manually, but the server could not be analyzed.[/dim]")
515
+ if not yes and not typer.confirm("Continue with manual submission?", default=False):
516
+ raise typer.Abort()
517
+ else:
518
+ if detected_name:
519
+ rprint(f" Server name: [cyan]{detected_name}[/cyan]")
520
+ if detected_desc:
521
+ rprint(f" Description: [dim]{detected_desc[:80]}{'...' if len(detected_desc) > 80 else ''}[/dim]")
522
+ if tools:
523
+ rprint(f" Tools found: [green]{len(tools)}[/green]")
524
+ for t in tools[:10]:
525
+ doc = t.get("docstring", t.get("description", ""))
526
+ rprint(f" [cyan]*[/cyan] {t.get('name', '?')}: {doc[:60] if doc else '[dim](no description)[/dim]'}")
527
+ if len(tools) > 10:
528
+ rprint(f" [dim]...and {len(tools) - 10} more[/dim]")
529
+ if detected_env_vars:
530
+ rprint(f" Env vars: [green]{len(detected_env_vars)}[/green]")
531
+ for ev in detected_env_vars:
532
+ ev_name = ev.get("name", ev) if isinstance(ev, dict) else ev
533
+ rprint(f" [cyan]*[/cyan] {ev_name}")
534
+ if not detected_name and not tools:
535
+ rprint(" [dim]No MCP metadata detected. You will need to fill in all fields manually.[/dim]")
536
+
537
+ if issues:
538
+ rprint(f"\n [bold yellow]Warnings ({len(issues)}):[/bold yellow]")
539
+ for issue in issues:
540
+ rprint(f" [yellow]![/yellow] {issue}")
541
+ rprint()
542
+ if not yes and not typer.confirm("This server has quality issues. Submit anyway?", default=False):
543
+ raise typer.Abort()
544
+
545
+ rprint("[bold]------------------------[/bold]\n")
546
+
547
+ # ── Auto-accept detected fields, only prompt for missing/required ──
548
+ # MCP servers are IDE-agnostic — config generation handles all IDEs.
549
+ supported_ides = list(VALID_IDES)
550
+
551
+ # Build parsed dict from analysis for config preview
552
+ parsed: dict = {}
553
+ if detected_command:
554
+ parsed["command"] = detected_command
555
+ parsed["args"] = detected_args or []
556
+ parsed["transport"] = "stdio"
557
+ parsed["environment_variables"] = detected_env_vars
558
+ if detected_docker_image:
559
+ parsed["docker_image"] = detected_docker_image
560
+
561
+ # Derive framework from command
562
+ _framework: str | None = None
563
+ if detected_command:
564
+ if detected_command == "docker":
565
+ _framework = "docker"
566
+ elif detected_command in ("python", "python3"):
567
+ _framework = "python"
568
+ elif detected_command in ("npx", "node"):
569
+ _framework = "typescript"
570
+ elif detected_framework:
571
+ fw_lower = detected_framework.lower()
572
+ if "typescript" in fw_lower or "ts" in fw_lower:
573
+ _framework = "typescript"
574
+ elif "go" in fw_lower:
575
+ _framework = "go"
576
+ elif "docker" in fw_lower:
577
+ _framework = "docker"
578
+ else:
579
+ _framework = "python"
580
+ elif detected_framework:
581
+ fw_lower = detected_framework.lower()
582
+ if "typescript" in fw_lower or "ts" in fw_lower:
583
+ _framework = "typescript"
584
+ elif "go" in fw_lower:
585
+ _framework = "go"
586
+ elif "docker" in fw_lower:
587
+ _framework = "docker"
588
+ else:
589
+ _framework = "python"
590
+ elif prefill.get("entry_point"):
591
+ _framework = "python"
592
+
593
+ # Command/args confirmation
594
+ _command = detected_command
595
+ _args = detected_args
596
+ _docker_image = detected_docker_image
597
+
598
+ if yes:
599
+ _name = name or detected_name
600
+ _version = detected_ver
601
+ _desc = detected_desc
602
+ _owner = "default"
603
+ _category = category or "general"
604
+ if not _framework:
605
+ _framework = "python"
606
+ _setup = ""
607
+ _changelog = "Initial release"
608
+ # Detect $VAR patterns in args and merge into env vars
609
+ dollar_vars = _extract_dollar_vars(_args or [], {})
610
+ existing_names = {(ev.get("name", ev) if isinstance(ev, dict) else ev) for ev in detected_env_vars}
611
+ for var_name in dollar_vars:
612
+ if var_name not in existing_names:
613
+ detected_env_vars.append({"name": var_name, "description": "", "required": True})
614
+ existing_names.add(var_name)
615
+ if dollar_vars:
616
+ rprint(f"\n[dim]Auto-detected {len(dollar_vars)} input variable(s) from $VAR patterns in args.[/dim]")
617
+ env_vars = detected_env_vars
618
+ else:
619
+ # Show config preview if command was detected
620
+ if detected_command:
621
+ preview_name = name or detected_name or "my-server"
622
+ rprint("[bold]Startup config:[/bold]")
623
+ console.print_json(json.dumps(_build_config_preview(preview_name, parsed), indent=2))
624
+ if detected_docker_suggested:
625
+ rprint(
626
+ f" [dim](Docker image [cyan]{detected_docker_image}[/cyan]"
627
+ " was inferred from the GitHub URL — verify it exists)[/dim]"
628
+ )
629
+ choice = (
630
+ typer.prompt(
631
+ "Startup config looks correct? [Y/n/edit]",
632
+ default="Y",
633
+ show_default=False,
634
+ )
635
+ .strip()
636
+ .lower()
637
+ )
638
+ if choice == "n":
639
+ raise typer.Abort()
640
+ elif choice == "edit":
641
+ _command = typer.prompt("Command", default=detected_command or "")
642
+ raw_args = typer.prompt(
643
+ "Args (space-separated)",
644
+ default=" ".join(detected_args) if detected_args else "",
645
+ )
646
+ _args = raw_args.split() if raw_args.strip() else []
647
+ # Re-derive framework
648
+ if _command == "docker":
649
+ _framework = "docker"
650
+ for arg in reversed(_args):
651
+ if not arg.startswith("-"):
652
+ _docker_image = arg
653
+ break
654
+ elif _command in ("python", "python3"):
655
+ _framework = "python"
656
+ elif _command in ("npx", "node"):
657
+ _framework = "typescript"
658
+ elif not detected_command:
659
+ rprint("[dim]No startup command was detected.[/dim]")
660
+ custom_cmd = typer.prompt("Command (e.g. docker, python, npx — Enter to skip)", default="")
661
+ if custom_cmd:
662
+ _command = custom_cmd
663
+ raw_args = typer.prompt("Args (space-separated)", default="")
664
+ _args = raw_args.split() if raw_args.strip() else []
665
+ if _command == "docker":
666
+ _framework = "docker"
667
+ for arg in reversed(_args):
668
+ if not arg.startswith("-"):
669
+ _docker_image = arg
670
+ break
671
+ elif _command in ("python", "python3"):
672
+ _framework = "python"
673
+ elif _command in ("npx", "node"):
674
+ _framework = "typescript"
675
+
676
+ # Name: auto-accept if detected, otherwise ask
677
+ if name:
678
+ _name = name
679
+ elif detected_name:
680
+ _name = detected_name
681
+ rprint(f" Server name: [cyan]{_name}[/cyan] [dim](from analysis)[/dim]")
682
+ else:
683
+ _name = typer.prompt("Server name")
684
+
685
+ # Version: auto-accept detected
686
+ _version = detected_ver
687
+ rprint(f" Version: [cyan]{_version}[/cyan]")
688
+
689
+ # Description: auto-accept if detected, otherwise ask
690
+ if detected_desc:
691
+ _desc = detected_desc
692
+ rprint(
693
+ f" Description: [cyan]{_desc[:60]}{'...' if len(_desc) > 60 else ''}[/cyan] [dim](from analysis)[/dim]"
694
+ )
695
+ else:
696
+ _desc = typer.prompt("Description (what does this server do?)")
697
+
698
+ _owner = typer.prompt("\nOwner / Team (e.g. your GitHub username)")
699
+ rprint()
700
+
701
+ _category = category or select_one("Category", VALID_MCP_CATEGORIES, default="general")
702
+
703
+ _setup = typer.prompt("Setup instructions (optional, press Enter to skip)", default="")
704
+ _changelog = typer.prompt("Changelog", default="Initial release")
705
+
706
+ # Detect $VAR patterns in final args and merge into detected env vars
707
+ dollar_vars = _extract_dollar_vars(_args or [], {})
708
+ existing_names = {(ev.get("name", ev) if isinstance(ev, dict) else ev) for ev in detected_env_vars}
709
+ for var_name in dollar_vars:
710
+ if var_name not in existing_names:
711
+ detected_env_vars.append({"name": var_name, "description": "", "required": True})
712
+ existing_names.add(var_name)
713
+ if dollar_vars:
714
+ rprint("\n[bold yellow]Input variables detected in args:[/bold yellow]")
715
+ rprint(
716
+ "[dim]Dollar-sign variables will become install-time"
717
+ " dependencies — users will be prompted for these values.[/dim]\n"
718
+ )
719
+ for var in dollar_vars:
720
+ rprint(f" [cyan]$[/cyan]{var}")
721
+ rprint()
722
+
723
+ # Interactive env var configuration — developer reviews, edits,
724
+ # or provides env vars instead of blindly including auto-detected ones.
725
+ env_vars = _configure_env_vars_interactive(detected_env_vars)
726
+
727
+ submit_payload = {
728
+ "git_url": git_url,
729
+ "name": _name,
730
+ "version": _version,
731
+ "category": _category,
732
+ "description": _desc,
733
+ "owner": _owner,
734
+ "supported_ides": supported_ides,
735
+ "environment_variables": env_vars,
736
+ "setup_instructions": _setup,
737
+ "changelog": _changelog,
738
+ }
739
+ if _framework:
740
+ submit_payload["framework"] = _framework
741
+ if _docker_image:
742
+ submit_payload["docker_image"] = _docker_image
743
+ if _command:
744
+ submit_payload["command"] = _command
745
+ if _args is not None:
746
+ submit_payload["args"] = _args
747
+
748
+ if analyzed_locally:
749
+ submit_payload["client_analysis"] = {
750
+ "tools": prefill.get("tools", []),
751
+ "issues": prefill.get("issues", []),
752
+ "framework": prefill.get("framework", ""),
753
+ "entry_point": prefill.get("entry_point", ""),
754
+ "command": prefill.get("command"),
755
+ "args": prefill.get("args"),
756
+ "docker_image": prefill.get("docker_image"),
757
+ }
758
+
759
+ endpoint = "/api/v1/mcps/draft" if draft else "/api/v1/mcps/submit"
760
+ label = "Saving draft..." if draft else "Submitting..."
761
+ with spinner(label):
762
+ result = client.post(endpoint, submit_payload)
763
+ msg = "Draft saved!" if draft else "Submitted!"
764
+ rprint(f"\n[green]{msg}[/green] ID: [bold]{result['id']}[/bold]")
765
+ if _framework:
766
+ rprint(f" Framework: [cyan]{_framework}[/cyan]")
767
+ rprint(f" Status: {status_badge(result.get('status', 'pending'))}")
768
+
769
+
770
+ def _list_impl(category, search, limit, sort, output, interactive=False):
771
+ params = {}
772
+ if category:
773
+ params["category"] = category
774
+ if search:
775
+ params["search"] = search
776
+
777
+ with spinner("Fetching MCP servers..."):
778
+ data = client.get("/api/v1/mcps", params=params)
779
+
780
+ if not data:
781
+ rprint("[dim]No MCP servers found.[/dim]")
782
+ return
783
+
784
+ if interactive:
785
+
786
+ def _display(item: dict) -> str:
787
+ return f"{item['name']} v{item.get('version', '?')} [{item.get('category', '')}] {item.get('owner', '')}"
788
+
789
+ selected = fuzzy_select(data, _display, label="Select MCP server")
790
+ if selected:
791
+ _show_impl(str(selected["id"]), "table")
792
+ return
793
+
794
+ # Sort
795
+ key_map = {"name": "name", "category": "category", "version": "version"}
796
+ sk = key_map.get(sort, "name")
797
+ data = sorted(data, key=lambda x: x.get(sk, ""))[:limit]
798
+
799
+ # Cache IDs for numeric shorthand
800
+ config.save_last_results(data)
801
+
802
+ if output == "json":
803
+ output_json(data)
804
+ return
805
+
806
+ if output == "plain":
807
+ for item in data:
808
+ rprint(f"{item['id']} {item['name']} v{item.get('version', '?')} [{item.get('category', '')}]")
809
+ return
810
+
811
+ table = Table(title=f"MCP Servers ({len(data)})", show_lines=False, padding=(0, 1))
812
+ table.add_column("#", style="dim", width=3)
813
+ table.add_column("Name", style="bold cyan", no_wrap=True)
814
+ table.add_column("Version", style="green")
815
+ table.add_column("Category")
816
+ table.add_column("Owner", style="dim")
817
+ table.add_column("IDEs")
818
+ table.add_column("ID", style="dim", max_width=12)
819
+ for i, item in enumerate(data, 1):
820
+ table.add_row(
821
+ str(i),
822
+ item["name"],
823
+ item.get("version", ""),
824
+ item.get("category", ""),
825
+ item.get("owner", ""),
826
+ ide_tags(item.get("supported_ides", [])),
827
+ str(item["id"])[:8] + "…",
828
+ )
829
+ console.print(table)
830
+
831
+
832
+ def _show_impl(mcp_id, output):
833
+ resolved = config.resolve_alias(mcp_id)
834
+ with spinner():
835
+ item = client.get(f"/api/v1/mcps/{resolved}")
836
+
837
+ if output == "json":
838
+ output_json(item)
839
+ return
840
+
841
+ console.print(
842
+ kv_panel(
843
+ f"{item['name']} v{item.get('version', '?')}",
844
+ [
845
+ ("Status", status_badge(item.get("status", ""))),
846
+ ("Category", item.get("category", "N/A")),
847
+ ("Owner", item.get("owner", "N/A")),
848
+ ("Description", item.get("description", "")),
849
+ ("IDEs", ide_tags(item.get("supported_ides", []))),
850
+ ("Git", f"[link={item.get('git_url', '')}]{item.get('git_url', 'N/A')}[/link]"),
851
+ ("Setup", item.get("setup_instructions") or "[dim]none[/dim]"),
852
+ ("Changelog", item.get("changelog") or "[dim]none[/dim]"),
853
+ ("Created", relative_time(item.get("created_at"))),
854
+ ("ID", f"[dim]{item['id']}[/dim]"),
855
+ ],
856
+ border_style="cyan",
857
+ )
858
+ )
859
+
860
+ if item.get("validation_results"):
861
+ rprint("\n[bold]Validation:[/bold]")
862
+ for v in item["validation_results"]:
863
+ icon = "[green]✓[/green]" if v["passed"] else "[red]✗[/red]"
864
+ rprint(f" {icon} {v['stage']}: {v.get('details', '') or 'passed'}")
865
+
866
+
867
+ def _install_impl(mcp_id, ide, raw):
868
+ import json as _json
869
+
870
+ resolved = config.resolve_alias(mcp_id)
871
+
872
+ # Fetch listing details to check for required env vars
873
+ with spinner("Fetching server details..."):
874
+ listing = client.get(f"/api/v1/mcps/{resolved}")
875
+
876
+ env_values: dict[str, str] = {}
877
+ env_var_list = listing.get("environment_variables") or []
878
+ if env_var_list and not raw:
879
+ required = [ev for ev in env_var_list if ev.get("required", True)]
880
+ optional = [ev for ev in env_var_list if not ev.get("required", True)]
881
+
882
+ if required:
883
+ rprint(f"\n[bold]This server requires {len(required)} environment variable(s):[/bold]")
884
+ for ev in required:
885
+ desc = f" [dim]({ev['description']})[/dim]" if ev.get("description") else ""
886
+ val = typer.prompt(f" {ev['name']}{desc}")
887
+ env_values[ev["name"]] = val
888
+
889
+ if optional:
890
+ rprint(f"\n[dim]{len(optional)} optional env var(s) available:[/dim]")
891
+ for ev in optional:
892
+ desc = f" [dim]({ev['description']})[/dim]" if ev.get("description") else ""
893
+ val = typer.prompt(f" {ev['name']}{desc} (press Enter to skip)", default="")
894
+ if val:
895
+ env_values[ev["name"]] = val
896
+ elif env_var_list and raw:
897
+ # In raw mode, include placeholders so the user knows what's needed
898
+ for ev in env_var_list:
899
+ env_values[ev["name"]] = f"<{ev['name']}>"
900
+
901
+ # Prompt for headers (SSE/HTTP servers with auth)
902
+ header_values: dict[str, str] = {}
903
+ header_list = listing.get("headers") or []
904
+ if header_list and not raw:
905
+ required_headers = [h for h in header_list if h.get("required", True)]
906
+ optional_headers = [h for h in header_list if not h.get("required", True)]
907
+ if required_headers:
908
+ rprint(f"\n[bold]This server requires {len(required_headers)} header(s):[/bold]")
909
+ for h in required_headers:
910
+ desc = f" [dim]({h['description']})[/dim]" if h.get("description") else ""
911
+ val = typer.prompt(f" {h['name']}{desc}")
912
+ header_values[h["name"]] = val
913
+ if optional_headers:
914
+ rprint(f"\n[dim]{len(optional_headers)} optional header(s) available:[/dim]")
915
+ for h in optional_headers:
916
+ desc = f" [dim]({h['description']})[/dim]" if h.get("description") else ""
917
+ val = typer.prompt(f" {h['name']}{desc} (press Enter to skip)", default="")
918
+ if val:
919
+ header_values[h["name"]] = val
920
+ elif header_list and raw:
921
+ for h in header_list:
922
+ header_values[h["name"]] = f"<{h['name']}>"
923
+
924
+ with spinner(f"Generating {ide} config..."):
925
+ result = client.post(
926
+ f"/api/v1/mcps/{resolved}/install",
927
+ {"ide": ide, "env_values": env_values, "header_values": header_values},
928
+ )
929
+
930
+ snippet = result.get("config_snippet", {})
931
+ if raw:
932
+ print(_json.dumps(snippet, indent=2))
933
+ return
934
+
935
+ ide_config_paths = {
936
+ "kiro": ".kiro/settings/mcp.json",
937
+ "cursor": ".cursor/mcp.json",
938
+ "vscode": ".vscode/mcp.json",
939
+ "claude-code": "(run the command below)",
940
+ "claude_code": "(run the command below)",
941
+ "gemini-cli": ".gemini/settings.json",
942
+ "gemini_cli": ".gemini/settings.json",
943
+ }
944
+
945
+ rprint(f"\n[bold]Config for {ide}:[/bold]\n")
946
+ console.print_json(_json.dumps(snippet, indent=2))
947
+ config_path = ide_config_paths.get(ide, "")
948
+ if config_path and not config_path.startswith("("):
949
+ rprint(f"\n[dim]Add to:[/dim] [bold]{config_path}[/bold]")
950
+ rprint(f"[dim]Or pipe:[/dim] observal install {mcp_id} --ide {ide} --raw > {config_path}")
951
+
952
+ # Warn about any empty env vars the user skipped
953
+ missing = [k for k, v in env_values.items() if not v or v.startswith("<")]
954
+ if missing:
955
+ rprint(f"\n[yellow]Warning: {len(missing)} env var(s) still need values:[/yellow]")
956
+ for m in missing:
957
+ rprint(f" [yellow]![/yellow] {m}")
958
+ rprint("[dim]Set these in your IDE config or shell environment before running the server.[/dim]")
959
+
960
+
961
+ def _delete_impl(mcp_id, yes):
962
+ resolved = config.resolve_alias(mcp_id)
963
+ if not yes:
964
+ with spinner():
965
+ item = client.get(f"/api/v1/mcps/{resolved}")
966
+ if not typer.confirm(f"Delete [bold]{item['name']}[/bold] ({resolved})?"):
967
+ raise typer.Abort()
968
+ with spinner("Deleting..."):
969
+ client.delete(f"/api/v1/mcps/{resolved}")
970
+ rprint(f"[green]✓ Deleted {resolved}[/green]")
971
+
972
+
973
+ # ── Canonical commands (on mcp_app) ─────────────────────────
974
+
975
+
976
+ @mcp_app.command()
977
+ def submit(
978
+ git_url: str = typer.Argument(None, help="Git repository URL (optional if --config used)"),
979
+ name: str = typer.Option(None, "--name", "-n", help="Skip name prompt"),
980
+ category: str = typer.Option(None, "--category", "-c", help="Skip category prompt"),
981
+ yes: bool = typer.Option(False, "--yes", "-y", help="Accept defaults from repo analysis"),
982
+ config: bool = typer.Option(False, "--config", help="Submit via direct JSON config (paste mode)"),
983
+ draft: bool = typer.Option(False, "--draft", help="Save as draft instead of submitting for review"),
984
+ submit_draft: str | None = typer.Option(None, "--submit", help="Submit a draft for review (MCP ID)"),
985
+ ):
986
+ """Submit an MCP server for review."""
987
+ if draft and submit_draft:
988
+ rprint(
989
+ "[red]Cannot use --draft and --submit together.[/red] Use --draft to save a new draft, or --submit to submit an existing draft."
990
+ )
991
+ raise typer.Exit(code=1)
992
+ if submit_draft:
993
+ from observal_cli import config as cfg
994
+
995
+ resolved = cfg.resolve_alias(submit_draft)
996
+ with spinner("Submitting draft for review..."):
997
+ result = client.post(f"/api/v1/mcps/{resolved}/submit")
998
+ rprint(f"[green]✓ Draft submitted for review![/green] ID: [bold]{result['id']}[/bold]")
999
+ return
1000
+ if not git_url and not config:
1001
+ rprint("[red]Provide a git URL or use --config[/red]")
1002
+ raise typer.Exit(1)
1003
+ _submit_impl(git_url, name, category, yes, config, draft=draft)
1004
+
1005
+
1006
+ @mcp_app.command(name="list")
1007
+ def list_mcps(
1008
+ category: str | None = typer.Option(None, "--category", "-c", help="Filter by category"),
1009
+ search: str | None = typer.Option(None, "--search", "-s", help="Search by name/description"),
1010
+ interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive search mode"),
1011
+ limit: int = typer.Option(50, "--limit", "-n", help="Max results"),
1012
+ sort: str = typer.Option("name", "--sort", help="Sort by: name, category, version"),
1013
+ output: str = typer.Option("table", "--output", "-o", help="Output: table, json, plain"),
1014
+ ):
1015
+ """List approved MCP servers."""
1016
+ _list_impl(category, search, limit, sort, output, interactive=interactive)
1017
+
1018
+
1019
+ @mcp_app.command()
1020
+ def show(
1021
+ mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
1022
+ output: str = typer.Option("table", "--output", "-o", help="Output: table, json"),
1023
+ ):
1024
+ """Show full details of an MCP server."""
1025
+ _show_impl(mcp_id, output)
1026
+
1027
+
1028
+ @mcp_app.command()
1029
+ def install(
1030
+ mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
1031
+ ide: str = typer.Option(..., "--ide", "-i", help="Target IDE"),
1032
+ raw: bool = typer.Option(False, "--raw", help="Output raw JSON only (for piping)"),
1033
+ ):
1034
+ """Get install config snippet for an MCP server."""
1035
+ _install_impl(mcp_id, ide, raw)
1036
+
1037
+
1038
+ @mcp_app.command(name="delete")
1039
+ def delete_mcp(
1040
+ mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
1041
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
1042
+ ):
1043
+ """Delete an MCP server."""
1044
+ _delete_impl(mcp_id, yes)