Merge pull request #1343 from NousResearch/hermes/hermes-5d160594
feat: compress cron management into one tool
This commit is contained in:
315
cli.py
315
cli.py
@@ -429,8 +429,8 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||
from hermes_cli import callbacks as _callbacks
|
||||
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
|
||||
|
||||
# Cron job system for scheduled tasks (CRUD only — execution is handled by the gateway)
|
||||
from cron import create_job, list_jobs, remove_job, get_job
|
||||
# Cron job system for scheduled tasks (execution is handled by the gateway)
|
||||
from cron import get_job
|
||||
|
||||
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
|
||||
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
|
||||
@@ -2627,139 +2627,248 @@ class HermesCLI:
|
||||
|
||||
def _handle_cron_command(self, cmd: str):
|
||||
"""Handle the /cron command to manage scheduled tasks."""
|
||||
parts = cmd.split(maxsplit=2)
|
||||
|
||||
if len(parts) == 1:
|
||||
# /cron - show help and list
|
||||
import shlex
|
||||
from tools.cronjob_tools import cronjob as cronjob_tool
|
||||
|
||||
def _cron_api(**kwargs):
|
||||
return json.loads(cronjob_tool(**kwargs))
|
||||
|
||||
def _normalize_skills(values):
|
||||
normalized = []
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
if text and text not in normalized:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
def _parse_flags(tokens):
|
||||
opts = {
|
||||
"name": None,
|
||||
"deliver": None,
|
||||
"repeat": None,
|
||||
"skills": [],
|
||||
"add_skills": [],
|
||||
"remove_skills": [],
|
||||
"clear_skills": False,
|
||||
"all": False,
|
||||
"prompt": None,
|
||||
"schedule": None,
|
||||
"positionals": [],
|
||||
}
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if token == "--name" and i + 1 < len(tokens):
|
||||
opts["name"] = tokens[i + 1]
|
||||
i += 2
|
||||
elif token == "--deliver" and i + 1 < len(tokens):
|
||||
opts["deliver"] = tokens[i + 1]
|
||||
i += 2
|
||||
elif token == "--repeat" and i + 1 < len(tokens):
|
||||
try:
|
||||
opts["repeat"] = int(tokens[i + 1])
|
||||
except ValueError:
|
||||
print("(._.) --repeat must be an integer")
|
||||
return None
|
||||
i += 2
|
||||
elif token == "--skill" and i + 1 < len(tokens):
|
||||
opts["skills"].append(tokens[i + 1])
|
||||
i += 2
|
||||
elif token == "--add-skill" and i + 1 < len(tokens):
|
||||
opts["add_skills"].append(tokens[i + 1])
|
||||
i += 2
|
||||
elif token == "--remove-skill" and i + 1 < len(tokens):
|
||||
opts["remove_skills"].append(tokens[i + 1])
|
||||
i += 2
|
||||
elif token == "--clear-skills":
|
||||
opts["clear_skills"] = True
|
||||
i += 1
|
||||
elif token == "--all":
|
||||
opts["all"] = True
|
||||
i += 1
|
||||
elif token == "--prompt" and i + 1 < len(tokens):
|
||||
opts["prompt"] = tokens[i + 1]
|
||||
i += 2
|
||||
elif token == "--schedule" and i + 1 < len(tokens):
|
||||
opts["schedule"] = tokens[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
opts["positionals"].append(token)
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
tokens = shlex.split(cmd)
|
||||
|
||||
if len(tokens) == 1:
|
||||
print()
|
||||
print("+" + "-" * 60 + "+")
|
||||
print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|")
|
||||
print("+" + "-" * 60 + "+")
|
||||
print("+" + "-" * 68 + "+")
|
||||
print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|")
|
||||
print("+" + "-" * 68 + "+")
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" /cron - List scheduled jobs")
|
||||
print(" /cron list - List scheduled jobs")
|
||||
print(' /cron add <schedule> <prompt> - Add a new job')
|
||||
print(" /cron remove <job_id> - Remove a job")
|
||||
print(" /cron list")
|
||||
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
|
||||
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
|
||||
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
|
||||
print(" /cron edit <job_id> --remove-skill blogwatcher")
|
||||
print(" /cron edit <job_id> --clear-skills")
|
||||
print(" /cron pause <job_id>")
|
||||
print(" /cron resume <job_id>")
|
||||
print(" /cron run <job_id>")
|
||||
print(" /cron remove <job_id>")
|
||||
print()
|
||||
print(" Schedule formats:")
|
||||
print(" 30m, 2h, 1d - One-shot delay")
|
||||
print(' "every 30m", "every 2h" - Recurring interval')
|
||||
print(' "0 9 * * *" - Cron expression')
|
||||
print()
|
||||
|
||||
# Show current jobs
|
||||
jobs = list_jobs()
|
||||
result = _cron_api(action="list")
|
||||
jobs = result.get("jobs", []) if result.get("success") else []
|
||||
if jobs:
|
||||
print(" Current Jobs:")
|
||||
print(" " + "-" * 55)
|
||||
print(" " + "-" * 63)
|
||||
for job in jobs:
|
||||
# Format repeat status
|
||||
times = job["repeat"].get("times")
|
||||
completed = job["repeat"].get("completed", 0)
|
||||
if times is None:
|
||||
repeat_str = "forever"
|
||||
else:
|
||||
repeat_str = f"{completed}/{times}"
|
||||
|
||||
print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}")
|
||||
prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt']
|
||||
print(f" {prompt_preview}")
|
||||
repeat_str = job.get("repeat", "?")
|
||||
print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}")
|
||||
if job.get("skills"):
|
||||
print(f" Skills: {', '.join(job['skills'])}")
|
||||
print(f" {job.get('prompt_preview', '')}")
|
||||
if job.get("next_run_at"):
|
||||
from datetime import datetime
|
||||
next_run = datetime.fromisoformat(job["next_run_at"])
|
||||
print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f" Next: {job['next_run_at']}")
|
||||
print()
|
||||
else:
|
||||
print(" No scheduled jobs. Use '/cron add' to create one.")
|
||||
print()
|
||||
return
|
||||
|
||||
subcommand = parts[1].lower()
|
||||
|
||||
|
||||
subcommand = tokens[1].lower()
|
||||
opts = _parse_flags(tokens[2:])
|
||||
if opts is None:
|
||||
return
|
||||
|
||||
if subcommand == "list":
|
||||
# /cron list - just show jobs
|
||||
jobs = list_jobs()
|
||||
result = _cron_api(action="list", include_disabled=opts["all"])
|
||||
jobs = result.get("jobs", []) if result.get("success") else []
|
||||
if not jobs:
|
||||
print("(._.) No scheduled jobs.")
|
||||
return
|
||||
|
||||
|
||||
print()
|
||||
print("Scheduled Jobs:")
|
||||
print("-" * 70)
|
||||
print("-" * 80)
|
||||
for job in jobs:
|
||||
times = job["repeat"].get("times")
|
||||
completed = job["repeat"].get("completed", 0)
|
||||
repeat_str = "forever" if times is None else f"{completed}/{times}"
|
||||
|
||||
print(f" ID: {job['id']}")
|
||||
print(f" ID: {job['job_id']}")
|
||||
print(f" Name: {job['name']}")
|
||||
print(f" Schedule: {job['schedule_display']} ({repeat_str})")
|
||||
print(f" State: {job.get('state', '?')}")
|
||||
print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})")
|
||||
print(f" Next run: {job.get('next_run_at', 'N/A')}")
|
||||
print(f" Prompt: {job['prompt'][:80]}{'...' if len(job['prompt']) > 80 else ''}")
|
||||
if job.get("skills"):
|
||||
print(f" Skills: {', '.join(job['skills'])}")
|
||||
print(f" Prompt: {job.get('prompt_preview', '')}")
|
||||
if job.get("last_run_at"):
|
||||
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
|
||||
print()
|
||||
|
||||
elif subcommand == "add":
|
||||
# /cron add <schedule> <prompt>
|
||||
if len(parts) < 3:
|
||||
return
|
||||
|
||||
if subcommand in {"add", "create"}:
|
||||
positionals = opts["positionals"]
|
||||
if not positionals:
|
||||
print("(._.) Usage: /cron add <schedule> <prompt>")
|
||||
print(" Example: /cron add 30m Remind me to take a break")
|
||||
print(' Example: /cron add "every 2h" Check server status at 192.168.1.1')
|
||||
return
|
||||
|
||||
# Parse schedule and prompt
|
||||
rest = parts[2].strip()
|
||||
|
||||
# Handle quoted schedule (e.g., "every 30m" or "0 9 * * *")
|
||||
if rest.startswith('"'):
|
||||
# Find closing quote
|
||||
close_quote = rest.find('"', 1)
|
||||
if close_quote == -1:
|
||||
print("(._.) Unmatched quote in schedule")
|
||||
return
|
||||
schedule = rest[1:close_quote]
|
||||
prompt = rest[close_quote + 1:].strip()
|
||||
schedule = opts["schedule"] or positionals[0]
|
||||
prompt = opts["prompt"] or " ".join(positionals[1:])
|
||||
skills = _normalize_skills(opts["skills"])
|
||||
if not prompt and not skills:
|
||||
print("(._.) Please provide a prompt or at least one skill")
|
||||
return
|
||||
result = _cron_api(
|
||||
action="create",
|
||||
schedule=schedule,
|
||||
prompt=prompt or None,
|
||||
name=opts["name"],
|
||||
deliver=opts["deliver"],
|
||||
repeat=opts["repeat"],
|
||||
skills=skills or None,
|
||||
)
|
||||
if result.get("success"):
|
||||
print(f"(^_^)b Created job: {result['job_id']}")
|
||||
print(f" Schedule: {result['schedule']}")
|
||||
if result.get("skills"):
|
||||
print(f" Skills: {', '.join(result['skills'])}")
|
||||
print(f" Next run: {result['next_run_at']}")
|
||||
else:
|
||||
# First word is schedule
|
||||
schedule_parts = rest.split(maxsplit=1)
|
||||
schedule = schedule_parts[0]
|
||||
prompt = schedule_parts[1] if len(schedule_parts) > 1 else ""
|
||||
|
||||
if not prompt:
|
||||
print("(._.) Please provide a prompt for the job")
|
||||
print(f"(x_x) Failed to create job: {result.get('error')}")
|
||||
return
|
||||
|
||||
if subcommand == "edit":
|
||||
positionals = opts["positionals"]
|
||||
if not positionals:
|
||||
print("(._.) Usage: /cron edit <job_id> [--schedule ...] [--prompt ...] [--skill ...]")
|
||||
return
|
||||
|
||||
try:
|
||||
job = create_job(prompt=prompt, schedule=schedule)
|
||||
print(f"(^_^)b Created job: {job['id']}")
|
||||
print(f" Schedule: {job['schedule_display']}")
|
||||
print(f" Next run: {job['next_run_at']}")
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to create job: {e}")
|
||||
|
||||
elif subcommand == "remove" or subcommand == "rm" or subcommand == "delete":
|
||||
# /cron remove <job_id>
|
||||
if len(parts) < 3:
|
||||
print("(._.) Usage: /cron remove <job_id>")
|
||||
return
|
||||
|
||||
job_id = parts[2].strip()
|
||||
job = get_job(job_id)
|
||||
|
||||
if not job:
|
||||
job_id = positionals[0]
|
||||
existing = get_job(job_id)
|
||||
if not existing:
|
||||
print(f"(._.) Job not found: {job_id}")
|
||||
return
|
||||
|
||||
if remove_job(job_id):
|
||||
print(f"(^_^)b Removed job: {job['name']} ({job_id})")
|
||||
|
||||
final_skills = None
|
||||
replacement_skills = _normalize_skills(opts["skills"])
|
||||
add_skills = _normalize_skills(opts["add_skills"])
|
||||
remove_skills = set(_normalize_skills(opts["remove_skills"]))
|
||||
existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")]))
|
||||
if opts["clear_skills"]:
|
||||
final_skills = []
|
||||
elif replacement_skills:
|
||||
final_skills = replacement_skills
|
||||
elif add_skills or remove_skills:
|
||||
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
|
||||
for skill in add_skills:
|
||||
if skill not in final_skills:
|
||||
final_skills.append(skill)
|
||||
|
||||
result = _cron_api(
|
||||
action="update",
|
||||
job_id=job_id,
|
||||
schedule=opts["schedule"],
|
||||
prompt=opts["prompt"],
|
||||
name=opts["name"],
|
||||
deliver=opts["deliver"],
|
||||
repeat=opts["repeat"],
|
||||
skills=final_skills,
|
||||
)
|
||||
if result.get("success"):
|
||||
job = result["job"]
|
||||
print(f"(^_^)b Updated job: {job['job_id']}")
|
||||
print(f" Schedule: {job['schedule']}")
|
||||
if job.get("skills"):
|
||||
print(f" Skills: {', '.join(job['skills'])}")
|
||||
else:
|
||||
print(" Skills: none")
|
||||
else:
|
||||
print(f"(x_x) Failed to remove job: {job_id}")
|
||||
|
||||
else:
|
||||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, remove")
|
||||
print(f"(x_x) Failed to update job: {result.get('error')}")
|
||||
return
|
||||
|
||||
if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
|
||||
positionals = opts["positionals"]
|
||||
if not positionals:
|
||||
print(f"(._.) Usage: /cron {subcommand} <job_id>")
|
||||
return
|
||||
job_id = positionals[0]
|
||||
action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
|
||||
result = _cron_api(action=action, job_id=job_id, reason="paused from /cron" if action == "pause" else None)
|
||||
if not result.get("success"):
|
||||
print(f"(x_x) Failed to {action} job: {result.get('error')}")
|
||||
return
|
||||
if action == "pause":
|
||||
print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})")
|
||||
elif action == "resume":
|
||||
print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})")
|
||||
print(f" Next run: {result['job'].get('next_run_at')}")
|
||||
elif action == "run":
|
||||
print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})")
|
||||
print(" It will run on the next scheduler tick.")
|
||||
else:
|
||||
removed = result.get("removed_job", {})
|
||||
print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})")
|
||||
return
|
||||
|
||||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, edit, pause, resume, run, remove")
|
||||
|
||||
def _handle_skills_command(self, cmd: str):
|
||||
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
|
||||
|
||||
Reference in New Issue
Block a user