Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05601e1f03 | |||
| 811adca277 | |||
| aafe37012a | |||
| 909de72426 | |||
| ba1b600bce | |||
| fcd1645223 | |||
| 253a9adc72 | |||
| 300964178f | |||
| 7a3682ac3f | |||
| 9f01244137 | |||
| 0a80dd9c7a | |||
| 4764e06fde | |||
| 4c532c153b | |||
| a99c0478d0 | |||
| c6e3084baf | |||
| dcbdfdbb2b | |||
| 91b881f931 |
@@ -0,0 +1,13 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitmodules
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
tags: nousresearch/hermes-agent:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test image starts
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
||||
nousresearch/hermes-agent:test --help
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
nousresearch/hermes-agent:latest
|
||||
nousresearch/hermes-agent:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM debian:13.4
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev
|
||||
|
||||
COPY . /opt/hermes
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
RUN pip install -e ".[all]" --break-system-packages
|
||||
RUN npm install
|
||||
RUN npx playwright install --with-deps chromium
|
||||
WORKDIR /opt/hermes/scripts/whatsapp-bridge
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /opt/hermes
|
||||
RUN chmod +x /opt/hermes/docker/entrypoint.sh
|
||||
|
||||
ENV HERMES_HOME=/opt/data
|
||||
VOLUME [ "/opt/data" ]
|
||||
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
|
||||
@@ -74,7 +74,7 @@ def main() -> None:
|
||||
|
||||
agent = HermesACPAgent()
|
||||
try:
|
||||
asyncio.run(acp.run_agent(agent))
|
||||
asyncio.run(acp.run_agent(agent, use_unstable_protocol=True))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down (KeyboardInterrupt)")
|
||||
except Exception:
|
||||
|
||||
+46
-3
@@ -25,6 +25,9 @@ from acp.schema import (
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeResponse,
|
||||
ResourceContentBlock,
|
||||
SessionCapabilities,
|
||||
SessionForkCapabilities,
|
||||
@@ -94,11 +97,14 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
protocol_version: int,
|
||||
protocol_version: int | None = None,
|
||||
client_capabilities: ClientCapabilities | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
**kwargs: Any,
|
||||
) -> InitializeResponse:
|
||||
resolved_protocol_version = (
|
||||
protocol_version if isinstance(protocol_version, int) else acp.PROTOCOL_VERSION
|
||||
)
|
||||
provider = detect_provider()
|
||||
auth_methods = None
|
||||
if provider:
|
||||
@@ -111,7 +117,11 @@ class HermesACPAgent(acp.Agent):
|
||||
]
|
||||
|
||||
client_name = client_info.name if client_info else "unknown"
|
||||
logger.info("Initialize from %s (protocol v%s)", client_name, protocol_version)
|
||||
logger.info(
|
||||
"Initialize from %s (protocol v%s)",
|
||||
client_name,
|
||||
resolved_protocol_version,
|
||||
)
|
||||
|
||||
return InitializeResponse(
|
||||
protocol_version=acp.PROTOCOL_VERSION,
|
||||
@@ -471,7 +481,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
):
|
||||
) -> SetSessionModelResponse | None:
|
||||
"""Switch the model for a session (called by ACP protocol)."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state:
|
||||
@@ -489,4 +499,37 @@ class HermesACPAgent(acp.Agent):
|
||||
)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: model switched to %s", session_id, model_id)
|
||||
return SetSessionModelResponse()
|
||||
logger.warning("Session %s: model switch requested for missing session", session_id)
|
||||
return None
|
||||
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
"""Persist the editor-requested mode so ACP clients do not fail on mode switches."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: mode switch requested for missing session", session_id)
|
||||
return None
|
||||
setattr(state, "mode", mode_id)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: mode switched to %s", session_id, mode_id)
|
||||
return SetSessionModeResponse()
|
||||
|
||||
async def set_config_option(
|
||||
self, config_id: str, session_id: str, value: str, **kwargs: Any
|
||||
) -> SetSessionConfigOptionResponse | None:
|
||||
"""Accept ACP config option updates even when Hermes has no typed ACP config surface yet."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: config update requested for missing session", session_id)
|
||||
return None
|
||||
|
||||
options = getattr(state, "config_options", None)
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
options[str(config_id)] = value
|
||||
setattr(state, "config_options", options)
|
||||
self.session_manager.save_session(session_id)
|
||||
logger.info("Session %s: config option %s updated", session_id, config_id)
|
||||
return SetSessionConfigOptionResponse(config_options=[])
|
||||
|
||||
+59
-1
@@ -18,6 +18,7 @@ from typing import Optional
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
get_disabled_skill_names,
|
||||
iter_skill_index_files,
|
||||
parse_frontmatter,
|
||||
@@ -444,16 +445,23 @@ def build_skills_system_prompt(
|
||||
mtime/size manifest — survives process restarts
|
||||
|
||||
Falls back to a full filesystem scan when both layers miss.
|
||||
|
||||
External skill directories (``skills.external_dirs`` in config.yaml) are
|
||||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
skills_dir = hermes_home / "skills"
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
|
||||
if not skills_dir.exists():
|
||||
if not skills_dir.exists() and not external_dirs:
|
||||
return ""
|
||||
|
||||
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
tuple(sorted(str(t) for t in (available_tools or set()))),
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
)
|
||||
@@ -540,6 +548,56 @@ def build_skills_system_prompt(
|
||||
category_descriptions,
|
||||
)
|
||||
|
||||
# ── External skill directories ─────────────────────────────────────
|
||||
# Scan external dirs directly (no snapshot caching — they're read-only
|
||||
# and typically small). Local skills already in skills_by_category take
|
||||
# precedence: we track seen names and skip duplicates from external dirs.
|
||||
seen_skill_names: set[str] = set()
|
||||
for cat_skills in skills_by_category.values():
|
||||
for name, _desc in cat_skills:
|
||||
seen_skill_names.add(name)
|
||||
|
||||
for ext_dir in external_dirs:
|
||||
if not ext_dir.exists():
|
||||
continue
|
||||
for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"):
|
||||
try:
|
||||
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
||||
if not is_compatible:
|
||||
continue
|
||||
entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
|
||||
skill_name = entry["skill_name"]
|
||||
if skill_name in seen_skill_names:
|
||||
continue
|
||||
if entry["frontmatter_name"] in disabled or skill_name in disabled:
|
||||
continue
|
||||
if not _skill_should_show(
|
||||
extract_skill_conditions(frontmatter),
|
||||
available_tools,
|
||||
available_toolsets,
|
||||
):
|
||||
continue
|
||||
seen_skill_names.add(skill_name)
|
||||
skills_by_category.setdefault(entry["category"], []).append(
|
||||
(skill_name, entry["description"])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Error reading external skill %s: %s", skill_file, e)
|
||||
|
||||
# External category descriptions
|
||||
for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"):
|
||||
try:
|
||||
content = desc_file.read_text(encoding="utf-8")
|
||||
fm, _ = parse_frontmatter(content)
|
||||
cat_desc = fm.get("description")
|
||||
if not cat_desc:
|
||||
continue
|
||||
rel = desc_file.relative_to(ext_dir)
|
||||
cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
|
||||
category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\""))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
|
||||
+45
-30
@@ -128,7 +128,11 @@ def _build_skill_message(
|
||||
supporting.append(rel)
|
||||
|
||||
if supporting and skill_dir:
|
||||
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
|
||||
try:
|
||||
skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
|
||||
except ValueError:
|
||||
# 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:]")
|
||||
for sf in supporting:
|
||||
@@ -158,38 +162,49 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
_skill_commands = {}
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||
if not SKILLS_DIR.exists():
|
||||
return _skill_commands
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
disabled = _get_disabled_skill_names()
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
seen_names: set = set()
|
||||
|
||||
# Scan local dir first, then external dirs
|
||||
dirs_to_scan = []
|
||||
if SKILLS_DIR.exists():
|
||||
dirs_to_scan.append(SKILLS_DIR)
|
||||
dirs_to_scan.extend(get_external_skills_dirs())
|
||||
|
||||
for scan_dir in dirs_to_scan:
|
||||
for skill_md in scan_dir.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
# Respect user's disabled skills config
|
||||
if name in disabled:
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
name = frontmatter.get('name', skill_md.parent.name)
|
||||
if name in seen_names:
|
||||
continue
|
||||
# Respect user's disabled skills config
|
||||
if name in disabled:
|
||||
continue
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
for line in body.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
description = line[:80]
|
||||
break
|
||||
seen_names.add(name)
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
"skill_dir": str(skill_md.parent),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
for line in body.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
description = line[:80]
|
||||
break
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
"skill_md_path": str(skill_md),
|
||||
"skill_dir": str(skill_md.parent),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return _skill_commands
|
||||
|
||||
@@ -158,6 +158,73 @@ def _normalize_string_set(values) -> Set[str]:
|
||||
return {str(v).strip() for v in values if str(v).strip()}
|
||||
|
||||
|
||||
# ── External skills directories ──────────────────────────────────────────
|
||||
|
||||
|
||||
def get_external_skills_dirs() -> List[Path]:
|
||||
"""Read ``skills.external_dirs`` from config.yaml and return validated paths.
|
||||
|
||||
Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute
|
||||
path. Only directories that actually exist are returned. Duplicates and
|
||||
paths that resolve to the local ``~/.hermes/skills/`` are silently skipped.
|
||||
"""
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return []
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
if not isinstance(skills_cfg, dict):
|
||||
return []
|
||||
|
||||
raw_dirs = skills_cfg.get("external_dirs")
|
||||
if not raw_dirs:
|
||||
return []
|
||||
if isinstance(raw_dirs, str):
|
||||
raw_dirs = [raw_dirs]
|
||||
if not isinstance(raw_dirs, list):
|
||||
return []
|
||||
|
||||
local_skills = (get_hermes_home() / "skills").resolve()
|
||||
seen: Set[Path] = set()
|
||||
result: List[Path] = []
|
||||
|
||||
for entry in raw_dirs:
|
||||
entry = str(entry).strip()
|
||||
if not entry:
|
||||
continue
|
||||
# Expand ~ and environment variables
|
||||
expanded = os.path.expanduser(os.path.expandvars(entry))
|
||||
p = Path(expanded).resolve()
|
||||
if p == local_skills:
|
||||
continue
|
||||
if p in seen:
|
||||
continue
|
||||
if p.is_dir():
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
else:
|
||||
logger.debug("External skills dir does not exist, skipping: %s", p)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_skills_dirs() -> List[Path]:
|
||||
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
|
||||
|
||||
The local dir is always first (and always included even if it doesn't exist
|
||||
yet — callers handle that). External dirs follow in config order.
|
||||
"""
|
||||
dirs = [get_hermes_home() / "skills"]
|
||||
dirs.extend(get_external_skills_dirs())
|
||||
return dirs
|
||||
|
||||
|
||||
# ── Condition extraction ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -402,6 +402,15 @@ skills:
|
||||
# Set to 0 to disable.
|
||||
creation_nudge_interval: 15
|
||||
|
||||
# External skill directories — share skills across tools/agents without
|
||||
# copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR})
|
||||
# and resolved to an absolute path. External dirs are read-only: skill
|
||||
# creation always writes to ~/.hermes/skills/. Local skills take precedence
|
||||
# when names collide.
|
||||
# external_dirs:
|
||||
# - ~/.agents/skills
|
||||
# - /home/shared/team-skills
|
||||
|
||||
# =============================================================================
|
||||
# Agent Behavior
|
||||
# =============================================================================
|
||||
|
||||
@@ -70,7 +70,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
from hermes_constants import get_hermes_home, display_hermes_home, OPENROUTER_BASE_URL
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
@@ -3594,7 +3594,7 @@ class HermesCLI:
|
||||
print(" To start the gateway:")
|
||||
print(" python cli.py --gateway")
|
||||
print()
|
||||
print(" Configuration file: ~/.hermes/config.yaml")
|
||||
print(f" Configuration file: {display_hermes_home()}/config.yaml")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
@@ -3604,7 +3604,7 @@ class HermesCLI:
|
||||
print(" 1. Set environment variables:")
|
||||
print(" TELEGRAM_BOT_TOKEN=your_token")
|
||||
print(" DISCORD_BOT_TOKEN=your_token")
|
||||
print(" 2. Or configure settings in ~/.hermes/config.yaml")
|
||||
print(f" 2. Or configure settings in {display_hermes_home()}/config.yaml")
|
||||
print()
|
||||
|
||||
def process_command(self, command: str) -> bool:
|
||||
@@ -3811,7 +3811,7 @@ class HermesCLI:
|
||||
plugins = mgr.list_plugins()
|
||||
if not plugins:
|
||||
print("No plugins installed.")
|
||||
print("Drop plugin directories into ~/.hermes/plugins/ to get started.")
|
||||
print(f"Drop plugin directories into {display_hermes_home()}/plugins/ to get started.")
|
||||
else:
|
||||
print(f"Plugins ({len(plugins)}):")
|
||||
for p in plugins:
|
||||
@@ -4340,7 +4340,7 @@ class HermesCLI:
|
||||
source = f" ({s['source']})" if s["source"] == "user" else ""
|
||||
print(f" {marker} {s['name']}{source} — {s['description']}")
|
||||
print("\n Usage: /skin <name>")
|
||||
print(" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
|
||||
print(f" Custom skins: drop a YAML file in {display_hermes_home()}/skins/\n")
|
||||
return
|
||||
|
||||
new_skin = parts[1].strip().lower()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Hermes Agent Persona
|
||||
|
||||
<!--
|
||||
This file defines the agent's personality and tone.
|
||||
The agent will embody whatever you write here.
|
||||
Edit this to customize how Hermes communicates with you.
|
||||
|
||||
Examples:
|
||||
- "You are a warm, playful assistant who uses kaomoji occasionally."
|
||||
- "You are a concise technical expert. No fluff, just facts."
|
||||
- "You speak like a friendly coworker who happens to know everything."
|
||||
|
||||
This file is loaded fresh each message -- no restart needed.
|
||||
Delete the contents (or this file) to use the default personality.
|
||||
-->
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes.
|
||||
set -e
|
||||
|
||||
HERMES_HOME="/opt/data"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# Create essential directory structure. Cache and platform directories
|
||||
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
|
||||
# demand by the application — don't pre-create them here so new installs
|
||||
# get the consolidated layout from get_hermes_dir().
|
||||
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills}
|
||||
|
||||
# .env
|
||||
if [ ! -f "$HERMES_HOME/.env" ]; then
|
||||
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env"
|
||||
fi
|
||||
|
||||
# config.yaml
|
||||
if [ ! -f "$HERMES_HOME/config.yaml" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# SOUL.md
|
||||
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
|
||||
cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md"
|
||||
fi
|
||||
|
||||
# Sync bundled skills (manifest-based so user edits are preserved)
|
||||
if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
python3 "$INSTALL_DIR/tools/skills_sync.py"
|
||||
fi
|
||||
|
||||
exec hermes "$@"
|
||||
@@ -1005,7 +1005,7 @@ class BasePlatformAdapter(ABC):
|
||||
# simultaneous messages. Queue them without interrupting the active run,
|
||||
# then process them immediately after the current task finishes.
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
print(f"[{self.name}] 🖼️ Queuing photo follow-up for session {session_key} without interrupt")
|
||||
logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
|
||||
existing = self._pending_messages.get(session_key)
|
||||
if existing and existing.message_type == MessageType.PHOTO:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
@@ -1020,7 +1020,7 @@ class BasePlatformAdapter(ABC):
|
||||
return # Don't interrupt now - will run after current task completes
|
||||
|
||||
# Default behavior for non-photo follow-ups: interrupt the running agent
|
||||
print(f"[{self.name}] ⚡ New message while session {session_key} is active - triggering interrupt")
|
||||
logger.debug("[%s] New message while session %s is active — triggering interrupt", self.name, session_key)
|
||||
self._pending_messages[session_key] = event
|
||||
# Signal the interrupt (the processing task checks this)
|
||||
self._active_sessions[session_key].set()
|
||||
@@ -1206,9 +1206,9 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
|
||||
if not media_result.success:
|
||||
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
|
||||
logger.warning("[%s] Failed to send media (%s): %s", self.name, ext, media_result.error)
|
||||
except Exception as media_err:
|
||||
print(f"[{self.name}] Error sending media: {media_err}")
|
||||
logger.warning("[%s] Error sending media: %s", self.name, media_err)
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
@@ -1240,7 +1240,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
pending_event = self._pending_messages.pop(session_key)
|
||||
print(f"[{self.name}] 📨 Processing queued message from interrupt")
|
||||
logger.debug("[%s] Processing queued message from interrupt", self.name)
|
||||
# Clean up current session before processing pending
|
||||
if session_key in self._active_sessions:
|
||||
del self._active_sessions[session_key]
|
||||
@@ -1254,9 +1254,7 @@ class BasePlatformAdapter(ABC):
|
||||
return # Already cleaned up
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Error handling message: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
|
||||
# Send the error to the user so they aren't left with radio silence
|
||||
try:
|
||||
error_type = type(e).__name__
|
||||
|
||||
@@ -1429,15 +1429,23 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
command_text: str,
|
||||
followup_msg: str | None = None,
|
||||
) -> None:
|
||||
"""Common handler for simple slash commands that dispatch a command string."""
|
||||
"""Common handler for simple slash commands that dispatch a command string.
|
||||
|
||||
Defers the interaction (shows "thinking..."), dispatches the command,
|
||||
then cleans up the deferred response. If *followup_msg* is provided
|
||||
the "thinking..." indicator is replaced with that text; otherwise it
|
||||
is deleted so the channel isn't cluttered.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, command_text)
|
||||
await self.handle_message(event)
|
||||
if followup_msg:
|
||||
try:
|
||||
await interaction.followup.send(followup_msg, ephemeral=True)
|
||||
except Exception as e:
|
||||
logger.debug("Discord followup failed: %s", e)
|
||||
try:
|
||||
if followup_msg:
|
||||
await interaction.edit_original_response(content=followup_msg)
|
||||
else:
|
||||
await interaction.delete_original_response()
|
||||
except Exception as e:
|
||||
logger.debug("Discord interaction cleanup failed: %s", e)
|
||||
|
||||
def _register_slash_commands(self) -> None:
|
||||
"""Register Discord slash commands on the command tree."""
|
||||
@@ -1462,9 +1470,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
@tree.command(name="reasoning", description="Show or change reasoning effort")
|
||||
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
|
||||
async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
|
||||
await self.handle_message(event)
|
||||
await self._run_simple_slash(interaction, f"/reasoning {effort}".strip())
|
||||
|
||||
@tree.command(name="personality", description="Set a personality")
|
||||
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
||||
@@ -1537,9 +1543,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
discord.app_commands.Choice(name="status — show current mode", value="status"),
|
||||
])
|
||||
async def slash_voice(interaction: discord.Interaction, mode: str = ""):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, f"/voice {mode}".strip())
|
||||
await self.handle_message(event)
|
||||
await self._run_simple_slash(interaction, f"/voice {mode}".strip())
|
||||
|
||||
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
||||
async def slash_update(interaction: discord.Interaction):
|
||||
|
||||
@@ -603,9 +603,19 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
# For DMs, user_id is sufficient. For channels, check for @mention.
|
||||
message_text = post.get("message", "")
|
||||
|
||||
# Mention-only mode: skip channel messages that don't @mention the bot.
|
||||
# DMs (type "D") are always processed.
|
||||
# Mention-gating for non-DM channels.
|
||||
# Config (env vars):
|
||||
# MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
|
||||
# MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
|
||||
if channel_type_raw != "D":
|
||||
require_mention = os.getenv(
|
||||
"MATTERMOST_REQUIRE_MENTION", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
|
||||
free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "")
|
||||
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
|
||||
is_free_channel = channel_id in free_channels
|
||||
|
||||
mention_patterns = [
|
||||
f"@{self._bot_username}",
|
||||
f"@{self._bot_user_id}",
|
||||
@@ -614,13 +624,21 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
pattern.lower() in message_text.lower()
|
||||
for pattern in mention_patterns
|
||||
)
|
||||
if not has_mention:
|
||||
|
||||
if require_mention and not is_free_channel and not has_mention:
|
||||
logger.debug(
|
||||
"Mattermost: skipping non-DM message without @mention (channel=%s)",
|
||||
channel_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Strip @mention from the message text so the agent sees clean input.
|
||||
if has_mention:
|
||||
for pattern in mention_patterns:
|
||||
message_text = re.sub(
|
||||
re.escape(pattern), "", message_text, flags=re.IGNORECASE
|
||||
).strip()
|
||||
|
||||
# Resolve sender info.
|
||||
sender_id = post.get("user_id", "")
|
||||
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
|
||||
|
||||
@@ -22,7 +22,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -253,7 +253,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _sse_listener(self) -> None:
|
||||
"""Listen for SSE events from signal-cli daemon."""
|
||||
url = f"{self.http_url}/api/v1/events?account={self.account}"
|
||||
url = f"{self.http_url}/api/v1/events?account={quote(self.account, safe='')}"
|
||||
backoff = SSE_RETRY_DELAY_INITIAL
|
||||
|
||||
while self._running:
|
||||
@@ -521,7 +521,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
"""Fetch an attachment via JSON-RPC and cache it. Returns (path, ext)."""
|
||||
result = await self._rpc("getAttachment", {
|
||||
"account": self.account,
|
||||
"attachmentId": attachment_id,
|
||||
"id": attachment_id,
|
||||
})
|
||||
|
||||
if not result:
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ import httpx
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_constants import OPENROUTER_BASE_URL, display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2021,7 +2021,7 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
|
||||
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
|
||||
print()
|
||||
print("Login successful!")
|
||||
print(" Auth state: ~/.hermes/auth.json")
|
||||
print(f" Auth state: {display_hermes_home()}/auth.json")
|
||||
print(f" Config updated: {config_path} (model.provider=openai-codex)")
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import getpass
|
||||
|
||||
from hermes_cli.banner import cprint, _DIM, _RST
|
||||
from hermes_cli.config import save_env_value_secure
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
def clarify_callback(cli, question, choices):
|
||||
@@ -131,7 +132,8 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
}
|
||||
|
||||
stored = save_env_value_secure(var_name, value)
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
|
||||
_dhh = display_hermes_home()
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
|
||||
return {
|
||||
**stored,
|
||||
"skipped": False,
|
||||
@@ -183,7 +185,8 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
}
|
||||
|
||||
stored = save_env_value_secure(var_name, value)
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
|
||||
_dhh = display_hermes_home()
|
||||
cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
|
||||
return {
|
||||
**stored,
|
||||
"skipped": False,
|
||||
|
||||
@@ -366,6 +366,13 @@ DEFAULT_CONFIG = {
|
||||
# Never saved to sessions, logs, or trajectories.
|
||||
"prefill_messages_file": "",
|
||||
|
||||
# Skills — external skill directories for sharing skills across tools/agents.
|
||||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||||
# always goes to ~/.hermes/skills/.
|
||||
"skills": {
|
||||
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
# This section is only needed for hermes-specific overrides; everything else
|
||||
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
|
||||
@@ -817,6 +824,20 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATTERMOST_REQUIRE_MENTION": {
|
||||
"description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
|
||||
"prompt": "Require @mention in channels",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": {
|
||||
"description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
|
||||
"prompt": "Free-response channel IDs (comma-separated)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"MATRIX_HOMESERVER": {
|
||||
"description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
|
||||
"prompt": "Matrix homeserver URL",
|
||||
|
||||
+26
-24
@@ -10,9 +10,11 @@ import subprocess
|
||||
import shutil
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
|
||||
|
||||
# Load environment variables from ~/.hermes/.env so API key checks work
|
||||
from dotenv import load_dotenv
|
||||
@@ -209,14 +211,14 @@ def run_doctor(args):
|
||||
# Check ~/.hermes/.env (primary location for user config)
|
||||
env_path = HERMES_HOME / '.env'
|
||||
if env_path.exists():
|
||||
check_ok("~/.hermes/.env file exists")
|
||||
check_ok(f"{_DHH}/.env file exists")
|
||||
|
||||
# Check for common issues
|
||||
content = env_path.read_text()
|
||||
if _has_provider_env_config(content):
|
||||
check_ok("API key or custom endpoint configured")
|
||||
else:
|
||||
check_warn("No API key found in ~/.hermes/.env")
|
||||
check_warn(f"No API key found in {_DHH}/.env")
|
||||
issues.append("Run 'hermes setup' to configure API keys")
|
||||
else:
|
||||
# Also check project root as fallback
|
||||
@@ -224,11 +226,11 @@ def run_doctor(args):
|
||||
if fallback_env.exists():
|
||||
check_ok(".env file exists (in project directory)")
|
||||
else:
|
||||
check_fail("~/.hermes/.env file missing")
|
||||
check_fail(f"{_DHH}/.env file missing")
|
||||
if should_fix:
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.touch()
|
||||
check_ok("Created empty ~/.hermes/.env")
|
||||
check_ok(f"Created empty {_DHH}/.env")
|
||||
check_info("Run 'hermes setup' to configure API keys")
|
||||
fixed_count += 1
|
||||
else:
|
||||
@@ -238,7 +240,7 @@ def run_doctor(args):
|
||||
# Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)
|
||||
config_path = HERMES_HOME / 'config.yaml'
|
||||
if config_path.exists():
|
||||
check_ok("~/.hermes/config.yaml exists")
|
||||
check_ok(f"{_DHH}/config.yaml exists")
|
||||
else:
|
||||
fallback_config = PROJECT_ROOT / 'cli-config.yaml'
|
||||
if fallback_config.exists():
|
||||
@@ -248,11 +250,11 @@ def run_doctor(args):
|
||||
if should_fix and example_config.exists():
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(example_config), str(config_path))
|
||||
check_ok("Created ~/.hermes/config.yaml from cli-config.yaml.example")
|
||||
check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
|
||||
fixed_count += 1
|
||||
elif should_fix:
|
||||
check_warn("config.yaml not found and no example to copy from")
|
||||
manual_issues.append("Create ~/.hermes/config.yaml manually")
|
||||
manual_issues.append(f"Create {_DHH}/config.yaml manually")
|
||||
else:
|
||||
check_warn("config.yaml not found", "(using defaults)")
|
||||
|
||||
@@ -294,28 +296,28 @@ def run_doctor(args):
|
||||
|
||||
hermes_home = HERMES_HOME
|
||||
if hermes_home.exists():
|
||||
check_ok("~/.hermes directory exists")
|
||||
check_ok(f"{_DHH} directory exists")
|
||||
else:
|
||||
if should_fix:
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
check_ok("Created ~/.hermes directory")
|
||||
check_ok(f"Created {_DHH} directory")
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn("~/.hermes not found", "(will be created on first use)")
|
||||
check_warn(f"{_DHH} not found", "(will be created on first use)")
|
||||
|
||||
# Check expected subdirectories
|
||||
expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"]
|
||||
for subdir_name in expected_subdirs:
|
||||
subdir_path = hermes_home / subdir_name
|
||||
if subdir_path.exists():
|
||||
check_ok(f"~/.hermes/{subdir_name}/ exists")
|
||||
check_ok(f"{_DHH}/{subdir_name}/ exists")
|
||||
else:
|
||||
if should_fix:
|
||||
subdir_path.mkdir(parents=True, exist_ok=True)
|
||||
check_ok(f"Created ~/.hermes/{subdir_name}/")
|
||||
check_ok(f"Created {_DHH}/{subdir_name}/")
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn(f"~/.hermes/{subdir_name}/ not found", "(will be created on first use)")
|
||||
check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)")
|
||||
|
||||
# Check for SOUL.md persona file
|
||||
soul_path = hermes_home / "SOUL.md"
|
||||
@@ -324,11 +326,11 @@ def run_doctor(args):
|
||||
# Check if it's just the template comments (no real content)
|
||||
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
|
||||
if lines:
|
||||
check_ok("~/.hermes/SOUL.md exists (persona configured)")
|
||||
check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
|
||||
else:
|
||||
check_info("~/.hermes/SOUL.md exists but is empty — edit it to customize personality")
|
||||
check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
|
||||
else:
|
||||
check_warn("~/.hermes/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
if should_fix:
|
||||
soul_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
soul_path.write_text(
|
||||
@@ -337,13 +339,13 @@ def run_doctor(args):
|
||||
"You are Hermes, a helpful AI assistant.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
check_ok("Created ~/.hermes/SOUL.md with basic template")
|
||||
check_ok(f"Created {_DHH}/SOUL.md with basic template")
|
||||
fixed_count += 1
|
||||
|
||||
# Check memory directory
|
||||
memories_dir = hermes_home / "memories"
|
||||
if memories_dir.exists():
|
||||
check_ok("~/.hermes/memories/ directory exists")
|
||||
check_ok(f"{_DHH}/memories/ directory exists")
|
||||
memory_file = memories_dir / "MEMORY.md"
|
||||
user_file = memories_dir / "USER.md"
|
||||
if memory_file.exists():
|
||||
@@ -357,10 +359,10 @@ def run_doctor(args):
|
||||
else:
|
||||
check_info("USER.md not created yet (will be created when the agent first writes a memory)")
|
||||
else:
|
||||
check_warn("~/.hermes/memories/ not found", "(will be created on first use)")
|
||||
check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
|
||||
if should_fix:
|
||||
memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
check_ok("Created ~/.hermes/memories/")
|
||||
check_ok(f"Created {_DHH}/memories/")
|
||||
fixed_count += 1
|
||||
|
||||
# Check SQLite session store
|
||||
@@ -372,11 +374,11 @@ def run_doctor(args):
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
check_ok(f"~/.hermes/state.db exists ({count} sessions)")
|
||||
check_ok(f"{_DHH}/state.db exists ({count} sessions)")
|
||||
except Exception as e:
|
||||
check_warn(f"~/.hermes/state.db exists but has issues: {e}")
|
||||
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
else:
|
||||
check_info("~/.hermes/state.db not created yet (will be created on first session)")
|
||||
check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
|
||||
|
||||
_check_gateway_service_linger(issues)
|
||||
|
||||
@@ -691,7 +693,7 @@ def run_doctor(args):
|
||||
if github_token:
|
||||
check_ok("GitHub token configured (authenticated API access)")
|
||||
else:
|
||||
check_warn("No GITHUB_TOKEN", "(60 req/hr rate limit — set in ~/.hermes/.env for better rates)")
|
||||
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
|
||||
|
||||
# =========================================================================
|
||||
# Honcho memory
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
@@ -935,7 +936,7 @@ def launchd_install(force: bool = False):
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" hermes gateway status # Check status")
|
||||
print(" tail -f ~/.hermes/logs/gateway.log # View logs")
|
||||
print(f" tail -f {display_hermes_home()}/logs/gateway.log # View logs")
|
||||
|
||||
def launchd_uninstall():
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
+16
-1
@@ -980,6 +980,7 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "openrouter"
|
||||
model["base_url"] = OPENROUTER_BASE_URL
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
print(f"Default model set to: {selected} (via OpenRouter)")
|
||||
@@ -1203,6 +1204,7 @@ def _model_flow_custom(config):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = effective_url
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -1984,6 +1986,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2090,6 +2093,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model["api_mode"] = "chat_completions"
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2121,7 +2125,8 @@ def _run_anthropic_oauth_flow(save_env_value):
|
||||
):
|
||||
use_anthropic_claude_code_credentials(save_fn=save_env_value)
|
||||
print(" ✓ Claude Code credentials linked.")
|
||||
print(" Hermes will use Claude's credential store directly instead of copying a setup-token into ~/.hermes/.env.")
|
||||
from hermes_constants import display_hermes_home as _dhh_fn
|
||||
print(f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env.")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -3774,6 +3779,16 @@ For more help on a command:
|
||||
|
||||
plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
|
||||
plugins_enable = plugins_subparsers.add_parser(
|
||||
"enable", help="Enable a disabled plugin"
|
||||
)
|
||||
plugins_enable.add_argument("name", help="Plugin name to enable")
|
||||
|
||||
plugins_disable = plugins_subparsers.add_parser(
|
||||
"disable", help="Disable a plugin without removing it"
|
||||
)
|
||||
plugins_disable.add_argument("name", help="Plugin name to disable")
|
||||
|
||||
def cmd_plugins(args):
|
||||
from hermes_cli.plugins_cmd import plugins_command
|
||||
plugins_command(args)
|
||||
|
||||
@@ -24,6 +24,7 @@ from hermes_cli.config import (
|
||||
get_hermes_home, # noqa: F401 — used by test mocks
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -244,7 +245,7 @@ def cmd_mcp_add(args):
|
||||
api_key = _prompt("API key / Bearer token", password=True)
|
||||
if api_key:
|
||||
save_env_value(env_key, api_key)
|
||||
_success(f"Saved to ~/.hermes/.env as {env_key}")
|
||||
_success(f"Saved to {display_hermes_home()}/.env as {env_key}")
|
||||
|
||||
# Set header with env var interpolation
|
||||
if api_key or existing_key:
|
||||
@@ -332,7 +333,7 @@ def cmd_mcp_add(args):
|
||||
_save_mcp_server(name, server_config)
|
||||
|
||||
print()
|
||||
_success(f"Saved '{name}' to ~/.hermes/config.yaml ({tool_count}/{total} tools enabled)")
|
||||
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{total} tools enabled)")
|
||||
_info("Start a new session to use these tools.")
|
||||
|
||||
|
||||
|
||||
+19
-1
@@ -68,6 +68,17 @@ def _env_enabled(name: str) -> bool:
|
||||
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_disabled_plugins() -> set:
|
||||
"""Read the disabled plugins list from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -199,8 +210,15 @@ class PluginManager:
|
||||
# 3. Pip / entry-point plugins
|
||||
manifests.extend(self._scan_entry_points())
|
||||
|
||||
# Load each manifest
|
||||
# Load each manifest (skip user-disabled plugins)
|
||||
disabled = _get_disabled_plugins()
|
||||
for manifest in manifests:
|
||||
if manifest.name in disabled:
|
||||
loaded = LoadedPlugin(manifest=manifest, enabled=False)
|
||||
loaded.error = "disabled via config"
|
||||
self._plugins[manifest.name] = loaded
|
||||
logger.debug("Skipping disabled plugin '%s'", manifest.name)
|
||||
continue
|
||||
self._load_plugin(manifest)
|
||||
|
||||
if manifests:
|
||||
|
||||
+153
-2
@@ -374,6 +374,73 @@ def cmd_remove(name: str) -> None:
|
||||
_display_removed(name, plugins_dir)
|
||||
|
||||
|
||||
def _get_disabled_set() -> set:
|
||||
"""Read the disabled plugins set from config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _save_disabled_set(disabled: set) -> None:
|
||||
"""Write the disabled plugins list to config.yaml."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
if "plugins" not in config:
|
||||
config["plugins"] = {}
|
||||
config["plugins"]["disabled"] = sorted(disabled)
|
||||
save_config(config)
|
||||
|
||||
|
||||
def cmd_enable(name: str) -> None:
|
||||
"""Enable a previously disabled plugin."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Verify the plugin exists
|
||||
target = plugins_dir / name
|
||||
if not target.is_dir():
|
||||
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
if name not in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
|
||||
return
|
||||
|
||||
disabled.discard(name)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. Takes effect on next session.")
|
||||
|
||||
|
||||
def cmd_disable(name: str) -> None:
|
||||
"""Disable a plugin without removing it."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Verify the plugin exists
|
||||
target = plugins_dir / name
|
||||
if not target.is_dir():
|
||||
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
if name in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
|
||||
return
|
||||
|
||||
disabled.add(name)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f"[yellow]⊘[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.")
|
||||
|
||||
|
||||
def cmd_list() -> None:
|
||||
"""List installed plugins."""
|
||||
from rich.console import Console
|
||||
@@ -393,8 +460,11 @@ def cmd_list() -> None:
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
table = Table(title="Installed Plugins", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Status")
|
||||
table.add_column("Version", style="dim")
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
@@ -420,11 +490,86 @@ def cmd_list() -> None:
|
||||
if (d / ".git").exists():
|
||||
source = "git"
|
||||
|
||||
table.add_row(name, str(version), description, source)
|
||||
is_disabled = name in disabled or d.name in disabled
|
||||
status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]"
|
||||
table.add_row(name, status, str(version), description, source)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
|
||||
|
||||
|
||||
def cmd_toggle() -> None:
|
||||
"""Interactive curses checklist to enable/disable installed plugins."""
|
||||
from rich.console import Console
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
|
||||
if not dirs:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
# Build items list: "name — description" for display
|
||||
names = []
|
||||
labels = []
|
||||
selected = set()
|
||||
|
||||
for i, d in enumerate(dirs):
|
||||
manifest_file = d / "plugin.yaml"
|
||||
name = d.name
|
||||
description = ""
|
||||
|
||||
if manifest_file.exists() and yaml:
|
||||
try:
|
||||
with open(manifest_file) as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
name = manifest.get("name", d.name)
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
names.append(name)
|
||||
label = f"{name} — {description}" if description else name
|
||||
labels.append(label)
|
||||
|
||||
if name not in disabled and d.name not in disabled:
|
||||
selected.add(i)
|
||||
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
result = curses_checklist(
|
||||
title="Plugins — toggle enabled/disabled",
|
||||
items=labels,
|
||||
selected=selected,
|
||||
)
|
||||
|
||||
# Compute new disabled set from deselected items
|
||||
new_disabled = set()
|
||||
for i, name in enumerate(names):
|
||||
if i not in result:
|
||||
new_disabled.add(name)
|
||||
|
||||
if new_disabled != disabled:
|
||||
_save_disabled_set(new_disabled)
|
||||
enabled_count = len(names) - len(new_disabled)
|
||||
console.print(
|
||||
f"\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. "
|
||||
f"Takes effect on next session."
|
||||
)
|
||||
else:
|
||||
console.print("\n[dim]No changes.[/dim]")
|
||||
|
||||
|
||||
def plugins_command(args) -> None:
|
||||
@@ -437,8 +582,14 @@ def plugins_command(args) -> None:
|
||||
cmd_update(args.name)
|
||||
elif action in ("remove", "rm", "uninstall"):
|
||||
cmd_remove(args.name)
|
||||
elif action in ("list", "ls") or action is None:
|
||||
elif action == "enable":
|
||||
cmd_enable(args.name)
|
||||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in ("list", "ls"):
|
||||
cmd_list()
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
+4
-3
@@ -289,6 +289,7 @@ from hermes_cli.config import (
|
||||
get_env_value,
|
||||
ensure_hermes_home,
|
||||
)
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -683,7 +684,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
print_warning(
|
||||
"Some tools are disabled. Run 'hermes setup tools' to configure them,"
|
||||
)
|
||||
print_warning("or edit ~/.hermes/.env directly to add the missing API keys.")
|
||||
print_warning(f"or edit {display_hermes_home()}/.env directly to add the missing API keys.")
|
||||
print()
|
||||
|
||||
# Done banner
|
||||
@@ -706,7 +707,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
print()
|
||||
|
||||
# Show file locations prominently
|
||||
print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD))
|
||||
print(color(f"📁 All your files are in {display_hermes_home()}/:", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
|
||||
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
|
||||
@@ -2837,7 +2838,7 @@ def setup_gateway(config: dict):
|
||||
save_env_value("WEBHOOK_ENABLED", "true")
|
||||
print()
|
||||
print_success("Webhooks enabled! Next steps:")
|
||||
print_info(" 1. Define webhook routes in ~/.hermes/config.yaml")
|
||||
print_info(f" 1. Define webhook routes in {display_hermes_home()}/config.yaml")
|
||||
print_info(" 2. Point your service (GitHub, GitLab, etc.) at:")
|
||||
print_info(" http://your-server:8644/webhooks/<route-name>")
|
||||
print()
|
||||
|
||||
@@ -21,6 +21,7 @@ from rich.table import Table
|
||||
|
||||
# Lazy imports to avoid circular dependencies and slow startup.
|
||||
# tools.skills_hub and tools.skills_guard are imported inside functions.
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
_console = Console()
|
||||
|
||||
@@ -388,7 +389,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
"[bold bright_cyan]This is an official optional skill maintained by Nous Research.[/]\n\n"
|
||||
"It ships with hermes-agent but is not activated by default.\n"
|
||||
"Installing will copy it to your skills directory where the agent can use it.\n\n"
|
||||
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
f"Files will be at: [cyan]{display_hermes_home()}/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
title="Official Skill",
|
||||
border_style="bright_cyan",
|
||||
))
|
||||
@@ -398,7 +399,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
"External skills can contain instructions that influence agent behavior,\n"
|
||||
"shell commands, and scripts. Even after automated scanning, you should\n"
|
||||
"review the installed files before use.\n\n"
|
||||
f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
f"Files will be at: [cyan]{display_hermes_home()}/skills/{category + '/' if category else ''}{bundle.name}/[/]",
|
||||
title="Disclaimer",
|
||||
border_style="yellow",
|
||||
))
|
||||
@@ -744,7 +745,7 @@ def do_publish(skill_path: str, target: str = "github", repo: str = "",
|
||||
auth = GitHubAuth()
|
||||
if not auth.is_authenticated():
|
||||
c.print("[bold red]Error:[/] GitHub authentication required.\n"
|
||||
"Set GITHUB_TOKEN in ~/.hermes/.env or run 'gh auth login'.\n")
|
||||
f"Set GITHUB_TOKEN in {display_hermes_home()}/.env or run 'gh auth login'.\n")
|
||||
return
|
||||
|
||||
c.print(f"[bold]Publishing '{name}' to {repo}...[/]")
|
||||
|
||||
@@ -326,7 +326,8 @@ def _run_post_setup(post_setup_key: str):
|
||||
if result.returncode == 0:
|
||||
_print_success(" Node.js dependencies installed")
|
||||
else:
|
||||
_print_warning(" npm install failed - run manually: cd ~/.hermes/hermes-agent && npm install")
|
||||
from hermes_constants import display_hermes_home
|
||||
_print_warning(f" npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
@@ -1264,7 +1265,8 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)"
|
||||
|
||||
print()
|
||||
print(color(" Tool configuration saved to ~/.hermes/config.yaml", Colors.DIM))
|
||||
from hermes_constants import display_hermes_home
|
||||
print(color(f" Tool configuration saved to {display_hermes_home()}/config.yaml", Colors.DIM))
|
||||
print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM))
|
||||
print()
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
|
||||
@@ -76,13 +78,15 @@ def _get_webhook_base_url() -> str:
|
||||
return f"http://{display_host}:{port}"
|
||||
|
||||
|
||||
_SETUP_HINT = """
|
||||
def _setup_hint() -> str:
|
||||
_dhh = display_hermes_home()
|
||||
return f"""
|
||||
Webhook platform is not enabled. To set it up:
|
||||
|
||||
1. Run the gateway setup wizard:
|
||||
hermes gateway setup
|
||||
|
||||
2. Or manually add to ~/.hermes/config.yaml:
|
||||
2. Or manually add to {_dhh}/config.yaml:
|
||||
platforms:
|
||||
webhook:
|
||||
enabled: true
|
||||
@@ -91,7 +95,7 @@ _SETUP_HINT = """
|
||||
port: 8644
|
||||
secret: "your-global-hmac-secret"
|
||||
|
||||
3. Or set environment variables in ~/.hermes/.env:
|
||||
3. Or set environment variables in {_dhh}/.env:
|
||||
WEBHOOK_ENABLED=true
|
||||
WEBHOOK_PORT=8644
|
||||
WEBHOOK_SECRET=your-global-secret
|
||||
@@ -104,7 +108,7 @@ def _require_webhook_enabled() -> bool:
|
||||
"""Check webhook is enabled. Print setup guide and return False if not."""
|
||||
if _is_webhook_enabled():
|
||||
return True
|
||||
print(_SETUP_HINT)
|
||||
print(_setup_hint())
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,26 @@ def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
||||
return home / new_subpath
|
||||
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
"""Return a user-friendly display string for the current HERMES_HOME.
|
||||
|
||||
Uses ``~/`` shorthand for readability::
|
||||
|
||||
default: ``~/.hermes``
|
||||
profile: ``~/.hermes/profiles/coder``
|
||||
custom: ``/opt/hermes-custom``
|
||||
|
||||
Use this in **user-facing** print/log messages instead of hardcoding
|
||||
``~/.hermes``. For code that needs a real ``Path``, use
|
||||
:func:`get_hermes_home` instead.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
return "~/" + str(home.relative_to(Path.home()))
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
|
||||
VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
name: siyuan
|
||||
description: SiYuan Note API for searching, reading, creating, and managing blocks and documents in a self-hosted knowledge base via curl.
|
||||
version: 1.0.0
|
||||
author: FEUAZUR
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [SiYuan, Notes, Knowledge Base, PKM, API]
|
||||
related_skills: [obsidian, notion]
|
||||
homepage: https://github.com/siyuan-note/siyuan
|
||||
prerequisites:
|
||||
env_vars: [SIYUAN_TOKEN]
|
||||
commands: [curl, jq]
|
||||
required_environment_variables:
|
||||
- name: SIYUAN_TOKEN
|
||||
prompt: SiYuan API token
|
||||
help: "Settings > About in SiYuan desktop app"
|
||||
- name: SIYUAN_URL
|
||||
prompt: SiYuan instance URL (default http://127.0.0.1:6806)
|
||||
required_for: remote instances
|
||||
---
|
||||
|
||||
# SiYuan Note API
|
||||
|
||||
Use the [SiYuan](https://github.com/siyuan-note/siyuan) kernel API via curl to search, read, create, update, and delete blocks and documents in a self-hosted knowledge base. No extra tools needed -- just curl and an API token.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install and run SiYuan (desktop or Docker)
|
||||
2. Get your API token: **Settings > About > API token**
|
||||
3. Store it in `~/.hermes/.env`:
|
||||
```
|
||||
SIYUAN_TOKEN=your_token_here
|
||||
SIYUAN_URL=http://127.0.0.1:6806
|
||||
```
|
||||
`SIYUAN_URL` defaults to `http://127.0.0.1:6806` if not set.
|
||||
|
||||
## API Basics
|
||||
|
||||
All SiYuan API calls are **POST with JSON body**. Every request follows this pattern:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/..." \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"param": "value"}'
|
||||
```
|
||||
|
||||
Responses are JSON with this structure:
|
||||
```json
|
||||
{"code": 0, "msg": "", "data": { ... }}
|
||||
```
|
||||
`code: 0` means success. Any other value is an error -- check `msg` for details.
|
||||
|
||||
**ID format:** SiYuan IDs look like `20210808180117-6v0mkxr` (14-digit timestamp + 7 alphanumeric chars).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Operation | Endpoint |
|
||||
|-----------|----------|
|
||||
| Full-text search | `/api/search/fullTextSearchBlock` |
|
||||
| SQL query | `/api/query/sql` |
|
||||
| Read block | `/api/block/getBlockKramdown` |
|
||||
| Read children | `/api/block/getChildBlocks` |
|
||||
| Get path | `/api/filetree/getHPathByID` |
|
||||
| Get attributes | `/api/attr/getBlockAttrs` |
|
||||
| List notebooks | `/api/notebook/lsNotebooks` |
|
||||
| List documents | `/api/filetree/listDocsByPath` |
|
||||
| Create notebook | `/api/notebook/createNotebook` |
|
||||
| Create document | `/api/filetree/createDocWithMd` |
|
||||
| Append block | `/api/block/appendBlock` |
|
||||
| Update block | `/api/block/updateBlock` |
|
||||
| Rename document | `/api/filetree/renameDocByID` |
|
||||
| Set attributes | `/api/attr/setBlockAttrs` |
|
||||
| Delete block | `/api/block/deleteBlock` |
|
||||
| Delete document | `/api/filetree/removeDocByID` |
|
||||
| Export as Markdown | `/api/export/exportMdContent` |
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Search (Full-Text)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/search/fullTextSearchBlock" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "meeting notes", "page": 0}' | jq '.data.blocks[:5]'
|
||||
```
|
||||
|
||||
### Search (SQL)
|
||||
|
||||
Query the blocks database directly. Only SELECT statements are safe.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/query/sql" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"stmt": "SELECT id, content, type, box FROM blocks WHERE content LIKE '\''%keyword%'\'' AND type='\''p'\'' LIMIT 20"}' | jq '.data'
|
||||
```
|
||||
|
||||
Useful columns: `id`, `parent_id`, `root_id`, `box` (notebook ID), `path`, `content`, `type`, `subtype`, `created`, `updated`.
|
||||
|
||||
### Read Block Content
|
||||
|
||||
Returns block content in Kramdown (Markdown-like) format.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/block/getBlockKramdown" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "20210808180117-6v0mkxr"}' | jq '.data.kramdown'
|
||||
```
|
||||
|
||||
### Read Child Blocks
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/block/getChildBlocks" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "20210808180117-6v0mkxr"}' | jq '.data'
|
||||
```
|
||||
|
||||
### Get Human-Readable Path
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/filetree/getHPathByID" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "20210808180117-6v0mkxr"}' | jq '.data'
|
||||
```
|
||||
|
||||
### Get Block Attributes
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/attr/getBlockAttrs" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "20210808180117-6v0mkxr"}' | jq '.data'
|
||||
```
|
||||
|
||||
### List Notebooks
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/notebook/lsNotebooks" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' | jq '.data.notebooks[] | {id, name, closed}'
|
||||
```
|
||||
|
||||
### List Documents in a Notebook
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/filetree/listDocsByPath" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"notebook": "NOTEBOOK_ID", "path": "/"}' | jq '.data.files[] | {id, name}'
|
||||
```
|
||||
|
||||
### Create a Document
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/filetree/createDocWithMd" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notebook": "NOTEBOOK_ID",
|
||||
"path": "/Meeting Notes/2026-03-22",
|
||||
"markdown": "# Meeting Notes\n\n- Discussed project timeline\n- Assigned tasks"
|
||||
}' | jq '.data'
|
||||
```
|
||||
|
||||
### Create a Notebook
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/notebook/createNotebook" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My New Notebook"}' | jq '.data.notebook.id'
|
||||
```
|
||||
|
||||
### Append Block to Document
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/block/appendBlock" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"parentID": "DOCUMENT_OR_BLOCK_ID",
|
||||
"data": "New paragraph added at the end.",
|
||||
"dataType": "markdown"
|
||||
}' | jq '.data'
|
||||
```
|
||||
|
||||
Also available: `/api/block/prependBlock` (same params, inserts at the beginning) and `/api/block/insertBlock` (uses `previousID` instead of `parentID` to insert after a specific block).
|
||||
|
||||
### Update Block Content
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/block/updateBlock" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "BLOCK_ID",
|
||||
"data": "Updated content here.",
|
||||
"dataType": "markdown"
|
||||
}' | jq '.data'
|
||||
```
|
||||
|
||||
### Rename a Document
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/filetree/renameDocByID" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "DOCUMENT_ID", "title": "New Title"}'
|
||||
```
|
||||
|
||||
### Set Block Attributes
|
||||
|
||||
Custom attributes must be prefixed with `custom-`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/attr/setBlockAttrs" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "BLOCK_ID",
|
||||
"attrs": {
|
||||
"custom-status": "reviewed",
|
||||
"custom-priority": "high"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Delete a Block
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/block/deleteBlock" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "BLOCK_ID"}'
|
||||
```
|
||||
|
||||
To delete a whole document: use `/api/filetree/removeDocByID` with `{"id": "DOC_ID"}`.
|
||||
To delete a notebook: use `/api/notebook/removeNotebook` with `{"notebook": "NOTEBOOK_ID"}`.
|
||||
|
||||
### Export Document as Markdown
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${SIYUAN_URL:-http://127.0.0.1:6806}/api/export/exportMdContent" \
|
||||
-H "Authorization: Token $SIYUAN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id": "DOCUMENT_ID"}' | jq -r '.data.content'
|
||||
```
|
||||
|
||||
## Block Types
|
||||
|
||||
Common `type` values in SQL queries:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `d` | Document (root block) |
|
||||
| `p` | Paragraph |
|
||||
| `h` | Heading |
|
||||
| `l` | List |
|
||||
| `i` | List item |
|
||||
| `c` | Code block |
|
||||
| `m` | Math block |
|
||||
| `t` | Table |
|
||||
| `b` | Blockquote |
|
||||
| `s` | Super block |
|
||||
| `html` | HTML block |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **All endpoints are POST** -- even read-only operations. Do not use GET.
|
||||
- **SQL safety**: only use SELECT queries. INSERT/UPDATE/DELETE/DROP are dangerous and should never be sent.
|
||||
- **ID validation**: IDs match the pattern `YYYYMMDDHHmmss-xxxxxxx`. Reject anything else.
|
||||
- **Error responses**: always check `code != 0` in responses before processing `data`.
|
||||
- **Large documents**: block content and export results can be very large. Use `LIMIT` in SQL and pipe through `jq` to extract only what you need.
|
||||
- **Notebook IDs**: when working with a specific notebook, get its ID first via `lsNotebooks`.
|
||||
|
||||
## Alternative: MCP Server
|
||||
|
||||
If you prefer a native integration instead of curl, install the SiYuan MCP server:
|
||||
|
||||
```yaml
|
||||
# In ~/.hermes/config.yaml under mcp_servers:
|
||||
mcp_servers:
|
||||
siyuan:
|
||||
command: npx
|
||||
args: ["-y", "@porkll/siyuan-mcp"]
|
||||
env:
|
||||
SIYUAN_TOKEN: "your_token"
|
||||
SIYUAN_URL: "http://127.0.0.1:6806"
|
||||
```
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
name: scrapling
|
||||
description: Web scraping with Scrapling - HTTP fetching, stealth browser automation, Cloudflare bypass, and spider crawling via CLI and Python.
|
||||
version: 1.0.0
|
||||
author: FEUAZUR
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Web Scraping, Browser, Cloudflare, Stealth, Crawling, Spider]
|
||||
related_skills: [duckduckgo-search, domain-intel]
|
||||
homepage: https://github.com/D4Vinci/Scrapling
|
||||
prerequisites:
|
||||
commands: [scrapling, python]
|
||||
---
|
||||
|
||||
# Scrapling
|
||||
|
||||
[Scrapling](https://github.com/D4Vinci/Scrapling) is a web scraping framework with anti-bot bypass, stealth browser automation, and a spider framework. It provides three fetching strategies (HTTP, dynamic JS, stealth/Cloudflare) and a full CLI.
|
||||
|
||||
**This skill is for educational and research purposes only.** Users must comply with local/international data scraping laws and respect website Terms of Service.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Scraping static HTML pages (faster than browser tools)
|
||||
- Scraping JS-rendered pages that need a real browser
|
||||
- Bypassing Cloudflare Turnstile or bot detection
|
||||
- Crawling multiple pages with a spider
|
||||
- When the built-in `web_extract` tool does not return the data you need
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install "scrapling[all]"
|
||||
scrapling install
|
||||
```
|
||||
|
||||
Minimal install (HTTP only, no browser):
|
||||
```bash
|
||||
pip install scrapling
|
||||
```
|
||||
|
||||
With browser automation only:
|
||||
```bash
|
||||
pip install "scrapling[fetchers]"
|
||||
scrapling install
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Approach | Class | Use When |
|
||||
|----------|-------|----------|
|
||||
| HTTP | `Fetcher` / `FetcherSession` | Static pages, APIs, fast bulk requests |
|
||||
| Dynamic | `DynamicFetcher` / `DynamicSession` | JS-rendered content, SPAs |
|
||||
| Stealth | `StealthyFetcher` / `StealthySession` | Cloudflare, anti-bot protected sites |
|
||||
| Spider | `Spider` | Multi-page crawling with link following |
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Extract Static Page
|
||||
|
||||
```bash
|
||||
scrapling extract get 'https://example.com' output.md
|
||||
```
|
||||
|
||||
With CSS selector and browser impersonation:
|
||||
|
||||
```bash
|
||||
scrapling extract get 'https://example.com' output.md \
|
||||
--css-selector '.content' \
|
||||
--impersonate 'chrome'
|
||||
```
|
||||
|
||||
### Extract JS-Rendered Page
|
||||
|
||||
```bash
|
||||
scrapling extract fetch 'https://example.com' output.md \
|
||||
--css-selector '.dynamic-content' \
|
||||
--disable-resources \
|
||||
--network-idle
|
||||
```
|
||||
|
||||
### Extract Cloudflare-Protected Page
|
||||
|
||||
```bash
|
||||
scrapling extract stealthy-fetch 'https://protected-site.com' output.html \
|
||||
--solve-cloudflare \
|
||||
--block-webrtc \
|
||||
--hide-canvas
|
||||
```
|
||||
|
||||
### POST Request
|
||||
|
||||
```bash
|
||||
scrapling extract post 'https://example.com/api' output.json \
|
||||
--json '{"query": "search term"}'
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
The output format is determined by the file extension:
|
||||
- `.html` -- raw HTML
|
||||
- `.md` -- converted to Markdown
|
||||
- `.txt` -- plain text
|
||||
- `.json` / `.jsonl` -- JSON
|
||||
|
||||
## Python: HTTP Scraping
|
||||
|
||||
### Single Request
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import Fetcher
|
||||
|
||||
page = Fetcher.get('https://quotes.toscrape.com/')
|
||||
quotes = page.css('.quote .text::text').getall()
|
||||
for q in quotes:
|
||||
print(q)
|
||||
```
|
||||
|
||||
### Session (Persistent Cookies)
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import FetcherSession
|
||||
|
||||
with FetcherSession(impersonate='chrome') as session:
|
||||
page = session.get('https://example.com/', stealthy_headers=True)
|
||||
links = page.css('a::attr(href)').getall()
|
||||
for link in links[:5]:
|
||||
sub = session.get(link)
|
||||
print(sub.css('h1::text').get())
|
||||
```
|
||||
|
||||
### POST / PUT / DELETE
|
||||
|
||||
```python
|
||||
page = Fetcher.post('https://api.example.com/data', json={"key": "value"})
|
||||
page = Fetcher.put('https://api.example.com/item/1', data={"name": "updated"})
|
||||
page = Fetcher.delete('https://api.example.com/item/1')
|
||||
```
|
||||
|
||||
### With Proxy
|
||||
|
||||
```python
|
||||
page = Fetcher.get('https://example.com', proxy='http://user:pass@proxy:8080')
|
||||
```
|
||||
|
||||
## Python: Dynamic Pages (JS-Rendered)
|
||||
|
||||
For pages that require JavaScript execution (SPAs, lazy-loaded content):
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import DynamicFetcher
|
||||
|
||||
page = DynamicFetcher.fetch('https://example.com', headless=True)
|
||||
data = page.css('.js-loaded-content::text').getall()
|
||||
```
|
||||
|
||||
### Wait for Specific Element
|
||||
|
||||
```python
|
||||
page = DynamicFetcher.fetch(
|
||||
'https://example.com',
|
||||
wait_selector=('.results', 'visible'),
|
||||
network_idle=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Disable Resources for Speed
|
||||
|
||||
Blocks fonts, images, media, stylesheets (~25% faster):
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import DynamicSession
|
||||
|
||||
with DynamicSession(headless=True, disable_resources=True, network_idle=True) as session:
|
||||
page = session.fetch('https://example.com')
|
||||
items = page.css('.item::text').getall()
|
||||
```
|
||||
|
||||
### Custom Page Automation
|
||||
|
||||
```python
|
||||
from playwright.sync_api import Page
|
||||
from scrapling.fetchers import DynamicFetcher
|
||||
|
||||
def scroll_and_click(page: Page):
|
||||
page.mouse.wheel(0, 3000)
|
||||
page.wait_for_timeout(1000)
|
||||
page.click('button.load-more')
|
||||
page.wait_for_selector('.extra-results')
|
||||
|
||||
page = DynamicFetcher.fetch('https://example.com', page_action=scroll_and_click)
|
||||
results = page.css('.extra-results .item::text').getall()
|
||||
```
|
||||
|
||||
## Python: Stealth Mode (Anti-Bot Bypass)
|
||||
|
||||
For Cloudflare-protected or heavily fingerprinted sites:
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import StealthyFetcher
|
||||
|
||||
page = StealthyFetcher.fetch(
|
||||
'https://protected-site.com',
|
||||
headless=True,
|
||||
solve_cloudflare=True,
|
||||
block_webrtc=True,
|
||||
hide_canvas=True,
|
||||
)
|
||||
content = page.css('.protected-content::text').getall()
|
||||
```
|
||||
|
||||
### Stealth Session
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import StealthySession
|
||||
|
||||
with StealthySession(headless=True, solve_cloudflare=True) as session:
|
||||
page1 = session.fetch('https://protected-site.com/page1')
|
||||
page2 = session.fetch('https://protected-site.com/page2')
|
||||
```
|
||||
|
||||
## Element Selection
|
||||
|
||||
All fetchers return a `Selector` object with these methods:
|
||||
|
||||
### CSS Selectors
|
||||
|
||||
```python
|
||||
page.css('h1::text').get() # First h1 text
|
||||
page.css('a::attr(href)').getall() # All link hrefs
|
||||
page.css('.quote .text::text').getall() # Nested selection
|
||||
```
|
||||
|
||||
### XPath
|
||||
|
||||
```python
|
||||
page.xpath('//div[@class="content"]/text()').getall()
|
||||
page.xpath('//a/@href').getall()
|
||||
```
|
||||
|
||||
### Find Methods
|
||||
|
||||
```python
|
||||
page.find_all('div', class_='quote') # By tag + attribute
|
||||
page.find_by_text('Read more', tag='a') # By text content
|
||||
page.find_by_regex(r'\$\d+\.\d{2}') # By regex pattern
|
||||
```
|
||||
|
||||
### Similar Elements
|
||||
|
||||
Find elements with similar structure (useful for product listings, etc.):
|
||||
|
||||
```python
|
||||
first_product = page.css('.product')[0]
|
||||
all_similar = first_product.find_similar()
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```python
|
||||
el = page.css('.target')[0]
|
||||
el.parent # Parent element
|
||||
el.children # Child elements
|
||||
el.next_sibling # Next sibling
|
||||
el.prev_sibling # Previous sibling
|
||||
```
|
||||
|
||||
## Python: Spider Framework
|
||||
|
||||
For multi-page crawling with link following:
|
||||
|
||||
```python
|
||||
from scrapling.spiders import Spider, Request, Response
|
||||
|
||||
class QuotesSpider(Spider):
|
||||
name = "quotes"
|
||||
start_urls = ["https://quotes.toscrape.com/"]
|
||||
concurrent_requests = 10
|
||||
download_delay = 1
|
||||
|
||||
async def parse(self, response: Response):
|
||||
for quote in response.css('.quote'):
|
||||
yield {
|
||||
"text": quote.css('.text::text').get(),
|
||||
"author": quote.css('.author::text').get(),
|
||||
"tags": quote.css('.tag::text').getall(),
|
||||
}
|
||||
|
||||
next_page = response.css('.next a::attr(href)').get()
|
||||
if next_page:
|
||||
yield response.follow(next_page)
|
||||
|
||||
result = QuotesSpider().start()
|
||||
print(f"Scraped {len(result.items)} quotes")
|
||||
result.items.to_json("quotes.json")
|
||||
```
|
||||
|
||||
### Multi-Session Spider
|
||||
|
||||
Route requests to different fetcher types:
|
||||
|
||||
```python
|
||||
from scrapling.fetchers import FetcherSession, AsyncStealthySession
|
||||
|
||||
class SmartSpider(Spider):
|
||||
name = "smart"
|
||||
start_urls = ["https://example.com/"]
|
||||
|
||||
def configure_sessions(self, manager):
|
||||
manager.add("fast", FetcherSession(impersonate="chrome"))
|
||||
manager.add("stealth", AsyncStealthySession(headless=True), lazy=True)
|
||||
|
||||
async def parse(self, response: Response):
|
||||
for link in response.css('a::attr(href)').getall():
|
||||
if "protected" in link:
|
||||
yield Request(link, sid="stealth")
|
||||
else:
|
||||
yield Request(link, sid="fast", callback=self.parse)
|
||||
```
|
||||
|
||||
### Pause/Resume Crawling
|
||||
|
||||
```python
|
||||
spider = QuotesSpider(crawldir="./crawl_checkpoint")
|
||||
spider.start() # Ctrl+C to pause, re-run to resume from checkpoint
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Browser install required**: run `scrapling install` after pip install -- without it, `DynamicFetcher` and `StealthyFetcher` will fail
|
||||
- **Timeouts**: DynamicFetcher/StealthyFetcher timeout is in **milliseconds** (default 30000), Fetcher timeout is in **seconds**
|
||||
- **Cloudflare bypass**: `solve_cloudflare=True` adds 5-15 seconds to fetch time -- only enable when needed
|
||||
- **Resource usage**: StealthyFetcher runs a real browser -- limit concurrent usage
|
||||
- **Legal**: always check robots.txt and website ToS before scraping. This library is for educational and research purposes
|
||||
- **Python version**: requires Python 3.10+
|
||||
+4
-3
@@ -45,7 +45,7 @@ import fire
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
@@ -6924,8 +6924,9 @@ class AIAgent:
|
||||
print(f"{self.log_prefix} Auth method: {auth_method}")
|
||||
print(f"{self.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{self.log_prefix} Token: (empty or short)")
|
||||
print(f"{self.log_prefix} Troubleshooting:")
|
||||
print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in ~/.hermes/.env for Hermes-managed OAuth/setup tokens")
|
||||
print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in ~/.hermes/.env for API keys or legacy token values")
|
||||
_dhh = display_hermes_home()
|
||||
print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in {_dhh}/.env for Hermes-managed OAuth/setup tokens")
|
||||
print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in {_dhh}/.env for API keys or legacy token values")
|
||||
print(f"{self.log_prefix} • For API keys: verify at https://console.anthropic.com/settings/keys")
|
||||
print(f"{self.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry")
|
||||
print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_TOKEN \"\"")
|
||||
|
||||
@@ -4,6 +4,11 @@ description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via
|
||||
version: 1.0.0
|
||||
author: Nous Research
|
||||
license: MIT
|
||||
required_credential_files:
|
||||
- path: google_token.json
|
||||
description: Google OAuth2 token (created by setup script)
|
||||
- path: google_client_secret.json
|
||||
description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: duckduckgo-search
|
||||
description: Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Use the Python DDGS library or CLI to search, then web_extract for full content.
|
||||
version: 1.2.0
|
||||
description: Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Prefer the `ddgs` CLI when installed; use the Python DDGS library only after verifying that `ddgs` is available in the current runtime.
|
||||
version: 1.3.0
|
||||
author: gamedevCloudy
|
||||
license: MIT
|
||||
metadata:
|
||||
@@ -9,26 +9,96 @@ metadata:
|
||||
tags: [search, duckduckgo, web-search, free, fallback]
|
||||
related_skills: [arxiv]
|
||||
fallback_for_toolsets: [web]
|
||||
prerequisites:
|
||||
commands: [ddgs]
|
||||
---
|
||||
|
||||
# DuckDuckGo Search
|
||||
|
||||
Free web search using DuckDuckGo. **No API key required.**
|
||||
|
||||
Preferred when `web_search` tool is unavailable or unsuitable (no `FIRECRAWL_API_KEY` set). Can also be used as a standalone search tool.
|
||||
Preferred when `web_search` is unavailable or unsuitable (for example when `FIRECRAWL_API_KEY` is not set). Can also be used as a standalone search path when DuckDuckGo results are specifically desired.
|
||||
|
||||
## Setup
|
||||
## Detection Flow
|
||||
|
||||
Check what is actually available before choosing an approach:
|
||||
|
||||
```bash
|
||||
# Install the ddgs package (one-time)
|
||||
pip install ddgs
|
||||
# Check CLI availability
|
||||
command -v ddgs >/dev/null && echo "DDGS_CLI=installed" || echo "DDGS_CLI=missing"
|
||||
```
|
||||
|
||||
## Python API (Primary)
|
||||
Decision tree:
|
||||
1. If `ddgs` CLI is installed, prefer `terminal` + `ddgs`
|
||||
2. If `ddgs` CLI is missing, do not assume `execute_code` can import `ddgs`
|
||||
3. If the user wants DuckDuckGo specifically, install `ddgs` first in the relevant environment
|
||||
4. Otherwise fall back to built-in web/browser tools
|
||||
|
||||
Use the `DDGS` class in `execute_code` for structured results with typed fields.
|
||||
Important runtime note:
|
||||
- Terminal and `execute_code` are separate runtimes
|
||||
- A successful shell install does not guarantee `execute_code` can import `ddgs`
|
||||
- Never assume third-party Python packages are preinstalled inside `execute_code`
|
||||
|
||||
## Installation
|
||||
|
||||
Install `ddgs` only when DuckDuckGo search is specifically needed and the runtime does not already provide it.
|
||||
|
||||
```bash
|
||||
# Python package + CLI entrypoint
|
||||
pip install ddgs
|
||||
|
||||
# Verify CLI
|
||||
ddgs --help
|
||||
```
|
||||
|
||||
If a workflow depends on Python imports, verify that same runtime can import `ddgs` before using `from ddgs import DDGS`.
|
||||
|
||||
## Method 1: CLI Search (Preferred)
|
||||
|
||||
Use the `ddgs` command via `terminal` when it exists. This is the preferred path because it avoids assuming the `execute_code` sandbox has the `ddgs` Python package installed.
|
||||
|
||||
```bash
|
||||
# Text search
|
||||
ddgs text -k "python async programming" -m 5
|
||||
|
||||
# News search
|
||||
ddgs news -k "artificial intelligence" -m 5
|
||||
|
||||
# Image search
|
||||
ddgs images -k "landscape photography" -m 10
|
||||
|
||||
# Video search
|
||||
ddgs videos -k "python tutorial" -m 5
|
||||
|
||||
# With region filter
|
||||
ddgs text -k "best restaurants" -m 5 -r us-en
|
||||
|
||||
# Recent results only (d=day, w=week, m=month, y=year)
|
||||
ddgs text -k "latest AI news" -m 5 -t w
|
||||
|
||||
# JSON output for parsing
|
||||
ddgs text -k "fastapi tutorial" -m 5 -o json
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `-k` | Keywords (query) — **required** | `-k "search terms"` |
|
||||
| `-m` | Max results | `-m 5` |
|
||||
| `-r` | Region | `-r us-en` |
|
||||
| `-t` | Time limit | `-t w` (week) |
|
||||
| `-s` | Safe search | `-s off` |
|
||||
| `-o` | Output format | `-o json` |
|
||||
|
||||
## Method 2: Python API (Only After Verification)
|
||||
|
||||
Use the `DDGS` class in `execute_code` or another Python runtime only after verifying that `ddgs` is installed there. Do not assume `execute_code` includes third-party packages by default.
|
||||
|
||||
Safe wording:
|
||||
- "Use `execute_code` with `ddgs` after installing or verifying the package if needed"
|
||||
|
||||
Avoid saying:
|
||||
- "`execute_code` includes `ddgs`"
|
||||
- "DuckDuckGo search works by default in `execute_code`"
|
||||
|
||||
**Important:** `max_results` must always be passed as a **keyword argument** — positional usage raises an error on all methods.
|
||||
|
||||
@@ -76,7 +146,7 @@ from ddgs import DDGS
|
||||
with DDGS() as ddgs:
|
||||
for r in ddgs.images("semiconductor chip", max_results=5):
|
||||
print(r["title"])
|
||||
print(r["image"]) # direct image URL
|
||||
print(r["image"])
|
||||
print(r.get("thumbnail", ""))
|
||||
print(r.get("source", ""))
|
||||
print()
|
||||
@@ -94,9 +164,9 @@ from ddgs import DDGS
|
||||
with DDGS() as ddgs:
|
||||
for r in ddgs.videos("FastAPI tutorial", max_results=5):
|
||||
print(r["title"])
|
||||
print(r.get("content", "")) # video URL
|
||||
print(r.get("duration", "")) # e.g. "26:03"
|
||||
print(r.get("provider", "")) # YouTube, etc.
|
||||
print(r.get("content", ""))
|
||||
print(r.get("duration", ""))
|
||||
print(r.get("provider", ""))
|
||||
print(r.get("published", ""))
|
||||
print()
|
||||
```
|
||||
@@ -112,50 +182,17 @@ Returns: `title`, `content`, `description`, `duration`, `provider`, `published`,
|
||||
| `images()` | Visuals, diagrams | title, image, thumbnail, url |
|
||||
| `videos()` | Tutorials, demos | title, content, duration, provider |
|
||||
|
||||
## CLI (Alternative)
|
||||
|
||||
Use the `ddgs` command via terminal when you don't need structured field access.
|
||||
|
||||
```bash
|
||||
# Text search
|
||||
ddgs text -k "python async programming" -m 5
|
||||
|
||||
# News search
|
||||
ddgs news -k "artificial intelligence" -m 5
|
||||
|
||||
# Image search
|
||||
ddgs images -k "landscape photography" -m 10
|
||||
|
||||
# Video search
|
||||
ddgs videos -k "python tutorial" -m 5
|
||||
|
||||
# With region filter
|
||||
ddgs text -k "best restaurants" -m 5 -r us-en
|
||||
|
||||
# Recent results only (d=day, w=week, m=month, y=year)
|
||||
ddgs text -k "latest AI news" -m 5 -t w
|
||||
|
||||
# JSON output for parsing
|
||||
ddgs text -k "fastapi tutorial" -m 5 -o json
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `-k` | Keywords (query) — **required** | `-k "search terms"` |
|
||||
| `-m` | Max results | `-m 5` |
|
||||
| `-r` | Region | `-r us-en` |
|
||||
| `-t` | Time limit | `-t w` (week) |
|
||||
| `-s` | Safe search | `-s off` |
|
||||
| `-o` | Output format | `-o json` |
|
||||
|
||||
## Workflow: Search then Extract
|
||||
|
||||
DuckDuckGo returns titles, URLs, and snippets — not full page content. To get full content, follow up with `web_extract`:
|
||||
DuckDuckGo returns titles, URLs, and snippets — not full page content. To get full page content, search first and then extract the most relevant URL with `web_extract`, browser tools, or curl.
|
||||
|
||||
1. **Search** with ddgs to find relevant URLs
|
||||
2. **Extract** content using the `web_extract` tool (if available) or curl
|
||||
CLI example:
|
||||
|
||||
```bash
|
||||
ddgs text -k "fastapi deployment guide" -m 3 -o json
|
||||
```
|
||||
|
||||
Python example, only after verifying `ddgs` is installed in that runtime:
|
||||
|
||||
```python
|
||||
from ddgs import DDGS
|
||||
@@ -164,25 +201,37 @@ with DDGS() as ddgs:
|
||||
results = list(ddgs.text("fastapi deployment guide", max_results=3))
|
||||
for r in results:
|
||||
print(r["title"], "->", r["href"])
|
||||
|
||||
# Then use web_extract tool on the best URL
|
||||
```
|
||||
|
||||
Then extract the best URL with `web_extract` or another content-retrieval tool.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Rate limiting**: DuckDuckGo may throttle after many rapid requests. Add a short delay between searches if needed.
|
||||
- **No content extraction**: ddgs returns snippets, not full page content. Use `web_extract` or curl for that.
|
||||
- **No content extraction**: `ddgs` returns snippets, not full page content. Use `web_extract`, browser tools, or curl for the full article/page.
|
||||
- **Results quality**: Generally good but less configurable than Firecrawl's search.
|
||||
- **Availability**: DuckDuckGo may block requests from some cloud IPs. If searches return empty, try different keywords or wait a few seconds.
|
||||
- **Field variability**: Return fields may vary between results or ddgs versions. Use `.get()` for optional fields to avoid KeyError.
|
||||
- **Field variability**: Return fields may vary between results or `ddgs` versions. Use `.get()` for optional fields to avoid `KeyError`.
|
||||
- **Separate runtimes**: A successful `ddgs` install in terminal does not automatically mean `execute_code` can import it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Likely Cause | What To Do |
|
||||
|---------|--------------|------------|
|
||||
| `ddgs: command not found` | CLI not installed in the shell environment | Install `ddgs`, or use built-in web/browser tools instead |
|
||||
| `ModuleNotFoundError: No module named 'ddgs'` | Python runtime does not have the package installed | Do not use Python DDGS there until that runtime is prepared |
|
||||
| Search returns nothing | Temporary rate limiting or poor query | Wait a few seconds, retry, or adjust the query |
|
||||
| CLI works but `execute_code` import fails | Terminal and `execute_code` are different runtimes | Keep using CLI, or separately prepare the Python runtime |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`max_results` is keyword-only**: `ddgs.text("query", 5)` raises an error. Use `ddgs.text("query", max_results=5)`.
|
||||
- **Do not assume the CLI exists**: Check `command -v ddgs` before using it.
|
||||
- **Do not assume `execute_code` can import `ddgs`**: `from ddgs import DDGS` may fail with `ModuleNotFoundError` unless that runtime was prepared separately.
|
||||
- **Package name**: The package is `ddgs` (previously `duckduckgo-search`). Install with `pip install ddgs`.
|
||||
- **Don't confuse `-k` and `-m`** (CLI): `-k` is for keywords, `-m` is for max results count.
|
||||
- **Package name**: The package is `ddgs` (was previously `duckduckgo-search`). Install with `pip install ddgs`.
|
||||
- **Empty results**: If ddgs returns nothing, it may be rate-limited. Wait a few seconds and retry.
|
||||
- **Empty results**: If `ddgs` returns nothing, it may be rate-limited. Wait a few seconds and retry.
|
||||
|
||||
## Validated With
|
||||
|
||||
Smoke-tested with `ddgs==9.11.2` on Python 3.13. All four methods (text, news, images, videos) confirmed working with keyword `max_results`.
|
||||
Validated examples against `ddgs==9.11.2` semantics. Skill guidance now treats CLI availability and Python import availability as separate concerns so the documented workflow matches actual runtime behavior.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tests for acp_adapter.entry startup wiring."""
|
||||
|
||||
import acp
|
||||
|
||||
from acp_adapter import entry
|
||||
|
||||
|
||||
def test_main_enables_unstable_protocol(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
async def fake_run_agent(agent, **kwargs):
|
||||
calls["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: None)
|
||||
monkeypatch.setattr(entry, "_load_env", lambda: None)
|
||||
monkeypatch.setattr(acp, "run_agent", fake_run_agent)
|
||||
|
||||
entry.main()
|
||||
|
||||
assert calls["kwargs"]["use_unstable_protocol"] is True
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
import acp
|
||||
from acp.agent.router import build_agent_router
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AuthenticateResponse,
|
||||
@@ -18,6 +19,8 @@ from acp.schema import (
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModeResponse,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
Usage,
|
||||
@@ -168,6 +171,74 @@ class TestListAndFork:
|
||||
assert fork_resp.session_id != new_resp.session_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# session configuration / model routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionConfiguration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_session_mode_returns_response(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
resp = await agent.set_session_mode(mode_id="chat", session_id=new_resp.session_id)
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, SetSessionModeResponse)
|
||||
assert getattr(state, "mode", None) == "chat"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_returns_response(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
resp = await agent.set_config_option(
|
||||
config_id="approval_mode",
|
||||
session_id=new_resp.session_id,
|
||||
value="auto",
|
||||
)
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, SetSessionConfigOptionResponse)
|
||||
assert getattr(state, "config_options", {}) == {"approval_mode": "auto"}
|
||||
assert resp.config_options == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_accepts_stable_session_config_methods(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
router = build_agent_router(agent)
|
||||
|
||||
mode_result = await router(
|
||||
"session/set_mode",
|
||||
{"modeId": "chat", "sessionId": new_resp.session_id},
|
||||
False,
|
||||
)
|
||||
config_result = await router(
|
||||
"session/set_config_option",
|
||||
{
|
||||
"configId": "approval_mode",
|
||||
"sessionId": new_resp.session_id,
|
||||
"value": "auto",
|
||||
},
|
||||
False,
|
||||
)
|
||||
|
||||
assert mode_result == {}
|
||||
assert config_result == {"configOptions": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_accepts_unstable_model_switch_when_enabled(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/tmp")
|
||||
router = build_agent_router(agent, use_unstable_protocol=True)
|
||||
|
||||
result = await router(
|
||||
"session/set_model",
|
||||
{"modelId": "gpt-5.4", "sessionId": new_resp.session_id},
|
||||
False,
|
||||
)
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
assert result == {}
|
||||
assert state.model == "gpt-5.4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for external skill directories (skills.external_dirs config)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def external_skills_dir(tmp_path):
|
||||
"""Create a temp dir with a sample external skill."""
|
||||
ext_dir = tmp_path / "external-skills"
|
||||
skill_dir = ext_dir / "my-external-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-external-skill\ndescription: A skill from an external directory\n---\n\n# My External Skill\n\nDo external things.\n"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path):
|
||||
"""Create a minimal HERMES_HOME with config."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "skills").mkdir()
|
||||
return home
|
||||
|
||||
|
||||
class TestGetExternalSkillsDirs:
|
||||
def test_empty_config(self, hermes_home):
|
||||
(hermes_home / "config.yaml").write_text("skills:\n external_dirs: []\n")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert result == []
|
||||
|
||||
def test_nonexistent_dir_skipped(self, hermes_home):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"skills:\n external_dirs:\n - /nonexistent/path\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert result == []
|
||||
|
||||
def test_valid_dir_returned(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert len(result) == 1
|
||||
assert result[0] == external_skills_dir.resolve()
|
||||
|
||||
def test_duplicate_dirs_deduplicated(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n - {external_skills_dir}\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert len(result) == 1
|
||||
|
||||
def test_local_skills_dir_excluded(self, hermes_home):
|
||||
local_skills = hermes_home / "skills"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {local_skills}\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert result == []
|
||||
|
||||
def test_no_config_file(self, hermes_home):
|
||||
# No config.yaml at all
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert result == []
|
||||
|
||||
def test_string_value_converted_to_list(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs: {external_skills_dir}\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
result = get_external_skills_dirs()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestGetAllSkillsDirs:
|
||||
def test_local_always_first(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
|
||||
)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
result = get_all_skills_dirs()
|
||||
assert result[0] == hermes_home / "skills"
|
||||
assert result[1] == external_skills_dir.resolve()
|
||||
|
||||
|
||||
class TestExternalSkillsInFindAll:
|
||||
def test_external_skills_found(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
|
||||
)
|
||||
local_skills = hermes_home / "skills"
|
||||
with (
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
|
||||
patch("tools.skills_tool.SKILLS_DIR", local_skills),
|
||||
):
|
||||
from tools.skills_tool import _find_all_skills
|
||||
skills = _find_all_skills()
|
||||
names = [s["name"] for s in skills]
|
||||
assert "my-external-skill" in names
|
||||
|
||||
def test_local_takes_precedence(self, hermes_home, external_skills_dir):
|
||||
"""If the same skill name exists locally and externally, local wins."""
|
||||
local_skills = hermes_home / "skills"
|
||||
local_skill = local_skills / "my-external-skill"
|
||||
local_skill.mkdir(parents=True)
|
||||
(local_skill / "SKILL.md").write_text(
|
||||
"---\nname: my-external-skill\ndescription: Local version\n---\n\nLocal.\n"
|
||||
)
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
|
||||
)
|
||||
with (
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
|
||||
patch("tools.skills_tool.SKILLS_DIR", local_skills),
|
||||
):
|
||||
from tools.skills_tool import _find_all_skills
|
||||
skills = _find_all_skills()
|
||||
matching = [s for s in skills if s["name"] == "my-external-skill"]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["description"] == "Local version"
|
||||
|
||||
|
||||
class TestExternalSkillView:
|
||||
def test_skill_view_finds_external(self, hermes_home, external_skills_dir):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_skills_dir}\n"
|
||||
)
|
||||
local_skills = hermes_home / "skills"
|
||||
with (
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}),
|
||||
patch("tools.skills_tool.SKILLS_DIR", local_skills),
|
||||
):
|
||||
from tools.skills_tool import skill_view
|
||||
result = json.loads(skill_view("my-external-skill"))
|
||||
assert result["success"] is True
|
||||
assert "external things" in result["content"]
|
||||
@@ -5,6 +5,8 @@ import importlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.prompt_builder import (
|
||||
_scan_context_content,
|
||||
_truncate_content,
|
||||
@@ -194,7 +196,7 @@ class TestParseSkillFile:
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
with patch("agent.skill_utils.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
is_compat, _, _ = _parse_skill_file(skill_file)
|
||||
assert is_compat is False
|
||||
@@ -234,9 +236,6 @@ class TestPromptBuilderImports:
|
||||
# =========================================================================
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBuildSkillsSystemPrompt:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_skills_cache(self):
|
||||
@@ -296,7 +295,7 @@ class TestBuildSkillsSystemPrompt:
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
with patch("agent.skill_utils.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
result = build_skills_system_prompt()
|
||||
|
||||
@@ -574,6 +573,10 @@ class TestBuildContextFilesPrompt:
|
||||
result = build_context_files_prompt(cwd=str(tmp_path))
|
||||
assert "Lowercase claude rules" in result
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "darwin",
|
||||
reason="APFS default volume is case-insensitive; CLAUDE.md and claude.md alias the same path",
|
||||
)
|
||||
def test_claude_md_uppercase_takes_priority(self, tmp_path):
|
||||
(tmp_path / "CLAUDE.md").write_text("From uppercase.")
|
||||
(tmp_path / "claude.md").write_text("From lowercase.")
|
||||
|
||||
@@ -246,20 +246,10 @@ Generate some audio.
|
||||
def test_preserves_remaining_remote_setup_warning(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_ENV", "ssh")
|
||||
monkeypatch.delenv("TENOR_API_KEY", raising=False)
|
||||
|
||||
def fake_secret_callback(var_name, prompt, metadata=None):
|
||||
os.environ[var_name] = "stored-in-test"
|
||||
return {
|
||||
"success": True,
|
||||
"stored_as": var_name,
|
||||
"validated": False,
|
||||
"skipped": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
skills_tool_module,
|
||||
"_secret_capture_callback",
|
||||
fake_secret_callback,
|
||||
None,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for Mattermost platform adapter."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -269,6 +270,7 @@ class TestMattermostWebSocketParsing:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._bot_user_id = "bot_user_id"
|
||||
self.adapter._bot_username = "hermes-bot"
|
||||
# Mock handle_message to capture the MessageEvent without processing
|
||||
self.adapter.handle_message = AsyncMock()
|
||||
|
||||
@@ -293,7 +295,8 @@ class TestMattermostWebSocketParsing:
|
||||
await self.adapter._handle_ws_event(event)
|
||||
assert self.adapter.handle_message.called
|
||||
msg_event = self.adapter.handle_message.call_args[0][0]
|
||||
assert msg_event.text == "@bot_user_id Hello from Matrix!"
|
||||
# @mention is stripped from the message text
|
||||
assert msg_event.text == "Hello from Matrix!"
|
||||
assert msg_event.message_id == "post_abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -410,6 +413,87 @@ class TestMattermostWebSocketParsing:
|
||||
assert not self.adapter.handle_message.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mention behavior (require_mention + free_response_channels)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMattermostMentionBehavior:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._bot_user_id = "bot_user_id"
|
||||
self.adapter._bot_username = "hermes-bot"
|
||||
self.adapter.handle_message = AsyncMock()
|
||||
|
||||
def _make_event(self, message, channel_type="O", channel_id="chan_456"):
|
||||
post_data = {
|
||||
"id": "post_mention",
|
||||
"user_id": "user_123",
|
||||
"channel_id": channel_id,
|
||||
"message": message,
|
||||
}
|
||||
return {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"post": json.dumps(post_data),
|
||||
"channel_type": channel_type,
|
||||
"sender_name": "@alice",
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_true_skips_without_mention(self):
|
||||
"""Default: messages without @mention in channels are skipped."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("MATTERMOST_REQUIRE_MENTION", None)
|
||||
os.environ.pop("MATTERMOST_FREE_RESPONSE_CHANNELS", None)
|
||||
await self.adapter._handle_ws_event(self._make_event("hello"))
|
||||
assert not self.adapter.handle_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_false_responds_to_all(self):
|
||||
"""MATTERMOST_REQUIRE_MENTION=false: respond to all channel messages."""
|
||||
with patch.dict(os.environ, {"MATTERMOST_REQUIRE_MENTION": "false"}):
|
||||
await self.adapter._handle_ws_event(self._make_event("hello"))
|
||||
assert self.adapter.handle_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_response_channel_responds_without_mention(self):
|
||||
"""Messages in free-response channels don't need @mention."""
|
||||
with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_456,chan_789"}):
|
||||
os.environ.pop("MATTERMOST_REQUIRE_MENTION", None)
|
||||
await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456"))
|
||||
assert self.adapter.handle_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_free_channel_still_requires_mention(self):
|
||||
"""Channels NOT in free-response list still require @mention."""
|
||||
with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_789"}):
|
||||
os.environ.pop("MATTERMOST_REQUIRE_MENTION", None)
|
||||
await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456"))
|
||||
assert not self.adapter.handle_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_always_responds(self):
|
||||
"""DMs (channel_type=D) always respond regardless of mention settings."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("MATTERMOST_REQUIRE_MENTION", None)
|
||||
await self.adapter._handle_ws_event(self._make_event("hello", channel_type="D"))
|
||||
assert self.adapter.handle_message.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mention_stripped_from_text(self):
|
||||
"""@mention is stripped from message text."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("MATTERMOST_REQUIRE_MENTION", None)
|
||||
await self.adapter._handle_ws_event(
|
||||
self._make_event("@hermes-bot what is 2+2")
|
||||
)
|
||||
assert self.adapter.handle_message.called
|
||||
msg = self.adapter.handle_message.call_args[0][0]
|
||||
assert "@hermes-bot" not in msg.text
|
||||
assert "2+2" in msg.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File upload (send_image)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+102
-30
@@ -1,11 +1,42 @@
|
||||
"""Tests for Signal messenger platform adapter."""
|
||||
import base64
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from urllib.parse import quote
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_signal_adapter(monkeypatch, account="+15551234567", **extra):
|
||||
"""Create a SignalAdapter with sensible test defaults."""
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", extra.pop("group_allowed", ""))
|
||||
from gateway.platforms.signal import SignalAdapter
|
||||
config = PlatformConfig()
|
||||
config.enabled = True
|
||||
config.extra = {
|
||||
"http_url": "http://localhost:8080",
|
||||
"account": account,
|
||||
**extra,
|
||||
}
|
||||
return SignalAdapter(config)
|
||||
|
||||
|
||||
def _stub_rpc(return_value):
|
||||
"""Return an async mock for SignalAdapter._rpc that captures call params."""
|
||||
captured = []
|
||||
|
||||
async def mock_rpc(method, params, rpc_id=None):
|
||||
captured.append({"method": method, "params": dict(params)})
|
||||
return return_value
|
||||
|
||||
return mock_rpc, captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform & Config
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,48 +92,22 @@ class TestSignalConfigLoading:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalAdapterInit:
|
||||
def _make_config(self, **extra):
|
||||
config = PlatformConfig()
|
||||
config.enabled = True
|
||||
config.extra = {
|
||||
"http_url": "http://localhost:8080",
|
||||
"account": "+15551234567",
|
||||
**extra,
|
||||
}
|
||||
return config
|
||||
|
||||
def test_init_parses_config(self, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "group123,group456")
|
||||
|
||||
from gateway.platforms.signal import SignalAdapter
|
||||
adapter = SignalAdapter(self._make_config())
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch, group_allowed="group123,group456")
|
||||
assert adapter.http_url == "http://localhost:8080"
|
||||
assert adapter.account == "+15551234567"
|
||||
assert "group123" in adapter.group_allow_from
|
||||
|
||||
def test_init_empty_allowlist(self, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "")
|
||||
|
||||
from gateway.platforms.signal import SignalAdapter
|
||||
adapter = SignalAdapter(self._make_config())
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
assert len(adapter.group_allow_from) == 0
|
||||
|
||||
def test_init_strips_trailing_slash(self, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "")
|
||||
|
||||
from gateway.platforms.signal import SignalAdapter
|
||||
adapter = SignalAdapter(self._make_config(http_url="http://localhost:8080/"))
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch, http_url="http://localhost:8080/")
|
||||
assert adapter.http_url == "http://localhost:8080"
|
||||
|
||||
def test_self_message_filtering(self, monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "")
|
||||
|
||||
from gateway.platforms.signal import SignalAdapter
|
||||
adapter = SignalAdapter(self._make_config())
|
||||
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
assert adapter._account_normalized == "+15551234567"
|
||||
|
||||
|
||||
@@ -189,6 +194,73 @@ class TestSignalHelpers:
|
||||
assert check_signal_requirements() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE URL Encoding (Bug Fix: phone numbers with + must be URL-encoded)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSSEUrlEncoding:
|
||||
"""Verify that phone numbers with + are URL-encoded in the SSE endpoint."""
|
||||
|
||||
def test_sse_url_encodes_plus_in_account(self):
|
||||
"""The + in E.164 phone numbers must be percent-encoded in the SSE query string."""
|
||||
encoded = quote("+31612345678", safe="")
|
||||
assert encoded == "%2B31612345678"
|
||||
|
||||
def test_sse_url_encoding_preserves_digits(self):
|
||||
"""Digits and country codes should pass through URL encoding unchanged."""
|
||||
assert quote("+15551234567", safe="") == "%2B15551234567"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachment Fetch (Bug Fix: parameter must be "id" not "attachmentId")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalAttachmentFetch:
|
||||
"""Verify that _fetch_attachment uses the correct RPC parameter name."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_attachment_uses_id_parameter(self, monkeypatch):
|
||||
"""RPC getAttachment must use 'id', not 'attachmentId' (signal-cli requirement)."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
|
||||
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
b64_data = base64.b64encode(png_data).decode()
|
||||
|
||||
adapter._rpc, captured = _stub_rpc({"data": b64_data})
|
||||
|
||||
with patch("gateway.platforms.signal.cache_image_from_bytes", return_value="/tmp/test.png"):
|
||||
await adapter._fetch_attachment("attachment-123")
|
||||
|
||||
call = captured[0]
|
||||
assert call["method"] == "getAttachment"
|
||||
assert call["params"]["id"] == "attachment-123"
|
||||
assert "attachmentId" not in call["params"], "Must NOT use 'attachmentId' — causes NullPointerException in signal-cli"
|
||||
assert call["params"]["account"] == "+15551234567"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_attachment_returns_none_on_empty(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._rpc, _ = _stub_rpc(None)
|
||||
path, ext = await adapter._fetch_attachment("missing-id")
|
||||
assert path is None
|
||||
assert ext == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_attachment_handles_dict_response(self, monkeypatch):
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
|
||||
pdf_data = b"%PDF-1.4" + b"\x00" * 100
|
||||
b64_data = base64.b64encode(pdf_data).decode()
|
||||
|
||||
adapter._rpc, _ = _stub_rpc({"data": b64_data})
|
||||
|
||||
with patch("gateway.platforms.signal.cache_document_from_bytes", return_value="/tmp/test.pdf"):
|
||||
path, ext = await adapter._fetch_attachment("doc-456")
|
||||
|
||||
assert path == "/tmp/test.pdf"
|
||||
assert ext == ".pdf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for credential file passthrough registry (tools/credential_files.py)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.credential_files import (
|
||||
clear_credential_files,
|
||||
get_credential_file_mounts,
|
||||
register_credential_file,
|
||||
register_credential_files,
|
||||
reset_config_cache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Reset registry between tests."""
|
||||
clear_credential_files()
|
||||
reset_config_cache()
|
||||
yield
|
||||
clear_credential_files()
|
||||
reset_config_cache()
|
||||
|
||||
|
||||
class TestRegisterCredentialFile:
|
||||
def test_registers_existing_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text('{"token": "abc"}')
|
||||
|
||||
result = register_credential_file("token.json")
|
||||
|
||||
assert result is True
|
||||
mounts = get_credential_file_mounts()
|
||||
assert len(mounts) == 1
|
||||
assert mounts[0]["host_path"] == str(tmp_path / "token.json")
|
||||
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
|
||||
|
||||
def test_skips_missing_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
result = register_credential_file("nonexistent.json")
|
||||
|
||||
assert result is False
|
||||
assert get_credential_file_mounts() == []
|
||||
|
||||
def test_custom_container_base(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "cred.json").write_text("{}")
|
||||
|
||||
register_credential_file("cred.json", container_base="/home/user/.hermes")
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
|
||||
|
||||
def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text("{}")
|
||||
|
||||
register_credential_file("token.json")
|
||||
register_credential_file("token.json")
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
assert len(mounts) == 1
|
||||
|
||||
|
||||
class TestRegisterCredentialFiles:
|
||||
def test_string_entries(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "b.json").write_text("{}")
|
||||
|
||||
missing = register_credential_files(["a.json", "b.json"])
|
||||
|
||||
assert missing == []
|
||||
assert len(get_credential_file_mounts()) == 2
|
||||
|
||||
def test_dict_entries(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text("{}")
|
||||
|
||||
missing = register_credential_files([
|
||||
{"path": "token.json", "description": "OAuth token"},
|
||||
])
|
||||
|
||||
assert missing == []
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
def test_returns_missing_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "exists.json").write_text("{}")
|
||||
|
||||
missing = register_credential_files([
|
||||
"exists.json",
|
||||
"missing.json",
|
||||
{"path": "also_missing.json"},
|
||||
])
|
||||
|
||||
assert missing == ["missing.json", "also_missing.json"]
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
def test_empty_list(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
assert register_credential_files([]) == []
|
||||
|
||||
|
||||
class TestConfigCredentialFiles:
|
||||
def test_loads_from_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "oauth.json").write_text("{}")
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - oauth.json\n"
|
||||
)
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
|
||||
assert len(mounts) == 1
|
||||
assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
|
||||
|
||||
def test_config_skips_missing_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - nonexistent.json\n"
|
||||
)
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
assert mounts == []
|
||||
|
||||
def test_combines_skill_and_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skill_token.json").write_text("{}")
|
||||
(tmp_path / "config_token.json").write_text("{}")
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - config_token.json\n"
|
||||
)
|
||||
|
||||
register_credential_file("skill_token.json")
|
||||
mounts = get_credential_file_mounts()
|
||||
|
||||
assert len(mounts) == 2
|
||||
paths = {m["container_path"] for m in mounts}
|
||||
assert "/root/.hermes/skill_token.json" in paths
|
||||
assert "/root/.hermes/config_token.json" in paths
|
||||
|
||||
|
||||
class TestGetMountsRechecksExistence:
|
||||
def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
token = tmp_path / "token.json"
|
||||
token.write_text("{}")
|
||||
|
||||
register_credential_file("token.json")
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
# Delete the file after registration
|
||||
token.unlink()
|
||||
assert get_credential_file_mounts() == []
|
||||
@@ -96,6 +96,7 @@ class TestGetProviderFallbackPriority:
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "groq"
|
||||
@@ -130,9 +131,10 @@ class TestExplicitProviderRespected:
|
||||
def test_explicit_local_no_fallback_to_openai(self, monkeypatch):
|
||||
"""GH-1774: provider=local must not silently fall back to openai
|
||||
even when an OpenAI API key is set."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key-here")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "***")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
result = _get_provider({"provider": "local"})
|
||||
@@ -141,6 +143,7 @@ class TestExplicitProviderRespected:
|
||||
def test_explicit_local_no_fallback_to_groq(self, monkeypatch):
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
result = _get_provider({"provider": "local"})
|
||||
@@ -181,6 +184,7 @@ class TestExplicitProviderRespected:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
# Empty dict = no explicit provider, uses DEFAULT_PROVIDER auto-detect
|
||||
@@ -191,6 +195,7 @@ class TestExplicitProviderRespected:
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
result = _get_provider({})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Credential file passthrough registry for remote terminal backends.
|
||||
|
||||
Skills that declare ``required_credential_files`` in their frontmatter need
|
||||
those files available inside sandboxed execution environments (Modal, Docker).
|
||||
By default remote backends create bare containers with no host files.
|
||||
|
||||
This module provides a session-scoped registry so skill-declared credential
|
||||
files (and user-configured overrides) are mounted into remote sandboxes.
|
||||
|
||||
Two sources feed the registry:
|
||||
|
||||
1. **Skill declarations** — when a skill is loaded via ``skill_view``, its
|
||||
``required_credential_files`` entries are registered here if the files
|
||||
exist on the host.
|
||||
2. **User config** — ``terminal.credential_files`` in config.yaml lets users
|
||||
explicitly list additional files to mount.
|
||||
|
||||
Remote backends (``tools/environments/modal.py``, ``docker.py``) call
|
||||
:func:`get_credential_file_mounts` at sandbox creation time.
|
||||
|
||||
Each registered entry is a dict::
|
||||
|
||||
{
|
||||
"host_path": "/home/user/.hermes/google_token.json",
|
||||
"container_path": "/root/.hermes/google_token.json",
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session-scoped list of credential files to mount.
|
||||
# Key: container_path (deduplicated), Value: host_path
|
||||
_registered_files: Dict[str, str] = {}
|
||||
|
||||
# Cache for config-based file list (loaded once per process).
|
||||
_config_files: List[Dict[str, str]] | None = None
|
||||
|
||||
|
||||
def _resolve_hermes_home() -> Path:
|
||||
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def register_credential_file(
|
||||
relative_path: str,
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> bool:
|
||||
"""Register a credential file for mounting into remote sandboxes.
|
||||
|
||||
*relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``).
|
||||
Returns True if the file exists on the host and was registered.
|
||||
"""
|
||||
hermes_home = _resolve_hermes_home()
|
||||
host_path = hermes_home / relative_path
|
||||
if not host_path.is_file():
|
||||
logger.debug("credential_files: skipping %s (not found)", host_path)
|
||||
return False
|
||||
|
||||
container_path = f"{container_base.rstrip('/')}/{relative_path}"
|
||||
_registered_files[container_path] = str(host_path)
|
||||
logger.debug("credential_files: registered %s -> %s", host_path, container_path)
|
||||
return True
|
||||
|
||||
|
||||
def register_credential_files(
|
||||
entries: list,
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> List[str]:
|
||||
"""Register multiple credential files from skill frontmatter entries.
|
||||
|
||||
Each entry is either a string (relative path) or a dict with a ``path``
|
||||
key. Returns the list of relative paths that were NOT found on the host
|
||||
(i.e. missing files).
|
||||
"""
|
||||
missing = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
rel_path = entry.strip()
|
||||
elif isinstance(entry, dict):
|
||||
rel_path = (entry.get("path") or "").strip()
|
||||
else:
|
||||
continue
|
||||
if not rel_path:
|
||||
continue
|
||||
if not register_credential_file(rel_path, container_base):
|
||||
missing.append(rel_path)
|
||||
return missing
|
||||
|
||||
|
||||
def _load_config_files() -> List[Dict[str, str]]:
|
||||
"""Load ``terminal.credential_files`` from config.yaml (cached)."""
|
||||
global _config_files
|
||||
if _config_files is not None:
|
||||
return _config_files
|
||||
|
||||
result: List[Dict[str, str]] = []
|
||||
try:
|
||||
hermes_home = _resolve_hermes_home()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
cred_files = cfg.get("terminal", {}).get("credential_files")
|
||||
if isinstance(cred_files, list):
|
||||
for item in cred_files:
|
||||
if isinstance(item, str) and item.strip():
|
||||
host_path = hermes_home / item.strip()
|
||||
if host_path.is_file():
|
||||
container_path = f"/root/.hermes/{item.strip()}"
|
||||
result.append({
|
||||
"host_path": str(host_path),
|
||||
"container_path": container_path,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("Could not read terminal.credential_files from config: %s", e)
|
||||
|
||||
_config_files = result
|
||||
return _config_files
|
||||
|
||||
|
||||
def get_credential_file_mounts() -> List[Dict[str, str]]:
|
||||
"""Return all credential files that should be mounted into remote sandboxes.
|
||||
|
||||
Each item has ``host_path`` and ``container_path`` keys.
|
||||
Combines skill-registered files and user config.
|
||||
"""
|
||||
mounts: Dict[str, str] = {}
|
||||
|
||||
# Skill-registered files
|
||||
for container_path, host_path in _registered_files.items():
|
||||
# Re-check existence (file may have been deleted since registration)
|
||||
if Path(host_path).is_file():
|
||||
mounts[container_path] = host_path
|
||||
|
||||
# Config-based files
|
||||
for entry in _load_config_files():
|
||||
cp = entry["container_path"]
|
||||
if cp not in mounts and Path(entry["host_path"]).is_file():
|
||||
mounts[cp] = entry["host_path"]
|
||||
|
||||
return [
|
||||
{"host_path": hp, "container_path": cp}
|
||||
for cp, hp in mounts.items()
|
||||
]
|
||||
|
||||
|
||||
def clear_credential_files() -> None:
|
||||
"""Reset the skill-scoped registry (e.g. on session reset)."""
|
||||
_registered_files.clear()
|
||||
|
||||
|
||||
def reset_config_cache() -> None:
|
||||
"""Force re-read of config on next access (for testing)."""
|
||||
global _config_files
|
||||
_config_files = None
|
||||
@@ -312,6 +312,24 @@ class DockerEnvironment(BaseEnvironment):
|
||||
elif workspace_explicitly_mounted:
|
||||
logger.debug("Skipping docker cwd mount: /workspace already mounted by user config")
|
||||
|
||||
# Mount credential files (OAuth tokens, etc.) declared by skills.
|
||||
# Read-only so the container can authenticate but not modify host creds.
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
volume_args.extend([
|
||||
"-v",
|
||||
f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro",
|
||||
])
|
||||
logger.info(
|
||||
"Docker: mounting credential %s -> %s",
|
||||
mount_entry["host_path"],
|
||||
mount_entry["container_path"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Docker: could not load credential file mounts: %s", e)
|
||||
|
||||
logger.info(f"Docker volume_args: {volume_args}")
|
||||
all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args
|
||||
logger.info(f"Docker run_args: {all_run_args}")
|
||||
@@ -406,8 +424,17 @@ class DockerEnvironment(BaseEnvironment):
|
||||
if effective_stdin is not None:
|
||||
cmd.append("-i")
|
||||
cmd.extend(["-w", work_dir])
|
||||
hermes_env = _load_hermes_env_vars() if self._forward_env else {}
|
||||
for key in self._forward_env:
|
||||
# Combine explicit docker_forward_env with skill-declared env_passthrough
|
||||
# vars so skills that declare required_environment_variables (e.g. Notion)
|
||||
# have their keys forwarded into the container automatically.
|
||||
forward_keys = set(self._forward_env)
|
||||
try:
|
||||
from tools.env_passthrough import get_all_passthrough
|
||||
forward_keys |= get_all_passthrough()
|
||||
except Exception:
|
||||
pass
|
||||
hermes_env = _load_hermes_env_vars() if forward_keys else {}
|
||||
for key in sorted(forward_keys):
|
||||
value = os.getenv(key)
|
||||
if value is None:
|
||||
value = hermes_env.get(key)
|
||||
|
||||
@@ -137,6 +137,28 @@ class ModalEnvironment(BaseEnvironment):
|
||||
],
|
||||
)
|
||||
|
||||
# Mount credential files (OAuth tokens, etc.) declared by skills.
|
||||
# These are read-only copies so the sandbox can authenticate with
|
||||
# external services but can't modify the host's credentials.
|
||||
cred_mounts = []
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
|
||||
for mount_entry in get_credential_file_mounts():
|
||||
cred_mounts.append(
|
||||
_modal.Mount.from_local_file(
|
||||
mount_entry["host_path"],
|
||||
remote_path=mount_entry["container_path"],
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Modal: mounting credential %s -> %s",
|
||||
mount_entry["host_path"],
|
||||
mount_entry["container_path"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Modal: could not load credential file mounts: %s", e)
|
||||
|
||||
# Start the async worker thread and create sandbox on it
|
||||
# so all gRPC channels are bound to the worker's event loop.
|
||||
self._worker.start()
|
||||
@@ -145,23 +167,90 @@ class ModalEnvironment(BaseEnvironment):
|
||||
app = await _modal.App.lookup.aio(
|
||||
"hermes-agent", create_if_missing=True
|
||||
)
|
||||
create_kwargs = dict(sandbox_kwargs)
|
||||
if cred_mounts:
|
||||
existing_mounts = list(create_kwargs.pop("mounts", []))
|
||||
existing_mounts.extend(cred_mounts)
|
||||
create_kwargs["mounts"] = existing_mounts
|
||||
sandbox = await _modal.Sandbox.create.aio(
|
||||
"sleep", "infinity",
|
||||
image=effective_image,
|
||||
app=app,
|
||||
timeout=int(sandbox_kwargs.pop("timeout", 3600)),
|
||||
**sandbox_kwargs,
|
||||
timeout=int(create_kwargs.pop("timeout", 3600)),
|
||||
**create_kwargs,
|
||||
)
|
||||
return app, sandbox
|
||||
|
||||
self._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(), timeout=300
|
||||
)
|
||||
# Track synced credential files to avoid redundant pushes.
|
||||
# Key: container_path, Value: (mtime, size) of last synced version.
|
||||
self._synced_creds: Dict[str, tuple] = {}
|
||||
logger.info("Modal: sandbox created (task=%s)", self._task_id)
|
||||
|
||||
def _sync_credential_files(self) -> None:
|
||||
"""Push credential files into the running sandbox.
|
||||
|
||||
Mounts are set at sandbox creation, but credentials may be created
|
||||
later (e.g. OAuth setup mid-session). This writes the current file
|
||||
content into the sandbox via exec(), so new/updated credentials are
|
||||
available without recreating the sandbox.
|
||||
"""
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
if not mounts:
|
||||
return
|
||||
|
||||
for entry in mounts:
|
||||
host_path = entry["host_path"]
|
||||
container_path = entry["container_path"]
|
||||
hp = Path(host_path)
|
||||
try:
|
||||
stat = hp.stat()
|
||||
file_key = (stat.st_mtime, stat.st_size)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Skip if already synced with same mtime+size
|
||||
if self._synced_creds.get(container_path) == file_key:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = hp.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Write via base64 to avoid shell escaping issues with JSON
|
||||
import base64
|
||||
b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
container_dir = str(Path(container_path).parent)
|
||||
cmd = (
|
||||
f"mkdir -p {shlex.quote(container_dir)} && "
|
||||
f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}"
|
||||
)
|
||||
|
||||
_cp = container_path # capture for closure
|
||||
|
||||
async def _write():
|
||||
proc = await self._sandbox.exec.aio("bash", "-c", cmd)
|
||||
await proc.wait.aio()
|
||||
|
||||
self._worker.run_coroutine(_write(), timeout=15)
|
||||
self._synced_creds[container_path] = file_key
|
||||
logger.debug("Modal: synced credential %s -> %s", host_path, container_path)
|
||||
except Exception as e:
|
||||
logger.debug("Modal: credential file sync failed: %s", e)
|
||||
|
||||
def execute(self, command: str, cwd: str = "", *,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None) -> dict:
|
||||
# Sync credential files before each command so mid-session
|
||||
# OAuth setups are picked up without requiring a restart.
|
||||
self._sync_credential_files()
|
||||
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
while marker in stdin_data:
|
||||
|
||||
+131
-67
@@ -494,7 +494,7 @@ def _is_skill_disabled(name: str, platform: str = None) -> bool:
|
||||
|
||||
|
||||
def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Recursively find all skills in ~/.hermes/skills/.
|
||||
"""Recursively find all skills in ~/.hermes/skills/ and external dirs.
|
||||
|
||||
Args:
|
||||
skip_disabled: If True, return ALL skills regardless of disabled
|
||||
@@ -504,59 +504,68 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||
Returns:
|
||||
List of skill metadata dicts (name, description, category).
|
||||
"""
|
||||
skills = []
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
|
||||
if not SKILLS_DIR.exists():
|
||||
return skills
|
||||
skills = []
|
||||
seen_names: set = set()
|
||||
|
||||
# Load disabled set once (not per-skill)
|
||||
disabled = set() if skip_disabled else _get_disabled_skill_names()
|
||||
|
||||
# Scan local dir first, then external dirs (local takes precedence)
|
||||
dirs_to_scan = []
|
||||
if SKILLS_DIR.exists():
|
||||
dirs_to_scan.append(SKILLS_DIR)
|
||||
dirs_to_scan.extend(get_external_skills_dirs())
|
||||
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
|
||||
continue
|
||||
|
||||
skill_dir = skill_md.parent
|
||||
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")[:4000]
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
|
||||
if not skill_matches_platform(frontmatter):
|
||||
for scan_dir in dirs_to_scan:
|
||||
for skill_md in scan_dir.rglob("SKILL.md"):
|
||||
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
|
||||
continue
|
||||
|
||||
name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH]
|
||||
if name in disabled:
|
||||
skill_dir = skill_md.parent
|
||||
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")[:4000]
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
|
||||
name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH]
|
||||
if name in seen_names:
|
||||
continue
|
||||
if name in disabled:
|
||||
continue
|
||||
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
for line in body.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
description = line
|
||||
break
|
||||
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
|
||||
|
||||
category = _get_category_from_path(skill_md)
|
||||
|
||||
seen_names.add(name)
|
||||
skills.append({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": category,
|
||||
})
|
||||
|
||||
except (UnicodeDecodeError, PermissionError) as e:
|
||||
logger.debug("Failed to read skill file %s: %s", skill_md, e)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
for line in body.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
description = line
|
||||
break
|
||||
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
|
||||
|
||||
category = _get_category_from_path(skill_md)
|
||||
|
||||
skills.append({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": category,
|
||||
})
|
||||
|
||||
except (UnicodeDecodeError, PermissionError) as e:
|
||||
logger.debug("Failed to read skill file %s: %s", skill_md, e)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
return skills
|
||||
|
||||
@@ -756,7 +765,15 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
JSON string with skill content or error message
|
||||
"""
|
||||
try:
|
||||
if not SKILLS_DIR.exists():
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
|
||||
# Build list of all skill directories to search
|
||||
all_dirs = []
|
||||
if SKILLS_DIR.exists():
|
||||
all_dirs.append(SKILLS_DIR)
|
||||
all_dirs.extend(get_external_skills_dirs())
|
||||
|
||||
if not all_dirs:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
@@ -768,27 +785,37 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
skill_dir = None
|
||||
skill_md = None
|
||||
|
||||
# Try direct path first (e.g., "mlops/axolotl")
|
||||
direct_path = SKILLS_DIR / name
|
||||
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
|
||||
skill_dir = direct_path
|
||||
skill_md = direct_path / "SKILL.md"
|
||||
elif direct_path.with_suffix(".md").exists():
|
||||
skill_md = direct_path.with_suffix(".md")
|
||||
# Search all dirs: local first, then external (first match wins)
|
||||
for search_dir in all_dirs:
|
||||
# Try direct path first (e.g., "mlops/axolotl")
|
||||
direct_path = search_dir / name
|
||||
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
|
||||
skill_dir = direct_path
|
||||
skill_md = direct_path / "SKILL.md"
|
||||
break
|
||||
elif direct_path.with_suffix(".md").exists():
|
||||
skill_md = direct_path.with_suffix(".md")
|
||||
break
|
||||
|
||||
# Search by directory name
|
||||
# Search by directory name across all dirs
|
||||
if not skill_md:
|
||||
for found_skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if found_skill_md.parent.name == name:
|
||||
skill_dir = found_skill_md.parent
|
||||
skill_md = found_skill_md
|
||||
for search_dir in all_dirs:
|
||||
for found_skill_md in search_dir.rglob("SKILL.md"):
|
||||
if found_skill_md.parent.name == name:
|
||||
skill_dir = found_skill_md.parent
|
||||
skill_md = found_skill_md
|
||||
break
|
||||
if skill_md:
|
||||
break
|
||||
|
||||
# Legacy: flat .md files
|
||||
if not skill_md:
|
||||
for found_md in SKILLS_DIR.rglob(f"{name}.md"):
|
||||
if found_md.name != "SKILL.md":
|
||||
skill_md = found_md
|
||||
for search_dir in all_dirs:
|
||||
for found_md in search_dir.rglob(f"{name}.md"):
|
||||
if found_md.name != "SKILL.md":
|
||||
skill_md = found_md
|
||||
break
|
||||
if skill_md:
|
||||
break
|
||||
|
||||
if not skill_md or not skill_md.exists():
|
||||
@@ -815,12 +842,21 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# Security: warn if skill is loaded from outside the trusted skills directory
|
||||
# Security: warn if skill is loaded from outside trusted directories
|
||||
# (local skills dir + configured external_dirs are all trusted)
|
||||
_outside_skills_dir = True
|
||||
_trusted_dirs = [SKILLS_DIR.resolve()]
|
||||
try:
|
||||
skill_md.resolve().relative_to(SKILLS_DIR.resolve())
|
||||
_outside_skills_dir = False
|
||||
except ValueError:
|
||||
_outside_skills_dir = True
|
||||
_trusted_dirs.extend(d.resolve() for d in all_dirs[1:])
|
||||
except Exception:
|
||||
pass
|
||||
for _td in _trusted_dirs:
|
||||
try:
|
||||
skill_md.resolve().relative_to(_td)
|
||||
_outside_skills_dir = False
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Security: detect common prompt injection patterns
|
||||
_INJECTION_PATTERNS = [
|
||||
@@ -1058,7 +1094,11 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
if script_files:
|
||||
linked_files["scripts"] = script_files
|
||||
|
||||
rel_path = str(skill_md.relative_to(SKILLS_DIR))
|
||||
try:
|
||||
rel_path = str(skill_md.relative_to(SKILLS_DIR))
|
||||
except ValueError:
|
||||
# External skill — use path relative to the skill's own parent dir
|
||||
rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name
|
||||
skill_name = frontmatter.get(
|
||||
"name", skill_md.stem if not skill_dir else skill_dir.name
|
||||
)
|
||||
@@ -1106,6 +1146,27 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Register credential files for mounting into remote sandboxes
|
||||
# (Modal, Docker). Files that exist on the host are registered;
|
||||
# missing ones are added to the setup_needed indicators.
|
||||
required_cred_files_raw = frontmatter.get("required_credential_files", [])
|
||||
if not isinstance(required_cred_files_raw, list):
|
||||
required_cred_files_raw = []
|
||||
missing_cred_files: list = []
|
||||
if required_cred_files_raw:
|
||||
try:
|
||||
from tools.credential_files import register_credential_files
|
||||
|
||||
missing_cred_files = register_credential_files(required_cred_files_raw)
|
||||
if missing_cred_files:
|
||||
setup_needed = True
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not register credential files for skill %s",
|
||||
skill_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"name": skill_name,
|
||||
@@ -1121,6 +1182,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
"required_environment_variables": required_env_vars,
|
||||
"required_commands": [],
|
||||
"missing_required_environment_variables": remaining_missing_required_envs,
|
||||
"missing_credential_files": missing_cred_files,
|
||||
"missing_required_commands": [],
|
||||
"setup_needed": setup_needed,
|
||||
"setup_skipped": capture_result["setup_skipped"],
|
||||
@@ -1139,6 +1201,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
if setup_needed:
|
||||
missing_items = [
|
||||
f"env ${env_name}" for env_name in remaining_missing_required_envs
|
||||
] + [
|
||||
f"file {path}" for path in missing_cred_files
|
||||
]
|
||||
setup_note = _build_setup_note(
|
||||
SkillReadinessStatus.SETUP_NEEDED,
|
||||
|
||||
@@ -48,6 +48,7 @@ logger = logging.getLogger(__name__)
|
||||
# long-running subprocesses immediately instead of blocking until timeout.
|
||||
# ---------------------------------------------------------------------------
|
||||
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -157,7 +158,7 @@ def _handle_sudo_failure(output: str, env_type: str) -> str:
|
||||
|
||||
for failure in sudo_failures:
|
||||
if failure in output:
|
||||
return output + "\n\n💡 Tip: To enable sudo over messaging, add SUDO_PASSWORD to ~/.hermes/.env on the agent machine."
|
||||
return output + f"\n\n💡 Tip: To enable sudo over messaging, add SUDO_PASSWORD to {display_hermes_home()}/.env on the agent machine."
|
||||
|
||||
return output
|
||||
|
||||
@@ -443,7 +444,7 @@ def _parse_env_var(name: str, default: str, converter=int, type_label: str = "in
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
raise ValueError(
|
||||
f"Invalid value for {name}: {raw!r} (expected {type_label}). "
|
||||
f"Check ~/.hermes/.env or environment variables."
|
||||
f"Check {display_hermes_home()}/.env or environment variables."
|
||||
)
|
||||
|
||||
|
||||
@@ -1283,7 +1284,7 @@ if __name__ == "__main__":
|
||||
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
|
||||
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', '~/.hermes/sandboxes')}")
|
||||
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{display_hermes_home()}/sandboxes')}")
|
||||
print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}")
|
||||
print(f" TERMINAL_LIFETIME_SECONDS: {os.getenv('TERMINAL_LIFETIME_SECONDS', '300')}")
|
||||
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from typing import Callable, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -832,7 +832,7 @@ TTS_SCHEMA = {
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional custom file path to save the audio. Defaults to ~/.hermes/cache/audio/<timestamp>.mp3"
|
||||
"description": f"Optional custom file path to save the audio. Defaults to {display_hermes_home()}/cache/audio/<timestamp>.mp3"
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
|
||||
@@ -168,11 +168,38 @@ required_environment_variables:
|
||||
The user can skip setup and keep loading the skill. Hermes never exposes the raw secret value to the model. Gateway and messaging sessions show local setup guidance instead of collecting secrets in-band.
|
||||
|
||||
:::tip Sandbox Passthrough
|
||||
When your skill is loaded, any declared `required_environment_variables` that are set are **automatically passed through** to `execute_code` and `terminal` sandboxes. Your skill's scripts can access `$TENOR_API_KEY` (or `os.environ["TENOR_API_KEY"]` in Python) without the user needing to configure anything extra. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details.
|
||||
When your skill is loaded, any declared `required_environment_variables` that are set are **automatically passed through** to `execute_code` and `terminal` sandboxes — including remote backends like Docker and Modal. Your skill's scripts can access `$TENOR_API_KEY` (or `os.environ["TENOR_API_KEY"]` in Python) without the user needing to configure anything extra. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details.
|
||||
:::
|
||||
|
||||
Legacy `prerequisites.env_vars` remains supported as a backward-compatible alias.
|
||||
|
||||
### Credential File Requirements (OAuth tokens, etc.)
|
||||
|
||||
Skills that use OAuth or file-based credentials can declare files that need to be mounted into remote sandboxes. This is for credentials stored as **files** (not env vars) — typically OAuth token files produced by a setup script.
|
||||
|
||||
```yaml
|
||||
required_credential_files:
|
||||
- path: google_token.json
|
||||
description: Google OAuth2 token (created by setup script)
|
||||
- path: google_client_secret.json
|
||||
description: Google OAuth2 client credentials
|
||||
```
|
||||
|
||||
Each entry supports:
|
||||
- `path` (required) — file path relative to `~/.hermes/`
|
||||
- `description` (optional) — explains what the file is and how it's created
|
||||
|
||||
When loaded, Hermes checks if these files exist. Missing files trigger `setup_needed`. Existing files are automatically:
|
||||
- **Mounted into Docker** containers as read-only bind mounts
|
||||
- **Synced into Modal** sandboxes (at creation + before each command, so mid-session OAuth works)
|
||||
- Available on **local** backend without any special handling
|
||||
|
||||
:::tip When to use which
|
||||
Use `required_environment_variables` for simple API keys and tokens (strings stored in `~/.hermes/.env`). Use `required_credential_files` for OAuth token files, client secrets, service account JSON, certificates, or any credential that's a file on disk.
|
||||
:::
|
||||
|
||||
See the `skills/productivity/google-workspace/SKILL.md` for a complete example using both.
|
||||
|
||||
## Skill Guidelines
|
||||
|
||||
### No External Dependencies
|
||||
|
||||
@@ -399,17 +399,22 @@ See [MCP Config Reference](./mcp-config-reference.md) and [Use MCP with Hermes](
|
||||
## `hermes plugins`
|
||||
|
||||
```bash
|
||||
hermes plugins <subcommand>
|
||||
hermes plugins [subcommand]
|
||||
```
|
||||
|
||||
Manage Hermes Agent plugins.
|
||||
Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launches an interactive curses checklist to enable/disable installed plugins.
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| *(none)* | Interactive toggle UI — enable/disable plugins with arrow keys and space. |
|
||||
| `install <identifier> [--force]` | Install a plugin from a Git URL or `owner/repo`. |
|
||||
| `update <name>` | Pull latest changes for an installed plugin. |
|
||||
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
|
||||
| `list` (alias: `ls`) | List installed plugins. |
|
||||
| `enable <name>` | Enable a disabled plugin. |
|
||||
| `disable <name>` | Disable a plugin without removing it. |
|
||||
| `list` (alias: `ls`) | List installed plugins with enabled/disabled status. |
|
||||
|
||||
Disabled plugins are stored in `config.yaml` under `plugins.disabled` and skipped during loading.
|
||||
|
||||
See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md).
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
|----------|-------------|
|
||||
| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` |
|
||||
| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11`) |
|
||||
| `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions |
|
||||
| `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. |
|
||||
| `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) |
|
||||
| `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | Advanced opt-in: mount the launch cwd into Docker `/workspace` (`true`/`false`, default: `false`) |
|
||||
| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path |
|
||||
@@ -200,6 +200,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
| `MATTERMOST_TOKEN` | Bot token or personal access token for Mattermost |
|
||||
| `MATTERMOST_ALLOWED_USERS` | Comma-separated Mattermost user IDs allowed to message the bot |
|
||||
| `MATTERMOST_HOME_CHANNEL` | Channel ID for proactive message delivery (cron, notifications) |
|
||||
| `MATTERMOST_REQUIRE_MENTION` | Require `@mention` in channels (default: `true`). Set to `false` to respond to all messages. |
|
||||
| `MATTERMOST_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where bot responds without `@mention` |
|
||||
| `MATTERMOST_REPLY_MODE` | Reply style: `thread` (threaded replies) or `off` (flat messages, default) |
|
||||
| `MATRIX_HOMESERVER` | Matrix homeserver URL (e.g. `https://matrix.org`) |
|
||||
| `MATRIX_ACCESS_TOKEN` | Matrix access token for bot authentication |
|
||||
|
||||
@@ -253,11 +253,18 @@ Skills for academic research, paper discovery, literature review, domain reconna
|
||||
| `arxiv` | Search and retrieve academic papers from arXiv using their free REST API. No API key needed. Search by keyword, author, category, or ID. Combine with web_extract or the ocr-and-documents skill to read full paper content. | `research/arxiv` |
|
||||
| `blogwatcher` | Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI. Add blogs, scan for new articles, and track what you've read. | `research/blogwatcher` |
|
||||
| `domain-intel` | Passive domain reconnaissance using Python stdlib. Subdomain discovery, SSL certificate inspection, WHOIS lookups, DNS records, domain availability checks, and bulk multi-domain analysis. No API keys required. | `research/domain-intel` |
|
||||
| `duckduckgo-search` | Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Use the Python DDGS library or CLI to search, then web_extract for full content. | `research/duckduckgo-search` |
|
||||
| `parallel-cli` | Optional vendor skill for Parallel CLI — agent-native web search, extraction, deep research, enrichment, FindAll, and monitoring. | `research/parallel-cli` |
|
||||
| `duckduckgo-search` | Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Prefer the `ddgs` CLI when installed; use the Python DDGS library only after verifying that `ddgs` is available in the current runtime. | `research/duckduckgo-search` |
|
||||
| `ml-paper-writing` | Write publication-ready ML/AI papers for NeurIPS, ICML, ICLR, ACL, AAAI, COLM. Use when drafting papers from research repos, structuring arguments, verifying citations, or preparing camera-ready submissions. Includes LaTeX templates, reviewer guidelines, and citation verificatio… | `research/ml-paper-writing` |
|
||||
| `polymarket` | Query Polymarket prediction market data — search markets, get prices, orderbooks, and price history. Read-only via public REST APIs, no API key needed. | `research/polymarket` |
|
||||
|
||||
## red-teaming
|
||||
|
||||
Skills for LLM red-teaming, jailbreaking, and safety filter bypass research.
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `godmode` | Jailbreak API-served LLMs using G0DM0D3 techniques — Parseltongue input obfuscation (33 techniques), GODMODE CLASSIC system prompt templates, ULTRAPLINIAN multi-model racing, encoding escalation, and Hermes-native prefill/system prompt integration. Works on any model accessible via API including closed-source models. | `red-teaming/godmode` |
|
||||
|
||||
## smart-home
|
||||
|
||||
Skills for controlling smart home devices — lights, switches, sensors, and home automation systems.
|
||||
@@ -285,3 +292,84 @@ Skills for interacting with social platforms — posting, reading, monitoring, a
|
||||
| `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` |
|
||||
| `test-driven-development` | Use when implementing any feature or bugfix, before writing implementation code. Enforces RED-GREEN-REFACTOR cycle with test-first approach. | `software-development/test-driven-development` |
|
||||
| `writing-plans` | Use when you have a spec or requirements for a multi-step task. Creates comprehensive implementation plans with bite-sized tasks, exact file paths, and complete code examples. | `software-development/writing-plans` |
|
||||
|
||||
---
|
||||
|
||||
# Optional Skills
|
||||
|
||||
Optional skills ship with the repository under `optional-skills/` but are **not active by default**. They cover heavier or niche use cases. Install them with:
|
||||
|
||||
```bash
|
||||
hermes skills install official/<category>/<skill>
|
||||
```
|
||||
|
||||
## autonomous-ai-agents
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `blackbox` | Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent with built-in judge that runs tasks through multiple LLMs and picks the best result. Requires the blackbox CLI and a Blackbox AI API key. | `autonomous-ai-agents/blackbox` |
|
||||
|
||||
## blockchain
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `base` | Query Base (Ethereum L2) blockchain data with USD pricing — wallet balances, token info, transaction details, gas analysis, contract inspection, whale detection, and live network stats. Uses Base RPC + CoinGecko. No API key required. | `blockchain/base` |
|
||||
| `solana` | Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. | `blockchain/solana` |
|
||||
|
||||
## creative
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `blender-mcp` | Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. | `creative/blender-mcp` |
|
||||
| `meme-generation` | Generate real meme images by picking a template and overlaying text with Pillow. Produces actual .png meme files. | `creative/meme-generation` |
|
||||
|
||||
## devops
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `docker-management` | Manage Docker containers, images, volumes, networks, and Compose stacks — lifecycle ops, debugging, cleanup, and Dockerfile optimization. | `devops/docker-management` |
|
||||
|
||||
## email
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `agentmail` | Give the agent its own dedicated email inbox via AgentMail. Send, receive, and manage email autonomously using agent-owned email addresses (e.g. hermes-agent@agentmail.to). | `email/agentmail` |
|
||||
|
||||
## health
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `neuroskill-bci` | Connect to a running NeuroSkill instance and incorporate the user's real-time cognitive and emotional state (focus, relaxation, mood, cognitive load, drowsiness, heart rate, HRV, sleep staging, and 40+ derived EXG scores) into responses. Requires a BCI wearable (Muse 2/S or OpenBCI) and the NeuroSkill desktop app. | `health/neuroskill-bci` |
|
||||
|
||||
## mcp
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `fastmcp` | Build, test, inspect, install, and deploy MCP servers with FastMCP in Python. Use when creating a new MCP server, wrapping an API or database as MCP tools, exposing resources or prompts, or preparing a FastMCP server for HTTP deployment. | `mcp/fastmcp` |
|
||||
|
||||
## migration
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `openclaw-migration` | Migrate a user's OpenClaw customization footprint into Hermes Agent. Imports Hermes-compatible memories, SOUL.md, command allowlists, user skills, and selected workspace assets from ~/.openclaw, then reports what could not be migrated and why. | `migration/openclaw-migration` |
|
||||
|
||||
## productivity
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `telephony` | Give Hermes phone capabilities — provision and persist a Twilio number, send and receive SMS/MMS, make direct calls, and place AI-driven outbound calls through Bland.ai or Vapi. | `productivity/telephony` |
|
||||
|
||||
## research
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `bioinformatics` | Gateway to 400+ bioinformatics skills from bioSkills and ClawBio. Covers genomics, transcriptomics, single-cell, variant calling, pharmacogenomics, metagenomics, structural biology, and more. | `research/bioinformatics` |
|
||||
| `qmd` | Search personal knowledge bases, notes, docs, and meeting transcripts locally using qmd — a hybrid retrieval engine with BM25, vector search, and LLM reranking. Supports CLI and MCP integration. | `research/qmd` |
|
||||
|
||||
## security
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `1password` | Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands. | `security/1password` |
|
||||
| `oss-forensics` | Supply chain investigation, evidence recovery, and forensic analysis for GitHub repositories. Covers deleted commit recovery, force-push detection, IOC extraction, multi-source evidence collection, and structured forensic reporting. | `security/oss-forensics` |
|
||||
| `sherlock` | OSINT username search across 400+ social networks. Hunt down social media accounts by username. | `security/sherlock` |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Hermes Agent — Docker
|
||||
|
||||
Want to run Hermes Agent, but without installing packages on your host? This'll sort you out.
|
||||
|
||||
This will let you run the agent in a container, with the most relevant modes outlined below.
|
||||
|
||||
The container stores all user data (config, API keys, sessions, skills, memories) in a single directory mounted from the host at `/opt/data`. The image itself is stateless and can be upgraded by pulling a new version without losing any configuration.
|
||||
|
||||
## Quick start
|
||||
|
||||
If this is your first time running Hermes Agent, create a data directory on the host and start the container interactively to run the setup wizard:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.hermes
|
||||
docker run -it --rm \
|
||||
-v ~/.hermes:/opt/data \
|
||||
nousresearch/hermes-agent
|
||||
```
|
||||
|
||||
This drops you into the setup wizard, which will prompt you for your API keys and write them to `~/.hermes/.env`. You only need to do this once. It is highly recommended to set up a chat system for the gateway to work with at this point.
|
||||
|
||||
## Running in gateway mode
|
||||
|
||||
Once configured, run the container in the background as a persistent gateway (Telegram, Discord, Slack, WhatsApp, etc.):
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name hermes \
|
||||
--restart unless-stopped \
|
||||
-v ~/.hermes:/opt/data \
|
||||
nousresearch/hermes-agent gateway run
|
||||
```
|
||||
|
||||
## Running interactively (CLI chat)
|
||||
|
||||
To open an interactive chat session against a running data directory:
|
||||
|
||||
```sh
|
||||
docker run -it --rm \
|
||||
-v ~/.hermes:/opt/data \
|
||||
nousresearch/hermes-agent
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
Pull the latest image and recreate the container. Your data directory is untouched.
|
||||
|
||||
```sh
|
||||
docker pull nousresearch/hermes-agent:latest
|
||||
docker rm -f hermes
|
||||
docker run -d \
|
||||
--name hermes \
|
||||
--restart unless-stopped \
|
||||
-v ~/.hermes:/opt/data \
|
||||
nousresearch/hermes-agent
|
||||
```
|
||||
@@ -87,9 +87,26 @@ The handler receives the argument string (everything after `/greet`) and returns
|
||||
|
||||
## Managing plugins
|
||||
|
||||
```
|
||||
/plugins # list loaded plugins in a session
|
||||
hermes config set display.show_cost true # show cost in status bar
|
||||
```bash
|
||||
hermes plugins # interactive toggle UI — enable/disable with checkboxes
|
||||
hermes plugins list # table view with enabled/disabled status
|
||||
hermes plugins install user/repo # install from Git
|
||||
hermes plugins update my-plugin # pull latest
|
||||
hermes plugins remove my-plugin # uninstall
|
||||
hermes plugins enable my-plugin # re-enable a disabled plugin
|
||||
hermes plugins disable my-plugin # disable without removing
|
||||
```
|
||||
|
||||
Running `hermes plugins` with no arguments launches an interactive curses checklist (same UI as `hermes tools`) where you can toggle plugins on/off with arrow keys and space.
|
||||
|
||||
Disabled plugins remain installed but are skipped during loading. The disabled list is stored in `config.yaml` under `plugins.disabled`:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
disabled:
|
||||
- my-noisy-plugin
|
||||
```
|
||||
|
||||
In a running session, `/plugins` shows which plugins are currently loaded.
|
||||
|
||||
See the **[full guide](/docs/guides/build-a-hermes-plugin)** for handler contracts, schema format, hook behavior, error handling, and common mistakes.
|
||||
|
||||
@@ -8,7 +8,9 @@ description: "On-demand knowledge documents — progressive disclosure, agent-ma
|
||||
|
||||
Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard.
|
||||
|
||||
All skills live in **`~/.hermes/skills/`** — a single directory that serves as the source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
|
||||
All skills live in **`~/.hermes/skills/`** — the primary directory and source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
|
||||
|
||||
You can also point Hermes at **external skill directories** — additional folders scanned alongside the local one. See [External Skill Directories](#external-skill-directories) below.
|
||||
|
||||
See also:
|
||||
|
||||
@@ -164,6 +166,47 @@ Once set, declared env vars are **automatically passed through** to `execute_cod
|
||||
└── .bundled_manifest # Tracks seeded bundled skills
|
||||
```
|
||||
|
||||
## External Skill Directories
|
||||
|
||||
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
|
||||
|
||||
Add `external_dirs` under the `skills` section in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
external_dirs:
|
||||
- ~/.agents/skills
|
||||
- /home/shared/team-skills
|
||||
- ${SKILLS_REPO}/skills
|
||||
```
|
||||
|
||||
Paths support `~` expansion and `${VAR}` environment variable substitution.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Read-only**: External dirs are only scanned for skill discovery. When the agent creates or edits a skill, it always writes to `~/.hermes/skills/`.
|
||||
- **Local precedence**: If the same skill name exists in both the local dir and an external dir, the local version wins.
|
||||
- **Full integration**: External skills appear in the system prompt index, `skills_list`, `skill_view`, and as `/skill-name` slash commands — no different from local skills.
|
||||
- **Non-existent paths are silently skipped**: If a configured directory doesn't exist, Hermes ignores it without errors. Useful for optional shared directories that may not be present on every machine.
|
||||
|
||||
### Example
|
||||
|
||||
```text
|
||||
~/.hermes/skills/ # Local (primary, read-write)
|
||||
├── devops/deploy-k8s/
|
||||
│ └── SKILL.md
|
||||
└── mlops/axolotl/
|
||||
└── SKILL.md
|
||||
|
||||
~/.agents/skills/ # External (read-only, shared)
|
||||
├── my-custom-workflow/
|
||||
│ └── SKILL.md
|
||||
└── team-conventions/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version.
|
||||
|
||||
## Agent-Managed Skills (skill_manage tool)
|
||||
|
||||
The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** — when it figures out a non-trivial workflow, it saves the approach as a skill for future reuse.
|
||||
|
||||
@@ -149,6 +149,12 @@ MATTERMOST_ALLOWED_USERS=3uo8dkh1p7g1mfk49ear5fzs5c
|
||||
|
||||
# Optional: reply mode (thread or off, default: off)
|
||||
# MATTERMOST_REPLY_MODE=thread
|
||||
|
||||
# Optional: respond without @mention (default: true = require mention)
|
||||
# MATTERMOST_REQUIRE_MENTION=false
|
||||
|
||||
# Optional: channels where bot responds without @mention (comma-separated channel IDs)
|
||||
# MATTERMOST_FREE_RESPONSE_CHANNELS=channel_id_1,channel_id_2
|
||||
```
|
||||
|
||||
Optional behavior settings in `~/.hermes/config.yaml`:
|
||||
@@ -206,6 +212,19 @@ Set it in your `~/.hermes/.env`:
|
||||
MATTERMOST_REPLY_MODE=thread
|
||||
```
|
||||
|
||||
## Mention Behavior
|
||||
|
||||
By default, the bot only responds in channels when `@mentioned`. You can change this:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MATTERMOST_REQUIRE_MENTION` | `true` | Set to `false` to respond to all messages in channels (DMs always work). |
|
||||
| `MATTERMOST_FREE_RESPONSE_CHANNELS` | _(none)_ | Comma-separated channel IDs where the bot responds without `@mention`, even when require_mention is true. |
|
||||
|
||||
To find a channel ID in Mattermost: open the channel, click the channel name header, and look for the ID in the URL or channel details.
|
||||
|
||||
When the bot is `@mentioned`, the mention is automatically stripped from the message before processing.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot is not responding to messages
|
||||
|
||||
@@ -278,7 +278,11 @@ required_environment_variables:
|
||||
help: Get a key from https://developers.google.com/tenor
|
||||
```
|
||||
|
||||
After loading this skill, `TENOR_API_KEY` passes through to both `execute_code` and `terminal` subprocesses — no manual configuration needed.
|
||||
After loading this skill, `TENOR_API_KEY` passes through to `execute_code`, `terminal` (local), **and remote backends (Docker, Modal)** — no manual configuration needed.
|
||||
|
||||
:::info Docker & Modal
|
||||
Prior to v0.5.1, Docker's `forward_env` was a separate system from the skill passthrough. They are now merged — skill-declared env vars are automatically forwarded into Docker containers and Modal sandboxes without needing to add them to `docker_forward_env` manually.
|
||||
:::
|
||||
|
||||
**2. Config-based passthrough (manual)**
|
||||
|
||||
@@ -291,17 +295,49 @@ terminal:
|
||||
- ANOTHER_TOKEN
|
||||
```
|
||||
|
||||
### Credential File Passthrough (OAuth tokens, etc.) {#credential-file-passthrough}
|
||||
|
||||
Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` in `~/.hermes/`. Skills declare these in frontmatter:
|
||||
|
||||
```yaml
|
||||
required_credential_files:
|
||||
- path: google_token.json
|
||||
description: Google OAuth2 token (created by setup script)
|
||||
- path: google_client_secret.json
|
||||
description: Google OAuth2 client credentials
|
||||
```
|
||||
|
||||
When loaded, Hermes checks if these files exist in `~/.hermes/` and registers them for mounting:
|
||||
|
||||
- **Docker**: Read-only bind mounts (`-v host:container:ro`)
|
||||
- **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)
|
||||
- **Local**: No action needed (files already accessible)
|
||||
|
||||
You can also list credential files manually in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
credential_files:
|
||||
- google_token.json
|
||||
- my_custom_oauth_token.json
|
||||
```
|
||||
|
||||
Paths are relative to `~/.hermes/`. Files are mounted to `/root/.hermes/` inside the container.
|
||||
|
||||
### What Each Sandbox Filters
|
||||
|
||||
| Sandbox | Default Filter | Passthrough Override |
|
||||
|---------|---------------|---------------------|
|
||||
| **execute_code** | Blocks vars containing `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `PASSWD`, `AUTH` in name; only allows safe-prefix vars through | ✅ Passthrough vars bypass both checks |
|
||||
| **terminal** (local) | Blocks explicit Hermes infrastructure vars (provider keys, gateway tokens, tool API keys) | ✅ Passthrough vars bypass the blocklist |
|
||||
| **terminal** (Docker) | No host env vars by default | ✅ Passthrough vars + `docker_forward_env` forwarded via `-e` |
|
||||
| **terminal** (Modal) | No host env/files by default | ✅ Credential files mounted; env passthrough via sync |
|
||||
| **MCP** | Blocks everything except safe system vars + explicitly configured `env` | ❌ Not affected by passthrough (use MCP `env` config instead) |
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- The passthrough only affects vars you or your skills explicitly declare — the default security posture is unchanged for arbitrary LLM-generated code
|
||||
- Credential files are mounted **read-only** into Docker containers
|
||||
- Skills Guard scans skill content for suspicious env access patterns before installation
|
||||
- Missing/unset vars are never registered (you can't leak what doesn't exist)
|
||||
- Hermes infrastructure secrets (provider API keys, gateway tokens) should never be added to `env_passthrough` — they have dedicated mechanisms
|
||||
|
||||
@@ -37,6 +37,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/configuration',
|
||||
'user-guide/sessions',
|
||||
'user-guide/security',
|
||||
'user-guide/docker',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Messaging Gateway',
|
||||
|
||||
Reference in New Issue
Block a user