Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
)

Expand Down Expand Up @@ -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),
)
)

Expand Down
78 changes: 78 additions & 0 deletions hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)``."""
Expand Down Expand Up @@ -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
Expand All @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This write occurs under redirect_stderr(devnull) from run_oneshot() line 226, so the advertised warning is never visible in a real -z invocation. Surface it through the saved stderr outside the redirect, or fail when every requested skill is missing as normal CLI does.

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)

Expand Down
154 changes: 154 additions & 0 deletions tests/cli/test_oneshot_preloaded_skills.py
Original file line number Diff line number Diff line change
@@ -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"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_run_agent() returns (final_response, result) (also declared by this PR at hermes_cli/oneshot.py:300), but this scalar stub makes run_oneshot() hit its unpacking-error path. Return a tuple and assert _run_agent received the expected flag-derived behavior.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_run_agent() calls agent.run_conversation() at hermes_cli/oneshot.py:450, not agent.chat(). Set mock_agent.run_conversation.return_value to a result dict and assert that method instead; this assertion otherwise fails.


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"