feat(skills+terminal): make bundled skill scripts runnable out of the box (#13384)

* 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 <file>' 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.
This commit is contained in:
Teknium 2026-04-21 00:39:19 -07:00 committed by GitHub
parent b48ea41d27
commit 328223576b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 665 additions and 3 deletions

View file

@ -8,6 +8,7 @@ can invoke skills via /skill-name commands and prompt-only built-ins like
import json import json
import logging import logging
import re import re
import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional 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_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") _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( def build_plan_path(
user_instruction: str = "", user_instruction: str = "",
@ -133,14 +238,36 @@ def _build_skill_message(
activation_note: str, activation_note: str,
user_instruction: str = "", user_instruction: str = "",
runtime_note: str = "", runtime_note: str = "",
session_id: str | None = None,
) -> str: ) -> str:
"""Format a loaded skill into a user/system message payload.""" """Format a loaded skill into a user/system message payload."""
from tools.skills_tool import SKILLS_DIR from tools.skills_tool import SKILLS_DIR
content = str(loaded_skill.get("content") or "") 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()] 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 resolved skill config values ──
_inject_skill_config(loaded_skill, parts) _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 is from an external dir — use the skill name instead
skill_view_target = skill_dir.name skill_view_target = skill_dir.name
parts.append("") 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: for sf in supporting:
parts.append(f"- {sf}") parts.append(f"- {sf} -> {skill_dir / sf}")
parts.append( parts.append(
f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="<path>")' f'\nLoad any of these with skill_view(name="{skill_view_target}", '
f'file_path="<path>"), or run scripts directly by absolute path '
f"(e.g. `node {skill_dir}/scripts/foo.js`)."
) )
if user_instruction: if user_instruction:
@ -332,6 +461,7 @@ def build_skill_invocation_message(
activation_note, activation_note,
user_instruction=user_instruction, user_instruction=user_instruction,
runtime_note=runtime_note, runtime_note=runtime_note,
session_id=task_id,
) )
@ -370,6 +500,7 @@ def build_preloaded_skills_prompt(
loaded_skill, loaded_skill,
skill_dir, skill_dir,
activation_note, activation_note,
session_id=task_id,
) )
) )
loaded_names.append(skill_name) loaded_names.append(skill_name)

View file

@ -387,6 +387,26 @@ DEFAULT_CONFIG = {
# (terminal and execute_code). Skill-declared required_environment_variables # (terminal and execute_code). Skill-declared required_environment_variables
# are passed through automatically; this list is for non-skill use cases. # are passed through automatically; this list is for non-skill use cases.
"env_passthrough": [], "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_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [], "docker_forward_env": [],
# Explicit environment variables to set inside Docker containers. # Explicit environment variables to set inside Docker containers.
@ -704,6 +724,20 @@ DEFAULT_CONFIG = {
# always goes to ~/.hermes/skills/. # always goes to ~/.hermes/skills/.
"skills": { "skills": {
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-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. # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.

View file

@ -405,3 +405,191 @@ class TestPlanSkillHelpers:
assert "Add a /plan command" in msg assert "Add a /plan command" in msg
assert ".hermes/plans/plan.md" in msg assert ".hermes/plans/plan.md" in msg
assert "Runtime note:" 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", "")

View file

@ -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

View file

@ -213,6 +213,77 @@ def _make_run_env(env: dict) -> dict:
return run_env 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 <file>`` 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): class LocalEnvironment(BaseEnvironment):
"""Run commands directly on the host machine. """Run commands directly on the host machine.
@ -255,6 +326,16 @@ class LocalEnvironment(BaseEnvironment):
timeout: int = 120, timeout: int = 120,
stdin_data: str | None = None) -> subprocess.Popen: stdin_data: str | None = None) -> subprocess.Popen:
bash = _find_bash() 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] args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string]
run_env = _make_run_env(self.env) run_env = _make_run_env(self.env)

View file

@ -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. 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 <input>
```
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 ### Test It
Run the skill and verify the agent follows the instructions correctly: Run the skill and verify the agent follows the instructions correctly:

View file

@ -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. 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` #### `uv: command not found`
**Cause:** The `uv` package manager isn't installed or not in PATH. **Cause:** The `uv` package manager isn't installed or not in PATH.