diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 65df4cc5d937..83d1a0dc9396 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12460,6 +12460,9 @@ def _try_termux_fast_cli_launch() -> bool: model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + skills=getattr(args, "skills", None), + ignore_rules=getattr(args, "ignore_rules", False), + ignore_user_config=getattr(args, "ignore_user_config", False), ) ) @@ -13893,6 +13896,9 @@ def cmd_sessions(args): model=getattr(args, "model", None), provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), + skills=getattr(args, "skills", None), + ignore_rules=getattr(args, "ignore_rules", False), + ignore_user_config=getattr(args, "ignore_user_config", False), ) ) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 38d8e5e84bc9..b85c5621a709 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -122,11 +122,33 @@ def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | No return valid, None +def _parse_skills_list(skills: object = None) -> list[str] | None: + """Normalize a skills argument into a list of skill name strings. + + Accepts: + - None / empty → None + - A comma-separated string ("a,b") → ["a", "b"] + - Already a list → returned as-is (future compat for nargs='+') + + Returns None when there are no skills to load. + """ + if not skills: + return None + if isinstance(skills, (list, tuple)): + return [s.strip() for s in skills if s and s.strip()] or None + if isinstance(skills, str): + return [s.strip() for s in skills.split(",") if s.strip()] or None + return [str(skills).strip()] or None + + def run_oneshot( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, + skills: Optional[str] = None, + ignore_rules: bool = False, + ignore_user_config: bool = False, ) -> int: """Execute a single prompt and print only the final content block. @@ -137,9 +159,29 @@ def run_oneshot( provider: Optional provider override. Falls back to config.yaml's model.provider, then "auto". toolsets: Optional comma-separated string or iterable of toolsets. + skills: Optional comma-separated skill name list to preload. + Matches the chat mode ``--skills`` / ``-s`` flag behavior. + ignore_rules: If True, skip AGENTS.md/SOUL.md/.cursorrules injection, + memory entries, and config-driven preloaded skills. Mirrors + ``--ignore-rules`` in chat mode. + ignore_user_config: If True, ignore ~/.hermes/config.yaml behavioral + settings (credentials in .env still loaded). Mirrors + ``--ignore-user-config`` in chat mode. + + All three new parameters fulfill the module docstring promise that + "Rules / memory / AGENTS.md / preloaded skills = same as a normal chat + turn" — they were accepted by argparse but silently dropped before this fix. Returns the exit code. Caller should sys.exit() with the return. """ + # --ignore-user-config / --ignore-rules: set env vars BEFORE any + # downstream load_config() / AIAgent() picks them up, mirroring + # main.py:cmd_chat (lines 2328-2343). + if ignore_user_config: + os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" + if ignore_rules: + os.environ["HERMES_IGNORE_RULES"] = "1" + # Silence every stdlib logger for the duration. AIAgent, tools, and # provider adapters all log to stderr through the root logger; file # handlers added by setup_logging() keep working (they're attached to @@ -189,6 +231,7 @@ def run_oneshot( provider=provider, toolsets=explicit_toolsets, use_config_toolsets=use_config_toolsets, + skills=skills, ) except BaseException as exc: # noqa: BLE001 # Capture anything that escapes the agent (including OSError @@ -253,6 +296,7 @@ def _run_agent( provider: Optional[str] = None, toolsets: object = None, use_config_toolsets: bool = True, + skills: Optional[str] = None, ) -> tuple[str, dict]: """Build an AIAgent exactly like a normal CLI chat turn would, then run a single conversation. Returns ``(final_response, run_result)``.""" @@ -349,6 +393,11 @@ def _run_agent( session_db=session_db, credential_pool=runtime.get("credential_pool"), fallback_model=_fb or None, + # --ignore-rules: skip context files (AGENTS.md, SOUL.md, .cursorrules) + # and memory injection. Mirrors HermesCLI behavior where + # HERMES_IGNORE_RULES=1 sets skip_context_files=True, skip_memory=True. + skip_context_files=os.environ.get("HERMES_IGNORE_RULES") == "1", + skip_memory=os.environ.get("HERMES_IGNORE_RULES") == "1", # Interactive callbacks are intentionally NOT wired beyond this # one. In oneshot mode there's no user sitting at a terminal: # - clarify → returns a synthetic "pick a default" instruction @@ -369,6 +418,35 @@ def _run_agent( agent.stream_delta_callback = None agent.tool_gen_callback = None + # Inject preloaded skills into the system prompt, mirroring chat mode's + # --skills / -s behavior (cli.py:15190-15202). Must happen BEFORE the + # first model call (agent.chat) so the skill body is part of the prompt. + # + # We use ``ephemeral_system_prompt`` (appended at API-call time) rather + # than ``_cached_system_prompt`` because the cached prompt is rebuilt by + # ``_restore_or_build_system_prompt()`` inside the conversation loop, + # which would overwrite any direct mutation. ``ephemeral_system_prompt`` + # is injected at line 490/815 of conversation_loop.py, after the cached + # prompt is resolved — same mechanism chat mode uses for the skills + # prompt passed via HermesCLI.system_prompt → ephemeral_system_prompt. + parsed_skills = _parse_skills_list(skills) + if parsed_skills: + from agent.skill_commands import build_preloaded_skills_prompt + + skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt( + parsed_skills, + task_id=getattr(agent, "session_id", None), + ) + if missing_skills: + sys.stderr.write( + f"hermes -z: unknown skill(s): {', '.join(missing_skills)}\n" + ) + if skills_prompt: + existing = getattr(agent, "ephemeral_system_prompt", None) or "" + agent.ephemeral_system_prompt = "\n\n".join( + part for part in (existing, skills_prompt) if part + ).strip() + result = agent.run_conversation(prompt) return (result.get("final_response") or "", result) diff --git a/tests/cli/test_oneshot_preloaded_skills.py b/tests/cli/test_oneshot_preloaded_skills.py new file mode 100644 index 000000000000..9502654da5ad --- /dev/null +++ b/tests/cli/test_oneshot_preloaded_skills.py @@ -0,0 +1,154 @@ +"""Tests for oneshot (-z) mode's --skills / --ignore-rules / --ignore-user-config. + +These verify the three parameters that were previously accepted by argparse +but silently dropped by run_oneshot() — the fix connects them to the actual +agent construction, matching chat mode behavior. +""" +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + + +# --------------------------------------------------------------------------- +# Tests for _parse_skills_list +# --------------------------------------------------------------------------- + +class TestParseSkillsList: + """Test the _parse_skills_list helper.""" + + def test_none_returns_none(self): + from hermes_cli.oneshot import _parse_skills_list + assert _parse_skills_list(None) is None + + def test_empty_string_returns_none(self): + from hermes_cli.oneshot import _parse_skills_list + assert _parse_skills_list("") is None + + def test_comma_separated(self): + from hermes_cli.oneshot import _parse_skills_list + assert _parse_skills_list("a,b,c") == ["a", "b", "c"] + + def test_list_passthrough(self): + from hermes_cli.oneshot import _parse_skills_list + assert _parse_skills_list(["x", "y"]) == ["x", "y"] + + def test_whitespace_stripped(self): + from hermes_cli.oneshot import _parse_skills_list + assert _parse_skills_list(" a , b ") == ["a", "b"] + + +# --------------------------------------------------------------------------- +# Tests for run_oneshot env var setting +# --------------------------------------------------------------------------- + +class TestOneshotEnvVars: + """Test that --ignore-rules and --ignore-user-config set env vars.""" + + def test_oneshot_ignore_rules_sets_env_var(self, monkeypatch): + """run_oneshot(prompt, ignore_rules=True) should set HERMES_IGNORE_RULES=1.""" + from hermes_cli.oneshot import run_oneshot + + monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False) + + with patch("hermes_cli.oneshot._run_agent", return_value="mock response"): + run_oneshot("test", ignore_rules=True) + + assert os.environ.get("HERMES_IGNORE_RULES") == "1" + + def test_oneshot_ignore_user_config_sets_env_var(self, monkeypatch): + """run_oneshot(prompt, ignore_user_config=True) should set HERMES_IGNORE_USER_CONFIG=1.""" + from hermes_cli.oneshot import run_oneshot + + monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False) + + with patch("hermes_cli.oneshot._run_agent", return_value="mock response"): + run_oneshot("test", ignore_user_config=True) + + assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1" + + +# --------------------------------------------------------------------------- +# Tests for skills injection in _run_agent +# --------------------------------------------------------------------------- + +class TestOneshotSkillsInjection: + """Test that --skills is honored in oneshot _run_agent.""" + + def test_oneshot_applies_skills_to_system_prompt(self, monkeypatch): + """_run_agent(prompt, skills='x') should inject skill body into ephemeral_system_prompt.""" + from hermes_cli.oneshot import _run_agent + + mock_agent = MagicMock() + mock_agent.ephemeral_system_prompt = "" + mock_agent.session_id = "test-session-001" + mock_agent.chat.return_value = "mock response" + + with patch("run_agent.AIAgent", return_value=mock_agent), \ + patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=None), \ + patch("hermes_cli.oneshot.get_fallback_chain", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ + "api_key": "k", "base_url": None, "provider": "test", "api_mode": None, + "credential_pool": None, + }), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value=[]), \ + patch("hermes_cli.models.detect_provider_for_model", return_value=None), \ + patch("agent.skill_commands.build_preloaded_skills_prompt", + return_value=("SKILL_BODY libero-decompose", ["libero-decompose"], [])): + _run_agent("test prompt", skills="libero-decompose") + + assert "SKILL_BODY libero-decompose" in mock_agent.ephemeral_system_prompt + assert mock_agent.chat.called + + def test_oneshot_skills_none_leaves_system_prompt_unchanged(self, monkeypatch): + """When skills=None, ephemeral_system_prompt should not be modified.""" + from hermes_cli.oneshot import _run_agent + + mock_agent = MagicMock() + mock_agent.ephemeral_system_prompt = "" + mock_agent.session_id = "test-session-001" + mock_agent.chat.return_value = "mock response" + + with patch("run_agent.AIAgent", return_value=mock_agent), \ + patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=None), \ + patch("hermes_cli.oneshot.get_fallback_chain", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ + "api_key": "k", "base_url": None, "provider": "test", "api_mode": None, + "credential_pool": None, + }), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value=[]), \ + patch("hermes_cli.models.detect_provider_for_model", return_value=None): + _run_agent("test prompt", skills=None) + + assert mock_agent.ephemeral_system_prompt == "" + + def test_oneshot_unknown_skill_warns_not_fatal(self, monkeypatch, capsys): + """An unknown skill name should print a warning to stderr, not raise.""" + from hermes_cli.oneshot import _run_agent + + mock_agent = MagicMock() + mock_agent.ephemeral_system_prompt = "" + mock_agent.session_id = "test-session-001" + mock_agent.chat.return_value = "mock response" + + with patch("run_agent.AIAgent", return_value=mock_agent), \ + patch("hermes_cli.oneshot._create_session_db_for_oneshot", return_value=None), \ + patch("hermes_cli.oneshot.get_fallback_chain", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={ + "api_key": "k", "base_url": None, "provider": "test", "api_mode": None, + "credential_pool": None, + }), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value=[]), \ + patch("hermes_cli.models.detect_provider_for_model", return_value=None), \ + patch("agent.skill_commands.build_preloaded_skills_prompt", + return_value=("", [], ["nonexistent-skill"])): + # Should NOT raise — just warn + result = _run_agent("test prompt", skills="nonexistent-skill") + + assert result == "mock response"