Files
hermes-agent/tools/project_tools.py
Teknium 1545afb892 refactor(tools): simplify registry, schema_sanitizer, self_repo_guard, plugin_guard, project_tools, path_security (-25% LOC)
Zero behavior change; tool schemas byte-identical; golden corpus (376 guard
commands, 93 heredoc forms, sanitizer/unrename cases) identical old vs new.

registry.py (1263 -> 924): _memo_check per-pass check_fn memo (2 sites),
_grouped/_toolset_entries toolset grouping (6 sites), _unique_env replaces
_extend_unique, _attr accessor for get_schema/get_toolset_for_tool/get_emoji/
get_max_result_size, fold cache-prune loops, flatten _callable_module walk,
merge guard try-blocks, docstring/comment compaction (all WHY kept).
schema_sanitizer.py (511 -> 392): _rewrite bottom-up tree map shared by
_strip_ref_siblings/strip_nullable_unions/collapse_const_unions, _dict_nodes
generator replaces nested _walk closure, collapsed _const_branch_type guards.
self_repo_guard.py (678 -> 517): _scope_keys state machine as one if/elif
ladder, heredoc opener parsed by one regex (_HEREDOC_OPENER_RE), _masked_line
inlined, _operator_before via rstrip, folded tail expressions.
plugin_guard.py (235 -> 163): positional Finding ctor, packed tables, docs.
project_tools.py (181 -> 156): _activated shared by create/switch, _ACTIONS
dict dispatch replaces the if-chain in _handle_project.
path_security.py (24 -> 18): unused logger/logging import dropped.
2026-09-02 23:56:07 -07:00

157 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Project tools — the agent's INTENTIONAL handle on first-class Projects (per-profile
``projects.db``, the desktop sidebar's named workspaces). Creating/switching is an explicit
tool call, never a side effect of ``cd``. GUI-only: the `project` toolset stays off
``_HERMES_CORE_TOOLS``; the desktop/TUI gateway folds it in and wires
``set_project_workspace_callback`` so the live session's cwd and sidebar follow."""
import json
import os
from typing import Callable, Optional
from tools.registry import registry
# Set by the GUI gateway: ``(task_id, primary_path, project_name)`` re-anchors that session's
# workspace. ``None`` in CLI/messaging — the DB write still happens, nothing to move.
_workspace_callback: Optional[Callable[[str, str, str], None]] = None
def set_project_workspace_callback(fn: Optional[Callable[[str, str, str], None]]) -> None:
global _workspace_callback
_workspace_callback = fn
def _primary_path(proj) -> Optional[str]:
if getattr(proj, "primary_path", None):
return proj.primary_path
for folder in proj.folders:
if folder.is_primary:
return folder.path
return proj.folders[0].path if proj.folders else None
def _apply_workspace(task_id: Optional[str], path: Optional[str], name: str) -> None:
cb = _workspace_callback
if cb and task_id and path:
try:
cb(task_id, path, name)
except Exception:
pass
def _resolve(conn, token: str):
from hermes_cli import projects_db as pdb
token = (token or "").strip()
if not token:
return None
projects = pdb.list_projects(conn, include_archived=True)
# Exact id / slug / name first, then case-insensitive slug / name.
for proj in projects:
if token in (proj.id, proj.slug) or proj.name == token:
return proj
low = token.lower()
for proj in projects:
if proj.slug.lower() == low or proj.name.lower() == low:
return proj
return None
def _activated(proj, task_id: Optional[str]) -> str:
primary = _primary_path(proj)
_apply_workspace(task_id, primary, proj.name)
return json.dumps({
"success": True, "id": proj.id, "slug": proj.slug, "name": proj.name,
"primary_path": primary})
def project_list(task_id: Optional[str] = None) -> str:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
active = pdb.get_active_id(conn)
projects = pdb.list_projects(conn)
return json.dumps({
"active_id": active,
"projects": [
{
"id": p.id, "slug": p.slug, "name": p.name,
"primary_path": _primary_path(p), "active": p.id == active}
for p in projects]})
def project_create(name: str, path: Optional[str] = None, task_id: Optional[str] = None) -> str:
name = (name or "").strip()
if not name:
return json.dumps({"success": False, "error": "name is required"})
from hermes_cli import projects_db as pdb
folder = (path or "").strip()
if folder:
folder = os.path.abspath(os.path.expanduser(folder))
try:
with pdb.connect_closing() as conn:
existing = pdb.find_by_primary_path(conn, folder) if folder else None
if existing is not None:
# Idempotent create: duplicates would render N identical sidebar subtrees.
pdb.set_active(conn, existing.id)
proj = existing
else:
pid = pdb.create_project(conn, name=name, folders=[folder] if folder else [], primary_path=folder or None)
pdb.set_active(conn, pid)
proj = pdb.get_project(conn, pid)
except ValueError as exc:
return json.dumps({"success": False, "error": str(exc)})
if proj is None:
return json.dumps({"success": False, "error": "project vanished after create"})
return _activated(proj, task_id)
def project_switch(project: str, task_id: Optional[str] = None) -> str:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
proj = _resolve(conn, project)
if proj is None:
return json.dumps({"success": False, "error": f"no project matching '{project}'"})
pdb.set_active(conn, proj.id)
return _activated(proj, task_id)
_ACTIONS = {
"list": lambda args, tid: project_list(task_id=tid),
"create": lambda args, tid: project_create(
name=args.get("name", ""), path=args.get("path"), task_id=tid),
"switch": lambda args, tid: project_switch(project=args.get("name", ""), task_id=tid)}
def _handle_project(args, **kw):
action = _ACTIONS.get((args.get("action") or "").strip())
if action is None:
return json.dumps(
{"success": False, "error": "action must be one of: create, switch, list."})
return action(args, kw.get("task_id"))
# One action enum instead of three tools (each re-taught "desktop Projects"; 244 -> ~145 tok).
registry.register(
name="desktop_project",
toolset="project",
schema={
"name": "desktop_project",
"description": (
"Create or switch desktop Projects (named workspaces). create: one and switch "
"this chat into it — pass path to anchor it to a repo/folder (the "
"chat's workspace moves there, the sidebar follows). switch: move "
"this chat into an existing project by name/slug/id — the "
"intentional way to move the session, not `cd`. list: all projects + which is active."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["create", "switch", "list"]},
"name": {"type": "string", "description": "create: human name. switch: name, slug, or id."},
"path": {"type": "string", "description": "create: repo/folder to anchor to."},
},
"required": ["action"],
},
},
handler=_handle_project,
)