From 328223576b4dc29cbbb48a2037a82d0b37e8ac47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:39:19 -0700 Subject: [PATCH] feat(skills+terminal): make bundled skill scripts runnable out of the box (#13384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): inject absolute skill dir and expand ${HERMES_SKILL_DIR} templates When a skill loads, the activation message now exposes the absolute skill directory and substitutes ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in the SKILL.md body, so skills with bundled scripts can instruct the agent to run them by absolute path without an extra skill_view round-trip. Also adds opt-in inline-shell expansion: !`cmd` snippets in SKILL.md are pre-executed (with the skill directory as CWD) and their stdout is inlined into the message before the agent reads it. Off by default — enable via skills.inline_shell in config.yaml — because any snippet runs on the host without approval. Changes: - agent/skill_commands.py: template substitution, inline-shell expansion, absolute skill-dir header, supporting-files list now shows both relative and absolute forms. - hermes_cli/config.py: new skills.template_vars, skills.inline_shell, skills.inline_shell_timeout knobs. - tests/agent/test_skill_commands.py: coverage for header, both template tokens (present and missing session id), template_vars disable, inline-shell default-off, enabled, CWD, and timeout. - website/docs/developer-guide/creating-skills.md: documents the template tokens, the absolute-path header, and the opt-in inline shell with its security caveat. Validation: tests/agent/ 1591 passed (includes 9 new tests). E2E: loaded a real skill in an isolated HERMES_HOME; confirmed ${HERMES_SKILL_DIR} resolves to the absolute path, ${HERMES_SESSION_ID} resolves to the passed task_id, !`date` runs when opt-in is set, and stays literal when it isn't. * feat(terminal): source ~/.bashrc (and user-listed init files) into session snapshot bash login shells don't source ~/.bashrc, so tools that install themselves there — nvm, asdf, pyenv, cargo, custom PATH exports — stay invisible to the environment snapshot Hermes builds once per session. Under systemd or any context with a minimal parent env, that surfaces as 'node: command not found' in the terminal tool even though the binary is reachable from every interactive shell on the machine. Changes: - tools/environments/local.py: before the login-shell snapshot bootstrap runs, prepend guarded 'source ' lines for each resolved init file. Missing files are skipped, each source is wrapped with a '[ -r ... ] && . ... || true' guard so a broken rc can't abort the bootstrap. - hermes_cli/config.py: new terminal.shell_init_files (explicit list, supports ~ and ${VAR}) and terminal.auto_source_bashrc (default on) knobs. When shell_init_files is set it takes precedence; when it's empty and auto_source_bashrc is on, ~/.bashrc gets auto-sourced. - tests/tools/test_local_shell_init.py: 10 tests covering the resolver (auto-bashrc, missing file, explicit override, ~/${VAR} expansion, opt-out) and the prelude builder (quoting, guarded sourcing), plus a real-LocalEnvironment snapshot test that confirms exports in the init file land in subsequent commands' environment. - website/docs/reference/faq.md: documents the fix in Troubleshooting, including the zsh-user pattern of sourcing ~/.zshrc or nvm.sh directly via shell_init_files. Validation: 10/10 new tests pass; tests/tools/test_local_*.py 40/40 pass; tests/agent/ 1591/1591 pass; tests/hermes_cli/test_config.py 50/50 pass. E2E in an isolated HERMES_HOME: confirmed that a fake ~/.bashrc setting a marker var and PATH addition shows up in a real LocalEnvironment().execute() call, that auto_source_bashrc=false suppresses it, that an explicit shell_init_files entry wins over the auto default, and that a missing bashrc is silently skipped. --- agent/skill_commands.py | 137 ++++++++++++- hermes_cli/config.py | 34 ++++ tests/agent/test_skill_commands.py | 188 ++++++++++++++++++ tests/tools/test_local_shell_init.py | 162 +++++++++++++++ tools/environments/local.py | 81 ++++++++ .../docs/developer-guide/creating-skills.md | 39 ++++ website/docs/reference/faq.md | 27 +++ 7 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 tests/tools/test_local_shell_init.py diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 280105dac..a4345ca8c 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -8,6 +8,7 @@ can invoke skills via /skill-name commands and prompt-only built-ins like import json import logging import re +import subprocess from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional @@ -22,6 +23,110 @@ _PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+") _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") +# Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md. +# Tokens that don't resolve (e.g. ${HERMES_SESSION_ID} with no session) are +# left as-is so the user can debug them. +_SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}") + +# Matches inline shell snippets like: !`date +%Y-%m-%d` +# Non-greedy, single-line only — no newlines inside the backticks. +_INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`") + +# Cap inline-shell output so a runaway command can't blow out the context. +_INLINE_SHELL_MAX_OUTPUT = 4000 + + +def _load_skills_config() -> dict: + """Load the ``skills`` section of config.yaml (best-effort).""" + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + skills_cfg = cfg.get("skills") + if isinstance(skills_cfg, dict): + return skills_cfg + except Exception: + logger.debug("Could not read skills config", exc_info=True) + return {} + + +def _substitute_template_vars( + content: str, + skill_dir: Path | None, + session_id: str | None, +) -> str: + """Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in skill content. + + Only substitutes tokens for which a concrete value is available — + unresolved tokens are left in place so the author can spot them. + """ + if not content: + return content + + skill_dir_str = str(skill_dir) if skill_dir else None + + def _replace(match: re.Match) -> str: + token = match.group(1) + if token == "HERMES_SKILL_DIR" and skill_dir_str: + return skill_dir_str + if token == "HERMES_SESSION_ID" and session_id: + return str(session_id) + return match.group(0) + + return _SKILL_TEMPLATE_RE.sub(_replace, content) + + +def _run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: + """Execute a single inline-shell snippet and return its stdout (trimmed). + + Failures return a short ``[inline-shell error: ...]`` marker instead of + raising, so one bad snippet can't wreck the whole skill message. + """ + try: + completed = subprocess.run( + ["bash", "-c", command], + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + timeout=max(1, int(timeout)), + check=False, + ) + except subprocess.TimeoutExpired: + return f"[inline-shell timeout after {timeout}s: {command}]" + except FileNotFoundError: + return f"[inline-shell error: bash not found]" + except Exception as exc: + return f"[inline-shell error: {exc}]" + + output = (completed.stdout or "").rstrip("\n") + if not output and completed.stderr: + output = completed.stderr.rstrip("\n") + if len(output) > _INLINE_SHELL_MAX_OUTPUT: + output = output[:_INLINE_SHELL_MAX_OUTPUT] + "…[truncated]" + return output + + +def _expand_inline_shell( + content: str, + skill_dir: Path | None, + timeout: int, +) -> str: + """Replace every !`cmd` snippet in ``content`` with its stdout. + + Runs each snippet with the skill directory as CWD so relative paths in + the snippet work the way the author expects. + """ + if "!`" not in content: + return content + + def _replace(match: re.Match) -> str: + cmd = match.group(1).strip() + if not cmd: + return "" + return _run_inline_shell(cmd, skill_dir, timeout) + + return _INLINE_SHELL_RE.sub(_replace, content) + def build_plan_path( user_instruction: str = "", @@ -133,14 +238,36 @@ def _build_skill_message( activation_note: str, user_instruction: str = "", runtime_note: str = "", + session_id: str | None = None, ) -> 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 "") + # ── Template substitution and inline-shell expansion ── + # Done before anything else so downstream blocks (setup notes, + # supporting-file hints) see the expanded content. + skills_cfg = _load_skills_config() + if skills_cfg.get("template_vars", True): + content = _substitute_template_vars(content, skill_dir, session_id) + if skills_cfg.get("inline_shell", False): + timeout = int(skills_cfg.get("inline_shell_timeout", 10) or 10) + content = _expand_inline_shell(content, skill_dir, timeout) + parts = [activation_note, "", content.strip()] + # ── Inject the absolute skill directory so the agent can reference + # bundled scripts without an extra skill_view() round-trip. ── + if skill_dir: + parts.append("") + parts.append(f"[Skill directory: {skill_dir}]") + parts.append( + "Resolve any relative paths in this skill (e.g. `scripts/foo.js`, " + "`templates/config.yaml`) against that directory, then run them " + "with the terminal tool using the absolute path." + ) + # ── Inject resolved skill config values ── _inject_skill_config(loaded_skill, parts) @@ -188,11 +315,13 @@ def _build_skill_message( # Skill is from an external dir — use the skill name instead skill_view_target = skill_dir.name parts.append("") - parts.append("[This skill has supporting files you can load with the skill_view tool:]") + parts.append("[This skill has supporting files:]") for sf in supporting: - parts.append(f"- {sf}") + parts.append(f"- {sf} -> {skill_dir / sf}") parts.append( - f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="")' + f'\nLoad any of these with skill_view(name="{skill_view_target}", ' + f'file_path=""), or run scripts directly by absolute path ' + f"(e.g. `node {skill_dir}/scripts/foo.js`)." ) if user_instruction: @@ -332,6 +461,7 @@ def build_skill_invocation_message( activation_note, user_instruction=user_instruction, runtime_note=runtime_note, + session_id=task_id, ) @@ -370,6 +500,7 @@ def build_preloaded_skills_prompt( loaded_skill, skill_dir, activation_note, + session_id=task_id, ) ) loaded_names.append(skill_name) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b1566a2a5..4ed7eaf8e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -387,6 +387,26 @@ DEFAULT_CONFIG = { # (terminal and execute_code). Skill-declared required_environment_variables # are passed through automatically; this list is for non-skill use cases. "env_passthrough": [], + # Extra files to source in the login shell when building the + # per-session environment snapshot. Use this when tools like nvm, + # pyenv, asdf, or custom PATH entries are registered by files that + # a bash login shell would skip — most commonly ``~/.bashrc`` + # (bash doesn't source bashrc in non-interactive login mode) or + # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``. + # Paths support ``~`` / ``${VAR}``. Missing files are silently + # skipped. When empty, Hermes auto-appends ``~/.bashrc`` if the + # snapshot shell is bash (this is the ``auto_source_bashrc`` + # behaviour — disable with that key if you want strict login-only + # semantics). + "shell_init_files": [], + # When true (default), Hermes sources ``~/.bashrc`` in the login + # shell used to build the environment snapshot. This captures + # PATH additions, shell functions, and aliases defined in the + # user's bashrc — which a plain ``bash -l -c`` would otherwise + # miss because bash skips bashrc in non-interactive login mode. + # Turn this off if you have a bashrc that misbehaves when sourced + # non-interactively (e.g. one that hard-exits on TTY checks). + "auto_source_bashrc": True, "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_forward_env": [], # Explicit environment variables to set inside Docker containers. @@ -704,6 +724,20 @@ DEFAULT_CONFIG = { # always goes to ~/.hermes/skills/. "skills": { "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] + # Substitute ${HERMES_SKILL_DIR} and ${HERMES_SESSION_ID} in SKILL.md + # content with the absolute skill directory and the active session id + # before the agent sees it. Lets skill authors reference bundled + # scripts without the agent having to join paths. + "template_vars": True, + # Pre-execute inline shell snippets written as !`cmd` in SKILL.md + # body. Their stdout is inlined into the skill message before the + # agent reads it, so skills can inject dynamic context (dates, git + # state, detected tool versions, …). Off by default because any + # content from the skill author runs on the host without approval; + # only enable for skill sources you trust. + "inline_shell": False, + # Timeout (seconds) for each !`cmd` snippet when inline_shell is on. + "inline_shell_timeout": 10, }, # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth. diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 57ac7d6b5..e399db619 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -405,3 +405,191 @@ class TestPlanSkillHelpers: assert "Add a /plan command" in msg assert ".hermes/plans/plan.md" in msg assert "Runtime note:" in msg + + +class TestSkillDirectoryHeader: + """The activation message must expose the absolute skill directory and + explain how to resolve relative paths, so skills with bundled scripts + don't force the agent into a second ``skill_view()`` round-trip.""" + + def test_header_contains_absolute_skill_dir(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + skill_dir = _make_skill(tmp_path, "abs-dir-skill") + scan_skill_commands() + msg = build_skill_invocation_message("/abs-dir-skill", "go") + + assert msg is not None + assert f"[Skill directory: {skill_dir}]" in msg + assert "Resolve any relative paths" in msg + + def test_supporting_files_shown_with_absolute_paths(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + skill_dir = _make_skill(tmp_path, "scripted-skill") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.js").write_text("console.log('hi')") + scan_skill_commands() + msg = build_skill_invocation_message("/scripted-skill") + + assert msg is not None + # The supporting-files block must emit both the relative form (so the + # agent can call skill_view on it) and the absolute form (so it can + # run the script directly via terminal). + assert "scripts/run.js" in msg + assert str(skill_dir / "scripts" / "run.js") in msg + assert f"node {skill_dir}/scripts/foo.js" in msg + + +class TestTemplateVarSubstitution: + """``${HERMES_SKILL_DIR}`` and ``${HERMES_SESSION_ID}`` in SKILL.md body + are replaced before the agent sees the content.""" + + def test_substitutes_skill_dir(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + skill_dir = _make_skill( + tmp_path, + "templated", + body="Run: node ${HERMES_SKILL_DIR}/scripts/foo.js", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/templated") + + assert msg is not None + assert f"node {skill_dir}/scripts/foo.js" in msg + # The literal template token must not leak through. + assert "${HERMES_SKILL_DIR}" not in msg.split("[Skill directory:")[0] + + def test_substitutes_session_id_when_available(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, + "sess-templated", + body="Session: ${HERMES_SESSION_ID}", + ) + scan_skill_commands() + msg = build_skill_invocation_message( + "/sess-templated", task_id="abc-123" + ) + + assert msg is not None + assert "Session: abc-123" in msg + + def test_leaves_session_id_token_when_missing(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, + "sess-missing", + body="Session: ${HERMES_SESSION_ID}", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/sess-missing", task_id=None) + + assert msg is not None + # No session — token left intact so the author can spot it. + assert "Session: ${HERMES_SESSION_ID}" in msg + + def test_disable_template_vars_via_config(self, tmp_path): + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch( + "agent.skill_commands._load_skills_config", + return_value={"template_vars": False}, + ), + ): + _make_skill( + tmp_path, + "no-sub", + body="Run: node ${HERMES_SKILL_DIR}/scripts/foo.js", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/no-sub") + + assert msg is not None + # Template token must survive when substitution is disabled. + assert "${HERMES_SKILL_DIR}/scripts/foo.js" in msg + + +class TestInlineShellExpansion: + """Inline ``!`cmd`` snippets in SKILL.md run before the agent sees the + content — but only when the user has opted in via config.""" + + def test_inline_shell_is_off_by_default(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, + "dyn-default-off", + body="Today is !`echo INLINE_RAN`.", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/dyn-default-off") + + assert msg is not None + # Default config has inline_shell=False — snippet must stay literal. + assert "!`echo INLINE_RAN`" in msg + assert "Today is INLINE_RAN." not in msg + + def test_inline_shell_runs_when_enabled(self, tmp_path): + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch( + "agent.skill_commands._load_skills_config", + return_value={"template_vars": True, "inline_shell": True, + "inline_shell_timeout": 5}, + ), + ): + _make_skill( + tmp_path, + "dyn-on", + body="Marker: !`echo INLINE_RAN`.", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/dyn-on") + + assert msg is not None + assert "Marker: INLINE_RAN." in msg + assert "!`echo INLINE_RAN`" not in msg + + def test_inline_shell_runs_in_skill_directory(self, tmp_path): + """Inline snippets get the skill dir as CWD so relative paths work.""" + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch( + "agent.skill_commands._load_skills_config", + return_value={"template_vars": True, "inline_shell": True, + "inline_shell_timeout": 5}, + ), + ): + skill_dir = _make_skill( + tmp_path, + "dyn-cwd", + body="Here: !`pwd`", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/dyn-cwd") + + assert msg is not None + assert f"Here: {skill_dir}" in msg + + def test_inline_shell_timeout_does_not_break_message(self, tmp_path): + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch( + "agent.skill_commands._load_skills_config", + return_value={"template_vars": True, "inline_shell": True, + "inline_shell_timeout": 1}, + ), + ): + _make_skill( + tmp_path, + "dyn-slow", + body="Slow: !`sleep 5 && printf DYN_MARKER`", + ) + scan_skill_commands() + msg = build_skill_invocation_message("/dyn-slow") + + assert msg is not None + # Timeout is surfaced as a marker instead of propagating as an error, + # and the rest of the skill message still renders. + assert "inline-shell timeout" in msg + # The command's intended stdout never made it through — only the + # timeout marker (which echoes the command text) survives. + assert "DYN_MARKER" not in msg.replace("sleep 5 && printf DYN_MARKER", "") diff --git a/tests/tools/test_local_shell_init.py b/tests/tools/test_local_shell_init.py new file mode 100644 index 000000000..96e26e735 --- /dev/null +++ b/tests/tools/test_local_shell_init.py @@ -0,0 +1,162 @@ +"""Tests for terminal.shell_init_files / terminal.auto_source_bashrc. + +A bash ``-l -c`` invocation does NOT source ``~/.bashrc``, so tools that +register themselves there (nvm, asdf, pyenv) stay invisible to the +environment snapshot built by ``LocalEnvironment.init_session``. These +tests verify the config-driven prelude that fixes that. +""" + +import os +from unittest.mock import patch + +import pytest + +from tools.environments.local import ( + LocalEnvironment, + _prepend_shell_init, + _read_terminal_shell_init_config, + _resolve_shell_init_files, +) + + +class TestResolveShellInitFiles: + def test_auto_sources_bashrc_when_present(self, tmp_path, monkeypatch): + bashrc = tmp_path / ".bashrc" + bashrc.write_text('export MARKER=seen\n') + monkeypatch.setenv("HOME", str(tmp_path)) + + # Default config: auto_source_bashrc on, no explicit list. + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([], True), + ): + resolved = _resolve_shell_init_files() + + assert resolved == [str(bashrc)] + + def test_skips_bashrc_when_missing(self, tmp_path, monkeypatch): + # No bashrc written. + monkeypatch.setenv("HOME", str(tmp_path)) + + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([], True), + ): + resolved = _resolve_shell_init_files() + + assert resolved == [] + + def test_auto_source_bashrc_off_suppresses_default(self, tmp_path, monkeypatch): + bashrc = tmp_path / ".bashrc" + bashrc.write_text('export MARKER=seen\n') + monkeypatch.setenv("HOME", str(tmp_path)) + + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([], False), + ): + resolved = _resolve_shell_init_files() + + assert resolved == [] + + def test_explicit_list_wins_over_auto(self, tmp_path, monkeypatch): + bashrc = tmp_path / ".bashrc" + bashrc.write_text('export FROM_BASHRC=1\n') + custom = tmp_path / "custom.sh" + custom.write_text('export FROM_CUSTOM=1\n') + monkeypatch.setenv("HOME", str(tmp_path)) + + # auto_source_bashrc stays True but the explicit list takes precedence. + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([str(custom)], True), + ): + resolved = _resolve_shell_init_files() + + assert resolved == [str(custom)] + assert str(bashrc) not in resolved + + def test_expands_home_and_env_vars(self, tmp_path, monkeypatch): + target = tmp_path / "rc" / "custom.sh" + target.parent.mkdir() + target.write_text('export A=1\n') + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CUSTOM_RC_DIR", str(tmp_path / "rc")) + + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=(["~/rc/custom.sh"], False), + ): + resolved_home = _resolve_shell_init_files() + + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=(["${CUSTOM_RC_DIR}/custom.sh"], False), + ): + resolved_var = _resolve_shell_init_files() + + assert resolved_home == [str(target)] + assert resolved_var == [str(target)] + + def test_missing_explicit_files_are_skipped_silently(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([str(tmp_path / "does-not-exist.sh")], False), + ): + resolved = _resolve_shell_init_files() + + assert resolved == [] + + +class TestPrependShellInit: + def test_empty_list_returns_command_unchanged(self): + assert _prepend_shell_init("echo hi", []) == "echo hi" + + def test_prepends_guarded_source_lines(self): + wrapped = _prepend_shell_init("echo hi", ["/tmp/a.sh", "/tmp/b.sh"]) + assert "echo hi" in wrapped + # Each file is sourced through a guarded [ -r … ] && . '…' || true + # pattern so a missing/broken rc can't abort the bootstrap. + assert "/tmp/a.sh" in wrapped + assert "/tmp/b.sh" in wrapped + assert "|| true" in wrapped + assert "set +e" in wrapped + + def test_escapes_single_quotes(self): + wrapped = _prepend_shell_init("echo hi", ["/tmp/o'malley.sh"]) + # The path must survive as the shell receives it; embedded single + # quote is escaped as '\'' rather than breaking the outer quoting. + assert "o'\\''malley" in wrapped + + +@pytest.mark.skipif( + os.environ.get("CI") == "true" and not os.path.isfile("/bin/bash"), + reason="Requires bash; CI sandbox may strip it.", +) +class TestSnapshotEndToEnd: + """Spin up a real LocalEnvironment and confirm the snapshot sources + extra init files.""" + + def test_snapshot_picks_up_init_file_exports(self, tmp_path, monkeypatch): + init_file = tmp_path / "custom-init.sh" + init_file.write_text( + 'export HERMES_SHELL_INIT_PROBE="probe-ok"\n' + 'export PATH="/opt/shell-init-probe/bin:$PATH"\n' + ) + + with patch( + "tools.environments.local._read_terminal_shell_init_config", + return_value=([str(init_file)], False), + ): + env = LocalEnvironment(cwd=str(tmp_path), timeout=15) + try: + result = env.execute( + 'echo "PROBE=$HERMES_SHELL_INIT_PROBE"; echo "PATH=$PATH"' + ) + finally: + env.cleanup() + + output = result.get("output", "") + assert "PROBE=probe-ok" in output + assert "/opt/shell-init-probe/bin" in output diff --git a/tools/environments/local.py b/tools/environments/local.py index a1ab676d3..06fd66a2d 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -213,6 +213,77 @@ def _make_run_env(env: dict) -> dict: return run_env +def _read_terminal_shell_init_config() -> tuple[list[str], bool]: + """Return (shell_init_files, auto_source_bashrc) from config.yaml. + + Best-effort — returns sensible defaults on any failure so terminal + execution never breaks because the config file is unreadable. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + terminal_cfg = cfg.get("terminal") or {} + files = terminal_cfg.get("shell_init_files") or [] + if not isinstance(files, list): + files = [] + auto_bashrc = bool(terminal_cfg.get("auto_source_bashrc", True)) + return [str(f) for f in files if f], auto_bashrc + except Exception: + return [], True + + +def _resolve_shell_init_files() -> list[str]: + """Resolve the list of files to source before the login-shell snapshot. + + Expands ``~`` and ``${VAR}`` references and drops anything that doesn't + exist on disk, so a missing ``~/.bashrc`` never breaks the snapshot. + The ``auto_source_bashrc`` path runs only when the user hasn't supplied + an explicit list — once they have, Hermes trusts them. + """ + explicit, auto_bashrc = _read_terminal_shell_init_config() + + candidates: list[str] = [] + if explicit: + candidates.extend(explicit) + elif auto_bashrc and not _IS_WINDOWS: + # Bash's login-shell invocation does NOT source ~/.bashrc by default, + # so tools like nvm / asdf / pyenv that self-install there stay + # invisible to the snapshot without this nudge. + candidates.append("~/.bashrc") + + resolved: list[str] = [] + for raw in candidates: + try: + path = os.path.expandvars(os.path.expanduser(raw)) + except Exception: + continue + if path and os.path.isfile(path): + resolved.append(path) + return resolved + + +def _prepend_shell_init(cmd_string: str, files: list[str]) -> str: + """Prepend ``source `` lines (guarded + silent) to a bash script. + + Each file is wrapped so a failing rc file doesn't abort the whole + bootstrap: ``set +e`` keeps going on errors, ``2>/dev/null`` hides + noisy prompts, and ``|| true`` neutralises the exit status. + """ + if not files: + return cmd_string + + prelude_parts = ["set +e"] + for path in files: + # shlex.quote isn't available here without an import; the files list + # comes from os.path.expanduser output so it's a concrete absolute + # path. Escape single quotes defensively anyway. + safe = path.replace("'", "'\\''") + prelude_parts.append(f"[ -r '{safe}' ] && . '{safe}' 2>/dev/null || true") + prelude = "\n".join(prelude_parts) + "\n" + return prelude + cmd_string + + class LocalEnvironment(BaseEnvironment): """Run commands directly on the host machine. @@ -255,6 +326,16 @@ class LocalEnvironment(BaseEnvironment): timeout: int = 120, stdin_data: str | None = None) -> subprocess.Popen: bash = _find_bash() + # For login-shell invocations (used by init_session to build the + # environment snapshot), prepend sources for the user's bashrc / + # custom init files so tools registered outside bash_profile + # (nvm, asdf, pyenv, …) end up on PATH in the captured snapshot. + # Non-login invocations are already sourcing the snapshot and + # don't need this. + if login: + init_files = _resolve_shell_init_files() + if init_files: + cmd_string = _prepend_shell_init(cmd_string, init_files) args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string] run_env = _make_run_env(self.env) diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index 9fdb7fd11..43f088a9a 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -272,6 +272,45 @@ Put the most common workflow first. Edge cases and advanced usage go at the bott For XML/JSON parsing or complex logic, include helper scripts in `scripts/` — don't expect the LLM to write parsers inline every time. +#### Referencing bundled scripts from SKILL.md + +When a skill is loaded, the activation message exposes the absolute skill directory as `[Skill directory: /abs/path]` and also substitutes two template tokens anywhere in the SKILL.md body: + +| Token | Replaced with | +|---|---| +| `${HERMES_SKILL_DIR}` | Absolute path to the skill's directory | +| `${HERMES_SESSION_ID}` | The active session id (left in place if there is no session) | + +So a SKILL.md can tell the agent to run a bundled script directly with: + +```markdown +To analyse the input, run: + + node ${HERMES_SKILL_DIR}/scripts/analyse.js +``` + +The agent sees the substituted absolute path and invokes the `terminal` tool with a ready-to-run command — no path math, no extra `skill_view` round-trip. Disable substitution globally with `skills.template_vars: false` in `config.yaml`. + +#### Inline shell snippets (opt-in) + +Skills can also embed inline shell snippets written as `` !`cmd` `` in the SKILL.md body. When enabled, each snippet's stdout is inlined into the message before the agent reads it, so skills can inject dynamic context: + +```markdown +Current date: !`date -u +%Y-%m-%d` +Git branch: !`git -C ${HERMES_SKILL_DIR} rev-parse --abbrev-ref HEAD` +``` + +This is **off by default** — any snippet in a SKILL.md runs on the host without approval, so only enable it for skill sources you trust: + +```yaml +# config.yaml +skills: + inline_shell: true + inline_shell_timeout: 10 # seconds per snippet +``` + +Snippets run with the skill directory as their working directory, and output is capped at 4000 characters. Failures (timeouts, non-zero exits) show up as a short `[inline-shell error: ...]` marker instead of breaking the whole skill. + ### Test It Run the skill and verify the agent follows the instructions correctly: diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 132a4d00a..8a8b9df41 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -160,6 +160,33 @@ brew install python@3.12 # macOS The installer handles this automatically — if you see this error during manual installation, upgrade Python first. +#### Terminal commands say `node: command not found` (or `nvm`, `pyenv`, `asdf`, …) + +**Cause:** Hermes builds a per-session environment snapshot by running `bash -l` once at startup. A bash login shell reads `/etc/profile`, `~/.bash_profile`, and `~/.profile`, but **does not source `~/.bashrc`** — so tools that install themselves there (`nvm`, `asdf`, `pyenv`, `cargo`, custom `PATH` exports) stay invisible to the snapshot. This most commonly happens when Hermes runs under systemd or in a minimal shell where nothing has pre-loaded the interactive shell profile. + +**Solution:** Hermes auto-sources `~/.bashrc` by default. If that's not enough — e.g. you're a zsh user whose PATH lives in `~/.zshrc`, or you init `nvm` from a standalone file — list the extra files to source in `~/.hermes/config.yaml`: + +```yaml +terminal: + shell_init_files: + - ~/.zshrc # zsh users: pulls zsh-managed PATH into the bash snapshot + - ~/.nvm/nvm.sh # direct nvm init (works regardless of shell) + - /etc/profile.d/cargo.sh # system-wide rc files + # When this list is set, the default ~/.bashrc auto-source is NOT added — + # include it explicitly if you want both: + # - ~/.bashrc + # - ~/.zshrc +``` + +Missing files are skipped silently. Sourcing happens in bash, so files that rely on zsh-only syntax may error — if that's a concern, source just the PATH-setting portion (e.g. nvm's `nvm.sh` directly) rather than the whole rc file. + +To disable the auto-source behaviour (strict login-shell semantics only): + +```yaml +terminal: + auto_source_bashrc: false +``` + #### `uv: command not found` **Cause:** The `uv` package manager isn't installed or not in PATH.