diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 76bd204d59..b9d5135fd5 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -14,6 +14,110 @@ logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} +def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: + """Load a skill by name/path and return (loaded_payload, skill_dir, display_name).""" + raw_identifier = (skill_identifier or "").strip() + if not raw_identifier: + return None + + try: + from tools.skills_tool import SKILLS_DIR, skill_view + + identifier_path = Path(raw_identifier).expanduser() + if identifier_path.is_absolute(): + try: + normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + except Exception: + normalized = raw_identifier + else: + normalized = raw_identifier.lstrip("/") + + loaded_skill = json.loads(skill_view(normalized, task_id=task_id)) + except Exception: + return None + + if not loaded_skill.get("success"): + return None + + skill_name = str(loaded_skill.get("name") or normalized) + skill_path = str(loaded_skill.get("path") or "") + skill_dir = None + if skill_path: + try: + skill_dir = SKILLS_DIR / Path(skill_path).parent + except Exception: + skill_dir = None + + return loaded_skill, skill_dir, skill_name + + +def _build_skill_message( + loaded_skill: dict[str, Any], + skill_dir: Path | None, + activation_note: str, + user_instruction: str = "", +) -> str: + """Format a loaded skill into a user/system message payload.""" + from tools.skills_tool import SKILLS_DIR + + content = str(loaded_skill.get("content") or "") + + parts = [activation_note, "", content.strip()] + + if loaded_skill.get("setup_skipped"): + parts.extend( + [ + "", + "[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]", + ] + ) + elif loaded_skill.get("gateway_setup_hint"): + parts.extend( + [ + "", + f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]", + ] + ) + elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"): + parts.extend( + [ + "", + f"[Skill setup note: {loaded_skill['setup_note']}]", + ] + ) + + supporting = [] + linked_files = loaded_skill.get("linked_files") or {} + for entries in linked_files.values(): + if isinstance(entries, list): + supporting.extend(entries) + + if not supporting and skill_dir: + for subdir in ("references", "templates", "scripts", "assets"): + subdir_path = skill_dir / subdir + if subdir_path.exists(): + for f in sorted(subdir_path.rglob("*")): + if f.is_file(): + rel = str(f.relative_to(skill_dir)) + supporting.append(rel) + + if supporting and skill_dir: + skill_view_target = str(skill_dir.relative_to(SKILLS_DIR)) + parts.append("") + parts.append("[This skill has supporting files you can load with the skill_view tool:]") + for sf in supporting: + parts.append(f"- {sf}") + parts.append( + f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="")' + ) + + if user_instruction: + parts.append("") + parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") + + return "\n".join(parts) + + def scan_skill_commands() -> Dict[str, Dict[str, Any]]: """Scan ~/.hermes/skills/ and return a mapping of /command -> skill info. @@ -83,77 +187,60 @@ def build_skill_invocation_message( if not skill_info: return None - skill_name = skill_info["name"] - skill_path = skill_info["skill_dir"] + loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id) + if not loaded: + return f"[Failed to load skill: {skill_info['name']}]" - try: - from tools.skills_tool import SKILLS_DIR, skill_view + loaded_skill, skill_dir, skill_name = loaded + activation_note = ( + f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want ' + "you to follow its instructions. The full skill content is loaded below.]" + ) + return _build_skill_message( + loaded_skill, + skill_dir, + activation_note, + user_instruction=user_instruction, + ) - loaded_skill = json.loads(skill_view(skill_path, task_id=task_id)) - except Exception: - return f"[Failed to load skill: {skill_name}]" - if not loaded_skill.get("success"): - return f"[Failed to load skill: {skill_name}]" +def build_preloaded_skills_prompt( + skill_identifiers: list[str], + task_id: str | None = None, +) -> tuple[str, list[str], list[str]]: + """Load one or more skills for session-wide CLI preloading. - content = str(loaded_skill.get("content") or "") - skill_dir = Path(skill_info["skill_dir"]) + Returns (prompt_text, loaded_skill_names, missing_identifiers). + """ + prompt_parts: list[str] = [] + loaded_names: list[str] = [] + missing: list[str] = [] - parts = [ - f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', - "", - content.strip(), - ] + seen: set[str] = set() + for raw_identifier in skill_identifiers: + identifier = (raw_identifier or "").strip() + if not identifier or identifier in seen: + continue + seen.add(identifier) - if loaded_skill.get("setup_skipped"): - parts.extend( - [ - "", - "[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]", - ] + loaded = _load_skill_payload(identifier, task_id=task_id) + if not loaded: + missing.append(identifier) + continue + + loaded_skill, skill_dir, skill_name = loaded + activation_note = ( + f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill ' + "preloaded. Treat its instructions as active guidance for the duration of this " + "session unless the user overrides them.]" ) - elif loaded_skill.get("gateway_setup_hint"): - parts.extend( - [ - "", - f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]", - ] - ) - elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"): - parts.extend( - [ - "", - f"[Skill setup note: {loaded_skill['setup_note']}]", - ] + prompt_parts.append( + _build_skill_message( + loaded_skill, + skill_dir, + activation_note, + ) ) + loaded_names.append(skill_name) - supporting = [] - linked_files = loaded_skill.get("linked_files") or {} - for entries in linked_files.values(): - if isinstance(entries, list): - supporting.extend(entries) - - if not supporting: - for subdir in ("references", "templates", "scripts", "assets"): - subdir_path = skill_dir / subdir - if subdir_path.exists(): - for f in sorted(subdir_path.rglob("*")): - if f.is_file(): - rel = str(f.relative_to(skill_dir)) - supporting.append(rel) - - if supporting: - skill_view_target = str(Path(skill_path).relative_to(SKILLS_DIR)) - parts.append("") - parts.append("[This skill has supporting files you can load with the skill_view tool:]") - for sf in supporting: - parts.append(f"- {sf}") - parts.append( - f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="")' - ) - - if user_instruction: - parts.append("") - parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") - - return "\n".join(parts) + return "\n\n".join(prompt_parts), loaded_names, missing diff --git a/cli.py b/cli.py index 13bf4736b4..d696488f23 100755 --- a/cli.py +++ b/cli.py @@ -8,6 +8,7 @@ Features ASCII art branding, interactive REPL, toolset selection, and rich forma Usage: python cli.py # Start interactive mode with all tools python cli.py --toolsets web,terminal # Start with specific toolsets + python cli.py --skills hermes-agent-dev,github-auth python cli.py -q "your question" # Single query mode python cli.py --list-tools # List available tools and exit """ @@ -1043,11 +1044,40 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic # Skill Slash Commands — dynamic commands generated from installed skills # ============================================================================ -from agent.skill_commands import scan_skill_commands, get_skill_commands, build_skill_invocation_message +from agent.skill_commands import ( + scan_skill_commands, + get_skill_commands, + build_skill_invocation_message, + build_preloaded_skills_prompt, +) _skill_commands = scan_skill_commands() +def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) -> list[str]: + """Normalize a CLI skills flag into a deduplicated list of skill identifiers.""" + if not skills: + return [] + + if isinstance(skills, str): + raw_values = [skills] + elif isinstance(skills, (list, tuple)): + raw_values = [str(item) for item in skills if item is not None] + else: + raw_values = [str(skills)] + + parsed: list[str] = [] + seen: set[str] = set() + for raw in raw_values: + for part in raw.split(","): + normalized = part.strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + parsed.append(normalized) + return parsed + + def save_config_value(key_path: str, value: any) -> bool: """ Save a value to the active config file at the specified key path. @@ -1313,6 +1343,8 @@ class HermesCLI: self._command_status = "" self._attached_images: list[Path] = [] self._image_counter = 0 + self.preloaded_skills: list[str] = [] + self._startup_skills_line_shown = False # Voice mode state (also reinitialized inside run() for interactive TUI). self._voice_lock = threading.Lock() @@ -1599,6 +1631,13 @@ class HermesCLI: def show_banner(self): """Display the welcome banner in Claude Code style.""" self.console.clear() + if self.preloaded_skills and not self._startup_skills_line_shown: + skills_label = ", ".join(self.preloaded_skills) + self.console.print( + f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}" + ) + self.console.print() + self._startup_skills_line_shown = True # Auto-compact for narrow terminals — the full banner with caduceus # + tool list needs ~80 columns minimum to render without wrapping. @@ -5829,6 +5868,7 @@ def main( query: str = None, q: str = None, toolsets: str = None, + skills: str | list[str] | tuple[str, ...] = None, model: str = None, provider: str = None, api_key: str = None, @@ -5853,6 +5893,7 @@ def main( query: Single query to execute (then exit). Alias: -q q: Shorthand for --query toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal") + skills: Comma-separated or repeated list of skills to preload for the session model: Model to use (default: anthropic/claude-opus-4-20250514) provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn") api_key: API key for authentication @@ -5869,6 +5910,7 @@ def main( Examples: python cli.py # Start interactive mode python cli.py --toolsets web,terminal # Use specific toolsets + python cli.py --skills hermes-agent-dev,github-auth python cli.py -q "What is Python?" # Single query mode python cli.py --list-tools # List tools and exit python cli.py --resume 20260225_143052_a1b2c3 # Resume session @@ -5938,6 +5980,8 @@ def main( else: toolsets_list = ["hermes-cli"] + parsed_skills = _parse_skills_argument(skills) + # Create CLI instance cli = HermesCLI( model=model, @@ -5953,6 +5997,20 @@ def main( pass_session_id=pass_session_id, ) + if parsed_skills: + skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt( + parsed_skills, + task_id=cli.session_id, + ) + if missing_skills: + missing_display = ", ".join(missing_skills) + raise ValueError(f"Unknown skill(s): {missing_display}") + if skills_prompt: + cli.system_prompt = "\n\n".join( + part for part in (cli.system_prompt, skills_prompt) if part + ).strip() + cli.preloaded_skills = loaded_skills + # Inject worktree context into agent's system prompt if wt_info: wt_note = ( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3d910907d7..c1b60e5822 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -499,6 +499,7 @@ def cmd_chat(args): "model": args.model, "provider": getattr(args, "provider", None), "toolsets": args.toolsets, + "skills": getattr(args, "skills", None), "verbose": args.verbose, "quiet": getattr(args, "quiet", False), "query": args.query, @@ -510,7 +511,11 @@ def cmd_chat(args): # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} - cli_main(**kwargs) + try: + cli_main(**kwargs) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) def cmd_gateway(args): @@ -2276,6 +2281,7 @@ Examples: hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value hermes gateway Run messaging gateway + hermes -s hermes-agent-dev,github-auth hermes -w Start in isolated git worktree hermes gateway install Install as system service hermes sessions list List past sessions @@ -2314,6 +2320,12 @@ For more help on a command: default=False, help="Run in an isolated git worktree (for parallel agents)" ) + parser.add_argument( + "--skills", "-s", + action="append", + default=None, + help="Preload one or more skills for the session (repeat flag or comma-separate)" + ) parser.add_argument( "--yolo", action="store_true", @@ -2349,6 +2361,12 @@ For more help on a command: "-t", "--toolsets", help="Comma-separated toolsets to enable" ) + chat_parser.add_argument( + "-s", "--skills", + action="append", + default=None, + help="Preload one or more skills for the session (repeat flag or comma-separate)" + ) chat_parser.add_argument( "--provider", choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn"], diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 2e2ac64ccd..42a6fb4d84 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -4,7 +4,11 @@ import os from unittest.mock import patch import tools.skills_tool as skills_tool_module -from agent.skill_commands import scan_skill_commands, build_skill_invocation_message +from agent.skill_commands import ( + scan_skill_commands, + build_skill_invocation_message, + build_preloaded_skills_prompt, +) def _make_skill( @@ -79,6 +83,33 @@ class TestScanSkillCommands: assert "/generic-tool" in result +class TestBuildPreloadedSkillsPrompt: + def test_builds_prompt_for_multiple_named_skills(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "first-skill") + _make_skill(tmp_path, "second-skill") + prompt, loaded, missing = build_preloaded_skills_prompt( + ["first-skill", "second-skill"] + ) + + assert missing == [] + assert loaded == ["first-skill", "second-skill"] + assert "first-skill" in prompt + assert "second-skill" in prompt + assert "preloaded" in prompt.lower() + + def test_reports_missing_named_skills(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "present-skill") + prompt, loaded, missing = build_preloaded_skills_prompt( + ["present-skill", "missing-skill"] + ) + + assert "present-skill" in prompt + assert loaded == ["present-skill"] + assert missing == ["missing-skill"] + + class TestBuildSkillInvocationMessage: def test_loads_skill_by_stored_path_when_frontmatter_name_differs(self, tmp_path): skill_dir = tmp_path / "mlops" / "audiocraft" diff --git a/tests/hermes_cli/test_chat_skills_flag.py b/tests/hermes_cli/test_chat_skills_flag.py new file mode 100644 index 0000000000..8551b4105a --- /dev/null +++ b/tests/hermes_cli/test_chat_skills_flag.py @@ -0,0 +1,77 @@ +import sys + + +def test_top_level_skills_flag_defaults_to_chat(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_cmd_chat(args): + captured["skills"] = args.skills + captured["command"] = args.command + + monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "-s", "hermes-agent-dev,github-auth"], + ) + + main_mod.main() + + assert captured == { + "skills": ["hermes-agent-dev,github-auth"], + "command": None, + } + + +def test_chat_subcommand_accepts_skills_flag(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_cmd_chat(args): + captured["skills"] = args.skills + captured["query"] = args.query + + monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "chat", "-s", "github-auth", "-q", "hello"], + ) + + main_mod.main() + + assert captured == { + "skills": ["github-auth"], + "query": "hello", + } + + +def test_continue_worktree_and_skills_flags_work_together(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_cmd_chat(args): + captured["continue_last"] = args.continue_last + captured["worktree"] = args.worktree + captured["skills"] = args.skills + captured["command"] = args.command + + monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "-c", "-w", "-s", "hermes-agent-dev"], + ) + + main_mod.main() + + assert captured == { + "continue_last": True, + "worktree": True, + "skills": ["hermes-agent-dev"], + "command": "chat", + } diff --git a/tests/test_cli_preloaded_skills.py b/tests/test_cli_preloaded_skills.py new file mode 100644 index 0000000000..90fee6cf6a --- /dev/null +++ b/tests/test_cli_preloaded_skills.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import importlib +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_real_cli(**kwargs): + clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict( + "os.environ", clean_env, clear=False + ): + import cli as cli_mod + + cli_mod = importlib.reload(cli_mod) + with patch.object(cli_mod, "get_tool_definitions", return_value=[]), patch.dict( + cli_mod.__dict__, {"CLI_CONFIG": clean_config} + ): + return cli_mod.HermesCLI(**kwargs) + + +class _DummyCLI: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.session_id = "session-123" + self.system_prompt = "base prompt" + self.preloaded_skills = [] + + def show_banner(self): + return None + + def show_tools(self): + return None + + def show_toolsets(self): + return None + + def run(self): + return None + + +def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch): + import cli as cli_mod + + created = {} + + def fake_cli(**kwargs): + created["cli"] = _DummyCLI(**kwargs) + return created["cli"] + + monkeypatch.setattr(cli_mod, "HermesCLI", fake_cli) + monkeypatch.setattr( + cli_mod, + "build_preloaded_skills_prompt", + lambda skills, task_id=None: ("skill prompt", ["hermes-agent-dev", "github-auth"], []), + ) + + with pytest.raises(SystemExit): + cli_mod.main(skills="hermes-agent-dev,github-auth", list_tools=True) + + cli_obj = created["cli"] + assert cli_obj.system_prompt == "base prompt\n\nskill prompt" + assert cli_obj.preloaded_skills == ["hermes-agent-dev", "github-auth"] + + +def test_main_raises_for_unknown_preloaded_skill(monkeypatch): + import cli as cli_mod + + monkeypatch.setattr(cli_mod, "HermesCLI", lambda **kwargs: _DummyCLI(**kwargs)) + monkeypatch.setattr( + cli_mod, + "build_preloaded_skills_prompt", + lambda skills, task_id=None: ("", [], ["missing-skill"]), + ) + + with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"): + cli_mod.main(skills="missing-skill", list_tools=True) + + +def test_show_banner_prints_preloaded_skills_once_before_banner(): + cli_obj = _make_real_cli(compact=False) + cli_obj.preloaded_skills = ["hermes-agent-dev", "github-auth"] + cli_obj.console = MagicMock() + + with patch("cli.build_welcome_banner") as mock_banner, patch( + "shutil.get_terminal_size", return_value=os.terminal_size((120, 40)) + ): + cli_obj.show_banner() + cli_obj.show_banner() + + print_calls = [ + call.args[0] + for call in cli_obj.console.print.call_args_list + if call.args and isinstance(call.args[0], str) + ] + startup_lines = [line for line in print_calls if "Activated skills:" in line] + + assert len(startup_lines) == 1 + assert "Activated skills:" in startup_lines[0] + assert "hermes-agent-dev, github-auth" in startup_lines[0] + assert mock_banner.call_count == 2 diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index fb3c838379..44b42fffca 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -27,6 +27,10 @@ hermes chat --provider openrouter # Force OpenRouter # With specific toolsets hermes chat --toolsets "web,terminal,skills" +# Start with one or more skills preloaded +hermes -s hermes-agent-dev,github-auth +hermes chat -s github-pr-workflow -q "open a draft PR" + # Resume previous sessions hermes --continue # Resume the most recent CLI session (-c) hermes --resume # Resume a specific session by ID (-r) @@ -126,6 +130,17 @@ quick_commands: Then type `/status` or `/gpu` in any chat. See the [Configuration guide](/docs/user-guide/configuration#quick-commands) for more examples. +## Preloading Skills at Launch + +If you already know which skills you want active for the session, pass them at launch time: + +```bash +hermes -s hermes-agent-dev,github-auth +hermes chat -s github-pr-workflow -s github-auth +``` + +Hermes loads each named skill into the session prompt before the first turn. The same flag works in interactive mode and single-query mode. + ## Skill Slash Commands Every installed skill in `~/.hermes/skills/` is automatically registered as a slash command. The skill name becomes the command: