mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
b48ea41d27
commit
328223576b
7 changed files with 665 additions and 3 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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", "")
|
||||||
|
|
|
||||||
162
tests/tools/test_local_shell_init.py
Normal file
162
tests/tools/test_local_shell_init.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue