mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
8 Commits
fix/plugin
...
fix/relaun
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c116c74aa4 | ||
|
|
27c03fbd99 | ||
|
|
883de8f9a1 | ||
|
|
b2820cd207 | ||
|
|
e0c0167428 | ||
|
|
6d8423761b | ||
|
|
ec27f0a3fa | ||
|
|
8c8fc6c1ec |
@@ -319,7 +319,14 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
|
||||
schedule.get("expr"),
|
||||
)
|
||||
return None
|
||||
cron = croniter(schedule["expr"], now)
|
||||
# Use last_run_at as the croniter base when available, consistent
|
||||
# with interval jobs. This ensures that after a crash/restart,
|
||||
# the next run is anchored to the actual last execution time
|
||||
# rather than to an arbitrary restart time.
|
||||
base_time = now
|
||||
if last_run_at:
|
||||
base_time = _ensure_aware(datetime.fromisoformat(last_run_at))
|
||||
cron = croniter(schedule["expr"], base_time)
|
||||
next_run = cron.get_next(datetime)
|
||||
return next_run.isoformat()
|
||||
|
||||
|
||||
@@ -1053,7 +1053,18 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
#
|
||||
# Uses the agent's built-in activity tracker (updated by
|
||||
# _touch_activity() on every tool call, API call, and stream delta).
|
||||
_cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600))
|
||||
_raw_cron_timeout = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
if _raw_cron_timeout:
|
||||
try:
|
||||
_cron_timeout = float(_raw_cron_timeout)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Invalid HERMES_CRON_TIMEOUT=%r; using default 600s",
|
||||
_raw_cron_timeout,
|
||||
)
|
||||
_cron_timeout = 600.0
|
||||
else:
|
||||
_cron_timeout = 600.0
|
||||
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
|
||||
_POLL_INTERVAL = 5.0
|
||||
_cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
367
hermes_cli/_parser.py
Normal file
367
hermes_cli/_parser.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Top-level argparse construction for the hermes CLI.
|
||||
|
||||
Lives in its own module so other modules (e.g. ``relaunch.py``) can
|
||||
introspect the parser to discover which flags exist without running the
|
||||
``main`` fn.
|
||||
|
||||
Only the top-level parser and the ``chat`` subparser live here. Every other
|
||||
subparser (model, gateway, sessions, …) is built inline in ``main.py``
|
||||
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
|
||||
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
|
||||
# so it isn't on the parser. Listed here so all "carry over on relaunch"
|
||||
# metadata lives in one file.
|
||||
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
|
||||
("--profile", True),
|
||||
("-p", True),
|
||||
]
|
||||
|
||||
|
||||
def _inherited_flag(parser, *args, **kwargs):
|
||||
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
|
||||
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
|
||||
or after the setup wizard launches chat).
|
||||
|
||||
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
|
||||
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
|
||||
can find it via introspection.
|
||||
"""
|
||||
action = parser.add_argument(*args, **kwargs)
|
||||
action.inherit_on_relaunch = True
|
||||
return action
|
||||
|
||||
|
||||
_EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
"""
|
||||
|
||||
|
||||
def build_top_level_parser():
|
||||
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
|
||||
|
||||
Returns ``(parser, subparsers, chat_parser)``. The caller wires
|
||||
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
|
||||
other subparsers via ``subparsers.add_parser(...)``.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=_EPILOGUE,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
return parser, subparsers, chat_parser
|
||||
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
|
||||
consume = 1
|
||||
break
|
||||
|
||||
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
|
||||
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||
# profile choice without having to pass --profile again.
|
||||
if profile_name is None and os.environ.get("HERMES_HOME"):
|
||||
return
|
||||
|
||||
# 2. If no flag, check active_profile in the hermes root
|
||||
if profile_name is None:
|
||||
try:
|
||||
@@ -7758,302 +7764,9 @@ def cmd_logs(args):
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9723,15 +9436,8 @@ Examples:
|
||||
|
||||
# Launch hermes --resume <id> by replacing the current process
|
||||
print(f"Resuming session: {selected_id}")
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
|
||||
else:
|
||||
# Fallback: re-invoke via python -m
|
||||
os.execvp(
|
||||
sys.executable,
|
||||
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
|
||||
)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["--resume", selected_id])
|
||||
return # won't reach here after execvp
|
||||
|
||||
elif action == "stats":
|
||||
|
||||
149
hermes_cli/relaunch.py
Normal file
149
hermes_cli/relaunch.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Unified self-relaunch for Hermes CLI.
|
||||
|
||||
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
|
||||
process replacement so that ``hermes sessions browse`` or post-setup relaunch
|
||||
doesn't silently drop the user's UI mode or other preferences.
|
||||
|
||||
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_cli._parser import (
|
||||
PRE_ARGPARSE_INHERITED_FLAGS,
|
||||
build_top_level_parser,
|
||||
)
|
||||
|
||||
|
||||
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
|
||||
"""Build the ``(option_string, takes_value)`` table of flags that must
|
||||
survive a self-relaunch, by introspecting the real parser used by
|
||||
``hermes`` itself.
|
||||
|
||||
A flag participates if its argparse Action carries
|
||||
``inherit_on_relaunch = True`` — set by ``_parser._inherited_flag``.
|
||||
"""
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
table: list[tuple[str, bool]] = []
|
||||
seen: set[tuple[str, bool]] = set()
|
||||
for p in (parser, chat_parser):
|
||||
for action in p._actions:
|
||||
if not action.option_strings:
|
||||
continue # positional / no flag form
|
||||
if not getattr(action, "inherit_on_relaunch", False):
|
||||
continue
|
||||
takes_value = action.nargs != 0 # store_true/false set nargs=0
|
||||
for opt in action.option_strings:
|
||||
key = (opt, takes_value)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
table.append(key)
|
||||
|
||||
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
|
||||
return table
|
||||
|
||||
|
||||
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
|
||||
|
||||
|
||||
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
|
||||
"""Pull out flags that should carry over into a self-relaunched hermes."""
|
||||
flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if "=" in arg:
|
||||
key = arg.split("=", 1)[0]
|
||||
for flag, _ in _INHERITED_FLAGS_TABLE:
|
||||
if key == flag:
|
||||
flags.append(arg)
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
for flag, takes_value in _INHERITED_FLAGS_TABLE:
|
||||
if arg == flag:
|
||||
flags.append(arg)
|
||||
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
||||
flags.append(argv[i + 1])
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
return flags
|
||||
|
||||
|
||||
def resolve_hermes_bin() -> Optional[str]:
|
||||
"""Find the hermes entry point.
|
||||
|
||||
Priority:
|
||||
1. ``sys.argv[0]`` if it resolves to a real executable.
|
||||
2. ``shutil.which("hermes")`` on PATH.
|
||||
3. ``None`` → caller should fall back to ``python -m hermes_cli.main``.
|
||||
"""
|
||||
argv0 = sys.argv[0]
|
||||
|
||||
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
|
||||
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
|
||||
return argv0
|
||||
|
||||
# Relative path — resolve against CWD
|
||||
if not argv0.startswith("-") and os.path.isfile(argv0):
|
||||
abs_path = os.path.abspath(argv0)
|
||||
if os.access(abs_path, os.X_OK):
|
||||
return abs_path
|
||||
|
||||
# PATH lookup
|
||||
path_bin = shutil.which("hermes")
|
||||
if path_bin:
|
||||
return path_bin
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_relaunch_argv(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> list[str]:
|
||||
"""Construct an argv list for replacing the current process with hermes.
|
||||
|
||||
Args:
|
||||
extra_args: Arguments to append (e.g. ``["--resume", id]``).
|
||||
preserve_inherited: Whether to carry over UI / behaviour flags
|
||||
tagged with ``inherit_on_relaunch`` in the parser.
|
||||
original_argv: The original argv to scan for flags (defaults to
|
||||
``sys.argv[1:]``).
|
||||
"""
|
||||
bin_path = resolve_hermes_bin()
|
||||
|
||||
if bin_path:
|
||||
argv = [bin_path]
|
||||
else:
|
||||
argv = [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
||||
|
||||
if preserve_inherited:
|
||||
argv.extend(_extract_inherited_flags(src))
|
||||
|
||||
argv.extend(extra_args)
|
||||
return argv
|
||||
|
||||
|
||||
def relaunch(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Replace the current process with a fresh hermes invocation."""
|
||||
new_argv = build_relaunch_argv(
|
||||
extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv
|
||||
)
|
||||
os.execvp(new_argv[0], new_argv)
|
||||
@@ -3249,33 +3249,14 @@ def run_setup_wizard(args):
|
||||
_offer_launch_chat()
|
||||
|
||||
|
||||
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
||||
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
return [hermes_bin, "chat"]
|
||||
|
||||
try:
|
||||
if importlib.util.find_spec("hermes_cli") is not None:
|
||||
return [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _offer_launch_chat():
|
||||
"""Prompt the user to jump straight into chat after setup."""
|
||||
print()
|
||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||
return
|
||||
|
||||
chat_argv = _resolve_hermes_chat_argv()
|
||||
if not chat_argv:
|
||||
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
|
||||
return
|
||||
|
||||
os.execvp(chat_argv[0], chat_argv)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["chat"])
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
|
||||
@@ -67,6 +67,8 @@ AUTHOR_MAP = {
|
||||
"beliefanx@gmail.com": "BeliefanX",
|
||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||
"steve.westerhouse@origami-analytics.com": "westers",
|
||||
"yeyitech@users.noreply.github.com": "yeyitech",
|
||||
"260878550+beenherebefore@users.noreply.github.com": "beenherebefore",
|
||||
"liuhao03@bilibili.com": "liuhao1024",
|
||||
"130918800+devorun@users.noreply.github.com": "devorun",
|
||||
"surat.s@itm.kmutnb.ac.th": "beesrsj2500",
|
||||
|
||||
87
tests/cron/test_compute_next_run_last_run_at.py
Normal file
87
tests/cron/test_compute_next_run_last_run_at.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Test that compute_next_run uses last_run_at for cron jobs.
|
||||
|
||||
Regression test for: cron jobs computing next_run_at from _hermes_now()
|
||||
instead of from last_run_at, making them inconsistent with interval jobs.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
pytest.importorskip("croniter")
|
||||
|
||||
from cron.jobs import compute_next_run
|
||||
|
||||
|
||||
class TestCronComputeNextRunUsesLastRunAt:
|
||||
"""compute_next_run MUST use last_run_at as the croniter base for cron jobs,
|
||||
consistent with how interval jobs work."""
|
||||
|
||||
def test_cron_uses_last_run_at_for_every_6h_schedule(self, monkeypatch):
|
||||
"""For a schedule like 'every 6 hours', the base time matters.
|
||||
If last_run_at is Apr 6 14:10, next should be Apr 6 18:00.
|
||||
If now is Apr 10 22:00, next should be Apr 11 00:00.
|
||||
compute_next_run must use last_run_at, not now."""
|
||||
morocco = ZoneInfo("Africa/Casablanca")
|
||||
|
||||
# Job last ran April 6 at 14:10
|
||||
last_run = datetime(2026, 4, 6, 14, 10, 0, tzinfo=morocco)
|
||||
|
||||
# But now it's April 10 at 22:00 (e.g., gateway restarted)
|
||||
now = datetime(2026, 4, 10, 22, 0, 0, tzinfo=morocco)
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
schedule = {"kind": "cron", "expr": "0 */6 * * *"} # every 6 hours
|
||||
|
||||
result = compute_next_run(schedule, last_run_at=last_run.isoformat())
|
||||
assert result is not None
|
||||
next_dt = datetime.fromisoformat(result)
|
||||
|
||||
# With last_run_at as base (Apr 6 14:10), next is Apr 6 18:00.
|
||||
# With now as base (Apr 10 22:00), next is Apr 11 00:00.
|
||||
# The fix should use last_run_at, returning Apr 6 18:00
|
||||
# (stale detection in get_due_jobs() fast-forwards from there).
|
||||
assert next_dt.date().isoformat() == "2026-04-06", (
|
||||
f"Expected next run on Apr 6 (from last_run_at), got {next_dt}"
|
||||
)
|
||||
assert next_dt.hour == 18
|
||||
|
||||
def test_cron_without_last_run_at_uses_now(self, monkeypatch):
|
||||
"""When last_run_at is NOT provided, compute_next_run falls back to
|
||||
_hermes_now() as the croniter base (existing behavior)."""
|
||||
morocco = ZoneInfo("Africa/Casablanca")
|
||||
|
||||
now = datetime(2026, 4, 10, 22, 0, 0, tzinfo=morocco)
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
schedule = {"kind": "cron", "expr": "0 */6 * * *"}
|
||||
|
||||
result = compute_next_run(schedule)
|
||||
assert result is not None
|
||||
next_dt = datetime.fromisoformat(result)
|
||||
|
||||
# Without last_run_at, should compute from now -> Apr 11 00:00
|
||||
assert next_dt.date().isoformat() == "2026-04-11", (
|
||||
f"Expected next run on Apr 11 (from now), got {next_dt}"
|
||||
)
|
||||
assert next_dt.hour == 0
|
||||
|
||||
def test_cron_weekly_consistent_with_interval(self, monkeypatch):
|
||||
"""Both cron and interval jobs should anchor to last_run_at when
|
||||
provided, producing consistent behavior after a crash/restart."""
|
||||
morocco = ZoneInfo("Africa/Casablanca")
|
||||
|
||||
last_run = datetime(2026, 4, 6, 14, 10, 0, tzinfo=morocco)
|
||||
now = datetime(2026, 4, 10, 22, 0, 0, tzinfo=morocco)
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
cron_schedule = {"kind": "cron", "expr": "0 14 * * 1"}
|
||||
interval_schedule = {"kind": "interval", "minutes": 7 * 24 * 60}
|
||||
|
||||
cron_result = compute_next_run(cron_schedule, last_run_at=last_run.isoformat())
|
||||
interval_result = compute_next_run(interval_schedule, last_run_at=last_run.isoformat())
|
||||
|
||||
# Both should be after last_run_at
|
||||
cron_dt = datetime.fromisoformat(cron_result)
|
||||
interval_dt = datetime.fromisoformat(interval_result)
|
||||
assert cron_dt > last_run, f"Cron next {cron_dt} should be after last_run {last_run}"
|
||||
assert interval_dt > last_run, f"Interval next {interval_dt} should be after last_run {last_run}"
|
||||
@@ -169,10 +169,20 @@ class TestInactivityTimeout:
|
||||
|
||||
assert result["final_response"] == "Done"
|
||||
|
||||
def _parse_cron_timeout(self, raw_value):
|
||||
"""Mirror the defensive parsing logic from cron/scheduler.py run_job()."""
|
||||
if raw_value:
|
||||
try:
|
||||
return float(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
return 600.0
|
||||
return 600.0
|
||||
|
||||
def test_timeout_env_var_parsing(self, monkeypatch):
|
||||
"""HERMES_CRON_TIMEOUT env var is respected."""
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "1200")
|
||||
_cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600))
|
||||
raw = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
_cron_timeout = self._parse_cron_timeout(raw)
|
||||
assert _cron_timeout == 1200.0
|
||||
|
||||
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
|
||||
@@ -181,10 +191,27 @@ class TestInactivityTimeout:
|
||||
def test_timeout_zero_means_unlimited(self, monkeypatch):
|
||||
"""HERMES_CRON_TIMEOUT=0 yields None (unlimited)."""
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
|
||||
_cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600))
|
||||
raw = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
_cron_timeout = self._parse_cron_timeout(raw)
|
||||
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
|
||||
assert _cron_inactivity_limit is None
|
||||
|
||||
def test_timeout_invalid_value_falls_back_to_default(self, monkeypatch):
|
||||
"""HERMES_CRON_TIMEOUT=abc should fall back to 600s, not raise ValueError."""
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "abc")
|
||||
raw = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
_cron_timeout = self._parse_cron_timeout(raw)
|
||||
assert _cron_timeout == 600.0
|
||||
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
|
||||
assert _cron_inactivity_limit == 600.0
|
||||
|
||||
def test_timeout_empty_string_uses_default(self, monkeypatch):
|
||||
"""HERMES_CRON_TIMEOUT='' (empty) should use the 600s default."""
|
||||
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "")
|
||||
raw = os.getenv("HERMES_CRON_TIMEOUT", "").strip()
|
||||
_cron_timeout = self._parse_cron_timeout(raw)
|
||||
assert _cron_timeout == 600.0
|
||||
|
||||
def test_timeout_error_includes_diagnostics(self):
|
||||
"""The TimeoutError message should include last activity info."""
|
||||
agent = SlowFakeAgent(
|
||||
|
||||
@@ -224,22 +224,21 @@ class TestArgparseFlagsRegistered:
|
||||
assert args.ignore_rules is True
|
||||
|
||||
def test_main_py_registers_both_flags(self):
|
||||
"""E2E: the real hermes_cli/main.py parser accepts both flags.
|
||||
"""E2E: the real hermes parser accepts both flags."""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
We invoke the real argparse tree builder from hermes_cli.main.
|
||||
"""
|
||||
import hermes_cli.main as hm
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
top_dests = {a.dest for a in parser._actions}
|
||||
chat_dests = {a.dest for a in chat_parser._actions}
|
||||
assert "ignore_user_config" in top_dests
|
||||
assert "ignore_rules" in top_dests
|
||||
assert "ignore_user_config" in chat_dests
|
||||
assert "ignore_rules" in chat_dests
|
||||
|
||||
# hm has a helper that builds the argparse tree inside main().
|
||||
# We can extract it by catching the SystemExit on --help.
|
||||
# Simpler: just grep the source for the flag strings. Both approaches
|
||||
# are brittle; we use a combined test.
|
||||
import inspect
|
||||
src = inspect.getsource(hm)
|
||||
assert '"--ignore-user-config"' in src, \
|
||||
"chat subparser must register --ignore-user-config"
|
||||
assert '"--ignore-rules"' in src, \
|
||||
"chat subparser must register --ignore-rules"
|
||||
# And the cmd_chat env-var wiring must be present
|
||||
import inspect
|
||||
import hermes_cli.main as hm
|
||||
src = inspect.getsource(hm)
|
||||
assert "HERMES_IGNORE_USER_CONFIG" in src
|
||||
assert "HERMES_IGNORE_RULES" in src
|
||||
|
||||
155
tests/hermes_cli/test_relaunch.py
Normal file
155
tests/hermes_cli/test_relaunch.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for hermes_cli.relaunch — unified self-relaunch utility."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import relaunch as relaunch_mod
|
||||
|
||||
|
||||
class TestResolveHermesBin:
|
||||
def test_prefers_absolute_argv0_when_executable(self, monkeypatch):
|
||||
fake = "/nix/store/abc/bin/hermes"
|
||||
monkeypatch.setattr(sys, "argv", [fake])
|
||||
monkeypatch.setattr(relaunch_mod.os.path, "isfile", lambda p: p == fake)
|
||||
monkeypatch.setattr(relaunch_mod.os, "access", lambda p, mode: p == fake)
|
||||
assert relaunch_mod.resolve_hermes_bin() == fake
|
||||
|
||||
def test_resolves_relative_argv0(self, monkeypatch, tmp_path):
|
||||
fake = tmp_path / "hermes"
|
||||
fake.write_text("#!/bin/sh\n")
|
||||
fake.chmod(0o755)
|
||||
monkeypatch.setattr(sys, "argv", [str(fake.name)])
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Ensure we don't accidentally match a real 'hermes' on PATH
|
||||
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
|
||||
assert relaunch_mod.resolve_hermes_bin() == str(fake)
|
||||
|
||||
def test_falls_back_to_path_which(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["-c"]) # not a real path
|
||||
monkeypatch.setattr(
|
||||
relaunch_mod.shutil, "which", lambda name: "/usr/bin/hermes" if name == "hermes" else None
|
||||
)
|
||||
assert relaunch_mod.resolve_hermes_bin() == "/usr/bin/hermes"
|
||||
|
||||
def test_returns_none_when_unresolvable(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["-c"])
|
||||
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
|
||||
assert relaunch_mod.resolve_hermes_bin() is None
|
||||
|
||||
|
||||
class TestExtractInheritedFlags:
|
||||
def test_extracts_tui_and_dev(self):
|
||||
argv = ["--tui", "--dev", "chat"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["--tui", "--dev"]
|
||||
|
||||
def test_extracts_profile_with_value(self):
|
||||
argv = ["--profile", "work", "chat"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["--profile", "work"]
|
||||
|
||||
def test_extracts_short_p_with_value(self):
|
||||
argv = ["-p", "work"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["-p", "work"]
|
||||
|
||||
def test_extracts_equals_form(self):
|
||||
argv = ["--profile=work", "--model=anthropic/claude-sonnet-4"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == [
|
||||
"--profile=work",
|
||||
"--model=anthropic/claude-sonnet-4",
|
||||
]
|
||||
|
||||
def test_skips_unknown_flags(self):
|
||||
argv = ["--foo", "bar", "--tui"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"]
|
||||
|
||||
def test_does_not_consume_flag_like_value(self):
|
||||
argv = ["--tui", "--resume", "abc123"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"]
|
||||
|
||||
def test_preserves_multiple_skills(self):
|
||||
argv = ["-s", "foo", "-s", "bar", "--tui"]
|
||||
assert relaunch_mod._extract_inherited_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"]
|
||||
|
||||
|
||||
class TestInheritedFlagTable:
|
||||
"""Sanity-check the argparse-introspected table that drives extraction."""
|
||||
|
||||
def test_short_and_long_aliases_are_paired(self):
|
||||
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
|
||||
# Each pair declared together in the parser shares takes_value.
|
||||
for short, long_ in [
|
||||
("-p", "--profile"),
|
||||
("-m", "--model"),
|
||||
("-s", "--skills"),
|
||||
]:
|
||||
assert table[short] == table[long_], f"{short}/{long_} disagree"
|
||||
|
||||
def test_store_true_flags_do_not_take_value(self):
|
||||
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
|
||||
for flag in ["--tui", "--dev", "--yolo", "--ignore-user-config", "--ignore-rules"]:
|
||||
assert table[flag] is False, f"{flag} should not take a value"
|
||||
|
||||
def test_value_flags_take_value(self):
|
||||
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
|
||||
for flag in ["--profile", "--model", "--provider", "--skills"]:
|
||||
assert table[flag] is True, f"{flag} should take a value"
|
||||
|
||||
def test_excluded_flags_are_not_inherited(self):
|
||||
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
|
||||
# --worktree creates a new worktree per process; inheriting would
|
||||
# orphan the parent's. Chat-only flags (--quiet/-Q, --verbose/-v,
|
||||
# --source) can't be in argv at the existing relaunch callsites.
|
||||
for flag in ["-w", "--worktree", "-Q", "--quiet", "-v", "--verbose", "--source"]:
|
||||
assert flag not in table, f"{flag} should not be inherited"
|
||||
|
||||
|
||||
class TestBuildRelaunchArgv:
|
||||
def test_uses_bin_when_available(self, monkeypatch):
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
|
||||
assert argv[0] == "/usr/bin/hermes"
|
||||
|
||||
def test_falls_back_to_python_module(self, monkeypatch):
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
|
||||
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
|
||||
assert argv == [sys.executable, "-m", "hermes_cli.main", "--resume", "abc"]
|
||||
|
||||
def test_preserves_inherited_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||
original = ["--tui", "--dev", "--profile", "work", "sessions", "browse"]
|
||||
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"], original_argv=original)
|
||||
assert "--tui" in argv
|
||||
assert "--dev" in argv
|
||||
assert "--profile" in argv
|
||||
assert "work" in argv
|
||||
assert "--resume" in argv
|
||||
assert "abc" in argv
|
||||
# The original subcommand should not survive
|
||||
assert "sessions" not in argv
|
||||
assert "browse" not in argv
|
||||
|
||||
def test_can_disable_preserve(self, monkeypatch):
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||
original = ["--tui", "chat"]
|
||||
argv = relaunch_mod.build_relaunch_argv(
|
||||
["--resume", "abc"], preserve_inherited=False, original_argv=original
|
||||
)
|
||||
assert "--tui" not in argv
|
||||
assert argv == ["/usr/bin/hermes", "--resume", "abc"]
|
||||
|
||||
|
||||
class TestRelaunch:
|
||||
def test_calls_execvp(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_execvp(path, argv):
|
||||
calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
relaunch_mod.relaunch(["--resume", "abc"])
|
||||
|
||||
assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])]
|
||||
@@ -558,28 +558,12 @@ def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeyp
|
||||
assert defaults[" Vercel team ID"] == "linked-team"
|
||||
|
||||
|
||||
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None)
|
||||
|
||||
assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"]
|
||||
|
||||
|
||||
def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None)
|
||||
|
||||
assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
|
||||
|
||||
def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
||||
def test_offer_launch_chat_relaunches_via_bin(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
from hermes_cli import relaunch as relaunch_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"])
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes")
|
||||
|
||||
exec_calls = []
|
||||
|
||||
@@ -587,7 +571,7 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
||||
exec_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp)
|
||||
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_mod._offer_launch_chat()
|
||||
@@ -595,13 +579,22 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
||||
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
|
||||
|
||||
|
||||
def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys):
|
||||
def test_offer_launch_chat_falls_back_to_module(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
from hermes_cli import relaunch as relaunch_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None)
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
|
||||
|
||||
setup_mod._offer_launch_chat()
|
||||
exec_calls = []
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Run 'hermes chat' manually" in captured.out
|
||||
def fake_execvp(path, argv):
|
||||
exec_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_mod._offer_launch_chat()
|
||||
|
||||
assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
|
||||
|
||||
@@ -566,3 +566,151 @@ class TestSecurityScanGate:
|
||||
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
||||
assert _guard_agent_created_enabled() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# External skills directories (skills.external_dirs) — mutations in place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _two_roots(local_dir: Path, external_dir: Path):
|
||||
"""Patch the skill manager so local SKILLS_DIR = local_dir and
|
||||
get_all_skills_dirs() returns [local_dir, external_dir] in order."""
|
||||
with patch("tools.skill_manager_tool.SKILLS_DIR", local_dir), \
|
||||
patch("agent.skill_utils.get_all_skills_dirs",
|
||||
return_value=[local_dir, external_dir]):
|
||||
yield
|
||||
|
||||
|
||||
def _write_external_skill(external_dir: Path, name: str = "ext-skill") -> Path:
|
||||
skill_dir = external_dir / name
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: An external skill.\n---\n\n"
|
||||
"# External\n\nBody with OLD_MARKER here.\n"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestExternalSkillMutations:
|
||||
"""Verify skill_manage can patch/edit/write/remove/delete skills that live
|
||||
under skills.external_dirs — in place, without duplicating to local.
|
||||
|
||||
Regression for issues #4759 and #4381: the read-only gate used to refuse
|
||||
with 'Skill X is in an external directory and cannot be modified', which
|
||||
caused agents to create duplicate copies in ~/.hermes/skills/ as a
|
||||
workaround.
|
||||
"""
|
||||
|
||||
def test_patch_external_skill_writes_in_place(self, tmp_path):
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
skill_dir = _write_external_skill(external)
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _patch_skill("ext-skill", "OLD_MARKER", "NEW_MARKER")
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert "NEW_MARKER" in (skill_dir / "SKILL.md").read_text()
|
||||
# No duplicate in local
|
||||
assert not (local / "ext-skill").exists()
|
||||
|
||||
def test_edit_external_skill_writes_in_place(self, tmp_path):
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
skill_dir = _write_external_skill(external)
|
||||
|
||||
new_content = (
|
||||
"---\nname: ext-skill\ndescription: Rewritten.\n---\n\n"
|
||||
"# Rewritten\n\nBrand new body.\n"
|
||||
)
|
||||
with _two_roots(local, external):
|
||||
result = _edit_skill("ext-skill", new_content)
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert "Brand new body" in (skill_dir / "SKILL.md").read_text()
|
||||
assert not (local / "ext-skill").exists()
|
||||
|
||||
def test_write_file_on_external_skill(self, tmp_path):
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
skill_dir = _write_external_skill(external)
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _write_file("ext-skill", "references/notes.md", "# Notes\n")
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert (skill_dir / "references" / "notes.md").read_text() == "# Notes\n"
|
||||
assert not (local / "ext-skill").exists()
|
||||
|
||||
def test_remove_file_on_external_skill(self, tmp_path):
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
skill_dir = _write_external_skill(external)
|
||||
(skill_dir / "references").mkdir()
|
||||
(skill_dir / "references" / "notes.md").write_text("# Notes\n")
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _remove_file("ext-skill", "references/notes.md")
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert not (skill_dir / "references" / "notes.md").exists()
|
||||
|
||||
def test_delete_external_skill_removes_skill_not_root(self, tmp_path):
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
skill_dir = _write_external_skill(external)
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _delete_skill("ext-skill")
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert not skill_dir.exists()
|
||||
# The external root must NOT be rmdir'd, even when empty after deletion
|
||||
assert external.exists() and external.is_dir()
|
||||
|
||||
def test_delete_external_skill_cleans_empty_category(self, tmp_path):
|
||||
"""When a skill lives under external/<category>/<name>, deleting the
|
||||
last skill in the category should rmdir the empty category dir but
|
||||
stop at the external root."""
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
cat_dir = external / "team"
|
||||
cat_dir.mkdir()
|
||||
skill_dir = cat_dir / "ext-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: ext-skill\ndescription: An external skill.\n---\n\n"
|
||||
"# External\n\nBody.\n"
|
||||
)
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _delete_skill("ext-skill")
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert not skill_dir.exists()
|
||||
assert not cat_dir.exists() # empty category cleaned up
|
||||
assert external.exists() # but never the external root
|
||||
|
||||
def test_create_still_writes_to_local_root(self, tmp_path):
|
||||
"""Creating a new skill always lands in local SKILLS_DIR, never
|
||||
external_dirs — create is unchanged by this PR."""
|
||||
local = tmp_path / "local"
|
||||
external = tmp_path / "vault"
|
||||
local.mkdir(); external.mkdir()
|
||||
|
||||
with _two_roots(local, external):
|
||||
result = _create_skill("fresh-skill", VALID_SKILL_CONTENT.replace(
|
||||
"name: test-skill", "name: fresh-skill"))
|
||||
|
||||
assert result["success"] is True, result
|
||||
assert (local / "fresh-skill" / "SKILL.md").exists()
|
||||
assert not (external / "fresh-skill").exists()
|
||||
|
||||
|
||||
@@ -109,16 +109,28 @@ MAX_NAME_LENGTH = 64
|
||||
MAX_DESCRIPTION_LENGTH = 1024
|
||||
|
||||
|
||||
def _is_local_skill(skill_path: Path) -> bool:
|
||||
"""Check if a skill path is within the local SKILLS_DIR.
|
||||
|
||||
Skills found in external_dirs are read-only from the agent's perspective.
|
||||
def _containing_skills_root(skill_path: Path) -> Path:
|
||||
"""Return the skills root directory (local or external_dirs entry) that
|
||||
contains ``skill_path``. Falls back to the local ``SKILLS_DIR`` if no
|
||||
match is found (defensive — callers should have located the skill via
|
||||
``_find_skill`` first).
|
||||
"""
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
|
||||
try:
|
||||
skill_path.resolve().relative_to(SKILLS_DIR.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
resolved = skill_path.resolve()
|
||||
except OSError:
|
||||
resolved = skill_path
|
||||
|
||||
for root in get_all_skills_dirs():
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
return root
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
return SKILLS_DIR
|
||||
|
||||
|
||||
MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token
|
||||
MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file
|
||||
|
||||
@@ -397,9 +409,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
|
||||
|
||||
if not _is_local_skill(existing["path"]):
|
||||
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
|
||||
|
||||
skill_md = existing["path"] / "SKILL.md"
|
||||
# Back up original content for rollback
|
||||
original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
|
||||
@@ -440,9 +449,6 @@ def _patch_skill(
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
|
||||
if not _is_local_skill(existing["path"]):
|
||||
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
|
||||
if file_path:
|
||||
@@ -522,15 +528,13 @@ def _delete_skill(name: str) -> Dict[str, Any]:
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
|
||||
if not _is_local_skill(existing["path"]):
|
||||
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be deleted."}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
skills_root = _containing_skills_root(skill_dir)
|
||||
shutil.rmtree(skill_dir)
|
||||
|
||||
# Clean up empty category directories (don't remove SKILLS_DIR itself)
|
||||
# Clean up empty category directories (don't remove the skills root itself)
|
||||
parent = skill_dir.parent
|
||||
if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()):
|
||||
if parent != skills_root and parent.exists() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
return {
|
||||
@@ -567,9 +571,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
|
||||
|
||||
if not _is_local_skill(existing["path"]):
|
||||
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."}
|
||||
|
||||
target, err = _resolve_skill_target(existing["path"], file_path)
|
||||
if err:
|
||||
return {"success": False, "error": err}
|
||||
@@ -604,9 +605,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
|
||||
if not existing:
|
||||
return {"success": False, "error": f"Skill '{name}' not found."}
|
||||
|
||||
if not _is_local_skill(existing["path"]):
|
||||
return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified."}
|
||||
|
||||
skill_dir = existing["path"]
|
||||
|
||||
target, err = _resolve_skill_target(skill_dir, file_path)
|
||||
|
||||
Reference in New Issue
Block a user