hud-python 0.4.35__py3-none-any.whl → 0.4.37__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.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (63) hide show
  1. hud/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/agents/tests/test_claude.py +32 -7
  5. hud/agents/tests/test_openai.py +29 -6
  6. hud/cli/__init__.py +228 -79
  7. hud/cli/build.py +26 -6
  8. hud/cli/dev.py +21 -40
  9. hud/cli/eval.py +96 -15
  10. hud/cli/flows/tasks.py +198 -65
  11. hud/cli/init.py +222 -629
  12. hud/cli/pull.py +6 -0
  13. hud/cli/push.py +11 -1
  14. hud/cli/rl/__init__.py +14 -4
  15. hud/cli/rl/celebrate.py +187 -0
  16. hud/cli/rl/config.py +15 -8
  17. hud/cli/rl/local_runner.py +44 -20
  18. hud/cli/rl/remote_runner.py +166 -87
  19. hud/cli/rl/viewer.py +141 -0
  20. hud/cli/rl/wait_utils.py +89 -0
  21. hud/cli/tests/test_build.py +3 -27
  22. hud/cli/tests/test_mcp_server.py +1 -12
  23. hud/cli/utils/config.py +85 -0
  24. hud/cli/utils/docker.py +21 -39
  25. hud/cli/utils/env_check.py +196 -0
  26. hud/cli/utils/environment.py +4 -3
  27. hud/cli/utils/interactive.py +2 -1
  28. hud/cli/utils/local_runner.py +204 -0
  29. hud/cli/utils/metadata.py +3 -1
  30. hud/cli/utils/package_runner.py +292 -0
  31. hud/cli/utils/remote_runner.py +4 -1
  32. hud/cli/utils/source_hash.py +108 -0
  33. hud/clients/base.py +1 -1
  34. hud/clients/fastmcp.py +1 -1
  35. hud/clients/mcp_use.py +30 -7
  36. hud/datasets/parallel.py +3 -1
  37. hud/datasets/runner.py +4 -1
  38. hud/otel/config.py +1 -1
  39. hud/otel/context.py +40 -6
  40. hud/rl/buffer.py +3 -0
  41. hud/rl/tests/test_learner.py +1 -1
  42. hud/rl/vllm_adapter.py +1 -1
  43. hud/server/server.py +234 -7
  44. hud/server/tests/test_add_tool.py +60 -0
  45. hud/server/tests/test_context.py +128 -0
  46. hud/server/tests/test_mcp_server_handlers.py +44 -0
  47. hud/server/tests/test_mcp_server_integration.py +405 -0
  48. hud/server/tests/test_mcp_server_more.py +247 -0
  49. hud/server/tests/test_run_wrapper.py +53 -0
  50. hud/server/tests/test_server_extra.py +166 -0
  51. hud/server/tests/test_sigterm_runner.py +78 -0
  52. hud/settings.py +38 -0
  53. hud/shared/hints.py +2 -2
  54. hud/telemetry/job.py +2 -2
  55. hud/types.py +9 -2
  56. hud/utils/tasks.py +32 -24
  57. hud/utils/tests/test_version.py +1 -1
  58. hud/version.py +1 -1
  59. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/METADATA +43 -23
  60. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/RECORD +63 -46
  61. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
  62. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
  63. {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
hud/cli/eval.py CHANGED
@@ -5,15 +5,18 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import logging
7
7
  from pathlib import Path
8
- from typing import Any, Literal
8
+ from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
10
  import typer
11
11
 
12
12
  import hud
13
+ from hud.cli.utils.env_check import ensure_built, find_environment_dir
13
14
  from hud.settings import settings
14
15
  from hud.utils.group_eval import display_group_statistics, run_tasks_grouped
15
16
  from hud.utils.hud_console import HUDConsole
16
17
 
18
+ if TYPE_CHECKING:
19
+ from hud.types import Task
17
20
  logger = logging.getLogger(__name__)
18
21
  hud_console = HUDConsole()
19
22
 
@@ -27,7 +30,7 @@ def get_available_models() -> list[dict[str, str | None]]:
27
30
  try:
28
31
  from hud.cli.rl import rl_api
29
32
 
30
- hud_console.info("Fetching your models from https://app.hud.so/models")
33
+ hud_console.info("Fetching your models from https://hud.so/models")
31
34
  models = rl_api.list_models()
32
35
 
33
36
  # Filter for ready models only and sort by recency
@@ -66,7 +69,7 @@ def get_available_models() -> list[dict[str, str | None]]:
66
69
 
67
70
 
68
71
  def build_agent(
69
- agent_type: Literal["claude", "openai", "vllm"],
72
+ agent_type: Literal["claude", "openai", "vllm", "litellm"],
70
73
  *,
71
74
  model: str | None = None,
72
75
  allowed_tools: list[str] | None = None,
@@ -138,6 +141,22 @@ def build_agent(
138
141
  else:
139
142
  return OperatorAgent(verbose=verbose)
140
143
 
144
+ elif agent_type == "litellm":
145
+ try:
146
+ from hud.agents.lite_llm import LiteAgent
147
+ except ImportError as e:
148
+ hud_console.error(
149
+ "LiteLLM agent dependencies are not installed. "
150
+ "Please install with: pip install 'hud-python[agent]'"
151
+ )
152
+ raise typer.Exit(1) from e
153
+
154
+ return LiteAgent(
155
+ model_name=model or "gpt-4o-mini",
156
+ allowed_tools=allowed_tools,
157
+ verbose=verbose,
158
+ )
159
+
141
160
  # Fallback Claude agent (Anthropic)
142
161
  try:
143
162
  from hud.agents import ClaudeAgent
@@ -166,7 +185,7 @@ def build_agent(
166
185
  async def run_single_task(
167
186
  source: str,
168
187
  *,
169
- agent_type: Literal["claude", "openai", "vllm"] = "claude",
188
+ agent_type: Literal["claude", "openai", "vllm", "litellm"] = "claude",
170
189
  model: str | None = None,
171
190
  allowed_tools: list[str] | None = None,
172
191
  max_steps: int = 10,
@@ -192,7 +211,16 @@ async def run_single_task(
192
211
  hud_console.info("📊 Loading task file…")
193
212
 
194
213
  # Use unified loader for both JSON and JSONL
195
- tasks = load_tasks(str(path))
214
+ tasks: list[Task] = load_tasks(str(path)) # type: ignore[assignment]
215
+
216
+ # If tasks reference a local environment (nearby), ensure it's built/up-to-date.
217
+ try:
218
+ env_dir = find_environment_dir(path)
219
+ if env_dir is not None:
220
+ # Non-interactive for eval; warn but don't block
221
+ ensure_built(env_dir, interactive=True)
222
+ except Exception as e:
223
+ hud_console.debug(f"Eval preflight env check skipped: {e}")
196
224
 
197
225
  # Single task - use the first (and only) task
198
226
  task = tasks[0]
@@ -200,7 +228,7 @@ async def run_single_task(
200
228
  else:
201
229
  # Load from HuggingFace dataset or non-file source
202
230
  hud_console.info(f"📊 Loading tasks from: {source}…")
203
- tasks = load_tasks(source)
231
+ tasks: list[Task] = load_tasks(source) # type: ignore[assignment]
204
232
 
205
233
  if not tasks:
206
234
  hud_console.error(f"No tasks found in: {source}")
@@ -248,6 +276,16 @@ async def run_single_task(
248
276
  agent_config = {"verbose": verbose}
249
277
  if allowed_tools:
250
278
  agent_config["allowed_tools"] = allowed_tools
279
+ elif agent_type == "litellm":
280
+ from hud.agents.lite_llm import LiteAgent
281
+
282
+ agent_class = LiteAgent
283
+ agent_config = {
284
+ "model_name": model or "gpt-4o-mini",
285
+ "verbose": verbose,
286
+ }
287
+ if allowed_tools:
288
+ agent_config["allowed_tools"] = allowed_tools
251
289
  else:
252
290
  from hud.agents import ClaudeAgent
253
291
 
@@ -292,7 +330,7 @@ async def run_single_task(
292
330
  async def run_full_dataset(
293
331
  source: str,
294
332
  *,
295
- agent_type: Literal["claude", "openai", "vllm"] = "claude",
333
+ agent_type: Literal["claude", "openai", "vllm", "litellm"] = "claude",
296
334
  model: str | None = None,
297
335
  allowed_tools: list[str] | None = None,
298
336
  max_concurrent: int = 30,
@@ -322,7 +360,7 @@ async def run_full_dataset(
322
360
 
323
361
  # Load tasks using unified loader
324
362
  hud_console.info(f"📊 Loading tasks from: {source}…")
325
- tasks = load_tasks(source)
363
+ tasks: list[Task] = load_tasks(source) # type: ignore[assignment]
326
364
 
327
365
  if not tasks:
328
366
  hud_console.error(f"No tasks found in: {source}")
@@ -385,6 +423,25 @@ async def run_full_dataset(
385
423
  if allowed_tools:
386
424
  agent_config["allowed_tools"] = allowed_tools
387
425
 
426
+ elif agent_type == "litellm":
427
+ try:
428
+ from hud.agents.lite_llm import LiteAgent
429
+
430
+ agent_class = LiteAgent
431
+ except ImportError as e:
432
+ hud_console.error(
433
+ "LiteLLM agent dependencies are not installed. "
434
+ "Please install with: pip install 'hud-python[agent]'"
435
+ )
436
+ raise typer.Exit(1) from e
437
+
438
+ agent_config = {
439
+ "model_name": model or "gpt-4o-mini",
440
+ "verbose": verbose,
441
+ }
442
+ if allowed_tools:
443
+ agent_config["allowed_tools"] = allowed_tools
444
+
388
445
  else:
389
446
  try:
390
447
  from hud.agents import ClaudeAgent
@@ -501,10 +558,10 @@ def eval_command(
501
558
  "--full",
502
559
  help="Run the entire dataset (omit for single-task debug mode)",
503
560
  ),
504
- agent: Literal["claude", "openai", "vllm"] = typer.Option(
561
+ agent: Literal["claude", "openai", "vllm", "litellm"] = typer.Option(
505
562
  "claude",
506
563
  "--agent",
507
- help="Agent backend to use (claude, openai, or vllm for local server)",
564
+ help="Agent backend to use (claude, openai, vllm for local server, or litellm)",
508
565
  ),
509
566
  model: str | None = typer.Option(
510
567
  None,
@@ -546,6 +603,12 @@ def eval_command(
546
603
  "--verbose",
547
604
  help="Enable verbose output from the agent",
548
605
  ),
606
+ very_verbose: bool = typer.Option(
607
+ False,
608
+ "--very-verbose",
609
+ "-vv",
610
+ help="Enable debug-level logs for maximum visibility",
611
+ ),
549
612
  vllm_base_url: str | None = typer.Option(
550
613
  None,
551
614
  "--vllm-base-url",
@@ -595,17 +658,34 @@ def eval_command(
595
658
  """
596
659
  from hud.settings import settings
597
660
 
661
+ if very_verbose:
662
+ logging.basicConfig(
663
+ level=logging.DEBUG,
664
+ format="%(asctime)s - %(name)s - %(message)s",
665
+ datefmt="%H:%M:%S",
666
+ )
667
+ logging.getLogger("hud.agents").setLevel(logging.DEBUG)
668
+ logging.getLogger("hud.agents.base").setLevel(logging.DEBUG)
669
+ elif verbose:
670
+ logging.basicConfig(
671
+ level=logging.INFO,
672
+ format="%(asctime)s - %(name)s - %(message)s",
673
+ datefmt="%H:%M:%S",
674
+ )
675
+ logging.getLogger("hud.agents").setLevel(logging.INFO)
676
+ logging.getLogger("hud.agents.base").setLevel(logging.INFO)
677
+
598
678
  # Check for required API keys
599
679
  if agent == "claude":
600
680
  if not settings.anthropic_api_key:
601
681
  hud_console.error("ANTHROPIC_API_KEY is required for Claude agent")
602
682
  hud_console.info(
603
- "Set it in your environment or .env file: ANTHROPIC_API_KEY=your-key-here"
683
+ "Set it in your environment or run: hud set ANTHROPIC_API_KEY=your-key-here"
604
684
  )
605
685
  raise typer.Exit(1)
606
686
  elif agent == "openai" and not settings.openai_api_key:
607
687
  hud_console.error("OPENAI_API_KEY is required for OpenAI agent")
608
- hud_console.info("Set it in your environment or .env file: OPENAI_API_KEY=your-key-here")
688
+ hud_console.info("Set it in your environment or run: hud set OPENAI_API_KEY=your-key-here")
609
689
  raise typer.Exit(1)
610
690
  elif agent == "vllm":
611
691
  if model:
@@ -617,7 +697,8 @@ def eval_command(
617
697
  # Check for HUD_API_KEY if using HUD services
618
698
  if not settings.api_key:
619
699
  hud_console.warning("HUD_API_KEY not set. Some features may be limited.")
620
- hud_console.info("Get your API key at: https://app.hud.so")
700
+ hud_console.info("Get your API key at: https://hud.so")
701
+ hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
621
702
 
622
703
  # Parse allowed tools
623
704
  allowed_tools_list = (
@@ -641,7 +722,7 @@ def eval_command(
641
722
  parallel=parallel,
642
723
  max_workers=max_workers,
643
724
  max_concurrent_per_worker=max_concurrent_per_worker,
644
- verbose=verbose,
725
+ verbose=very_verbose or verbose,
645
726
  vllm_base_url=vllm_base_url,
646
727
  group_size=group_size,
647
728
  )
@@ -654,7 +735,7 @@ def eval_command(
654
735
  model=model,
655
736
  allowed_tools=allowed_tools_list,
656
737
  max_steps=max_steps,
657
- verbose=verbose,
738
+ verbose=very_verbose or verbose,
658
739
  vllm_base_url=vllm_base_url,
659
740
  group_size=group_size,
660
741
  )
hud/cli/flows/tasks.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  import re
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING, Any
@@ -8,10 +9,9 @@ from typing import TYPE_CHECKING, Any
8
9
  import typer
9
10
  import yaml
10
11
 
11
- from hud.cli.build import build_environment
12
12
  from hud.cli.push import push_environment
13
13
  from hud.cli.utils.docker import require_docker_running
14
- from hud.cli.utils.environment import is_environment_directory
14
+ from hud.cli.utils.env_check import ensure_built, find_environment_dir
15
15
  from hud.cli.utils.registry import extract_name_and_tag
16
16
  from hud.utils.hud_console import hud_console
17
17
  from hud.utils.tasks import load_tasks
@@ -20,6 +20,9 @@ if TYPE_CHECKING:
20
20
  from hud.types import Task
21
21
 
22
22
 
23
+ logger = logging.getLogger(__name__)
24
+
25
+
23
26
  def _is_remote_url(url: str) -> bool:
24
27
  """Match the remote url."""
25
28
  # See if a url is a remote url
@@ -53,62 +56,6 @@ def _validate_tasks(tasks: list[Task]) -> bool:
53
56
  return True
54
57
 
55
58
 
56
- def _find_environment_dir(tasks_path: Path) -> Path | None:
57
- """Find the environment directory related to a tasks file.
58
-
59
- Strategy:
60
- - Prefer a directory containing hud.lock.yaml
61
- - Fallback to a directory that looks like an environment (Dockerfile + pyproject.toml)
62
- - Search the tasks file directory, CWD, and a couple of parents
63
- """
64
- candidates: list[Path] = []
65
- cwd = Path.cwd()
66
- candidates.extend([tasks_path.parent, cwd])
67
-
68
- # Add parents (up to 2 levels for each)
69
- for base in list(candidates):
70
- p = base
71
- for _ in range(2):
72
- p = p.parent
73
- if p not in candidates:
74
- candidates.append(p)
75
-
76
- # Prefer those with hud.lock.yaml
77
- for d in candidates:
78
- if (d / "hud.lock.yaml").exists():
79
- return d
80
-
81
- # Otherwise, find a plausible environment dir
82
- for d in candidates:
83
- try:
84
- if is_environment_directory(d):
85
- return d
86
- except Exception as e:
87
- hud_console.debug(f"Skipping path {d}: {e}")
88
- continue
89
-
90
- return None
91
-
92
-
93
- def _ensure_built(env_dir: Path) -> dict[str, Any]:
94
- """Ensure the environment is built and a lock file exists; return lock data."""
95
- lock_path = env_dir / "hud.lock.yaml"
96
- if not lock_path.exists():
97
- hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
98
- if not hud_console.confirm("Build the environment now (runs 'hud build')?", default=True):
99
- raise typer.Exit(1)
100
- # Check Docker availability before attempting a build
101
- require_docker_running()
102
- # Run build (non-interactive). If Docker isn't running, this will raise and stop the flow.
103
- # Force linux/amd64 platform to ensure compatibility during RL flows.
104
- build_environment(str(env_dir), platform="linux/amd64")
105
-
106
- # Load lock file
107
- with open(lock_path) as f:
108
- lock_data = yaml.safe_load(f) or {}
109
- return lock_data
110
-
111
-
112
59
  def _ensure_pushed(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, Any]:
113
60
  """Ensure the environment is pushed to a registry; return updated lock data."""
114
61
  pushed = bool(lock_data.get("push"))
@@ -153,44 +100,228 @@ def _derive_remote_image(lock_data: dict[str, Any]) -> str:
153
100
  return f"{name}:{tag}"
154
101
 
155
102
 
103
+ def _extract_existing_images(tasks: list[Task]) -> set[str]:
104
+ """Extract all Mcp-Image references from tasks."""
105
+ images = set()
106
+
107
+ def _extract_from_obj(obj: Any) -> None:
108
+ if isinstance(obj, dict):
109
+ # Check for Mcp-Image in headers
110
+ if "headers" in obj and isinstance(obj["headers"], dict):
111
+ mcp_image = obj["headers"].get("Mcp-Image")
112
+ if mcp_image:
113
+ images.add(mcp_image)
114
+ # Recursively check nested objects
115
+ for v in obj.values():
116
+ _extract_from_obj(v)
117
+ elif isinstance(obj, list):
118
+ for item in obj:
119
+ _extract_from_obj(item)
120
+
121
+ for task in tasks:
122
+ if task.mcp_config:
123
+ _extract_from_obj(task.mcp_config)
124
+
125
+ return images
126
+
127
+
128
+ def _env_var_to_header_key(var_name: str) -> str:
129
+ """Convert ENV_VAR style to Env-Env-Var header style.
130
+
131
+ Example: OPENAI_API_KEY -> Env-Openai-Api-Key
132
+ """
133
+ parts = str(var_name).split("_")
134
+ return f"Env-{'-'.join(part.capitalize() for part in parts)}"
135
+
136
+
137
+ def _extract_api_key_vars(lock_data: dict[str, Any]) -> set[str]:
138
+ """Extract env var names from lock file's provided section (authoritative source).
139
+
140
+ We only use keys listed under environment.variables.provided, and exclude HUD_API_KEY
141
+ because Authorization already carries it.
142
+ """
143
+ provided_keys: set[str] = set()
144
+ if not isinstance(lock_data, dict):
145
+ return provided_keys
146
+ try:
147
+ env_section = (lock_data.get("environment") or {}).get("variables") or {}
148
+ provided = env_section.get("provided") or {}
149
+ for name in provided:
150
+ provided_keys.add(str(name))
151
+ except Exception as e:
152
+ logger.debug("Failed to parse provided env vars from lock data: %s", e)
153
+ provided_keys.discard("HUD_API_KEY")
154
+ return provided_keys
155
+
156
+
157
+ def _extract_dotenv_api_key_vars(env_dir: Path) -> set[str]:
158
+ """Parse .env for API-like variables to suggest as headers.
159
+
160
+ We intentionally include only keys that look like secrets to avoid noise:
161
+ any key containing one of: api, key, token, secret, password (case-insensitive).
162
+ """
163
+ dotenv_path = env_dir / ".env"
164
+ detected: set[str] = set()
165
+ if not dotenv_path.exists():
166
+ return detected
167
+ try:
168
+ for line in dotenv_path.read_text(encoding="utf-8").splitlines():
169
+ line = line.strip()
170
+ if not line or line.startswith("#"):
171
+ continue
172
+ if "=" not in line:
173
+ continue
174
+ name, _ = line.split("=", 1)
175
+ name = name.strip()
176
+ lowered = name.lower()
177
+ if any(s in lowered for s in ("api", "key", "token", "secret", "password")):
178
+ detected.add(name)
179
+ except Exception:
180
+ # Best-effort only
181
+ return detected
182
+ detected.discard("HUD_API_KEY")
183
+ return detected
184
+
185
+
156
186
  def convert_tasks_to_remote(tasks_file: str) -> str:
157
187
  """Convert a local tasks file to remote MCP tasks and return new filename.
158
188
 
159
189
  Steps:
160
190
  1) Find env dir; ensure built (hud.lock.yaml), otherwise build
161
191
  2) Ensure pushed to registry, otherwise push
162
- 3) Create remote_[tasks].json with mcp_config pointing to mcp.hud.so and Mcp-Image
163
- 4) Return the new tasks file path
192
+ 3) Check for outdated images in existing task configurations
193
+ 4) Create remote_[tasks].json with mcp_config pointing to mcp.hud.so and Mcp-Image
194
+ 5) Return the new tasks file path
164
195
  """
165
196
  tasks_path = Path(tasks_file).resolve()
166
197
 
167
- tasks = load_tasks(str(tasks_path))
198
+ # Load validated tasks for decision-making (may resolve env vars)
199
+ tasks: list[Task] = load_tasks(str(tasks_path)) # type: ignore[assignment]
200
+
201
+ # Load raw tasks to preserve placeholders when writing back to disk
202
+ raw_tasks: list[dict[str, Any]] = load_tasks(str(tasks_path), raw=True) # type: ignore[assignment]
168
203
 
169
204
  # Ensure HUD_API_KEY is available: prefer process env, else load from env_dir/.env
170
205
  from hud.settings import settings
171
206
 
172
207
  if not settings.api_key or not settings.api_key.strip():
173
208
  hud_console.error("HUD_API_KEY is not set")
209
+ hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
174
210
  raise typer.Exit(1)
175
211
 
212
+ # Check if tasks already have remote URLs
213
+ already_remote = _validate_tasks(tasks)
214
+
215
+ # Extract existing images from tasks
216
+ existing_images = _extract_existing_images(tasks)
217
+
176
218
  # Load tasks (supports .json and .jsonl)
177
- if _validate_tasks(tasks):
219
+ if already_remote and not existing_images:
220
+ # Tasks are remote but have no image references - just return as-is
178
221
  return str(tasks_path)
179
222
 
180
223
  # Locate environment
181
- env_dir = _find_environment_dir(tasks_path)
224
+ env_dir = find_environment_dir(tasks_path)
182
225
  if not env_dir:
183
226
  hud_console.error("Could not locate an environment directory (Dockerfile + pyproject.toml)")
184
227
  hud_console.hint("Ensure you're in or near your environment folder before running 'hud rl'")
185
228
  raise typer.Exit(1)
186
229
 
187
230
  # Ensure built and pushed
188
- lock_data = _ensure_built(env_dir)
231
+ lock_data = ensure_built(env_dir, interactive=True)
189
232
  lock_data = _ensure_pushed(env_dir, lock_data)
190
233
 
191
234
  # Derive remote image name org/name:tag
192
235
  remote_image = _derive_remote_image(lock_data)
193
236
 
237
+ # Check if existing images are outdated
238
+ needs_update = False
239
+ should_update_image = False
240
+ if existing_images:
241
+ # Check if any existing image differs from the latest
242
+ for existing_img in existing_images:
243
+ if existing_img != remote_image:
244
+ hud_console.warning(f"Detected outdated image reference: {existing_img}")
245
+ hud_console.info(f"Latest pushed image: {remote_image}")
246
+ needs_update = True
247
+ break
248
+
249
+ if needs_update:
250
+ confirm_msg = "Update task configuration with the latest image?"
251
+ if hud_console.confirm(confirm_msg, default=True):
252
+ hud_console.info("Updating task configuration with latest image...")
253
+ should_update_image = True
254
+ else:
255
+ # If user doesn't want to update, just return the original file
256
+ if already_remote:
257
+ return str(tasks_path)
258
+ # Otherwise, continue with conversion but keep old images
259
+ remote_image = next(iter(existing_images)) # Use the first existing image
260
+
261
+ # If tasks are already remote and up-to-date (no update needed), return original file
262
+ if already_remote and not needs_update:
263
+ return str(tasks_path)
264
+
265
+ # If tasks are already remote and we just need to update the image
266
+ if already_remote and should_update_image:
267
+ # Update image references in-place on RAW tasks (preserve placeholders)
268
+ def _update_image_refs_raw(obj: Any) -> Any:
269
+ if isinstance(obj, dict):
270
+ new_obj = {}
271
+ for k, v in obj.items():
272
+ if k == "Mcp-Image" and isinstance(v, str) and v in existing_images:
273
+ new_obj[k] = remote_image
274
+ else:
275
+ new_obj[k] = _update_image_refs_raw(v)
276
+ return new_obj
277
+ elif isinstance(obj, list):
278
+ return [_update_image_refs_raw(item) for item in obj]
279
+ else:
280
+ return obj
281
+
282
+ updated_raw_tasks: list[dict[str, Any]] = []
283
+ for t in raw_tasks:
284
+ td = dict(t)
285
+ if "mcp_config" in td:
286
+ td["mcp_config"] = _update_image_refs_raw(td["mcp_config"])
287
+ updated_raw_tasks.append(td)
288
+
289
+ # Write updated file (preserve original format - check if it's .jsonl)
290
+ if tasks_path.suffix == ".jsonl":
291
+ with open(tasks_path, "w", encoding="utf-8") as f:
292
+ for task in updated_raw_tasks:
293
+ json.dump(task, f, ensure_ascii=False)
294
+ f.write("\n")
295
+ else:
296
+ with open(tasks_path, "w", encoding="utf-8") as f:
297
+ json.dump(updated_raw_tasks, f, ensure_ascii=False, indent=2)
298
+ f.write("\n")
299
+
300
+ hud_console.success(f"Updated {tasks_path.name} with latest image: {remote_image}")
301
+ return str(tasks_path)
302
+
303
+ # Extract additional API key headers from lock and suggest from .env
304
+ provided_keys = _extract_api_key_vars(lock_data)
305
+ dotenv_keys = _extract_dotenv_api_key_vars(env_dir)
306
+
307
+ # If .env contains API-like vars not in lock, offer to include them
308
+ missing = sorted(dotenv_keys - provided_keys)
309
+ if missing:
310
+ names_preview = ", ".join(missing)
311
+ prompt = (
312
+ f"Detected env vars in .env that look like API keys: {names_preview}.\n"
313
+ "Include them as remote headers (values will be ${VAR} placeholders)?"
314
+ )
315
+ if hud_console.confirm(prompt, default=True):
316
+ provided_keys.update(missing)
317
+
318
+ extra_api_key_headers: dict[str, str] = {}
319
+ for var_name in provided_keys:
320
+ if str(var_name).upper() == "HUD_API_KEY":
321
+ continue
322
+ header_key = _env_var_to_header_key(var_name)
323
+ extra_api_key_headers[header_key] = f"${{{var_name}}}"
324
+
194
325
  # Helper to strip extra fields from tool calls
195
326
  def _simplify_tool_call(tool: Any) -> Any:
196
327
  def _one(x: Any) -> dict[str, Any]:
@@ -228,6 +359,9 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
228
359
  },
229
360
  }
230
361
 
362
+ # Merge additional API key headers
363
+ item["mcp_config"]["hud"]["headers"].update(extra_api_key_headers)
364
+
231
365
  # Optional fields, omit Nones
232
366
  if t.setup_tool is not None:
233
367
  item["setup_tool"] = _simplify_tool_call(t.setup_tool)
@@ -242,7 +376,6 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
242
376
 
243
377
  tasks_payload.append(item)
244
378
 
245
- # Write new file: remote_<name>.json (always JSON array)
246
379
  remote_name = f"remote_{tasks_path.stem}.json"
247
380
  remote_path = tasks_path.parent / remote_name
248
381
  with open(remote_path, "w", encoding="utf-8") as f: