feat(tools): bidirectional HIP-0300 action sync across Python/TS/Rust

Sync all tool actions across all three language implementations:
- code: +8 actions (search_symbol, outline, metrics, exports, types, hierarchy, rename, grep_replace)
- fetch: +2 actions (request, open)
- think: restructured to 12 action-based handlers
- tasks: +12 actions (stats, search, batch, archive, move, prioritize, assign, subtasks, notes, export, import, delete)
- plan: +13 actions (create, show, list, next, archive, add_step, remove_step, estimate, visualize, clone, cancel, notes, progress)
- mode: +3 actions (switch alias, list_presets, select_preset)
- memory: +10 actions (search, stats, clear, export, import, merge, tag, untag, namespaces, history)
- fs: +1 action (mv)
- git: +21 actions (blame, show, stash, tag, remote, merge, rebase, cherry_pick, reset, clean, init, clone, fetch, pull, push, config, worktree, reflog, shortlog, rev_parse, describe, bisect)
This commit is contained in:
2026-03-10 21:57:50 -07:00
parent 7090d1c2ae
commit 69dd83710b
9 changed files with 1441 additions and 135 deletions
@@ -640,5 +640,178 @@ All operations are PURE - safe to cache and parallelize.
"next_actions": next_actions,
}
# ── TS-parity actions ──────────────────────────────────────────
@self.action("search_symbol", "Find symbols across project")
async def search_symbol(
ctx: MCPContext,
query: str,
path: str | None = None,
max_results: int = 20,
) -> dict:
import re
search_dir = Path(path) if path else Path(self.cwd)
results = []
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
for p in search_dir.rglob("*"):
if len(results) >= max_results:
break
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
continue
try:
text = p.read_text(errors="ignore")
for i, line in enumerate(text.splitlines(), 1):
if query in line and re.search(r'\b(def|class|function|fn|struct|enum|interface|type|const|let|var|pub)\b', line):
results.append({"uri": str(p), "line": i, "text": line.strip(), "type": "definition"})
if len(results) >= max_results:
break
except (OSError, UnicodeDecodeError):
continue
return {"query": query, "results": results, "count": len(results)}
@self.action("outline", "List symbols with import count and export flag")
async def outline(ctx: MCPContext, path: str, text: str | None = None) -> dict:
import re
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if text is None:
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
text = full_path.read_text()
lines = text.splitlines()
syms = []
imports = 0
sym_re = re.compile(r'(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var|def|fn|pub\s+fn|pub\s+struct|struct|impl)\s+(\w+)')
imp_re = re.compile(r'^(?:import|from|require|use)\b')
for i, line in enumerate(lines, 1):
if imp_re.match(line.strip()):
imports += 1
m = sym_re.search(line)
if m:
kind_m = re.search(r'\b(class|interface|type|enum|function|const|struct|impl|fn|def)\b', line)
syms.append({"name": m.group(1), "kind": kind_m.group(1) if kind_m else "symbol", "line": i, "exported": line.strip().startswith(("export ", "pub "))})
return {"uri": str(full_path), "symbols": syms, "imports": imports, "lines": len(lines)}
@self.action("metrics", "Count files and lines by extension")
async def metrics(ctx: MCPContext, path: str | None = None) -> dict:
search_dir = Path(path) if path else Path(self.cwd)
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
by_ext: dict[str, dict[str, int]] = {}
total_files = 0
total_lines = 0
for p in search_dir.rglob("*"):
if any(sd in p.parts for sd in skip_dirs) or not p.is_file():
continue
ext = p.suffix or "other"
try:
lines = len(p.read_text(errors="ignore").splitlines())
if ext not in by_ext:
by_ext[ext] = {"files": 0, "lines": 0}
by_ext[ext]["files"] += 1
by_ext[ext]["lines"] += lines
total_files += 1
total_lines += lines
except (OSError, UnicodeDecodeError):
continue
return {"total_files": total_files, "total_lines": total_lines, "by_extension": by_ext}
@self.action("exports", "Extract public exports from file")
async def exports(ctx: MCPContext, path: str) -> dict:
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
text = full_path.read_text()
export_lines = [l.strip() for l in text.splitlines() if l.strip().startswith(("export ", "pub ", "__all__"))]
return {"uri": str(full_path), "exports": export_lines, "count": len(export_lines)}
@self.action("types", "Find type definitions in file")
async def types(ctx: MCPContext, path: str) -> dict:
import re
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
if not full_path.exists():
raise NotFoundError(f"File not found: {path}")
text = full_path.read_text()
type_defs = []
type_re = re.compile(r'(?:interface|type|enum|struct|class)\s+(\w+)')
for i, line in enumerate(text.splitlines(), 1):
m = type_re.search(line)
if m:
type_defs.append({"name": m.group(1), "line": i, "text": line.strip()})
return {"uri": str(full_path), "types": type_defs, "count": len(type_defs)}
@self.action("hierarchy", "Build class inheritance tree")
async def hierarchy(ctx: MCPContext, query: str, path: str | None = None) -> dict:
import re
search_dir = Path(path) if path else Path(self.cwd)
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
classes: dict[str, list[str]] = {}
class_re = re.compile(r'class\s+(\w+)(?:\s*\(([^)]+)\)|\s+extends\s+(\w+))?')
for p in search_dir.rglob("*"):
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
continue
try:
for m in class_re.finditer(p.read_text(errors="ignore")):
name = m.group(1)
parent = m.group(3) or (m.group(2).split(",")[0].strip() if m.group(2) else None)
if name not in classes:
classes[name] = []
if parent and parent not in ("object", "Object"):
classes.setdefault(parent, []).append(name)
except (OSError, UnicodeDecodeError):
continue
def build(n: str, d: int = 0) -> str:
out = " " * d + n + "\n"
for c in classes.get(n, []):
out += build(c, d + 1)
return out
return {"root": query, "tree": build(query), "children": classes.get(query, [])}
@self.action("rename", "Rename symbol across files")
async def rename(ctx: MCPContext, query: str, new_name: str, path: str | None = None) -> dict:
import re
search_dir = Path(path) if path else Path(self.cwd)
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
total_changes = 0
changed = []
word_re = re.compile(rf'\b{re.escape(query)}\b')
for p in search_dir.rglob("*"):
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
continue
try:
text = p.read_text(errors="ignore")
if word_re.search(text):
count = len(word_re.findall(text))
p.write_text(word_re.sub(new_name, text))
total_changes += count
changed.append(f"{p}: {count} replacements")
except (OSError, UnicodeDecodeError):
continue
return {"old_name": query, "new_name": new_name, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
@self.action("grep_replace", "Pattern replacement across codebase")
async def grep_replace(ctx: MCPContext, query: str, replacement: str, path: str | None = None) -> dict:
import re as re_mod
search_dir = Path(path) if path else Path(self.cwd)
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
total_changes = 0
changed = []
try:
regex = re_mod.compile(query)
except re_mod.error as e:
raise InvalidParamsError(f"Invalid regex: {e}", param="query")
for p in search_dir.rglob("*"):
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
continue
try:
text = p.read_text(errors="ignore")
if regex.search(text):
count = len(regex.findall(text))
p.write_text(regex.sub(replacement, text))
total_changes += count
changed.append(f"{p}: {count}")
except (OSError, UnicodeDecodeError):
continue
return {"pattern": query, "replacement": replacement, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
# Singleton instance
code_tool = CodeTool
@@ -24,14 +24,17 @@ class ModeTool(BaseTool):
@override
def description(self) -> str:
"""Get the tool description."""
return """Manage development modes (programmer personalities). Actions: list (default), activate, show, current.
return """Manage development modes (programmer personalities). Actions: list (default), activate, switch, show, current, list_presets, select_preset.
Usage:
mode
mode --action list
mode --action activate guido
mode --action switch guido
mode --action show linus
mode --action current"""
mode --action current
mode --action list_presets
mode --action select_preset --name fullstack"""
@override
async def run(
@@ -198,7 +201,7 @@ mode --action current"""
return "\n".join(output)
elif action == "activate":
elif action in ("activate", "switch"):
if not name:
return "Error: Mode name required for activate action"
@@ -304,8 +307,40 @@ mode --action current"""
return "\n".join(output)
elif action == "list_presets":
modes = ModeRegistry.list()
presets = [m for m in modes if m.name in [
"fullstack", "minimal", "data_scientist", "devops",
"security", "academic", "startup", "enterprise",
"creative", "hanzo",
]]
if not presets:
return "No presets available"
output = ["Available presets:"]
for p in presets:
output.append(f" {p.name}: {p.programmer} - {p.description}")
return "\n".join(output)
elif action == "select_preset":
if not name:
return "Error: Preset name required"
# select_preset is same as activate but only for preset modes
preset_names = [
"fullstack", "minimal", "data_scientist", "devops",
"security", "academic", "startup", "enterprise",
"creative", "hanzo",
]
if name not in preset_names:
return f"Error: '{name}' is not a preset. Use list_presets to see available presets."
try:
ModeRegistry.set_active(name)
mode = ModeRegistry.get(name)
return f"Selected preset: {mode.name} ({mode.programmer})"
except ValueError as e:
return str(e)
else:
return f"Unknown action: {action}. Use 'list', 'activate', 'show', or 'current'"
return f"Unknown action: {action}. Use 'list', 'activate', 'switch', 'show', 'current', 'list_presets', or 'select_preset'"
def register(self, server: FastMCP) -> None:
"""Register the tool with the MCP server."""
@@ -766,6 +766,35 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
},
}
@self.action("mv", "Move or rename file/directory")
async def mv(ctx: MCPContext, uri: str, destination: str) -> dict:
"""Move or rename a file or directory.
Args:
uri: Source path
destination: Destination path
"""
import shutil
src_str = uri.replace("file://", "") if uri.startswith("file://") else uri
dst_str = destination.replace("file://", "") if destination.startswith("file://") else destination
src_path = self._validate_path(src_str)
dst_path = self._validate_path(dst_str)
self._check_permission(str(src_path))
self._check_permission(str(dst_path))
if not src_path.exists():
raise NotFoundError(f"Source not found: {src_path}", uri=uri)
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_path), str(dst_path))
return {
"source": file_uri(str(src_path)),
"destination": file_uri(str(dst_path)),
"moved": True,
}
@self.action("mkdir", "Create directory")
async def mkdir(ctx: MCPContext, uri: str) -> dict:
"""Create directory and parent directories."""
@@ -66,6 +66,16 @@ Action = Annotated[
"summarize", # Summarize and store
"kb", # Manage knowledge bases
"list", # List memories/knowledge bases
"search", # Search memories (alias for recall)
"stats", # Memory statistics
"clear", # Clear all memories
"export", # Export memories as JSON
"import", # Import memories from JSON
"merge", # Merge duplicate memories
"tag", # Add tags to memory
"untag", # Remove tags from memory
"namespaces", # List namespaces/scopes
"history", # Memory change history
],
Field(description="Memory action to perform"),
]
@@ -187,8 +197,30 @@ Examples:
return await self._manage_kb(kb_action or "list", name)
elif action == "list":
return await self._list(list_type, scope, limit)
elif action == "search":
return await self._recall(
queries or ([query] if query else []), scope, limit, backend
)
elif action == "stats":
return await self._stats(scope)
elif action == "clear":
return await self._clear(scope)
elif action == "export":
return await self._export(scope, limit)
elif action == "import":
return await self._import(content)
elif action == "merge":
return await self._merge(ids or [])
elif action == "tag":
return await self._tag(id, tags or [])
elif action == "untag":
return await self._untag(id, tags or [])
elif action == "namespaces":
return await self._namespaces()
elif action == "history":
return await self._history(id, limit)
else:
return f"Unknown action: {action}. Use: recall, create, update, delete, manage, facts, store, summarize, kb, list"
return f"Unknown action: {action}. Use: recall, create, update, delete, manage, facts, store, summarize, kb, list, search, stats, clear, export, import, merge, tag, untag, namespaces, history"
except ImportError as e:
return f"Memory service not available: {e}"
@@ -517,6 +549,139 @@ Examples:
lines.append(f" [{mem.memory_id[:8]}] {mem.content[:60]}...")
return "\n".join(lines)
async def _stats(self, scope: str) -> str:
"""Get memory statistics."""
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
file_count = len(backend._file_memories)
session_count = len(backend._session_memories)
total = file_count + session_count
return f"Memory stats:\n Total: {total}\n File-backed: {file_count}\n Session: {session_count}\n Scope: {scope}"
async def _clear(self, scope: str) -> str:
"""Clear all memories in scope."""
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
if scope == "session":
count = len(backend._session_memories)
backend._session_memories.clear()
return f"Cleared {count} session memories"
return "Clear only supported for session scope. File-backed memories must be deleted individually."
async def _export(self, scope: str, limit: int) -> str:
"""Export memories as JSON."""
import json as json_mod
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
memories = list(backend._file_memories) + list(backend._session_memories)
items = []
for mem in memories[:limit]:
items.append({
"id": mem.memory_id,
"content": mem.content,
"scope": getattr(mem, "scope", "global"),
"source": getattr(mem, "source", "local"),
})
return json_mod.dumps({"memories": items, "count": len(items)}, indent=2, default=str)
async def _import(self, content: Optional[str]) -> str:
"""Import memories from JSON."""
import json as json_mod
if not content:
return "Error: content (JSON) required for import"
try:
data = json_mod.loads(content)
items = data if isinstance(data, list) else data.get("memories", [])
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
count = 0
for item in items:
if isinstance(item, dict) and "content" in item:
backend.add_memory(content=item["content"], scope=item.get("scope", "session"))
count += 1
return f"Imported {count} memories"
except Exception as e:
return f"Import error: {e}"
async def _merge(self, ids: List[str]) -> str:
"""Merge duplicate memories."""
if len(ids) < 2:
return "Error: at least 2 IDs required for merge"
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
contents = []
for mid in ids:
for mem in list(backend._file_memories) + list(backend._session_memories):
if mem.memory_id == mid or mem.memory_id.startswith(mid):
contents.append(mem.content)
break
if len(contents) < 2:
return "Error: could not find enough memories to merge"
merged = "\n---\n".join(contents)
mem = backend.add_memory(content=f"[Merged] {merged}", scope="session")
# Delete originals
for mid in ids:
backend.delete_memory(mid)
return f"Merged {len(ids)} memories into {mem.memory_id}"
async def _tag(self, id: Optional[str], tags: List[str]) -> str:
"""Add tags to a memory."""
if not id:
return "Error: id required for tag"
if not tags:
return "Error: tags list required"
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
for mem in list(backend._file_memories) + list(backend._session_memories):
if mem.memory_id == id or mem.memory_id.startswith(id):
existing = getattr(mem, 'tags', []) or []
mem.tags = list(set(existing + tags))
return f"Tagged {id}: {mem.tags}"
return f"Memory {id} not found"
async def _untag(self, id: Optional[str], tags: List[str]) -> str:
"""Remove tags from a memory."""
if not id:
return "Error: id required for untag"
if not tags:
return "Error: tags list required"
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
for mem in list(backend._file_memories) + list(backend._session_memories):
if mem.memory_id == id or mem.memory_id.startswith(id):
existing = getattr(mem, 'tags', []) or []
mem.tags = [t for t in existing if t not in tags]
return f"Untagged {id}: removed {tags}"
return f"Memory {id} not found"
async def _namespaces(self) -> str:
"""List available namespaces/scopes."""
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
scopes = set()
for mem in list(backend._file_memories) + list(backend._session_memories):
scopes.add(getattr(mem, 'scope', 'global'))
return f"Namespaces: {', '.join(sorted(scopes)) or 'none'}"
async def _history(self, id: Optional[str], limit: int) -> str:
"""Get memory change history (limited - returns current state)."""
if not id:
return "Error: id required for history"
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
backend._ensure_loaded()
for mem in list(backend._file_memories) + list(backend._session_memories):
if mem.memory_id == id or mem.memory_id.startswith(id):
return f"Memory {id}:\n Content: {mem.content[:200]}\n Created: {getattr(mem, 'created_at', 'unknown')}\n (Full history tracking requires cloud backend)"
return f"Memory {id} not found"
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@@ -10,6 +10,7 @@ Effect lattice position: NONDETERMINISTIC_EFFECT
Network operations are inherently non-deterministic.
"""
import asyncio
import json
import os
import re
@@ -462,5 +463,87 @@ Effect: NONDETERMINISTIC_EFFECT (network I/O)
return result
@self.action("request", "Full HTTP request with method/headers/body")
async def request(
ctx: MCPContext,
url: str,
method: str = "GET",
headers: dict | None = None,
body: str | None = None,
timeout: int = 30,
) -> dict:
"""HTTP request with full control over method, headers, body.
Unlike 'fetch' which focuses on text extraction, 'request' returns
the raw response with status, headers, and body.
Effect: NONDETERMINISTIC_EFFECT
"""
client = await self._get_client()
try:
response = await client.request(
method,
url,
headers=headers,
content=body,
timeout=timeout,
)
except Exception as e:
raise ToolError(
code="INTERNAL_ERROR",
message=f"Request failed: {e}",
details={"url": url, "method": method},
)
content_type = response.headers.get("content-type", "")
resp_headers = dict(response.headers)
if "json" in content_type:
try:
body_data = response.json()
except Exception:
body_data = response.text
else:
body_data = response.text[:50000] if len(response.text) > 50000 else response.text
return {
"status": response.status_code,
"headers": resp_headers,
"body": body_data,
}
@self.action("open", "Open URL in browser")
async def open_url(
ctx: MCPContext,
url: str,
) -> dict:
"""Open URL in the system default browser.
Effect: NONDETERMINISTIC_EFFECT
"""
import platform
import subprocess
system = platform.system().lower()
if system == "darwin":
cmd = ["open", url]
elif system == "linux":
cmd = ["xdg-open", url]
elif system == "windows":
cmd = ["start", url]
else:
cmd = ["xdg-open", url]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
return {"url": url, "opened": True}
# Singleton
fetch_tool = FetchTool
@@ -219,12 +219,16 @@ class PlanTool(BaseTool):
name: ClassVar[str] = "plan"
VERSION: ClassVar[str] = "0.2.0"
# In-memory tracked plan (matches Rust update_plan behavior)
# In-memory tracked plans (supports multiple named plans)
_tracked_plan: TrackedPlan | None = None
_plans: dict[str, TrackedPlan] = {}
_plan_counter: int = 0
def __init__(self):
super().__init__()
self._tracked_plan = None
self._plans = {}
self._plan_counter = 0
self._register_plan_actions()
@property
@@ -232,11 +236,26 @@ class PlanTool(BaseTool):
return """Unified plan orchestration tool (HIP-0300).
Actions:
- update: Track plan with step status (Rust parity - name, steps with status)
- create: Create a new plan with name and steps
- show: Show a plan by name
- update: Track plan with step status
- get: Get current tracked plan
- list: List all plans
- next: Get next pending step
- archive: Archive a plan
- add_step: Add a step to existing plan
- remove_step: Remove a step from plan
- estimate: Estimate plan completion
- visualize: Text visualization of plan progress
- clone: Clone an existing plan
- cancel: Cancel a plan
- notes: Add/view plan notes
- progress: Get progress percentage
- clear: Clear tracked plan
- intent: Parse natural language → IntentIR
- route: Map IntentIR → Plan (canonical operator chain)
- compose: Plan → ExecGraph (execution-ready DAG)
- route: Map IntentIR → Plan
- compose: Plan → ExecGraph
- chains: List canonical chains
Turns permissive natural language input into strict canonical operator chains.
"""
@@ -618,14 +637,7 @@ Turns permissive natural language input into strict canonical operator chains.
ctx: MCPContext,
category: str | None = None,
) -> dict:
"""List available canonical operator chains.
Args:
category: Filter by category (optional)
Returns:
Dictionary of available chains
"""
"""List available canonical operator chains."""
result = {}
for (cat, action), chain in CANONICAL_CHAINS.items():
if category and cat != category:
@@ -636,10 +648,217 @@ Turns permissive natural language input into strict canonical operator chains.
"tools": [s["tool"] for s in chain],
"has_policy_gate": any(s.get("policy_gate") for s in chain),
}
return {"chains": result, "total": len(result)}
# --- TS parity actions below ---
def _get_plan(self, name: str | None = None) -> TrackedPlan | None:
if name and name in self._plans:
return self._plans[name]
return self._tracked_plan
def _save_plan(self, plan: TrackedPlan):
now = datetime.now(timezone.utc).isoformat()
plan.updated_at = now
if plan.name:
self._plans[plan.name] = plan
self._tracked_plan = plan
def _plan_to_dict(self, plan: TrackedPlan) -> dict:
return {
"chains": result,
"total": len(result),
"name": plan.name,
"steps": [{"step": s.step, "status": s.status.value} for s in plan.steps],
"created_at": plan.created_at,
"updated_at": plan.updated_at,
}
@self.action("create", "Create a new plan")
async def create(
ctx: MCPContext,
name: str = "",
steps: list[str] | None = None,
**kwargs,
) -> dict:
"""Create a new named plan with steps."""
if not name:
self._plan_counter += 1
name = f"plan-{self._plan_counter}"
now = datetime.now(timezone.utc).isoformat()
tracked_steps = [TrackedStep(step=s) for s in (steps or [])]
plan = TrackedPlan(name=name, steps=tracked_steps, created_at=now, updated_at=now)
_save_plan(plan)
return {"message": f"Created plan '{name}'", "plan": _plan_to_dict(plan)}
@self.action("show", "Show a plan by name")
async def show(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Show plan details."""
plan = _get_plan(name or None)
if not plan:
return {"error": f"Plan '{name}' not found" if name else "No active plan"}
return {"plan": _plan_to_dict(plan)}
@self.action("list", "List all plans")
async def list_plans(ctx: MCPContext, **kwargs) -> dict:
"""List all tracked plans."""
plans = []
for n, p in self._plans.items():
completed = sum(1 for s in p.steps if s.status == StepStatus.COMPLETED)
plans.append({"name": n, "steps": len(p.steps), "completed": completed})
return {"plans": plans, "total": len(plans)}
@self.action("next", "Get next pending step")
async def next_step(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Get the next pending step from a plan."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
for i, s in enumerate(plan.steps):
if s.status == StepStatus.PENDING:
return {"index": i, "step": s.step, "status": s.status.value}
return {"message": "All steps completed"}
@self.action("archive", "Archive a plan")
async def archive(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Archive (remove) a plan."""
target = name or (self._tracked_plan.name if self._tracked_plan else None)
if target and target in self._plans:
del self._plans[target]
if self._tracked_plan and self._tracked_plan.name == target:
self._tracked_plan = None
return {"message": f"Archived plan '{target}'"}
return {"error": f"Plan '{target}' not found"}
@self.action("add_step", "Add a step to a plan")
async def add_step(
ctx: MCPContext,
step: str = "",
name: str = "",
position: int = -1,
**kwargs,
) -> dict:
"""Add a step to an existing plan."""
if not step:
return {"error": "step text required"}
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
new_step = TrackedStep(step=step)
if position >= 0 and position <= len(plan.steps):
plan.steps.insert(position, new_step)
else:
plan.steps.append(new_step)
_save_plan(plan)
return {"message": f"Added step to '{plan.name}'", "plan": _plan_to_dict(plan)}
@self.action("remove_step", "Remove a step from a plan")
async def remove_step(
ctx: MCPContext,
index: int = -1,
name: str = "",
**kwargs,
) -> dict:
"""Remove a step by index."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
if index < 0 or index >= len(plan.steps):
return {"error": f"Invalid index {index}, plan has {len(plan.steps)} steps"}
removed = plan.steps.pop(index)
_save_plan(plan)
return {"message": f"Removed step: {removed.step}", "plan": _plan_to_dict(plan)}
@self.action("estimate", "Estimate plan completion")
async def estimate(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Estimate plan completion based on step progress."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
total = len(plan.steps)
if total == 0:
return {"progress": 0, "remaining": 0, "total": 0}
completed = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
in_progress = sum(1 for s in plan.steps if s.status == StepStatus.IN_PROGRESS)
pct = round((completed + in_progress * 0.5) / total * 100, 1)
return {
"total": total,
"completed": completed,
"in_progress": in_progress,
"pending": total - completed - in_progress,
"progress_pct": pct,
}
@self.action("visualize", "Text visualization of plan progress")
async def visualize(ctx: MCPContext, name: str = "", **kwargs) -> str:
"""Render a text progress bar for the plan."""
plan = _get_plan(name or None)
if not plan:
return "No active plan"
lines = [f"Plan: {plan.name or '(unnamed)'}"]
for i, s in enumerate(plan.steps):
icon = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}.get(s.status.value, "[ ]")
lines.append(f" {i+1}. {icon} {s.step}")
total = len(plan.steps)
done = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
bar_len = 20
filled = int(bar_len * done / total) if total else 0
bar = "█" * filled + "░" * (bar_len - filled)
lines.append(f"\n [{bar}] {done}/{total}")
return "\n".join(lines)
@self.action("clone", "Clone an existing plan")
async def clone_plan(ctx: MCPContext, name: str = "", new_name: str = "", **kwargs) -> dict:
"""Clone a plan with a new name, resetting step status."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan to clone"}
if not new_name:
self._plan_counter += 1
new_name = f"{plan.name or 'plan'}-copy-{self._plan_counter}"
now = datetime.now(timezone.utc).isoformat()
new_steps = [TrackedStep(step=s.step) for s in plan.steps]
new_plan = TrackedPlan(name=new_name, steps=new_steps, created_at=now, updated_at=now)
_save_plan(new_plan)
return {"message": f"Cloned to '{new_name}'", "plan": _plan_to_dict(new_plan)}
@self.action("cancel", "Cancel a plan")
async def cancel(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Cancel a plan (archive with cancelled status)."""
target = name or (self._tracked_plan.name if self._tracked_plan else None)
if target and target in self._plans:
del self._plans[target]
if self._tracked_plan and self._tracked_plan.name == target:
self._tracked_plan = None
return {"message": f"Cancelled plan '{target}'"}
return {"error": f"Plan '{target}' not found"}
@self.action("notes", "Add or view plan notes")
async def notes(ctx: MCPContext, name: str = "", note: str = "", **kwargs) -> dict:
"""Add a note to a plan or view existing notes."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
if not hasattr(plan, '_notes'):
plan._notes = []
if note:
plan._notes.append({"text": note, "at": datetime.now(timezone.utc).isoformat()})
_save_plan(plan)
return {"message": "Note added", "notes": plan._notes}
return {"notes": getattr(plan, '_notes', [])}
@self.action("progress", "Get progress percentage")
async def progress(ctx: MCPContext, name: str = "", **kwargs) -> dict:
"""Get plan progress as percentage."""
plan = _get_plan(name or None)
if not plan:
return {"error": "No active plan"}
total = len(plan.steps)
if total == 0:
return {"progress": 100, "message": "Empty plan"}
completed = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
return {
"progress": round(completed / total * 100, 1),
"completed": completed,
"total": total,
}
# Singleton
@@ -1,139 +1,226 @@
"""Thinking tool implementation.
"""Unified thinking tool implementation (HIP-0300).
This module provides the ThinkingTool for Claude to engage in structured thinking.
This module provides the ThinkTool with action-based dispatch for
all reasoning operations: think, critic, review, consensus, summarize,
classify, explain, translate, compare, chain, agent, embed.
"""
from typing import Unpack, Annotated, TypedDict, final, override
from typing import ClassVar
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout, create_tool_context
Thought = Annotated[
str,
Field(
description="The detailed thought process to record",
min_length=1,
),
]
from hanzo_tools.core import BaseTool
class ThinkingToolParams(TypedDict):
"""Parameters for the ThinkingTool.
class ThinkTool(BaseTool):
"""Unified thinking and reasoning tool (HIP-0300).
Attributes:
thought: The detailed thought process to record
Actions:
- think: Structured thinking and brainstorming (default)
- critic: Critical analysis and devil's advocate
- review: Code review and quality analysis
- consensus: Multi-perspective consensus reasoning
- agent: Agent-style step-by-step reasoning
- summarize: Summarize content concisely
- classify: Classify content into categories
- explain: Explain a concept clearly
- translate: Translate between formats/languages
- compare: Compare multiple items
- chain: Chain-of-thought multi-step reasoning
- embed: Generate embedding representation (placeholder)
"""
thought: Thought
@final
class ThinkTool(BaseTool):
"""Tool for Claude to engage in structured thinking."""
name = "think"
@property
@override
def description(self) -> str:
"""Get the tool description.
Returns:
Tool description
"""
return """Use the tool to think about something. It will not obtain new information or make any changes to the repository, but just log the thought. Use it when complex reasoning or brainstorming is needed.
Ensure thinking content is concise and accurate, without needing to include code details
Common use cases:
1. When exploring a repository and discovering the source of a bug, call this tool to brainstorm several unique ways of fixing the bug, and assess which change(s) are likely to be simplest and most effective
2. After receiving test results, use this tool to brainstorm ways to fix failing tests
3. When planning a complex refactoring, use this tool to outline different approaches and their tradeoffs
4. When designing a new feature, use this tool to think through architecture decisions and implementation details
5. When debugging a complex issue, use this tool to organize your thoughts and hypotheses
6. When considering changes to the plan or shifts in thinking that the user has not previously mentioned, consider whether it is necessary to confirm with the user.
<think_example>
Feature Implementation Planning
- New code search feature requirements:
* Search for code patterns across multiple files
* Identify function usages and references
* Analyze import relationships
* Generate summary of matching patterns
- Implementation considerations:
* Need to leverage existing search mechanisms
* Should use regex for pattern matching
* Results need consistent format with other search methods
* Must handle large codebases efficiently
- Design approach:
1. Create new CodeSearcher class that follows existing search patterns
2. Implement core pattern matching algorithm
3. Add result formatting methods
4. Integrate with file traversal system
5. Add caching for performance optimization
- Testing strategy:
* Unit tests for search accuracy
* Integration tests with existing components
* Performance tests with large codebases
</think_example>"""
name: ClassVar[str] = "think"
VERSION: ClassVar[str] = "0.3.0"
def __init__(self) -> None:
"""Initialize the thinking tool."""
pass
super().__init__()
self._register_think_actions()
@override
@auto_timeout("thinking")
async def call(
self,
ctx: MCPContext,
**params: Unpack[ThinkingToolParams],
) -> str:
"""Execute the tool with the given parameters.
@property
def description(self) -> str:
return """Unified reasoning tool (HIP-0300).
Args:
ctx: MCP context
**params: Tool parameters
Actions:
- think: Structured thinking and brainstorming (default)
- critic: Critical analysis, devil's advocate
- review: Code review and quality check
- consensus: Multi-perspective reasoning
- agent: Step-by-step agent reasoning
- summarize: Summarize content
- classify: Classify into categories
- explain: Explain a concept
- translate: Translate between formats
- compare: Compare items
- chain: Chain-of-thought reasoning
- embed: Embedding representation (placeholder)
"""
Returns:
Tool result
"""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
def _register_think_actions(self):
"""Register all thinking actions."""
# Extract parameters
thought = params.get("thought")
@self.action("think", "Structured thinking and brainstorming")
async def think(ctx: MCPContext, thought: str = "", **kwargs) -> str:
"""Record a structured thought process.
# Validate required thought parameter
if not thought:
await tool_ctx.error(
"Parameter 'thought' is required but was None or empty"
)
return "Error: Parameter 'thought' is required but was None or empty"
Args:
thought: The thought to record
"""
if not thought or not thought.strip():
return "Error: 'thought' parameter is required"
return "Thinking recorded. Continue with your next action based on this analysis."
if thought.strip() == "":
await tool_ctx.error("Parameter 'thought' cannot be empty")
return "Error: Parameter 'thought' cannot be empty"
@self.action("critic", "Critical analysis and devil's advocate")
async def critic(ctx: MCPContext, analysis: str = "", thought: str = "", **kwargs) -> str:
"""Perform critical analysis.
# Log the thought but don't take action
await tool_ctx.info("Thinking process recorded")
Args:
analysis: The critical analysis to perform
thought: Alternative param name for the analysis
"""
text = analysis or thought
if not text or not text.strip():
return "Error: 'analysis' or 'thought' parameter is required"
return """Critical analysis complete. Remember to:
1. Address all identified issues before proceeding
2. Run comprehensive tests to verify fixes
3. Ensure all tests pass with proper coverage
4. Document any design decisions or trade-offs
5. Consider the analysis points in your implementation"""
# Return confirmation
return "I've recorded your thinking process. You can continue with your next action based on this analysis."
@self.action("review", "Code review and quality analysis")
async def review(ctx: MCPContext, code: str = "", thought: str = "", **kwargs) -> str:
"""Perform code review.
@override
def register(self, mcp_server: FastMCP) -> None:
"""Register this thinking tool with the MCP server.
Args:
code: Code or description to review
thought: Alternative param name
"""
text = code or thought
if not text or not text.strip():
return "Error: 'code' or 'thought' parameter is required"
return "Code review recorded. Apply findings to improve quality, correctness, and maintainability."
Creates a wrapper function with explicitly defined parameters that match
the tool's parameter schema and registers it with the MCP server.
@self.action("consensus", "Multi-perspective consensus reasoning")
async def consensus(ctx: MCPContext, topic: str = "", perspectives: int = 3, thought: str = "", **kwargs) -> str:
"""Reason from multiple perspectives to reach consensus.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
Args:
topic: The topic to reason about
perspectives: Number of perspectives to consider
thought: Alternative param name
"""
text = topic or thought
if not text or not text.strip():
return "Error: 'topic' or 'thought' parameter is required"
return f"Consensus reasoning recorded ({perspectives} perspectives). Use the synthesized viewpoint to guide decisions."
@mcp_server.tool(name=self.name, description=self.description)
async def think(thought: Thought, ctx: MCPContext) -> str:
return await tool_self.call(ctx, thought=thought)
@self.action("agent", "Agent-style step-by-step reasoning")
async def agent(ctx: MCPContext, goal: str = "", thought: str = "", **kwargs) -> str:
"""Perform agent-style reasoning with goal decomposition.
Args:
goal: The goal to reason about
thought: Alternative param name
"""
text = goal or thought
if not text or not text.strip():
return "Error: 'goal' or 'thought' parameter is required"
return "Agent reasoning recorded. Execute the planned steps sequentially."
@self.action("summarize", "Summarize content concisely")
async def summarize(ctx: MCPContext, content: str = "", thought: str = "", max_length: int = 0, **kwargs) -> str:
"""Summarize content.
Args:
content: Content to summarize
thought: Alternative param name
max_length: Optional max length hint
"""
text = content or thought
if not text or not text.strip():
return "Error: 'content' or 'thought' parameter is required"
return "Summary recorded. Use the condensed version for communication or documentation."
@self.action("classify", "Classify content into categories")
async def classify(ctx: MCPContext, content: str = "", categories: str = "", thought: str = "", **kwargs) -> str:
"""Classify content into categories.
Args:
content: Content to classify
categories: Comma-separated category options
thought: Alternative param name
"""
text = content or thought
if not text or not text.strip():
return "Error: 'content' or 'thought' parameter is required"
return "Classification recorded. Apply the categorization to guide next steps."
@self.action("explain", "Explain a concept clearly")
async def explain(ctx: MCPContext, concept: str = "", audience: str = "developer", thought: str = "", **kwargs) -> str:
"""Explain a concept.
Args:
concept: Concept to explain
audience: Target audience (developer, beginner, expert)
thought: Alternative param name
"""
text = concept or thought
if not text or not text.strip():
return "Error: 'concept' or 'thought' parameter is required"
return f"Explanation recorded (audience: {audience}). Use for documentation or communication."
@self.action("translate", "Translate between formats or languages")
async def translate(ctx: MCPContext, content: str = "", target: str = "", thought: str = "", **kwargs) -> str:
"""Translate content between formats or languages.
Args:
content: Content to translate
target: Target format or language
thought: Alternative param name
"""
text = content or thought
if not text or not text.strip():
return "Error: 'content' or 'thought' parameter is required"
target_desc = f" to {target}" if target else ""
return f"Translation{target_desc} recorded. Apply the translated version."
@self.action("compare", "Compare multiple items")
async def compare(ctx: MCPContext, items: str = "", criteria: str = "", thought: str = "", **kwargs) -> str:
"""Compare items against criteria.
Args:
items: Items to compare (comma-separated or description)
criteria: Comparison criteria
thought: Alternative param name
"""
text = items or thought
if not text or not text.strip():
return "Error: 'items' or 'thought' parameter is required"
return "Comparison recorded. Use the analysis to make an informed decision."
@self.action("chain", "Chain-of-thought multi-step reasoning")
async def chain(ctx: MCPContext, steps: str = "", thought: str = "", **kwargs) -> str:
"""Perform chain-of-thought reasoning.
Args:
steps: The reasoning steps
thought: Alternative param name
"""
text = steps or thought
if not text or not text.strip():
return "Error: 'steps' or 'thought' parameter is required"
return "Chain-of-thought reasoning recorded. Follow the logical progression to reach the conclusion."
@self.action("embed", "Generate embedding representation (placeholder)")
async def embed(ctx: MCPContext, content: str = "", thought: str = "", **kwargs) -> str:
"""Placeholder for embedding generation.
Args:
content: Content to embed
thought: Alternative param name
"""
text = content or thought
if not text or not text.strip():
return "Error: 'content' or 'thought' parameter is required"
return "Embedding placeholder recorded. Use a dedicated embedding service for production vectors."
@@ -133,12 +133,34 @@ todo --filter in_progress
return await self._handle_add(params, tool_ctx)
elif action == "update":
return await self._handle_update(params, tool_ctx)
elif action == "remove":
elif action in ("remove", "delete"):
return await self._handle_remove(params.get("id"), tool_ctx)
elif action == "clear":
return await self._handle_clear(params.get("filter"), tool_ctx)
elif action == "stats":
return await self._handle_stats(tool_ctx)
elif action == "search":
return await self._handle_search(params, tool_ctx)
elif action == "batch":
return await self._handle_batch(params, tool_ctx)
elif action == "archive":
return await self._handle_archive(params.get("id"), tool_ctx)
elif action == "move":
return await self._handle_move(params, tool_ctx)
elif action == "prioritize":
return await self._handle_prioritize(params, tool_ctx)
elif action == "assign":
return await self._handle_assign(params, tool_ctx)
elif action == "subtasks":
return await self._handle_subtasks(params, tool_ctx)
elif action == "notes":
return await self._handle_notes(params, tool_ctx)
elif action == "export":
return await self._handle_export(params, tool_ctx)
elif action == "import":
return await self._handle_import(params, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: list, add, update, remove, clear"
return f"Error: Unknown action '{action}'. Valid actions: list, add, update, remove, delete, clear, stats, search, batch, archive, move, prioritize, assign, subtasks, notes, export, import"
async def _handle_list(self, filter_status: Optional[str], tool_ctx) -> str:
"""List todos."""
@@ -260,6 +282,235 @@ todo --filter in_progress
await tool_ctx.info(f"Removed todo: {removed['content']}")
return f"Removed todo [{removed['id'][:8]}]: {removed['content']}"
async def _handle_stats(self, tool_ctx) -> str:
"""Get todo statistics."""
todos = self.read_todos()
if not todos:
return "No todos found."
by_status = {}
by_priority = {}
for todo in todos:
s = todo.get("status", "pending")
p = todo.get("priority", "medium")
by_status[s] = by_status.get(s, 0) + 1
by_priority[p] = by_priority.get(p, 0) + 1
lines = [f"Total: {len(todos)}"]
lines.append("By status:")
for s in ["in_progress", "pending", "completed"]:
if s in by_status:
lines.append(f" {s}: {by_status[s]}")
lines.append("By priority:")
for p in ["high", "medium", "low"]:
if p in by_priority:
lines.append(f" {p}: {by_priority[p]}")
return "\n".join(lines)
async def _handle_search(self, params: Dict[str, Any], tool_ctx) -> str:
"""Search todos by content."""
query = params.get("content") or params.get("query") or ""
if not query:
return "Error: search query required (use content or query param)"
todos = self.read_todos()
query_lower = query.lower()
matches = [t for t in todos if query_lower in t.get("content", "").lower()]
if not matches:
return f"No todos matching '{query}'"
lines = [f"Found {len(matches)} matching todo(s):"]
for t in matches:
lines.append(f" [{t['id'][:8]}] {t['content']} ({t.get('status', 'pending')})")
return "\n".join(lines)
async def _handle_batch(self, params: Dict[str, Any], tool_ctx) -> str:
"""Batch update todos by status."""
from_status = params.get("filter") or params.get("from_status")
to_status = params.get("status") or params.get("to_status")
if not from_status or not to_status:
return "Error: batch requires filter/from_status and status/to_status"
todos = self.read_todos()
count = 0
for t in todos:
if t.get("status") == from_status:
t["status"] = to_status
t["updated_at"] = datetime.now().isoformat()
count += 1
self.write_todos(todos)
return f"Batch updated {count} todo(s) from '{from_status}' to '{to_status}'"
async def _handle_archive(self, todo_id: Optional[str], tool_ctx) -> str:
"""Archive completed todos or a specific todo."""
todos = self.read_todos()
if todo_id:
for t in todos:
if t["id"].startswith(todo_id):
t["status"] = "archived"
t["updated_at"] = datetime.now().isoformat()
self.write_todos(todos)
return f"Archived todo [{t['id'][:8]}]"
return f"Error: Todo '{todo_id}' not found"
# Archive all completed
count = 0
for t in todos:
if t.get("status") == "completed":
t["status"] = "archived"
t["updated_at"] = datetime.now().isoformat()
count += 1
self.write_todos(todos)
return f"Archived {count} completed todo(s)"
async def _handle_move(self, params: Dict[str, Any], tool_ctx) -> str:
"""Move a todo to a different position."""
todo_id = params.get("id")
position = params.get("position")
if not todo_id:
return "Error: id required for move"
todos = self.read_todos()
idx = None
for i, t in enumerate(todos):
if t["id"].startswith(todo_id):
idx = i
break
if idx is None:
return f"Error: Todo '{todo_id}' not found"
item = todos.pop(idx)
try:
pos = int(position) if position is not None else 0
except (ValueError, TypeError):
pos = 0
pos = max(0, min(pos, len(todos)))
todos.insert(pos, item)
self.write_todos(todos)
return f"Moved todo [{item['id'][:8]}] to position {pos}"
async def _handle_prioritize(self, params: Dict[str, Any], tool_ctx) -> str:
"""Change todo priority."""
todo_id = params.get("id")
priority = params.get("priority")
if not todo_id or not priority:
return "Error: id and priority required"
todos = self.read_todos()
for t in todos:
if t["id"].startswith(todo_id):
t["priority"] = priority
t["updated_at"] = datetime.now().isoformat()
self.write_todos(todos)
return f"Set priority of [{t['id'][:8]}] to '{priority}'"
return f"Error: Todo '{todo_id}' not found"
async def _handle_assign(self, params: Dict[str, Any], tool_ctx) -> str:
"""Assign a todo to someone."""
todo_id = params.get("id")
assignee = params.get("assignee") or params.get("content")
if not todo_id:
return "Error: id required for assign"
todos = self.read_todos()
for t in todos:
if t["id"].startswith(todo_id):
t["assignee"] = assignee or ""
t["updated_at"] = datetime.now().isoformat()
self.write_todos(todos)
return f"Assigned [{t['id'][:8]}] to '{assignee or 'unassigned'}'"
return f"Error: Todo '{todo_id}' not found"
async def _handle_subtasks(self, params: Dict[str, Any], tool_ctx) -> str:
"""Manage subtasks of a todo."""
todo_id = params.get("id")
content = params.get("content")
if not todo_id:
return "Error: id required for subtasks"
todos = self.read_todos()
for t in todos:
if t["id"].startswith(todo_id):
if "subtasks" not in t:
t["subtasks"] = []
if content:
t["subtasks"].append({
"id": str(uuid.uuid4()),
"content": content,
"status": "pending",
"created_at": datetime.now().isoformat(),
})
self.write_todos(todos)
return f"Added subtask to [{t['id'][:8]}]: {content}"
else:
if not t["subtasks"]:
return f"No subtasks for [{t['id'][:8]}]"
lines = [f"Subtasks for [{t['id'][:8]}]:"]
for st in t["subtasks"]:
lines.append(f" [{st['id'][:8]}] {st['content']} ({st.get('status', 'pending')})")
return "\n".join(lines)
return f"Error: Todo '{todo_id}' not found"
async def _handle_notes(self, params: Dict[str, Any], tool_ctx) -> str:
"""Add or view notes on a todo."""
todo_id = params.get("id")
content = params.get("content")
if not todo_id:
return "Error: id required for notes"
todos = self.read_todos()
for t in todos:
if t["id"].startswith(todo_id):
if "notes" not in t:
t["notes"] = []
if content:
t["notes"].append({
"text": content,
"created_at": datetime.now().isoformat(),
})
self.write_todos(todos)
return f"Added note to [{t['id'][:8]}]"
else:
if not t["notes"]:
return f"No notes for [{t['id'][:8]}]"
lines = [f"Notes for [{t['id'][:8]}]:"]
for n in t["notes"]:
lines.append(f" [{n.get('created_at', '?')}] {n['text']}")
return "\n".join(lines)
return f"Error: Todo '{todo_id}' not found"
async def _handle_export(self, params: Dict[str, Any], tool_ctx) -> str:
"""Export todos as JSON."""
import json
todos = self.read_todos()
fmt = params.get("format") or "json"
if fmt == "json":
return json.dumps({"todos": todos}, indent=2, default=str)
elif fmt == "markdown":
lines = ["# Todos"]
for t in todos:
status_icon = {"completed": "x", "in_progress": "~", "pending": " "}.get(t.get("status", "pending"), " ")
lines.append(f"- [{status_icon}] {t['content']}")
return "\n".join(lines)
return json.dumps({"todos": todos}, indent=2, default=str)
async def _handle_import(self, params: Dict[str, Any], tool_ctx) -> str:
"""Import todos from JSON."""
import json
content = params.get("content")
if not content:
return "Error: content (JSON) required for import"
try:
data = json.loads(content)
items = data if isinstance(data, list) else data.get("todos", data.get("items", []))
todos = self.read_todos()
count = 0
for item in items:
if isinstance(item, dict) and "content" in item:
if "id" not in item:
item["id"] = str(uuid.uuid4())
if "status" not in item:
item["status"] = "pending"
if "priority" not in item:
item["priority"] = "medium"
item["created_at"] = datetime.now().isoformat()
todos.append(item)
count += 1
self.write_todos(todos)
return f"Imported {count} todo(s)"
except json.JSONDecodeError as e:
return f"Error: Invalid JSON: {e}"
async def _handle_clear(self, filter_status: Optional[str], tool_ctx) -> str:
"""Clear todos."""
todos = self.read_todos()
@@ -447,5 +447,269 @@ Outputs diffs in unified patch format for use with fs.apply_patch.
"total": len(commits),
}
# ── Advanced actions (TS parity) ──────────────────────────────────
@self.action("blame", "Show line-by-line authorship")
async def blame(ctx: MCPContext, path: str, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("blame", "--porcelain", path, cwd=work_dir)
return {"blame": stdout, "path": path}
@self.action("show", "Show commit or object")
async def show(ctx: MCPContext, ref: str = "HEAD", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("show", "--stat", ref, cwd=work_dir)
return {"output": stdout, "ref": ref}
@self.action("stash", "Stash operations")
async def stash(ctx: MCPContext, op: str = "list", message: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "push":
args = ["stash", "push"]
if message:
args.extend(["-m", message])
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
elif op == "pop":
stdout, _, _ = await self._run_git("stash", "pop", cwd=work_dir)
elif op == "drop":
stdout, _, _ = await self._run_git("stash", "drop", cwd=work_dir)
else:
stdout, _, _ = await self._run_git("stash", "list", cwd=work_dir)
return {"output": stdout, "op": op}
@self.action("tag", "Tag operations")
async def tag(ctx: MCPContext, op: str = "list", name: str | None = None, message: str | None = None, ref: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "create":
if not name:
raise InvalidParamsError("Tag name required", param="name")
args = ["tag"]
if message:
args.extend(["-a", name, "-m", message])
else:
args.append(name)
if ref:
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"created": name}
elif op == "delete":
if not name:
raise InvalidParamsError("Tag name required", param="name")
stdout, _, _ = await self._run_git("tag", "-d", name, cwd=work_dir)
return {"deleted": name}
else:
stdout, _, _ = await self._run_git("tag", "-l", cwd=work_dir)
return {"tags": stdout.splitlines()}
@self.action("remote", "Remote operations")
async def remote(ctx: MCPContext, op: str = "list", name: str | None = None, url: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "add":
if not name or not url:
raise InvalidParamsError("name and url required", param="name")
stdout, _, _ = await self._run_git("remote", "add", name, url, cwd=work_dir)
return {"added": name, "url": url}
elif op == "remove":
if not name:
raise InvalidParamsError("Remote name required", param="name")
stdout, _, _ = await self._run_git("remote", "remove", name, cwd=work_dir)
return {"removed": name}
else:
stdout, _, _ = await self._run_git("remote", "-v", cwd=work_dir)
return {"remotes": stdout}
@self.action("merge", "Merge branches")
async def merge(ctx: MCPContext, ref: str, message: str | None = None, no_ff: bool = False, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["merge"]
if no_ff:
args.append("--no-ff")
if message:
args.extend(["-m", message])
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir, check=False)
return {"output": stdout, "ref": ref}
@self.action("rebase", "Rebase commits")
async def rebase(ctx: MCPContext, ref: str | None = None, op: str = "start", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "abort":
stdout, _, _ = await self._run_git("rebase", "--abort", cwd=work_dir)
elif op == "continue":
stdout, _, _ = await self._run_git("rebase", "--continue", cwd=work_dir)
else:
if not ref:
raise InvalidParamsError("ref required for rebase", param="ref")
stdout, _, _ = await self._run_git("rebase", ref, cwd=work_dir)
return {"output": stdout, "op": op}
@self.action("cherry_pick", "Cherry-pick commits")
async def cherry_pick(ctx: MCPContext, ref: str, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("cherry-pick", ref, cwd=work_dir)
return {"output": stdout, "ref": ref}
@self.action("reset", "Reset HEAD")
async def reset(ctx: MCPContext, ref: str = "HEAD", mode: str = "mixed", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("reset", f"--{mode}", ref, cwd=work_dir)
return {"output": stdout, "ref": ref, "mode": mode}
@self.action("clean", "Remove untracked files")
async def clean(ctx: MCPContext, force: bool = False, directories: bool = False, dry_run: bool = True, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["clean"]
if dry_run:
args.append("-n")
if force:
args.append("-f")
if directories:
args.append("-d")
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout, "dry_run": dry_run}
@self.action("init", "Initialize repository")
async def init(ctx: MCPContext, path: str | None = None, bare: bool = False, cwd: str | None = None) -> dict:
work_dir = path or cwd or self.cwd
args = ["init"]
if bare:
args.append("--bare")
if path:
args.append(path)
stdout, _, _ = await self._run_git(*args, cwd=cwd or self.cwd)
return {"output": stdout}
@self.action("clone", "Clone repository")
async def clone(ctx: MCPContext, url: str, path: str | None = None, depth: int | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["clone"]
if depth:
args.extend(["--depth", str(depth)])
args.append(url)
if path:
args.append(path)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout, "url": url}
@self.action("fetch", "Fetch from remote")
async def fetch(ctx: MCPContext, remote: str = "origin", prune: bool = False, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["fetch", remote]
if prune:
args.append("--prune")
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout, "remote": remote}
@self.action("pull", "Pull from remote")
async def pull(ctx: MCPContext, remote: str = "origin", branch: str | None = None, rebase: bool = False, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["pull"]
if rebase:
args.append("--rebase")
args.append(remote)
if branch:
args.append(branch)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout}
@self.action("push", "Push to remote")
async def push(ctx: MCPContext, remote: str = "origin", branch: str | None = None, force: bool = False, tags: bool = False, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["push"]
if force:
args.append("--force-with-lease")
if tags:
args.append("--tags")
args.append(remote)
if branch:
args.append(branch)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout}
@self.action("config", "Get/set config")
async def config(ctx: MCPContext, key: str | None = None, value: str | None = None, scope: str = "local", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if key and value:
stdout, _, _ = await self._run_git("config", f"--{scope}", key, value, cwd=work_dir)
return {"set": key, "value": value}
elif key:
stdout, _, _ = await self._run_git("config", "--get", key, cwd=work_dir, check=False)
return {"key": key, "value": stdout.strip()}
else:
stdout, _, _ = await self._run_git("config", "--list", f"--{scope}", cwd=work_dir)
return {"config": stdout}
@self.action("worktree", "Worktree operations")
async def worktree(ctx: MCPContext, op: str = "list", path: str | None = None, branch: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "add":
if not path:
raise InvalidParamsError("path required", param="path")
args = ["worktree", "add", path]
if branch:
args.extend(["-b", branch])
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
elif op == "remove":
if not path:
raise InvalidParamsError("path required", param="path")
stdout, _, _ = await self._run_git("worktree", "remove", path, cwd=work_dir)
else:
stdout, _, _ = await self._run_git("worktree", "list", cwd=work_dir)
return {"output": stdout, "op": op}
@self.action("reflog", "Show reflog")
async def reflog(ctx: MCPContext, limit: int = 20, ref: str = "HEAD", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("reflog", f"-{limit}", ref, cwd=work_dir)
return {"output": stdout}
@self.action("shortlog", "Summarize commits by author")
async def shortlog(ctx: MCPContext, ref: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["shortlog", "-sne"]
if ref:
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
return {"output": stdout}
@self.action("rev_parse", "Parse revision")
async def rev_parse(ctx: MCPContext, ref: str = "HEAD", cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
stdout, _, _ = await self._run_git("rev-parse", ref, cwd=work_dir)
return {"sha": stdout.strip(), "ref": ref}
@self.action("describe", "Describe commit with tags")
async def describe(ctx: MCPContext, ref: str | None = None, tags: bool = True, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
args = ["describe"]
if tags:
args.append("--tags")
args.append("--always")
if ref:
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir, check=False)
return {"description": stdout.strip()}
@self.action("bisect", "Binary search for bugs")
async def bisect(ctx: MCPContext, op: str = "start", ref: str | None = None, cwd: str | None = None) -> dict:
work_dir = cwd or self.cwd
if op == "start":
stdout, _, _ = await self._run_git("bisect", "start", cwd=work_dir)
elif op == "good":
args = ["bisect", "good"]
if ref:
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
elif op == "bad":
args = ["bisect", "bad"]
if ref:
args.append(ref)
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
elif op == "reset":
stdout, _, _ = await self._run_git("bisect", "reset", cwd=work_dir)
else:
stdout, _, _ = await self._run_git("bisect", "log", cwd=work_dir, check=False)
return {"output": stdout, "op": op}
# Backward compatibility
git_tool = GitTool