style: auto-fix ruff lint and format for Python 3.12

Run ruff check --fix and ruff format across the repo to fix ~495
auto-fixable lint errors (mostly I001 import sorting) and reformat
225 files to match the py312 target.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-02-20 21:29:31 -08:00
co-authored by Hanzo Dev
parent ae94756bce
commit 0834abe2aa
267 changed files with 12974 additions and 6320 deletions
+10 -9
View File
@@ -1,26 +1,26 @@
from agents import Agent, ReflexionEngine, Runner
from hanzo_memory.client import SQLiteMemoryClient
from agents import Agent, Runner, ReflexionEngine
from agents.reflexion import Rule
from hanzo_memory.client import SQLiteMemoryClient
async def main():
# 1. Initialize Memory Client (SQLite)
# Ensure the DB exists or is created
db_client = SQLiteMemoryClient(user_id="test_user")
db_client = SQLiteMemoryClient(user_id="test_user")
# 2. Initialize Reflexion Engine
reflexion = ReflexionEngine(memory_client=db_client)
# 3. Add some initial rules (simulating past learning)
await reflexion.add_rule("Always double check file paths before writing.", context="coding")
# 4. Create an Agent with Reflexion
agent = Agent(
name="SelfLearningBot",
instructions="You are a helpful assistant that learns from mistakes.",
reflexion=reflexion
reflexion=reflexion,
)
# 5. Simulate a run (in a real scenario, the runner would use these)
print(f"Agent {agent.name} initialized.")
rules = await agent.reflexion.load_rules(context="coding")
@@ -33,5 +33,6 @@ async def main():
new_rule = await agent.reflexion.add_rule("Use pathlib for path manipulations.", context="coding")
print(f"Added new rule: {new_rule.content}")
if __name__ == "__main__":
asyncio.run(main())
+8 -7
View File
@@ -61,6 +61,7 @@ def configure_loop() -> bool:
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
_using_uvloop = True
return True
@@ -104,30 +105,30 @@ configure_loop()
from hanzo_async.files import (
read_file,
read_json,
read_lines,
write_file,
write_json,
append_file,
read_lines,
write_lines,
)
# Export path operations
from hanzo_async.paths import (
path_exists,
is_file,
is_dir,
glob,
stat,
mkdir,
rmdir,
is_dir,
unlink,
stat,
is_file,
listdir,
glob,
path_exists,
)
# Export process operations
from hanzo_async.process import (
run_command,
run_shell,
run_command,
check_command,
)
+4 -1
View File
@@ -5,7 +5,7 @@ All operations are fully async and safe for use in event loops.
"""
import json
from typing import Any, List, Optional, Union
from typing import Any, List, Union, Optional
from pathlib import Path
import aiofiles
@@ -76,6 +76,7 @@ async def write_file(
path = Path(path)
if mkdir_parents:
from hanzo_async.paths import mkdir
await mkdir(path.parent, parents=True, exist_ok=True)
async with aiofiles.open(path, "w", encoding=encoding) as f:
@@ -126,6 +127,7 @@ async def append_file(
path = Path(path)
if mkdir_parents:
from hanzo_async.paths import mkdir
await mkdir(path.parent, parents=True, exist_ok=True)
async with aiofiles.open(path, "a", encoding=encoding) as f:
@@ -212,6 +214,7 @@ async def write_bytes(
path = Path(path)
if mkdir_parents:
from hanzo_async.paths import mkdir
await mkdir(path.parent, parents=True, exist_ok=True)
async with aiofiles.open(path, "wb") as f:
+3 -1
View File
@@ -14,7 +14,7 @@ For file content operations (read/write), use hanzo_async.files which wraps aiof
import os
import asyncio
from glob import glob as sync_glob
from typing import List, Optional, Union
from typing import List, Union, Optional
from pathlib import Path
@@ -166,6 +166,7 @@ async def glob(
Returns:
List of matching paths
"""
def _glob():
if root_dir:
old_cwd = os.getcwd()
@@ -208,6 +209,7 @@ async def copy(src: Union[str, Path], dst: Union[str, Path]) -> None:
PermissionError: If permission denied
"""
import shutil
await _run_in_executor(shutil.copy2, str(src), str(dst))
+5 -3
View File
@@ -4,9 +4,9 @@ Provides non-blocking subprocess execution.
All operations are fully async and safe for use in event loops.
"""
import asyncio
import shutil
from typing import Dict, List, Optional, Tuple, Union
import asyncio
from typing import Dict, List, Tuple, Union, Optional
async def run_command(
@@ -113,7 +113,9 @@ async def run_shell(
# Create subprocess with shell
proc = await asyncio.create_subprocess_exec(
shell, "-c", command,
shell,
"-c",
command,
stdout=asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL,
cwd=cwd,
+7 -3
View File
@@ -20,9 +20,11 @@ from hanzo_cli import __version__
from hanzo_cli.auth import (
browser_login,
get_token_info,
logout as do_logout,
password_login,
)
from hanzo_cli.auth import (
logout as do_logout,
)
console = Console()
@@ -52,11 +54,11 @@ def login(ctx: click.Context, no_browser: bool, port: int) -> None:
else:
token_data = browser_login(port=port)
console.print(f"[green]Logged in successfully.[/green]")
console.print("[green]Logged in successfully.[/green]")
if token_data.get("access_token"):
# Decode the token subject for display
at = token_data["access_token"]
console.print(f"Token stored at ~/.hanzo/auth/token.json")
console.print("Token stored at ~/.hanzo/auth/token.json")
except Exception as e:
console.print(f"[red]Login failed:[/red] {e}")
raise SystemExit(1)
@@ -91,6 +93,7 @@ def whoami() -> None:
if access_token:
try:
import jwt
# Decode without verification just to show user info
claims = jwt.decode(access_token, options={"verify_signature": False})
table.add_row("User", claims.get("name", claims.get("sub", "—")))
@@ -102,6 +105,7 @@ def whoami() -> None:
login_time = token_data.get("login_time")
if login_time:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(login_time, tz=timezone.utc)
table.add_row("Login Time", dt.isoformat())
+1 -3
View File
@@ -11,8 +11,6 @@ Usage:
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
@@ -254,7 +252,7 @@ def sync_app(name: str, init_data: bool) -> None:
else:
console.print(f"Application: {target.owner}/{target.name}")
console.print(f"Client ID: {target.client_id}")
console.print(f"Redirect URIs:")
console.print("Redirect URIs:")
for uri in target.redirect_uris:
console.print(f" - {uri}")
finally:
+6 -1
View File
@@ -133,7 +133,12 @@ def get_secret(project: str, env: str, name: str, path: str, reveal: bool) -> No
@click.option("--path", default="/", help="Secret path prefix.")
@click.option("--comment", default=None, help="Secret comment.")
def set_secret(
project: str, env: str, name: str, value: str | None, path: str, comment: str | None,
project: str,
env: str,
name: str,
value: str | None,
path: str,
comment: str | None,
) -> None:
"""Create or update a secret. Prompts for value if not given."""
if not value:
+45 -27
View File
@@ -176,11 +176,13 @@ class PaaSClient:
new_rt = resp.headers.get("Refresh-Token")
if new_at:
self._at = new_at
_save_session({
"at": new_at,
"rt": new_rt or self._rt or "",
"login_time": int(time.time()),
})
_save_session(
{
"at": new_at,
"rt": new_rt or self._rt or "",
"login_time": int(time.time()),
}
)
# Rebuild client with new token
self.close()
@@ -244,9 +246,7 @@ class PaaSClient:
# ========================================================================
def list_envs(self, org_id: str, project_id: str) -> list[dict[str, Any]]:
return self._ok(
self.http.get(f"/v1/org/{org_id}/project/{project_id}/env")
)
return self._ok(self.http.get(f"/v1/org/{org_id}/project/{project_id}/env"))
# ========================================================================
# Containers (Deployments)
@@ -256,13 +256,20 @@ class PaaSClient:
return f"/v1/org/{org_id}/project/{project_id}/env/{env_id}/container"
def list_containers(
self, org_id: str, project_id: str, env_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
) -> list[dict[str, Any]]:
url = self._container_base(org_id, project_id, env_id)
return self._ok(self.http.get(url))
def get_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> dict[str, Any]:
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}"
return self._ok(self.http.get(url))
@@ -289,13 +296,21 @@ class PaaSClient:
return self._ok(self.http.put(url, json=payload))
def delete_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> dict[str, Any]:
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}"
return self._ok(self.http.delete(url))
def redeploy_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> dict[str, Any]:
"""Trigger a redeploy by re-PUTting the container config.
@@ -308,30 +323,33 @@ class PaaSClient:
return self._ok(self.http.put(url, json=container))
def get_container_pods(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> list[dict[str, Any]]:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/pods"
)
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}/pods"
return self._ok(self.http.get(url))
def get_container_logs(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> Any:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/logs"
)
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}/logs"
return self._ok(self.http.get(url))
def get_container_events(
self, org_id: str, project_id: str, env_id: str, container_id: str,
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
) -> list[dict[str, Any]]:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/events"
)
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}/events"
return self._ok(self.http.get(url))
# ========================================================================
+41 -12
View File
@@ -34,23 +34,25 @@ console = Console()
# ── shared option decorators ────────────────────────────────────────────
def _org_option(fn):
return click.option("--org", default=None, help="Organization ID (or from context).")(fn)
def _project_option(fn):
return click.option("--project", default=None, help="Project ID (or from context).")(fn)
def _env_option(fn):
return click.option("--env", "env_id", default=None, help="Environment ID (or from context).")(fn)
return click.option("--env", "env_id", default=None, help="Environment ID (or from context).")(
fn
)
def _require(label: str, value: str | None) -> str:
"""Ensure a value is set; exit with a helpful message if not."""
if not value:
console.print(
f"[red]{label} is required.[/red] "
"Pass it via flag or run 'hanzo paas use'."
)
console.print(f"[red]{label} is required.[/red] Pass it via flag or run 'hanzo paas use'.")
raise SystemExit(1)
return value
@@ -85,6 +87,7 @@ def paas() -> None:
# ── context ─────────────────────────────────────────────────────────────
@paas.command("use")
@click.option("--org", default=None, help="Organization ID to remember.")
@click.option("--project", default=None, help="Project ID to remember.")
@@ -244,6 +247,7 @@ def deploy() -> None:
# ── deploy list ─────────────────────────────────────────────────────────
@deploy.command("list")
@_org_option
@_project_option
@@ -287,16 +291,23 @@ def deploy_list(org: str | None, project: str | None, env_id: str | None) -> Non
# ── deploy create ───────────────────────────────────────────────────────
@deploy.command("create")
@click.argument("name")
@click.option("--image", default=None, help="Docker image URL (e.g. nginx:latest, ghcr.io/org/app:v1).")
@click.option(
"--image", default=None, help="Docker image URL (e.g. nginx:latest, ghcr.io/org/app:v1)."
)
@click.option("--repo", default=None, help="Git repository (owner/repo).")
@click.option("--branch", default="main", help="Branch to deploy (with --repo).")
@click.option("--dockerfile", default="Dockerfile", help="Dockerfile path (with --repo).")
@click.option("--port", type=int, default=3000, help="Container port.")
@click.option("--replicas", type=int, default=1, help="Desired replica count.")
@click.option("--type", "deploy_type", default="deployment",
type=click.Choice(["deployment", "statefulset", "cronjob"]))
@click.option(
"--type",
"deploy_type",
default="deployment",
type=click.Choice(["deployment", "statefulset", "cronjob"]),
)
@_org_option
@_project_option
@_env_option
@@ -416,13 +427,17 @@ def deploy_create(
# ── deploy status ───────────────────────────────────────────────────────
@deploy.command("status")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_status(
name: str, org: str | None, project: str | None, env_id: str | None,
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show container status and pods."""
org_id, project_id, env_id = resolve(org, project, env_id)
@@ -474,13 +489,17 @@ def deploy_status(
# ── deploy logs ─────────────────────────────────────────────────────────
@deploy.command("logs")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_logs(
name: str, org: str | None, project: str | None, env_id: str | None,
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show container logs."""
org_id, project_id, env_id = resolve(org, project, env_id)
@@ -524,13 +543,17 @@ def deploy_logs(
# ── deploy redeploy ─────────────────────────────────────────────────────
@deploy.command("redeploy")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_redeploy(
name: str, org: str | None, project: str | None, env_id: str | None,
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Trigger a rebuild and redeploy of a container."""
org_id, project_id, env_id = resolve(org, project, env_id)
@@ -551,6 +574,7 @@ def deploy_redeploy(
# ── deploy env ──────────────────────────────────────────────────────────
@deploy.command("env")
@click.argument("name")
@click.argument("vars", nargs=-1)
@@ -616,7 +640,11 @@ def deploy_env(
# PaaS requires the full container payload on PUT — merge vars into it
container["variables"] = merged
client.update_container(
org_id, project_id, env_id, cid, container,
org_id,
project_id,
env_id,
cid,
container,
)
console.print(f"[green]Set {len(new_vars)} variable(s) on '{name}'.[/green]")
finally:
@@ -625,6 +653,7 @@ def deploy_env(
# ── deploy delete ───────────────────────────────────────────────────────
@deploy.command("delete")
@click.argument("name")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
+67 -34
View File
@@ -11,8 +11,6 @@ Run:
from __future__ import annotations
import json
import os
import subprocess
import time
@@ -111,20 +109,31 @@ class TestPaaSRead:
assert "Platform" in r.stdout
def test_paas_envs(self):
r = run([
"paas", "envs",
"--org", "698cda6739f65183b3009313",
"--project", "698cda6739f65183b3009318",
])
r = run(
[
"paas",
"envs",
"--org",
"698cda6739f65183b3009313",
"--project",
"698cda6739f65183b3009318",
]
)
assert "production" in r.stdout
def test_paas_context_set_and_show(self):
run([
"paas", "use",
"--org", "698cda6739f65183b3009313",
"--project", "698cda6739f65183b3009318",
"--env", "698cda6739f65183b300931c",
])
run(
[
"paas",
"use",
"--org",
"698cda6739f65183b3009313",
"--project",
"698cda6739f65183b3009318",
"--env",
"698cda6739f65183b300931c",
]
)
r = run(["paas", "context"])
assert "698cda6739f65183b3009313" in r.stdout
assert "698cda6739f65183b3009318" in r.stdout
@@ -149,12 +158,18 @@ class TestPaaSDeployLifecycle:
@classmethod
def setup_class(cls):
"""Ensure context is set and clean up any leftover test containers."""
run([
"paas", "use",
"--org", "698cda6739f65183b3009313",
"--project", "698cda6739f65183b3009318",
"--env", "698cda6739f65183b300931c",
])
run(
[
"paas",
"use",
"--org",
"698cda6739f65183b3009313",
"--project",
"698cda6739f65183b3009318",
"--env",
"698cda6739f65183b300931c",
]
)
# Clean up leftover from previous failed runs
r = run(["paas", "deploy", "delete", cls.CONTAINER_NAME, "-y"], check=False)
@@ -164,11 +179,18 @@ class TestPaaSDeployLifecycle:
run(["paas", "deploy", "delete", cls.CONTAINER_NAME, "-y"], check=False)
def test_01_create(self):
r = run([
"paas", "deploy", "create", self.CONTAINER_NAME,
"--image", "nginx:latest",
"--port", "80",
])
r = run(
[
"paas",
"deploy",
"create",
self.CONTAINER_NAME,
"--image",
"nginx:latest",
"--port",
"80",
]
)
assert "Created container" in r.stdout
assert self.CONTAINER_NAME in r.stdout
@@ -207,10 +229,15 @@ class TestPaaSDeployLifecycle:
def test_07_env_set(self):
"""Set an env var on the container."""
r = run([
"paas", "deploy", "env", self.CONTAINER_NAME,
"TEST_VAR=hello_e2e",
])
r = run(
[
"paas",
"deploy",
"env",
self.CONTAINER_NAME,
"TEST_VAR=hello_e2e",
]
)
assert "Set 1 variable" in r.stdout
def test_08_env_verify(self):
@@ -248,12 +275,18 @@ class TestPaaSErrors:
assert "required" in r.stdout.lower() or "required" in r.stderr.lower()
def test_nonexistent_container(self):
run([
"paas", "use",
"--org", "698cda6739f65183b3009313",
"--project", "698cda6739f65183b3009318",
"--env", "698cda6739f65183b300931c",
])
run(
[
"paas",
"use",
"--org",
"698cda6739f65183b3009313",
"--project",
"698cda6739f65183b3009318",
"--env",
"698cda6739f65183b300931c",
]
)
r = run(["paas", "deploy", "status", "nonexistent-container-xyz"], check=False)
assert r.returncode != 0
assert "not found" in r.stdout.lower() or "not found" in r.stderr.lower()
@@ -11,7 +11,7 @@ from .consensus import State, Result, Consensus, run
__all__ = [
# Core
"Consensus",
"State",
"State",
"Result",
"run",
# MCP Mesh
@@ -26,6 +26,7 @@ T = TypeVar("T")
@dataclass
class Result:
"""Participant result."""
id: str
output: str
ok: bool
@@ -37,6 +38,7 @@ class Result:
@dataclass
class State:
"""Consensus state."""
prompt: str
participants: List[str]
rounds: int
@@ -44,7 +46,7 @@ class State:
alpha: float # Agreement threshold (0-1)
beta_1: float # Preference threshold (Phase I)
beta_2: float # Decision threshold (Phase II)
# State
responses: Dict[str, List[str]] = field(default_factory=dict)
confidence: Dict[str, float] = field(default_factory=dict)
@@ -58,7 +60,7 @@ class State:
@dataclass
class Consensus:
"""Metastable consensus protocol.
Args:
participants: List of participant IDs
execute: Async function (id, prompt) -> Result
@@ -68,6 +70,7 @@ class Consensus:
beta_1: Preference threshold (default: 0.5)
beta_2: Decision threshold (default: 0.8)
"""
participants: List[str]
execute: Callable[[str, str], Coroutine[Any, Any, Result]]
rounds: int = 3
@@ -75,7 +78,7 @@ class Consensus:
alpha: float = 0.6
beta_1: float = 0.5
beta_2: float = 0.8
async def run(self, prompt: str) -> State:
"""Run consensus protocol."""
state = State(
@@ -87,76 +90,68 @@ class Consensus:
beta_1=self.beta_1,
beta_2=self.beta_2,
)
# Initialize
for p in self.participants:
state.luminance[p] = 1.0
state.confidence[p] = 0.0
state.responses[p] = []
# Phase I: Sampling - initial proposals
initial = await asyncio.gather(*[
self.execute(p, prompt) for p in self.participants
])
initial = await asyncio.gather(*[self.execute(p, prompt) for p in self.participants])
for r in initial:
state.responses[r.id].append(r.output)
if r.ok and r.ms > 0:
# Faster = higher luminance
state.luminance[r.id] = 1.0 / (1.0 + r.ms / 1000.0)
# Sampling rounds
for _ in range(self.rounds):
# Luminance-weighted peer selection
weights = [state.luminance[p] for p in self.participants]
total = sum(weights)
sampled = random.choices(
self.participants,
[w / total for w in weights],
k=state.k
)
sampled = random.choices(self.participants, [w / total for w in weights], k=state.k)
# Build context from sampled peers
context = [f"Query: {prompt}", "", "Peers:"]
for p in sampled:
if state.responses[p]:
context.append(f"[{p}] {state.responses[p][-1][:1000]}")
context.append("\nRefine your response:")
# Each participant refines
round_results = await asyncio.gather(*[
self.execute(p, "\n".join(context)) for p in self.participants
])
round_results = await asyncio.gather(*[self.execute(p, "\n".join(context)) for p in self.participants])
for r in round_results:
state.responses[r.id].append(r.output)
if r.ok:
# Agreement metric
agreement = self._agreement(r.output, sampled, state)
state.confidence[r.id] = state.confidence[r.id] * 0.5 + agreement * 0.5
# Check β₁ threshold
if max(state.confidence.values()) >= self.beta_1:
break
# Phase II: Finality
scores = {p: state.confidence[p] * state.luminance[p] for p in self.participants}
state.winner = max(scores, key=lambda p: scores[p])
if scores[state.winner] >= self.beta_2:
state.finalized = True
# Synthesis
if state.responses[state.winner]:
state.synthesis = state.responses[state.winner][-1]
return state
def _agreement(self, output: str, sampled: List[str], state: State) -> float:
"""Calculate agreement with sampled peers."""
if not sampled:
return 0.0
total = 0.0
for p in sampled:
if state.responses[p]:
@@ -165,7 +160,7 @@ class Consensus:
if r_words and p_words:
overlap = len(r_words & p_words) / len(r_words | p_words)
total += overlap
return total / len(sampled)
@@ -180,7 +175,7 @@ async def run(
beta_2: float = 0.8,
) -> State:
"""Run metastable consensus.
Args:
prompt: Query to reach consensus on
participants: List of participant IDs
@@ -190,7 +185,7 @@ async def run(
alpha: Agreement threshold
beta_1: Preference threshold
beta_2: Decision threshold
Returns:
Final consensus state
"""
+50 -51
View File
@@ -5,12 +5,12 @@ Enables N rounds of tool calling/discussion between agents.
Usage:
from hanzo_consensus import MCPMesh, run_mcp_consensus
mesh = MCPMesh()
mesh.register("agent1", agent1_server)
mesh.register("agent2", agent2_server)
mesh.register("agent3", agent3_server)
state = await run_mcp_consensus(
mesh=mesh,
prompt="Discuss the best approach",
@@ -28,12 +28,12 @@ from .consensus import State, Result, run
@dataclass
class MCPAgent:
"""An agent in the MCP mesh."""
id: str
server: Any # FastMCP server
endpoint: Optional[str] = None # For remote agents
tools: List[str] = field(default_factory=list)
async def call_tool(self, tool_name: str, **kwargs) -> str:
"""Call a tool on this agent."""
if self.server:
@@ -45,52 +45,49 @@ class MCPAgent:
else:
# Remote agent - would use MCP client
raise NotImplementedError("Remote MCP agents not yet implemented")
async def discuss(self, prompt: str, context: Dict[str, str]) -> str:
"""Have this agent respond to a discussion prompt.
Args:
prompt: The discussion prompt
context: Previous responses from other agents
Returns:
Agent's response
"""
# Build context string from other agents' responses
context_str = "\n\n".join([
f"[{agent_id}]: {response}"
for agent_id, response in context.items()
])
context_str = "\n\n".join([f"[{agent_id}]: {response}" for agent_id, response in context.items()])
full_prompt = f"""Discussion prompt: {prompt}
Previous responses:
{context_str}
Please provide your perspective, building on or responding to the above."""
# Call the agent's "think" or "respond" tool
for tool_name in ["think", "respond", "discuss", "agent"]:
try:
return await self.call_tool(tool_name, prompt=full_prompt)
except (ValueError, KeyError):
continue
# Fallback - return a placeholder
return f"[{self.id}] I acknowledge the discussion but have no specific tools to respond."
class MCPMesh:
"""Mesh network of MCP agents.
Enables agent-to-agent communication where each agent
can call tools on any other agent in the mesh.
"""
def __init__(self):
self.agents: Dict[str, MCPAgent] = {}
self._lock = asyncio.Lock()
def register(
self,
agent_id: str,
@@ -98,23 +95,23 @@ class MCPMesh:
endpoint: Optional[str] = None,
) -> MCPAgent:
"""Register an agent in the mesh.
Args:
agent_id: Unique identifier for the agent
server: Local FastMCP server instance
endpoint: Remote MCP endpoint URL
Returns:
The registered MCPAgent
"""
if not server and not endpoint:
raise ValueError("Must provide either server or endpoint")
# Extract available tools
tools = []
if server and hasattr(server, "_tools"):
tools = list(server._tools.keys())
agent = MCPAgent(
id=agent_id,
server=server,
@@ -123,20 +120,20 @@ class MCPMesh:
)
self.agents[agent_id] = agent
return agent
def unregister(self, agent_id: str) -> None:
"""Remove an agent from the mesh."""
self.agents.pop(agent_id, None)
def get(self, agent_id: str) -> Optional[MCPAgent]:
"""Get an agent by ID."""
return self.agents.get(agent_id)
@property
def participant_ids(self) -> List[str]:
"""Get list of all agent IDs."""
return list(self.agents.keys())
async def call(
self,
from_agent: str,
@@ -145,22 +142,22 @@ class MCPMesh:
**kwargs,
) -> str:
"""Call a tool on one agent from another.
Args:
from_agent: ID of the calling agent
to_agent: ID of the target agent
tool_name: Name of the tool to call
**kwargs: Tool arguments
Returns:
Tool result
"""
target = self.agents.get(to_agent)
if not target:
raise ValueError(f"Agent {to_agent} not found in mesh")
return await target.call_tool(tool_name, **kwargs)
async def broadcast(
self,
from_agent: str,
@@ -168,32 +165,33 @@ class MCPMesh:
**kwargs,
) -> Dict[str, str]:
"""Call a tool on all other agents.
Args:
from_agent: ID of the calling agent
tool_name: Name of the tool to call
**kwargs: Tool arguments
Returns:
Dict mapping agent_id -> result
"""
results = {}
tasks = []
for agent_id in self.agents:
if agent_id != from_agent:
async def call_agent(aid: str):
try:
result = await self.agents[aid].call_tool(tool_name, **kwargs)
return (aid, result)
except Exception as e:
return (aid, f"Error: {e}")
tasks.append(call_agent(agent_id))
for result in await asyncio.gather(*tasks):
results[result[0]] = result[1]
return results
@@ -207,10 +205,10 @@ async def run_mcp_consensus(
beta_2: float = 0.8,
) -> State:
"""Run Metastable consensus over an MCP mesh.
Each round involves agents discussing with each other via MCP,
then voting on the best response.
Args:
mesh: The MCP mesh of agents
prompt: The consensus prompt
@@ -219,18 +217,19 @@ async def run_mcp_consensus(
alpha: Agreement threshold
beta_1: Preference threshold
beta_2: Decision threshold
Returns:
Consensus state with winner and synthesis
"""
# Track discussion history
discussion_history: List[Dict[str, str]] = []
async def execute(agent_id: str, round_prompt: str) -> Result:
"""Execute one round of discussion for an agent."""
import time
start = time.time()
agent = mesh.get(agent_id)
if not agent:
return Result(
@@ -239,17 +238,17 @@ async def run_mcp_consensus(
ok=False,
error=f"Agent {agent_id} not found",
)
try:
# Get context from previous rounds
context = {}
if discussion_history:
# Use most recent round's responses
context = discussion_history[-1]
response = await agent.discuss(round_prompt, context)
ms = int((time.time() - start) * 1000)
return Result(
id=agent_id,
output=response,
@@ -263,11 +262,11 @@ async def run_mcp_consensus(
ok=False,
error=str(e),
)
# Custom round callback to track discussion
async def on_round_complete(round_num: int, responses: Dict[str, str]):
discussion_history.append(responses)
# Run consensus with the mesh's agents
state = await run(
prompt=prompt,
@@ -279,10 +278,10 @@ async def run_mcp_consensus(
beta_1=beta_1,
beta_2=beta_2,
)
# Attach discussion history to state
state.discussion_history = discussion_history
return state
@@ -291,15 +290,15 @@ def create_mesh(
agents: Dict[str, Any],
) -> MCPMesh:
"""Create an MCP mesh from agent configurations.
Args:
agents: Dict mapping agent_id -> server or endpoint
Returns:
Configured MCPMesh
"""
mesh = MCPMesh()
for agent_id, config in agents.items():
if isinstance(config, str):
# URL endpoint
@@ -307,5 +306,5 @@ def create_mesh(
else:
# Server instance
mesh.register(agent_id, server=config)
return mesh
+43 -17
View File
@@ -359,9 +359,7 @@ class AsyncIAMClient:
JWTClaims with decoded token claims.
"""
if not self._config.certificate:
raise ValueError(
"Certificate not configured. Use validate_token() with JWKS instead."
)
raise ValueError("Certificate not configured. Use validate_token() with JWKS instead.")
options = {"verify_exp": verify_exp}
@@ -441,7 +439,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-user", params=params, headers=self._admin_headers(),
"/api/get-user",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -464,7 +464,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-users", params=params, headers=self._admin_headers(),
"/api/get-users",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -499,7 +501,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-user-count", params=params, headers=self._admin_headers(),
"/api/get-user-count",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -572,7 +576,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-application", params=params, headers=self._admin_headers(),
"/api/get-application",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -599,7 +605,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-organizations", params=params, headers=self._admin_headers(),
"/api/get-organizations",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -625,7 +633,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-organization", params=params, headers=self._admin_headers(),
"/api/get-organization",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -675,7 +685,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.post(
"/api/enforce", json=payload, headers=self._admin_headers(),
"/api/enforce",
json=payload,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -718,7 +730,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.post(
"/api/batch-enforce", json=payload, headers=self._admin_headers(),
"/api/batch-enforce",
json=payload,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -749,7 +763,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-roles", params=params, headers=self._admin_headers(),
"/api/get-roles",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -782,7 +798,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-role", params=params, headers=self._admin_headers(),
"/api/get-role",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -815,7 +833,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-user-roles", params=params, headers=self._admin_headers(),
"/api/get-user-roles",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -851,7 +871,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.post(
"/api/add-user-role", json=payload, headers=self._admin_headers(),
"/api/add-user-role",
json=payload,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -887,7 +909,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.post(
"/api/delete-user-role", json=payload, headers=self._admin_headers(),
"/api/delete-user-role",
json=payload,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -961,7 +985,9 @@ class AsyncIAMClient:
http = await self._get_http()
response = await http.get(
"/api/get-applications", params=params, headers=self._admin_headers(),
"/api/get-applications",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
+29 -11
View File
@@ -112,9 +112,11 @@ class IAMClient:
def get_env(key: str, default: str = "") -> str:
"""Get env var with fallback chain: IAM_ -> HANZO_IAM_ -> {ORG}_IAM_."""
return (os.getenv(f"IAM_{key}")
or os.getenv(f"HANZO_IAM_{key}")
or os.getenv(f"{org_prefix}_IAM_{key}", default))
return (
os.getenv(f"IAM_{key}")
or os.getenv(f"HANZO_IAM_{key}")
or os.getenv(f"{org_prefix}_IAM_{key}", default)
)
return IAMConfig(
server_url=get_env("URL", org.iam_url),
@@ -468,7 +470,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-user", params=params, headers=self._admin_headers(),
"/api/get-user",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -490,7 +494,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-users", params=params, headers=self._admin_headers(),
"/api/get-users",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -513,7 +519,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-application", params=params, headers=self._admin_headers(),
"/api/get-application",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -592,7 +600,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-organizations", params=params, headers=self._admin_headers(),
"/api/get-organizations",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -617,7 +627,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-organization", params=params, headers=self._admin_headers(),
"/api/get-organization",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -646,7 +658,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-providers", params=params, headers=self._admin_headers(),
"/api/get-providers",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -675,7 +689,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-roles", params=params, headers=self._admin_headers(),
"/api/get-roles",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -747,7 +763,9 @@ class IAMClient:
}
response = self.http.get(
"/api/get-applications", params=params, headers=self._admin_headers(),
"/api/get-applications",
params=params,
headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
+2 -8
View File
@@ -43,14 +43,8 @@ class IAMConfig(BaseModel):
server_url = os.environ.get(f"{p}ENDPOINT") or os.environ.get(f"{p}SERVER_URL", "")
client_id = os.environ.get(f"{p}CLIENT_ID", "")
client_secret = os.environ.get(f"{p}CLIENT_SECRET", "")
organization = (
os.environ.get(f"{p}ORG_NAME")
or os.environ.get(f"{p}ORGANIZATION", "hanzo")
)
application = (
os.environ.get(f"{p}APP_NAME")
or os.environ.get(f"{p}APPLICATION", "app")
)
organization = os.environ.get(f"{p}ORG_NAME") or os.environ.get(f"{p}ORGANIZATION", "hanzo")
application = os.environ.get(f"{p}APP_NAME") or os.environ.get(f"{p}APPLICATION", "app")
# Certificate can be content or file path
cert_val = os.environ.get(f"{p}CERTIFICATE", "")
+5 -6
View File
@@ -7,15 +7,11 @@ from fastapi.testclient import TestClient
from hanzo_iam.fastapi import (
configure,
get_config,
get_token,
require_token,
get_token_claims,
require_auth,
require_org,
require_admin,
require_org,
require_role,
)
from hanzo_iam.models import IAMConfig, JWTClaims, Organization
from hanzo_iam.models import Organization
class TestConfigure:
@@ -45,6 +41,7 @@ class TestConfigure:
def test_configure_missing_client_id_raises(self):
"""Missing client_id raises ValueError."""
import os
# Ensure env var is not set
os.environ.pop("HANZO_IAM_CLIENT_ID", None)
@@ -55,6 +52,7 @@ class TestConfigure:
"""get_config() before configure() raises RuntimeError."""
# Reset global state
import hanzo_iam.fastapi as module
module._config = None
with pytest.raises(RuntimeError, match="IAM not configured"):
@@ -123,6 +121,7 @@ class TestRequireAdmin:
def test_require_admin_is_async(self):
"""require_admin is an async function."""
import asyncio
assert asyncio.iscoroutinefunction(require_admin)
+1 -1
View File
@@ -33,8 +33,8 @@ Usage:
__version__ = "1.0.0"
from .client import KMSClient
from .async_client import AsyncKMSClient
from .client import KMSClient
from .models import (
AuthenticationOptions,
AWSIamAuthMethod,
+6 -2
View File
@@ -48,10 +48,14 @@ class AsyncKMSClient:
"""Create settings from environment variables."""
from .models import UniversalAuthMethod
site_url = os.getenv("HANZO_KMS_URL", os.getenv("INFISICAL_SITE_URL", "https://kms.hanzo.ai"))
site_url = os.getenv(
"HANZO_KMS_URL", os.getenv("INFISICAL_SITE_URL", "https://kms.hanzo.ai")
)
organization = os.getenv("HANZO_KMS_ORG", "hanzo")
client_id = os.getenv("HANZO_KMS_CLIENT_ID", os.getenv("INFISICAL_CLIENT_ID", ""))
client_secret = os.getenv("HANZO_KMS_CLIENT_SECRET", os.getenv("INFISICAL_CLIENT_SECRET", ""))
client_secret = os.getenv(
"HANZO_KMS_CLIENT_SECRET", os.getenv("INFISICAL_CLIENT_SECRET", "")
)
auth = None
if client_id and client_secret:
+13 -5
View File
@@ -70,10 +70,14 @@ class KMSClient:
"""Create settings from environment variables."""
from .models import UniversalAuthMethod
site_url = os.getenv("HANZO_KMS_URL", os.getenv("INFISICAL_SITE_URL", "https://kms.hanzo.ai"))
site_url = os.getenv(
"HANZO_KMS_URL", os.getenv("INFISICAL_SITE_URL", "https://kms.hanzo.ai")
)
organization = os.getenv("HANZO_KMS_ORG", "hanzo")
client_id = os.getenv("HANZO_KMS_CLIENT_ID", os.getenv("INFISICAL_CLIENT_ID", ""))
client_secret = os.getenv("HANZO_KMS_CLIENT_SECRET", os.getenv("INFISICAL_CLIENT_SECRET", ""))
client_secret = os.getenv(
"HANZO_KMS_CLIENT_SECRET", os.getenv("INFISICAL_CLIENT_SECRET", "")
)
auth = None
if client_id and client_secret:
@@ -150,8 +154,10 @@ class KMSClient:
if auth.aws_iam:
# Get AWS credentials from environment/instance metadata
import json
try:
import boto3
session = boto3.Session()
credentials = session.get_credentials()
region = session.region_name or "us-east-1"
@@ -162,9 +168,11 @@ class KMSClient:
"identityId": auth.aws_iam.identity_id,
"iamHttpRequestMethod": "POST",
"iamRequestBody": "",
"iamRequestHeaders": json.dumps({
"X-Amz-Date": credentials.token or "",
}),
"iamRequestHeaders": json.dumps(
{
"X-Amz-Date": credentials.token or "",
}
),
},
)
response.raise_for_status()
-1
View File
@@ -10,7 +10,6 @@ from typing import Any, Optional
from pydantic import BaseModel, Field
# =============================================================================
# Authentication Models
# =============================================================================
+279 -192
View File
@@ -6,19 +6,20 @@ Demonstration of Exact 6-Tool Implementation (Standalone)
Core implementation of the 6 universal tools without external dependencies.
"""
import os
import json
import asyncio
import tempfile
import json
import os
import subprocess
from typing import Any, Dict, List, Union, Optional
from pathlib import Path
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
@dataclass
@dataclass
class ToolResult:
"""Standard result format for all tools"""
ok: bool
root: str
language_used: Union[str, List[str]]
@@ -34,15 +35,15 @@ class ToolResult:
class WorkspaceDetector:
"""Intelligent workspace detection with go.work priority"""
@staticmethod
def detect(target_path: str) -> Dict[str, Any]:
"""Detect workspace root, preferring go.work"""
path = Path(target_path).absolute()
if path.is_file():
path = path.parent
# Check for go.work first (highest priority)
for current in [path] + list(path.parents):
if (current / "go.work").exists():
@@ -50,46 +51,30 @@ class WorkspaceDetector:
"root": str(current),
"type": "go_workspace",
"language": "go",
"go_work_file": str(current / "go.work")
"go_work_file": str(current / "go.work"),
}
# Then check other workspace types
for current in [path] + list(path.parents):
if (current / "go.mod").exists():
return {
"root": str(current),
"type": "go_module",
"language": "go"
}
return {"root": str(current), "type": "go_module", "language": "go"}
elif (current / "package.json").exists():
return {
"root": str(current),
"type": "node_project",
"language": "ts"
}
return {"root": str(current), "type": "node_project", "language": "ts"}
elif (current / "pyproject.toml").exists():
return {
"root": str(current),
"type": "python_project",
"language": "py"
"language": "py",
}
elif (current / "Cargo.toml").exists():
return {
"root": str(current),
"type": "rust_project",
"language": "rs"
}
return {
"root": str(path),
"type": "directory",
"language": "auto"
}
return {"root": str(current), "type": "rust_project", "language": "rs"}
return {"root": str(path), "type": "directory", "language": "auto"}
class BackendSelector:
"""Selects appropriate backend tools for each language/operation"""
@staticmethod
def select_backend(language: str, tool: str) -> str:
"""Select backend tool for language and operation"""
@@ -97,43 +82,45 @@ class BackendSelector:
"go": {
"fmt": "goimports",
"test": "go test",
"build": "go build",
"build": "go build",
"lint": "golangci-lint",
"edit": "gopls"
"edit": "gopls",
},
"ts": {
"fmt": "prettier",
"test": "npm test",
"build": "tsc",
"lint": "eslint",
"edit": "typescript-language-server"
"lint": "eslint",
"edit": "typescript-language-server",
},
"py": {
"fmt": "ruff format",
"test": "pytest",
"build": "python -m build",
"lint": "ruff check",
"edit": "pyright"
"edit": "pyright",
},
"rs": {
"fmt": "cargo fmt",
"test": "cargo test",
"build": "cargo build",
"lint": "cargo clippy",
"edit": "rust-analyzer"
}
"edit": "rust-analyzer",
},
}
return backend_map.get(language, {}).get(tool, "unknown")
class TargetResolver:
"""Resolves target specifications to concrete file lists"""
def __init__(self, workspace_detector: WorkspaceDetector):
self.workspace_detector = workspace_detector
def resolve(self, target: str, language: str = "auto", root: Optional[str] = None) -> Dict[str, Any]:
def resolve(
self, target: str, language: str = "auto", root: Optional[str] = None
) -> Dict[str, Any]:
"""Resolve target specification"""
if target.startswith("file:"):
return self._resolve_file(target[5:], language, root)
@@ -147,88 +134,98 @@ class TargetResolver:
return self._resolve_changed(language, root)
else:
raise ValueError(f"Unknown target format: {target}")
def _resolve_file(self, file_path: str, language: str, root: Optional[str]) -> Dict[str, Any]:
def _resolve_file(
self, file_path: str, language: str, root: Optional[str]
) -> Dict[str, Any]:
"""Resolve single file"""
path = Path(file_path).absolute()
workspace = self.workspace_detector.detect(str(path))
return {
"type": "file",
"paths": [str(path)],
"workspace": workspace,
"language": self._infer_language(path, workspace, language)
"language": self._infer_language(path, workspace, language),
}
def _resolve_dir(self, dir_path: str, language: str, root: Optional[str]) -> Dict[str, Any]:
def _resolve_dir(
self, dir_path: str, language: str, root: Optional[str]
) -> Dict[str, Any]:
"""Resolve directory subtree"""
path = Path(dir_path).absolute()
workspace = self.workspace_detector.detect(str(path))
# Find relevant files
extensions = self._get_extensions_for_language(language)
files = []
for ext in extensions:
files.extend(path.rglob(f"*{ext}"))
return {
"type": "directory",
"paths": [str(f) for f in files],
"workspace": workspace,
"language": language
"language": language,
}
def _resolve_workspace(self, language: str, root: Optional[str]) -> Dict[str, Any]:
"""Resolve workspace root"""
workspace = self.workspace_detector.detect(root or ".")
root_path = Path(workspace["root"])
extensions = self._get_extensions_for_language(language)
files = []
for ext in extensions:
files.extend(root_path.rglob(f"*{ext}"))
return {
"type": "workspace",
"paths": [str(f) for f in files],
"workspace": workspace,
"language": language
"language": language,
}
def _resolve_package(self, pkg_spec: str, language: str, root: Optional[str]) -> Dict[str, Any]:
def _resolve_package(
self, pkg_spec: str, language: str, root: Optional[str]
) -> Dict[str, Any]:
"""Resolve package specification (simplified)"""
workspace = self.workspace_detector.detect(root or ".")
if language == "go" and pkg_spec == "./...":
# All Go packages in workspace
return self._resolve_workspace("go", workspace["root"])
# Default to directory resolution
return self._resolve_dir(pkg_spec, language, root)
def _resolve_changed(self, language: str, root: Optional[str]) -> Dict[str, Any]:
"""Resolve git changed files"""
workspace = self.workspace_detector.detect(root or ".")
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"],
cwd=workspace["root"],
capture_output=True,
text=True,
check=True
check=True,
)
changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
changed_files = (
result.stdout.strip().split("\n") if result.stdout.strip() else []
)
# Make paths absolute
root_path = Path(workspace["root"])
absolute_files = [str(root_path / f) for f in changed_files if (root_path / f).exists()]
absolute_files = [
str(root_path / f) for f in changed_files if (root_path / f).exists()
]
return {
"type": "changed",
"paths": absolute_files,
"workspace": workspace,
"language": language
"language": language,
}
except subprocess.CalledProcessError:
return {
@@ -236,58 +233,65 @@ class TargetResolver:
"paths": [],
"workspace": workspace,
"language": language,
"error": "Not a git repository"
"error": "Not a git repository",
}
def _infer_language(self, path: Path, workspace: Dict, language_hint: str) -> str:
"""Infer language from context"""
if language_hint != "auto":
return language_hint
if workspace["language"] != "auto":
return workspace["language"]
# Infer from extension
if path.is_file():
extension_map = {
".go": "go",
".ts": "ts", ".js": "ts",
".ts": "ts",
".js": "ts",
".py": "py",
".rs": "rs"
".rs": "rs",
}
return extension_map.get(path.suffix, "auto")
return "auto"
def _get_extensions_for_language(self, language: str) -> List[str]:
"""Get file extensions for language"""
extension_map = {
"go": [".go"],
"ts": [".ts", ".js"],
"ts": [".ts", ".js"],
"py": [".py"],
"rs": [".rs"],
"auto": [".go", ".ts", ".js", ".py", ".rs"]
"auto": [".go", ".ts", ".js", ".py", ".rs"],
}
return extension_map.get(language, [".go", ".ts", ".py"])
class SimpleTools:
"""Simplified implementation of the 6 universal tools"""
def __init__(self):
self.workspace_detector = WorkspaceDetector()
self.target_resolver = TargetResolver(self.workspace_detector)
self.backend_selector = BackendSelector()
def fmt(self, target: str, language: str = "auto", dry_run: bool = False, opts: Optional[Dict] = None) -> ToolResult:
def fmt(
self,
target: str,
language: str = "auto",
dry_run: bool = False,
opts: Optional[Dict] = None,
) -> ToolResult:
"""Format tool: formatting + import normalization"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
detected_language = resolved["language"]
backend = self.backend_selector.select_backend(detected_language, "fmt")
if dry_run:
return ToolResult(
ok=True,
@@ -299,12 +303,12 @@ class SimpleTools:
stdout=f"Would format {len(resolved['paths'])} files with {backend}",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
# Mock formatting (would run actual formatter)
touched_files = resolved["paths"][:3] # Simulate some files being formatted
return ToolResult(
ok=True,
root=workspace["root"],
@@ -315,9 +319,9 @@ class SimpleTools:
stdout=f"Formatted {len(touched_files)} files",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
except Exception as e:
return ToolResult(
ok=False,
@@ -329,18 +333,24 @@ class SimpleTools:
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
def test(self, target: str, language: str = "auto", dry_run: bool = False, opts: Optional[Dict] = None) -> ToolResult:
def test(
self,
target: str,
language: str = "auto",
dry_run: bool = False,
opts: Optional[Dict] = None,
) -> ToolResult:
"""Test tool: run tests narrowly by default"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
detected_language = resolved["language"]
backend = self.backend_selector.select_backend(detected_language, "test")
if dry_run:
return ToolResult(
ok=True,
@@ -352,9 +362,9 @@ class SimpleTools:
stdout=f"Would run tests with {backend}",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
# Mock test run
return ToolResult(
ok=True,
@@ -366,43 +376,60 @@ class SimpleTools:
stdout=f"Tests passed for {len(resolved['paths'])} files",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
except Exception as e:
return ToolResult(
ok=False,
root=".",
language_used="unknown",
backend_used="unknown",
backend_used="unknown",
scope_resolved=[],
touched_files=[],
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
def edit(self, target: str, op: str, language: str = "auto", dry_run: bool = False, **kwargs) -> ToolResult:
def edit(
self,
target: str,
op: str,
language: str = "auto",
dry_run: bool = False,
**kwargs,
) -> ToolResult:
"""Edit tool: semantic refactors via LSP"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
detected_language = resolved["language"]
backend = self.backend_selector.select_backend(detected_language, "edit")
if op == "organize_imports":
touched_files = resolved["paths"] if not dry_run else []
action_desc = "Would organize imports" if dry_run else "Organized imports"
action_desc = (
"Would organize imports" if dry_run else "Organized imports"
)
elif op == "rename":
touched_files = [kwargs.get("file", "")] if not dry_run and kwargs.get("file") else []
touched_files = (
[kwargs.get("file", "")]
if not dry_run and kwargs.get("file")
else []
)
new_name = kwargs.get("new_name", "NewName")
action_desc = f"Would rename to {new_name}" if dry_run else f"Renamed to {new_name}"
action_desc = (
f"Would rename to {new_name}"
if dry_run
else f"Renamed to {new_name}"
)
else:
action_desc = f"Would perform {op}" if dry_run else f"Performed {op}"
touched_files = []
return ToolResult(
ok=True,
root=workspace["root"],
@@ -413,9 +440,9 @@ class SimpleTools:
stdout=action_desc,
stderr="",
exit_code=0,
errors=[]
errors=[],
)
except Exception as e:
return ToolResult(
ok=False,
@@ -427,20 +454,26 @@ class SimpleTools:
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
def build(self, target: str, language: str = "auto", dry_run: bool = False, opts: Optional[Dict] = None) -> ToolResult:
def build(
self,
target: str,
language: str = "auto",
dry_run: bool = False,
opts: Optional[Dict] = None,
) -> ToolResult:
"""Build tool: compile/build artifacts"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
detected_language = resolved["language"]
backend = self.backend_selector.select_backend(detected_language, "build")
action = "Would build" if dry_run else "Built"
return ToolResult(
ok=True,
root=workspace["root"],
@@ -451,9 +484,9 @@ class SimpleTools:
stdout=f"{action} project with {backend}",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
except Exception as e:
return ToolResult(
ok=False,
@@ -465,20 +498,26 @@ class SimpleTools:
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
def lint(self, target: str, language: str = "auto", dry_run: bool = False, opts: Optional[Dict] = None) -> ToolResult:
def lint(
self,
target: str,
language: str = "auto",
dry_run: bool = False,
opts: Optional[Dict] = None,
) -> ToolResult:
"""Lint tool: lint/typecheck in one place"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
detected_language = resolved["language"]
backend = self.backend_selector.select_backend(detected_language, "lint")
action = "Would lint" if dry_run else "Linted"
return ToolResult(
ok=True,
root=workspace["root"],
@@ -489,9 +528,9 @@ class SimpleTools:
stdout=f"{action} {len(resolved['paths'])} files with {backend}",
stderr="",
exit_code=0,
errors=[]
errors=[],
)
except Exception as e:
return ToolResult(
ok=False,
@@ -503,22 +542,30 @@ class SimpleTools:
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
def guard(self, target: str, rules: List[Dict], language: str = "auto", dry_run: bool = False) -> ToolResult:
def guard(
self,
target: str,
rules: List[Dict],
language: str = "auto",
dry_run: bool = False,
) -> ToolResult:
"""Guard tool: repo invariants"""
try:
resolved = self.target_resolver.resolve(target, language)
workspace = resolved["workspace"]
violations = []
for rule in rules:
# Simplified rule checking
if rule.get("type") == "import" and rule.get("forbid_import_prefix"):
# Mock finding violations
violations.append(f"Found forbidden import '{rule['forbid_import_prefix']}' in files matching {rule['glob']}")
violations.append(
f"Found forbidden import '{rule['forbid_import_prefix']}' in files matching {rule['glob']}"
)
return ToolResult(
ok=len(violations) == 0,
root=workspace["root"],
@@ -529,9 +576,9 @@ class SimpleTools:
stdout=f"Checked {len(rules)} rules, found {len(violations)} violations",
stderr="",
exit_code=0 if len(violations) == 0 else 1,
errors=violations
errors=violations,
)
except Exception as e:
return ToolResult(
ok=False,
@@ -543,23 +590,23 @@ class SimpleTools:
stdout="",
stderr=str(e),
exit_code=1,
errors=[str(e)]
errors=[str(e)],
)
async def demo_exact_tools():
"""Demonstrate the exact 6-tool implementation"""
print("🚀 Demonstrating Exact 6-Tool Implementation\n")
tools = SimpleTools()
# Create a temporary workspace
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create Go workspace structure
print("📁 Creating test Go workspace...")
# go.work file (highest priority)
go_work = workspace_dir / "go.work"
go_work.write_text("""go 1.21
@@ -569,14 +616,14 @@ use (
./cli
)
""")
# API module
api_dir = workspace_dir / "api"
api_dir.mkdir()
api_mod = api_dir / "go.mod"
api_mod.write_text("module github.com/luxfi/api\n\ngo 1.21\n")
api_main = api_dir / "main.go"
api_main.write_text("""package main
@@ -589,14 +636,14 @@ func UserHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello User")
}
""")
# CLI module
# CLI module
cli_dir = workspace_dir / "cli"
cli_dir.mkdir()
cli_mod = cli_dir / "go.mod"
cli_mod.write_text("module github.com/luxfi/cli\n\ngo 1.21\n")
cli_main = cli_dir / "main.go"
cli_main.write_text("""package main
@@ -606,104 +653,144 @@ func GreetUser(name string) {
fmt.Printf("Hello %s\\n", name)
}
""")
print(f" Workspace created at: {workspace_dir}")
print(f" go.work file: {go_work.exists()}")
# Test workspace detection
print("\n🔍 Testing workspace detection...")
workspace = tools.workspace_detector.detect(str(workspace_dir))
print(f" Detected type: {workspace['type']}")
print(f" Root: {workspace['root']}")
print(f" Language: {workspace['language']}")
print(f" ✅ go.work detected correctly")
print(" ✅ go.work detected correctly")
# Test target resolution
print("\n🎯 Testing target resolution...")
test_targets = [
("ws", "Entire workspace"),
(f"file:{api_main}", "Single file"),
(f"dir:{api_dir}", "Directory"),
("pkg:./...", "All packages"),
("changed", "Git changed files")
("changed", "Git changed files"),
]
for target, description in test_targets:
try:
resolved = tools.target_resolver.resolve(target, root=str(workspace_dir))
print(f" {description}: {len(resolved['paths'])} files, type: {resolved['type']}")
resolved = tools.target_resolver.resolve(
target, root=str(workspace_dir)
)
print(
f" {description}: {len(resolved['paths'])} files, type: {resolved['type']}"
)
except Exception as e:
print(f" {description}: ❌ {e}")
# Test all 6 tools
print(f"\n🔧 Testing all 6 tools (dry-run)...")
print("\n🔧 Testing all 6 tools (dry-run)...")
os.chdir(workspace_dir) # Change to workspace for git commands
# Initialize git repo for 'changed' target to work
subprocess.run(["git", "init"], capture_output=True)
subprocess.run(["git", "add", "."], capture_output=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], capture_output=True)
# Test each tool
test_cases = [
("fmt", tools.fmt, {"target": "ws", "dry_run": True, "opts": {"local_prefix": "github.com/luxfi"}}),
("edit", tools.edit, {"target": "file:" + str(api_main), "op": "organize_imports", "dry_run": True}),
(
"fmt",
tools.fmt,
{
"target": "ws",
"dry_run": True,
"opts": {"local_prefix": "github.com/luxfi"},
},
),
(
"edit",
tools.edit,
{
"target": "file:" + str(api_main),
"op": "organize_imports",
"dry_run": True,
},
),
("test", tools.test, {"target": "pkg:./...", "dry_run": True}),
("build", tools.build, {"target": "ws", "dry_run": True}),
("lint", tools.lint, {"target": "changed", "dry_run": True}),
("guard", tools.guard, {
"target": "ws",
"rules": [
{"id": "no-net-http", "type": "import", "glob": "api/*.go", "forbid_import_prefix": "net/http"}
],
"dry_run": True
})
(
"guard",
tools.guard,
{
"target": "ws",
"rules": [
{
"id": "no-net-http",
"type": "import",
"glob": "api/*.go",
"forbid_import_prefix": "net/http",
}
],
"dry_run": True,
},
),
]
for tool_name, tool_func, kwargs in test_cases:
try:
result = tool_func(**kwargs)
status = "✅" if result.ok else "❌"
print(f" {tool_name}: {status} {result.backend_used} - {result.stdout}")
print(
f" {tool_name}: {status} {result.backend_used} - {result.stdout}"
)
except Exception as e:
print(f" {tool_name}: ❌ {e}")
# Test composition workflow
print(f"\n🔄 Testing composition workflow...")
print("\n🔄 Testing composition workflow...")
print(" Workflow: edit(organize_imports) → fmt → test → guard")
# Step 1: Organize imports
edit_result = tools.edit(target="ws", op="organize_imports", dry_run=True)
print(f" 1. organize_imports: {'✅' if edit_result.ok else '❌'}")
# Step 2: Format (use 'changed' from previous step)
fmt_result = tools.fmt(target="changed", dry_run=True)
print(f" 2. format changed: {'✅' if fmt_result.ok else '❌'}")
# Step 3: Test affected packages
test_result = tools.test(target="pkg:./...", dry_run=True)
print(f" 3. test packages: {'✅' if test_result.ok else '❌'}")
# Step 4: Check guard rules
guard_result = tools.guard(
target="ws",
rules=[{"id": "no-http", "type": "import", "glob": "**/*.go", "forbid_import_prefix": "net/http"}],
dry_run=True
rules=[
{
"id": "no-http",
"type": "import",
"glob": "**/*.go",
"forbid_import_prefix": "net/http",
}
],
dry_run=True,
)
print(f" 4. guard check: {'✅' if guard_result.ok else '❌'} ({len(guard_result.errors)} violations)")
print(f"\n🎉 Demonstration complete!")
print(f"\n✨ Key Features Demonstrated:")
print(f" • Workspace detection with go.work priority")
print(f" • Target resolution (file:, dir:, pkg:, ws, changed)")
print(f" • Backend selection (language-specific tools)")
print(f" • All 6 universal tools (edit, fmt, test, build, lint, guard)")
print(f" • Tool composition workflows")
print(f" • Consistent input/output schemas")
print(f" • Dry-run support")
print(
f" 4. guard check: {'✅' if guard_result.ok else '❌'} ({len(guard_result.errors)} violations)"
)
print("\n🎉 Demonstration complete!")
print("\n✨ Key Features Demonstrated:")
print(" • Workspace detection with go.work priority")
print(" • Target resolution (file:, dir:, pkg:, ws, changed)")
print(" • Backend selection (language-specific tools)")
print(" • All 6 universal tools (edit, fmt, test, build, lint, guard)")
print(" • Tool composition workflows")
print(" • Consistent input/output schemas")
print(" • Dry-run support")
if __name__ == "__main__":
asyncio.run(demo_exact_tools())
asyncio.run(demo_exact_tools())
+26 -29
View File
@@ -8,11 +8,11 @@ This script shows how users can:
3. Use the memory system
"""
import json
import asyncio
import json
from pathlib import Path
from hanzo_mcp.config import load_config, save_global_config, get_global_config_path
from hanzo_mcp.config import get_global_config_path, load_config, save_global_config
from hanzo_mcp.memory_service import PluginMemoryService
@@ -20,11 +20,11 @@ async def demo_memory_system():
"""Demonstrate the memory system usage."""
print("🚀 Hanzo MCP Memory System Demo")
print("=" * 50)
# Step 1: Show configuration file location
config_path = get_global_config_path()
print(f"📁 Configuration file location: {config_path}")
# Step 2: Create a sample configuration
print("\n🔧 Creating sample configuration...")
sample_config = {
@@ -33,91 +33,88 @@ async def demo_memory_system():
"sqlite": {
"enabled": True,
"path": str(Path.home() / ".hanzo" / "memory.db"),
"settings": {
"auto_migrate": True,
"connection_timeout": 30
}
"settings": {"auto_migrate": True, "connection_timeout": 30},
}
},
"default_user_id": "demo-user",
"default_project_id": "demo-project"
"default_project_id": "demo-project",
}
print("📋 Sample configuration:")
print(json.dumps(sample_config, indent=2))
# Step 3: Save and load configuration
print(f"\n💾 Saving configuration to {config_path}")
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(sample_config, f, indent=2)
print("🔄 Loading configuration...")
config = load_config()
print(f" Enabled backends: {config.enabled_backends}")
# Step 4: Initialize memory service
print("\n⚡ Initializing memory service...")
service = PluginMemoryService()
await service.initialize(enabled_backends=config.enabled_backends)
print(f" Active backends: {service.get_active_backends()}")
# Step 5: Demonstrate memory operations
print("\n🧠 Demonstrating memory operations...")
# Store a memory
memory_id = await service.store_memory(
content="The Hanzo MCP modular architecture enables flexible backend selection",
metadata={"type": "architecture", "domain": "mcp", "importance": 0.9},
user_id=config.default_user_id,
project_id=config.default_project_id
project_id=config.default_project_id,
)
print(f" Stored memory with ID: {memory_id[:8]}...")
# Store another memory
memory_id2 = await service.store_memory(
content="SQLite backend provides lightweight persistence with vector search",
metadata={"type": "backend", "domain": "storage", "importance": 0.8},
user_id=config.default_user_id,
project_id=config.default_project_id
project_id=config.default_project_id,
)
print(f" Stored memory with ID: {memory_id2[:8]}...")
# Retrieve memories
results = await service.retrieve_memory(
query="architecture",
user_id=config.default_user_id,
project_id=config.default_project_id,
limit=5
limit=5,
)
print(f" Retrieved {len(results)} memories for 'architecture'")
# Step 6: Show available backends and capabilities
print("\n🧩 Available backends and capabilities:")
print(f" Available: {service.get_available_backends()}")
print(f" With persistence: {service.has_capability('persistence')}")
print(f" With vector search: {service.has_capability('vector_search')}")
print(f" With embeddings: {service.has_capability('embeddings')}")
# Step 7: Demonstrate backend management
print("\n⚙️ Backend management:")
print(f" Current active: {service.get_active_backends()}")
# Disable and re-enable
service.disable_backend("sqlite")
print(f" After disabling SQLite: {service.get_active_backends()}")
service.enable_backend("sqlite")
print(f" After re-enabling SQLite: {service.get_active_backends()}")
# Step 8: Cleanup
print("\n🧹 Cleaning up...")
await service.shutdown()
# Remove demo config
if config_path.exists():
config_path.unlink()
print(f" Removed config file: {config_path}")
print("\n✨ Demo completed successfully!")
print("\n💡 Usage:")
print(" 1. Install with memory support: pip install hanzo-mcp[memory]")
@@ -127,4 +124,4 @@ async def demo_memory_system():
if __name__ == "__main__":
asyncio.run(demo_memory_system())
asyncio.run(demo_memory_system())
+13 -5
View File
@@ -20,8 +20,8 @@ This is efficient because:
- They make surgical edits when needed
"""
import json
import asyncio
import json
# Simulated swarm tool usage
@@ -198,12 +198,18 @@ async def run_agent_grinding_demo():
# Show the key benefits
print("\n=== Key Benefits of Agent Grinding ===")
print("1. Parallel Processing: Multiple agents work on different files simultaneously")
print("2. Full Context: Each agent has all tools - can read, search, understand before editing")
print(
"1. Parallel Processing: Multiple agents work on different files simultaneously"
)
print(
"2. Full Context: Each agent has all tools - can read, search, understand before editing"
)
print("3. Smart Filtering: Agents skip files that don't need changes")
print("4. Precise Edits: Agents make surgical changes based on understanding")
print("5. Scalable: Add more agents to handle larger codebases")
print("6. Flexible: Different patterns (pipeline, parallel, consensus) for different tasks")
print(
"6. Flexible: Different patterns (pipeline, parallel, consensus) for different tasks"
)
# Show example swarm command
print("\n=== Example Swarm Command ===")
@@ -225,7 +231,9 @@ swarm_tool(
print("\nThe agents will:")
print("- finder: Search and categorize all FIXMEs")
print("- grinders: Work in parallel on different directories")
print("- Each grinder: Read file → Understand context → Fix if needed → Move to next")
print(
"- Each grinder: Read file → Understand context → Fix if needed → Move to next"
)
print("- reviewer: Ensure all fixes are appropriate")
+4 -4
View File
@@ -1,21 +1,21 @@
#!/usr/bin/env python3
"""Agent swarm demo using hanzo/net for local private AI inference."""
import sys
import asyncio
import sys
from pathlib import Path
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import (
create_tool,
create_router,
create_local_agent,
create_local_distributed_network,
create_router,
create_tool,
)
from hanzo_network.llm import HanzoNetProvider
from hanzo_network.core.router import RouterArgs
from hanzo_network.llm import HanzoNetProvider
async def main():
@@ -7,8 +7,8 @@ This example demonstrates the powerful pattern of:
3. Achieving massive performance gains for complex refactoring
"""
import json
import asyncio
import json
from typing import Any, Dict, List
# This example shows the pattern - in real usage you'd import the actual tools
@@ -191,7 +191,9 @@ async def demonstrate_performance_gains():
speedup = sequential_total / parallel_total
print(f"\nSPEEDUP: {speedup:.1f}x faster!")
print(f"Time saved: {sequential_total - parallel_total}s ({(sequential_total - parallel_total) / 60:.1f} minutes)")
print(
f"Time saved: {sequential_total - parallel_total}s ({(sequential_total - parallel_total) / 60:.1f} minutes)"
)
print("\n" + "=" * 60)
print("KEY INSIGHTS:")
+9 -4
View File
@@ -15,10 +15,11 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from hanzo_tools.agent.grok_cli_tool import GrokCLITool
from hanzo_tools.agent.codex_cli_tool import CodexCLITool
from hanzo_tools.agent.claude_cli_tool import ClaudeCLITool
from hanzo_tools.agent.codex_cli_tool import CodexCLITool
from hanzo_tools.agent.gemini_cli_tool import GeminiCLITool
from hanzo_tools.agent.grok_cli_tool import GrokCLITool
from hanzo_mcp.tools.common.permissions import PermissionManager
@@ -69,7 +70,9 @@ def show_usage_examples():
print("\n1. Claude Code CLI:")
print(" claude_cli(prompts='Fix the type errors in main.py')")
print(" claude_cli(prompts='Refactor this function', model='claude-3-opus-20240229')")
print(
" claude_cli(prompts='Refactor this function', model='claude-3-opus-20240229')"
)
print("\n2. OpenAI Codex CLI:")
print(" codex_cli(prompts='Generate unit tests for the Calculator class')")
@@ -77,7 +80,9 @@ def show_usage_examples():
print("\n3. Google Gemini CLI:")
print(" gemini_cli(prompts='Create a REST API with FastAPI')")
print(" gemini_cli(prompts='Analyze security vulnerabilities', model='gemini-1.5-flash')")
print(
" gemini_cli(prompts='Analyze security vulnerabilities', model='gemini-1.5-flash')"
)
print("\n4. xAI Grok CLI:")
print(" grok_cli(prompts='Explain this regex pattern')")
@@ -4,10 +4,10 @@
import asyncio
from hanzo_network import (
create_tool,
create_local_agent,
check_local_llm_status,
create_local_agent,
create_local_distributed_network,
create_tool,
)
@@ -34,8 +34,8 @@ async def list_files(directory: str = ".") -> str:
async def search_files(pattern: str, directory: str = ".") -> str:
"""Search for files matching a pattern."""
import os
import glob
import os
try:
matches = glob.glob(os.path.join(directory, pattern))
@@ -133,12 +133,16 @@ Work with the file_agent to read code files.""",
# Example 2: Read and analyze
print("\n📖 Example 2: Read and analyze a file")
result = await network.run(prompt="Read the pyproject.toml file and tell me what this project is about")
result = await network.run(
prompt="Read the pyproject.toml file and tell me what this project is about"
)
print(f"Response: {result['final_output']}")
# Example 3: Multi-agent collaboration
print("\n🤝 Example 3: Multi-agent collaboration")
result = await network.run(prompt="Find all Python files that might contain the main entry point and analyze them")
result = await network.run(
prompt="Find all Python files that might contain the main entry point and analyze them"
)
print(f"Response: {result['final_output']}")
print(f"Agents used: {result['iterations']}")
+10 -5
View File
@@ -5,13 +5,14 @@ This example shows how to achieve 10-100x performance gains when fixing
multiple files with similar errors by using the swarm tool for parallel execution.
"""
import re
import json
import asyncio
from typing import Set, Dict, List
import json
import re
from pathlib import Path
from typing import Dict, List, Set
from hanzo_tools.agent.swarm_tool import SwarmTool
from hanzo_mcp.tools.common.permissions import PermissionManager
@@ -107,7 +108,9 @@ IMPORTANT: Use multi_edit for efficiency - add all imports in one operation!
ctx = type("Context", (), {})() # Mock context for example
start_time = asyncio.get_event_loop().time()
result = await self.swarm.call(ctx, tasks=tasks, max_concurrency=max_concurrency)
result = await self.swarm.call(
ctx, tasks=tasks, max_concurrency=max_concurrency
)
end_time = asyncio.get_event_loop().time()
# Parse and display results
@@ -138,7 +141,9 @@ IMPORTANT: Use multi_edit for efficiency - add all imports in one operation!
print(f" Sequential estimate: {sequential_estimate:.1f}s")
print(f" Parallel actual: {parallel_time:.1f}s")
print(f" Speedup: {speedup:.1f}x")
print(f" Efficiency: {(speedup / min(max_concurrency, len(tasks))) * 100:.1f}%")
print(
f" Efficiency: {(speedup / min(max_concurrency, len(tasks))) * 100:.1f}%"
)
async def main():
+22 -9
View File
@@ -8,16 +8,17 @@ This demonstrates:
4. Proper pagination handling
"""
import asyncio
import os
import shutil
import asyncio
import tempfile
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.filesystem import Read, Write, DirectoryTree
from hanzo_mcp.tools.common.batch_tool import BatchTool
from hanzo_tools.agent.agent_tool import AgentTool
from hanzo_tools.agent.swarm_tool import SwarmTool
from hanzo_mcp.tools.common.batch_tool import BatchTool
from hanzo_tools.filesystem import DirectoryTree, Read, Write
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.common.permissions import PermissionManager
@@ -166,7 +167,11 @@ class TestModels(unittest.TestCase):
],
)
print(analysis_result[:1000] + "..." if len(analysis_result) > 1000 else analysis_result)
print(
analysis_result[:1000] + "..."
if len(analysis_result) > 1000
else analysis_result
)
print("\n" + "=" * 60)
print("PHASE 2: Parallel editing with swarm")
@@ -221,7 +226,9 @@ class TestModels(unittest.TestCase):
max_concurrent=2,
)
print(review_result[:1500] + "..." if len(review_result) > 1500 else review_result)
print(
review_result[:1500] + "..." if len(review_result) > 1500 else review_result
)
print("\n" + "=" * 60)
print("PHASE 4: Final verification with batch")
@@ -234,16 +241,22 @@ class TestModels(unittest.TestCase):
invocations=[
{
"tool_name": "read",
"input": {"file_path": os.path.join(test_dir, "src/models/user.py")},
"input": {
"file_path": os.path.join(test_dir, "src/models/user.py")
},
},
{
"tool_name": "agent",
"input": {"prompts": f"Verify that all files in {test_dir}/src/models/ now have proper type hints"},
"input": {
"prompts": f"Verify that all files in {test_dir}/src/models/ now have proper type hints"
},
},
],
)
print(verify_result[:1000] + "..." if len(verify_result) > 1000 else verify_result)
print(
verify_result[:1000] + "..." if len(verify_result) > 1000 else verify_result
)
print("\n" + "=" * 60)
print("TEST COMPLETE!")
@@ -4,19 +4,19 @@
This demonstrates how hanzo-mcp can launch agent swarms with local private AI inference.
"""
import sys
import asyncio
from typing import Any, List
import sys
from pathlib import Path
from typing import Any, List
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import (
create_tool,
create_local_agent,
check_local_llm_status,
create_local_agent,
create_local_distributed_network,
create_tool,
)
from hanzo_network.llm import HanzoNetProvider
@@ -124,7 +124,9 @@ class HanzoMCPSwarmLauncher:
# Code Generator Agent
def generate_code(spec: str) -> str:
return f"Generated code for: {spec}\ndef example():\n return 'Hello World'"
return (
f"Generated code for: {spec}\ndef example():\n return 'Hello World'"
)
generator_agent = create_local_agent(
name="generator_agent",
@@ -225,10 +227,14 @@ class HanzoMCPSwarmLauncher:
print(f"\n🎯 Coordinating swarms for: {complex_task}")
# Phase 1: Analysis
analysis_result = await self.launch_swarm("analysis", f"Analyze the codebase for: {complex_task}")
analysis_result = await self.launch_swarm(
"analysis", f"Analyze the codebase for: {complex_task}"
)
# Phase 2: Development based on analysis
dev_result = await self.launch_swarm("development", f"Based on analysis, implement: {complex_task}")
dev_result = await self.launch_swarm(
"development", f"Based on analysis, implement: {complex_task}"
)
return {"analysis": analysis_result, "development": dev_result}
@@ -257,7 +263,9 @@ async def main():
# Demo 1: Simple swarm launch
print("\n\n📋 Demo 1: Simple Analysis Swarm")
await launcher.launch_swarm("analysis", "Find all authentication-related code and analyze security")
await launcher.launch_swarm(
"analysis", "Find all authentication-related code and analyze security"
)
# Demo 2: Development swarm
print("\n\n📋 Demo 2: Development Swarm")
@@ -270,7 +278,9 @@ async def main():
# Show final statistics
print("\n\n📊 Session Statistics:")
print(f"Total swarms launched: {len(launcher.networks)}")
print(f"Total agents created: {sum(len(n.agents) for n in launcher.networks.values())}")
print(
f"Total agents created: {sum(len(n.agents) for n in launcher.networks.values())}"
)
print("Inference engine: hanzo/net (local)")
print("External API calls: 0")
print("Privacy: 100% on-device execution")
+7 -3
View File
@@ -4,10 +4,10 @@
import asyncio
from hanzo_network import (
create_tool,
create_local_agent,
check_local_llm_status,
create_local_agent,
create_local_distributed_network,
create_tool,
)
@@ -48,7 +48,11 @@ async def main():
name="code_analyzer",
description="Analyzes code using distributed inference",
system="You are powered by hanzo/net distributed inference. Analyze code efficiently.",
tools=[create_tool(name="analyze_code", description="Analyze code", handler=analyze_code)],
tools=[
create_tool(
name="analyze_code", description="Analyze code", handler=analyze_code
)
],
local_model="llama3.2", # Will use hanzo/net
)
+3 -2
View File
@@ -5,13 +5,14 @@ This example shows how to use the swarm tool to edit variables across
multiple files in parallel using Claude 3.5 Sonnet.
"""
import asyncio
import os
import shutil
import asyncio
import tempfile
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.agent.swarm_tool import SwarmTool
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.common.permissions import PermissionManager
+26 -10
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python
"""Run agents with local hanzo/net inference (using dummy engine for demo)."""
import sys
import asyncio
import sys
from pathlib import Path
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import create_tool, create_agent, create_distributed_network
from hanzo_network import create_agent, create_distributed_network, create_tool
from hanzo_network.core.agent import ModelConfig, ModelProvider
@@ -60,7 +60,9 @@ async def main():
search_agent = create_agent(
name="search_agent",
description="Searches through codebase",
model=ModelConfig(provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.3),
model=ModelConfig(
provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.3
),
system="""You are a code search specialist. Use the search_code tool to find patterns in code.
Be precise and helpful in locating code elements.""",
tools=[
@@ -76,7 +78,9 @@ Be precise and helpful in locating code elements.""",
analyzer_agent = create_agent(
name="analyzer",
description="Analyzes code structure and quality",
model=ModelConfig(provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.5),
model=ModelConfig(
provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.5
),
system="""You are a code analysis expert. Use your tools to analyze functions and provide insights.
Focus on code quality, performance, and best practices.""",
tools=[
@@ -92,7 +96,9 @@ Focus on code quality, performance, and best practices.""",
test_agent = create_agent(
name="test_generator",
description="Generates unit tests",
model=ModelConfig(provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.7),
model=ModelConfig(
provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.7
),
system="""You are a test generation specialist. Create comprehensive unit tests for given code.
Follow pytest conventions and ensure good coverage.""",
tools=[
@@ -108,7 +114,9 @@ Follow pytest conventions and ensure good coverage.""",
teacher_agent = create_agent(
name="teacher",
description="Explains programming concepts",
model=ModelConfig(provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.8),
model=ModelConfig(
provider=ModelProvider.LOCAL, model="llama3.2", temperature=0.8
),
system="""You are a programming teacher. Explain concepts clearly and provide examples.
Make complex topics accessible to learners.""",
tools=[
@@ -150,17 +158,23 @@ Make complex topics accessible to learners.""",
# Test 2: Code Analysis
print("\n📊 Test 2: Code Analysis")
result = await network.run(prompt="Analyze the add function implementation", initial_agent=analyzer_agent)
result = await network.run(
prompt="Analyze the add function implementation", initial_agent=analyzer_agent
)
print(f"Result: {result['final_output']}")
# Test 3: Test Generation
print("\n🧪 Test 3: Test Generation")
result = await network.run(prompt="Generate tests for an add function", initial_agent=test_agent)
result = await network.run(
prompt="Generate tests for an add function", initial_agent=test_agent
)
print(f"Result: {result['final_output']}")
# Test 4: Concept Explanation
print("\n📚 Test 4: Concept Explanation")
result = await network.run(prompt="Explain what recursion is", initial_agent=teacher_agent)
result = await network.run(
prompt="Explain what recursion is", initial_agent=teacher_agent
)
print(f"Result: {result['final_output']}")
# Test 5: Multi-Agent Collaboration
@@ -173,7 +187,9 @@ Make complex topics accessible to learners.""",
# Test 6: Complex Query
print("\n🎯 Test 6: Complex Development Task")
result = await network.run(prompt="I need help understanding and testing a binary search implementation")
result = await network.run(
prompt="I need help understanding and testing a binary search implementation"
)
print(f"Result: {result['final_output']}")
print("\n✅ Local agent demo complete!")
+14 -6
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python
"""Run agents with real local models using MLX."""
import sys
import asyncio
import sys
from pathlib import Path
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import create_tool, create_agent, create_distributed_network
from hanzo_network import create_agent, create_distributed_network, create_tool
from hanzo_network.core.agent import ModelConfig, ModelProvider
@@ -115,9 +115,13 @@ async def main():
coder = create_agent(
name="coder",
description="Code writing assistant using MLX",
model=ModelConfig(provider=ModelProvider.LOCAL, model="mlx", temperature=0.3, max_tokens=200),
model=ModelConfig(
provider=ModelProvider.LOCAL, model="mlx", temperature=0.3, max_tokens=200
),
system="You are a code writing assistant. Write clean, efficient code.",
tools=[create_tool(name="write_code", description="Write code", handler=write_code)],
tools=[
create_tool(name="write_code", description="Write code", handler=write_code)
],
)
# Create network
@@ -133,7 +137,9 @@ async def main():
# Test 1: Simple question
print("\n💬 Test 1: Simple Question")
result = await network.run(prompt="What is machine learning?", initial_agent=assistant)
result = await network.run(
prompt="What is machine learning?", initial_agent=assistant
)
print(f"Response: {result['final_output']}")
# Test 2: Summarization
@@ -146,7 +152,9 @@ async def main():
# Test 3: Code generation
print("\n💻 Test 3: Code Generation")
result = await network.run(prompt="Write a Python function to calculate factorial", initial_agent=coder)
result = await network.run(
prompt="Write a Python function to calculate factorial", initial_agent=coder
)
print(f"Response: {result['final_output']}")
# Test 4: Multi-agent
+3 -1
View File
@@ -113,7 +113,9 @@ def main():
print("=" * 60)
# Check for API key
has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY"))
has_key = bool(
os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
)
if has_key:
print("\n✅ Claude API key detected!")
print(" In real usage, actual Claude agents would edit the files.")
+5 -3
View File
@@ -4,10 +4,10 @@
import asyncio
from hanzo_network import (
create_tool,
create_local_agent,
check_local_llm_status,
create_local_agent,
create_local_distributed_network,
create_tool,
)
@@ -38,7 +38,9 @@ async def main():
# Check LLM status
ollama_status = await check_local_llm_status("ollama")
print(f"\n📡 Ollama status: {'✅ Available' if ollama_status['available'] else '❌ Not available'}")
print(
f"\n📡 Ollama status: {'✅ Available' if ollama_status['available'] else '❌ Not available'}"
)
# Create AI agents
analyzer = create_local_agent(
+15 -7
View File
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Test local private AI inference with hanzo/net."""
import sys
import asyncio
import sys
from pathlib import Path
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import create_tool, create_local_agent
from hanzo_network.llm import HanzoNetProvider
from hanzo_network import create_local_agent, create_tool
from hanzo_network.core.state import Message
from hanzo_network.llm import HanzoNetProvider
async def main():
@@ -80,13 +80,17 @@ async def main():
# Test with tool-aware prompt
messages_with_tools = [
Message(role="system", content="You are a helpful assistant with access to tools."),
Message(
role="system", content="You are a helpful assistant with access to tools."
),
Message(role="user", content="Calculate 100 divided by 4"),
]
tools = [{"name": "calculate", "description": "Calculate mathematical expressions"}]
response = await provider.generate(messages_with_tools, model="llama3.2", tools=tools)
response = await provider.generate(
messages_with_tools, model="llama3.2", tools=tools
)
print("\nTool-aware response:")
print(f"Output: {response['output'][0]['content']}")
@@ -96,7 +100,9 @@ async def main():
print("✅ All inference done locally via hanzo/net")
print("✅ No external API calls made")
print("✅ Using distributed inference engine: dummy")
print("\nNote: In production, this would use MLX (Apple Silicon) or Tinygrad engines")
print(
"\nNote: In production, this would use MLX (Apple Silicon) or Tinygrad engines"
)
print(" with actual model weights loaded locally.")
# Test 5: Concurrent inference
@@ -105,7 +111,9 @@ async def main():
async def run_inference(prompt: str, id: int):
"""Run a single inference."""
start = asyncio.get_event_loop().time()
response = await provider.generate([Message(role="user", content=prompt)], model="llama3.2")
response = await provider.generate(
[Message(role="user", content=prompt)], model="llama3.2"
)
end = asyncio.get_event_loop().time()
return {
"id": id,
@@ -13,6 +13,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from hanzo_tools.agent.code_auth import CodeAuthManager, get_latest_claude_model
from hanzo_mcp.tools.common.truncate import estimate_tokens
+11 -5
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env python3
"""Working agent swarm demo with hanzo/net local inference."""
import sys
import asyncio
import sys
from pathlib import Path
# Add hanzo-network to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
from hanzo_network import create_tool, create_local_agent
from hanzo_network import create_local_agent, create_tool
class LocalAgentSwarm:
@@ -23,7 +23,9 @@ class LocalAgentSwarm:
# Code Scanner Agent
def scan_files(pattern: str) -> str:
return f"Scanned files matching '{pattern}': Found 15 Python files, 8 JS files"
return (
f"Scanned files matching '{pattern}': Found 15 Python files, 8 JS files"
)
self.agents["scanner"] = create_local_agent(
name="scanner",
@@ -41,7 +43,9 @@ class LocalAgentSwarm:
# Vulnerability Detector Agent
def detect_vulnerabilities(code: str) -> str:
return "Found 2 potential issues: SQL injection risk, missing input validation"
return (
"Found 2 potential issues: SQL injection risk, missing input validation"
)
self.agents["detector"] = create_local_agent(
name="detector",
@@ -193,7 +197,9 @@ class LocalAgentSwarm:
yes_count = sum(1 for v in votes.values() if "yes" in v.lower())
no_count = len(votes) - yes_count
print(f"\n🗳️ Consensus: {'YES' if yes_count > no_count else 'NO'} ({yes_count} yes, {no_count} no)")
print(
f"\n🗳️ Consensus: {'YES' if yes_count > no_count else 'NO'} ({yes_count} yes, {no_count} no)"
)
return votes
+3 -1
View File
@@ -53,7 +53,9 @@ def main() -> None:
early_parser = argparse.ArgumentParser(add_help=False)
early_parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
# Support --enable-agent as alias for --enable-agent-tool (Claude Code compatibility)
early_parser.add_argument("--enable-agent", dest="enable_agent", action="store_true")
early_parser.add_argument(
"--enable-agent", dest="enable_agent", action="store_true"
)
early_args, _ = early_parser.parse_known_args()
# Configure logging VERY early based on transport
+1
View File
@@ -83,6 +83,7 @@ def _get_session_storage():
if _SessionStorage is None:
try:
from hanzo_tools.shell.session_storage import SessionStorage
_SessionStorage = SessionStorage
except ImportError:
# session_storage module not available - return sentinel value
@@ -30,21 +30,21 @@ TOOLS_ENTRY_POINT_GROUP = "hanzo.tools"
# HIP-0300 Operator Lattice: 16 tools organized by axis
PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
# Core operators (HIP-0300)
"filesystem": ["fs"], # Bytes + Paths axis
"core": ["id"], # Identity axis (hash, uri, ref, verify)
"code": ["code"], # Symbols + Structure axis (parse, transform, summarize)
"shell": ["proc"], # Execution axis
"vcs": ["vcs"], # History + Diffs axis
"test": ["test"], # Validation axis (check, build, test)
"net": ["net"], # Network axis (search, fetch, download, crawl)
"plan": ["plan"], # Orchestration axis (intent, route, compose)
"filesystem": ["fs"], # Bytes + Paths axis
"core": ["id"], # Identity axis (hash, uri, ref, verify)
"code": ["code"], # Symbols + Structure axis (parse, transform, summarize)
"shell": ["proc"], # Execution axis
"vcs": ["vcs"], # History + Diffs axis
"test": ["test"], # Validation axis (check, build, test)
"net": ["net"], # Network axis (search, fetch, download, crawl)
"plan": ["plan"], # Orchestration axis (intent, route, compose)
# Control surfaces
"browser": ["browser"], # Web content control (Playwright/extension)
"browser": ["browser"], # Web content control (Playwright/extension)
"computer": ["computer"], # OS/desktop control (computer use)
# Extended operators
"lsp": ["lsp"], # Semantic stream (diagnostics, code_actions)
"memory": ["memory"], # Knowledge persistence
"todo": ["todo"], # Task tracking
"lsp": ["lsp"], # Semantic stream (diagnostics, code_actions)
"memory": ["memory"], # Knowledge persistence
"todo": ["todo"], # Task tracking
"reasoning": ["think", "critic"],
"refactor": ["refactor"],
"database": ["sql", "graph"],
+88 -86
View File
@@ -4,7 +4,7 @@ Hanzo MCP Installation Script
============================
Unified installer for Hanzo MCP tools that provides:
1. MCP server installation
1. MCP server installation
2. VS Code extension setup
3. Browser extension setup (future)
4. CLI tools installation
@@ -12,47 +12,47 @@ Unified installer for Hanzo MCP tools that provides:
Usage:
python install_hanzo_mcp.py --all # Install everything
python install_hanzo_mcp.py --mcp # MCP server only
python install_hanzo_mcp.py --mcp # MCP server only
python install_hanzo_mcp.py --vscode # VS Code extension only
python install_hanzo_mcp.py --cli # CLI tools only
python install_hanzo_mcp.py --check # Check installation
"""
import os
import sys
import json
import shutil
import argparse
import json
import os
import shutil
import subprocess
from typing import Dict, List, Optional
import sys
from pathlib import Path
from typing import Dict, List, Optional
class HanzoMCPInstaller:
"""Unified installer for all Hanzo MCP components"""
def __init__(self):
self.home = Path.home()
self.hanzo_dir = self.home / ".hanzo"
self.sessions_dir = self.hanzo_dir / "sessions"
self.config_file = self.hanzo_dir / "config.json"
# Create directories
self.hanzo_dir.mkdir(exist_ok=True)
self.sessions_dir.mkdir(exist_ok=True)
def install_all(self) -> bool:
"""Install all components"""
print("🚀 Installing Hanzo MCP - Complete Development Environment")
print("=" * 60)
success = True
success &= self.install_python_packages()
success &= self.setup_mcp_server()
success &= self.install_vscode_extension()
success &= self.install_cli_tools()
success &= self.create_config()
if success:
print("\n✅ Hanzo MCP installation completed successfully!")
print("\nNext steps:")
@@ -61,32 +61,36 @@ class HanzoMCPInstaller:
print("3. Check ~/.hanzo/config.json for configuration options")
else:
print("\n❌ Installation failed. Check errors above.")
return success
def install_python_packages(self) -> bool:
"""Install Python packages with uv"""
print("📦 Installing Python packages...")
try:
# Install hanzo-mcp and all tools
subprocess.run([
"uv", "pip", "install", "-e", ".", "--all-extras"
], check=True, cwd=Path(__file__).parent)
subprocess.run(
["uv", "pip", "install", "-e", ".", "--all-extras"],
check=True,
cwd=Path(__file__).parent,
)
print("✓ Python packages installed")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Failed to install Python packages: {e}")
return False
except FileNotFoundError:
print("❌ uv not found. Please install uv first: curl -LsSf https://astral.sh/uv/install.sh | sh")
print(
"❌ uv not found. Please install uv first: curl -LsSf https://astral.sh/uv/install.sh | sh"
)
return False
def setup_mcp_server(self) -> bool:
"""Setup MCP server for Claude/other AI tools"""
print("🔌 Setting up MCP server...")
# Create MCP server config
mcp_config = {
"mcpServers": {
@@ -95,17 +99,17 @@ class HanzoMCPInstaller:
"args": ["--server"],
"env": {
"HANZO_SESSION_DIR": str(self.sessions_dir),
"HANZO_CONFIG": str(self.config_file)
}
"HANZO_CONFIG": str(self.config_file),
},
}
}
}
# Write to Claude MCP config location
claude_config_dir = self.home / ".config" / "claude-desktop"
claude_config_dir.mkdir(parents=True, exist_ok=True)
claude_config_file = claude_config_dir / "claude_desktop_config.json"
try:
if claude_config_file.exists():
# Merge with existing config
@@ -113,44 +117,44 @@ class HanzoMCPInstaller:
existing = json.load(f)
existing.update(mcp_config)
mcp_config = existing
with open(claude_config_file, "w") as f:
json.dump(mcp_config, f, indent=2)
print(f"✓ MCP server configured at {claude_config_file}")
return True
except Exception as e:
print(f"❌ Failed to setup MCP server: {e}")
return False
def install_vscode_extension(self) -> bool:
"""Install and build VS Code extension"""
print("🔧 Installing VS Code extension...")
extension_dir = Path(__file__).parent / "vscode-extension"
try:
# Install Node.js dependencies
subprocess.run(["npm", "install"], check=True, cwd=extension_dir)
# Compile TypeScript
subprocess.run(["npm", "run", "compile"], check=True, cwd=extension_dir)
# Package extension
subprocess.run(["npx", "vsce", "package"], check=True, cwd=extension_dir)
# Install extension
vsix_files = list(extension_dir.glob("*.vsix"))
if vsix_files:
subprocess.run([
"code", "--install-extension", str(vsix_files[0])
], check=True)
subprocess.run(
["code", "--install-extension", str(vsix_files[0])], check=True
)
print("✓ VS Code extension installed")
return True
else:
print("❌ No .vsix file found")
return False
except subprocess.CalledProcessError as e:
print(f"❌ Failed to install VS Code extension: {e}")
print("Note: Make sure you have Node.js, npm, and VS Code installed")
@@ -158,11 +162,11 @@ class HanzoMCPInstaller:
except FileNotFoundError as e:
print(f"❌ Required tool not found: {e}")
return False
def install_cli_tools(self) -> bool:
"""Install CLI tools and create shell aliases"""
print("💻 Installing CLI tools...")
try:
# Create CLI wrapper scripts
cli_script = """#!/usr/bin/env python3
@@ -215,13 +219,13 @@ async def main():
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
"""
# Install CLI script
cli_bin = Path("/usr/local/bin/hanzo-dev")
with open(cli_bin, "w") as f:
f.write(cli_script)
cli_bin.chmod(0o755)
# Create shell aliases
aliases = """
# Hanzo MCP Development Tools
@@ -232,25 +236,25 @@ alias hbuild='hanzo-dev build'
alias hlint='hanzo-dev lint'
alias hguard='hanzo-dev guard'
"""
# Add to shell profiles
for shell_profile in [".bashrc", ".zshrc", ".profile"]:
profile_path = self.home / shell_profile
if profile_path.exists():
with open(profile_path, "a") as f:
f.write(f"\n# Hanzo MCP aliases\n{aliases}\n")
print("✓ CLI tools installed")
return True
except Exception as e:
print(f"❌ Failed to install CLI tools: {e}")
return False
def create_config(self) -> bool:
"""Create default configuration"""
print("⚙️ Creating configuration...")
config = {
"version": "1.0.0",
"session_tracking": True,
@@ -258,57 +262,48 @@ alias hguard='hanzo-dev guard'
"workspace_detection": "auto",
"default_language": "auto",
"backends": {
"go": {
"fmt": "goimports",
"local_prefix": "github.com/luxfi"
},
"ts": {
"fmt": "prettier",
"package_manager": "pnpm"
},
"py": {
"fmt": "ruff",
"test": "pytest"
}
"go": {"fmt": "goimports", "local_prefix": "github.com/luxfi"},
"ts": {"fmt": "prettier", "package_manager": "pnpm"},
"py": {"fmt": "ruff", "test": "pytest"},
},
"guard_rules": [
{
"id": "no_node_in_sdk",
"type": "import",
"glob": "sdk/**",
"forbid_import_prefix": "github.com/luxfi/node/"
"forbid_import_prefix": "github.com/luxfi/node/",
},
{
"id": "no_generated_edits",
"type": "generated",
"glob": "api/pb/**",
"forbid_writes": True
}
]
"forbid_writes": True,
},
],
}
try:
with open(self.config_file, "w") as f:
json.dump(config, f, indent=2)
print(f"✓ Configuration created at {self.config_file}")
return True
except Exception as e:
print(f"❌ Failed to create configuration: {e}")
return False
def check_installation(self) -> bool:
"""Check if installation is working"""
print("🔍 Checking Hanzo MCP installation...")
checks = [
("Python package", self.check_python_package),
("MCP server", self.check_mcp_server),
("CLI tools", self.check_cli_tools),
("Configuration", self.check_config),
("Session directory", self.check_session_dir)
("Session directory", self.check_session_dir),
]
all_good = True
for name, check_fn in checks:
try:
@@ -319,60 +314,67 @@ alias hguard='hanzo-dev guard'
except Exception as e:
print(f"❌ {name}: {e}")
all_good = False
return all_good
def check_python_package(self) -> bool:
"""Check if Python package is installed"""
try:
import hanzo_mcp
return True
except ImportError:
return False
def check_mcp_server(self) -> bool:
"""Check if MCP server is configured"""
claude_config_file = self.home / ".config" / "claude-desktop" / "claude_desktop_config.json"
claude_config_file = (
self.home / ".config" / "claude-desktop" / "claude_desktop_config.json"
)
if not claude_config_file.exists():
return False
try:
with open(claude_config_file) as f:
config = json.load(f)
return "hanzo" in config.get("mcpServers", {})
except (OSError, json.JSONDecodeError, KeyError):
return False
def check_cli_tools(self) -> bool:
"""Check if CLI tools are installed"""
return Path("/usr/local/bin/hanzo-dev").exists()
def check_config(self) -> bool:
"""Check if configuration exists"""
return self.config_file.exists()
def check_session_dir(self) -> bool:
"""Check if session directory exists"""
return self.sessions_dir.exists()
def main():
parser = argparse.ArgumentParser(description="Install Hanzo MCP Development Environment")
parser = argparse.ArgumentParser(
description="Install Hanzo MCP Development Environment"
)
parser.add_argument("--all", action="store_true", help="Install all components")
parser.add_argument("--mcp", action="store_true", help="Install MCP server only")
parser.add_argument("--vscode", action="store_true", help="Install VS Code extension only")
parser.add_argument(
"--vscode", action="store_true", help="Install VS Code extension only"
)
parser.add_argument("--cli", action="store_true", help="Install CLI tools only")
parser.add_argument("--check", action="store_true", help="Check installation")
args = parser.parse_args()
if not any([args.all, args.mcp, args.vscode, args.cli, args.check]):
args.all = True # Default to installing everything
installer = HanzoMCPInstaller()
success = True
if args.check:
success = installer.check_installation()
else:
@@ -385,9 +387,9 @@ def main():
success &= installer.install_vscode_extension()
if args.cli:
success &= installer.install_cli_tools()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
main()
+17 -5
View File
@@ -5,8 +5,8 @@ This script updates test files to use the new test_utils infrastructure.
"""
import re
from typing import List
from pathlib import Path
from typing import List
# Patterns to replace
REPLACEMENTS = [
@@ -53,7 +53,9 @@ def migrate_file(file_path: Path, dry_run: bool = False) -> bool:
# Apply replacements
for pattern, replacement in REPLACEMENTS:
new_content = re.sub(pattern, replacement, content, flags=re.MULTILINE | re.DOTALL)
new_content = re.sub(
pattern, replacement, content, flags=re.MULTILINE | re.DOTALL
)
if new_content != content:
content = new_content
modified = True
@@ -81,8 +83,14 @@ def migrate_file(file_path: Path, dry_run: bool = False) -> bool:
import_section_done = False
for line in other_lines:
new_lines.append(line)
if not import_section_done and line.startswith("import ") or line.startswith("from "):
if not other_lines[other_lines.index(line) + 1].startswith(("import ", "from ")):
if (
not import_section_done
and line.startswith("import ")
or line.startswith("from ")
):
if not other_lines[other_lines.index(line) + 1].startswith(
("import ", "from ")
):
new_lines.extend(import_lines)
import_section_done = True
@@ -102,7 +110,11 @@ def migrate_file(file_path: Path, dry_run: bool = False) -> bool:
fromfile=str(file_path),
tofile=str(file_path),
)
print("".join(diff)[:1000] + "..." if len("".join(diff)) > 1000 else "".join(diff))
print(
"".join(diff)[:1000] + "..."
if len("".join(diff)) > 1000
else "".join(diff)
)
else:
with open(file_path, "w") as f:
f.write(content)
+37 -36
View File
@@ -3,27 +3,28 @@
Simple test of the exact tool specifications
"""
import asyncio
import os
import sys
import asyncio
import tempfile
from pathlib import Path
# Add the package to path
sys.path.insert(0, '/Users/z/work/hanzo/python-sdk/pkg/hanzo-mcp')
sys.path.insert(0, "/Users/z/work/hanzo/python-sdk/pkg/hanzo-mcp")
async def simple_test():
"""Simple test without external dependencies"""
print("🔧 Testing basic functionality...")
# Test workspace detection
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create a simple Go project structure
go_mod = workspace_dir / "go.mod"
go_mod.write_text("module test\n\ngo 1.21\n")
main_go = workspace_dir / "main.go"
main_go.write_text("""package main
@@ -33,99 +34,99 @@ func main() {
fmt.Println("Hello World")
}
""")
# Import and test workspace detection
try:
from hanzo_mcp.exact_tools import WorkspaceDetector
detector = WorkspaceDetector()
workspace = detector.detect(str(main_go))
print(f" Workspace type: {workspace['type']}")
print(f" Root: {workspace['root']}")
print(f" Language: {workspace['language']}")
print(" ✅ Workspace detection working")
except Exception as e:
print(f" ❌ Workspace detection failed: {e}")
return False
# Test target resolution
try:
from hanzo_mcp.exact_tools import TargetSpec, TargetResolver
from hanzo_mcp.exact_tools import TargetResolver, TargetSpec
resolver = TargetResolver(detector)
# Test file target
target_spec = TargetSpec(target=f"file:{main_go}")
resolved = resolver.resolve(target_spec)
print(f" File resolution: {len(resolved['paths'])} files")
print(f" Language inferred: {resolved['language']}")
print(" ✅ Target resolution working")
except Exception as e:
print(f" ❌ Target resolution failed: {e}")
return False
# Test backend selection
try:
from hanzo_mcp.exact_tools import BackendSelector
selector = BackendSelector()
go_fmt_backend = selector.select_backend("go", "fmt")
py_lint_backend = selector.select_backend("py", "lint")
ts_test_backend = selector.select_backend("ts", "test")
print(f" Go fmt backend: {go_fmt_backend}")
print(f" Python lint backend: {py_lint_backend}")
print(f" TypeScript test backend: {ts_test_backend}")
print(" ✅ Backend selection working")
except Exception as e:
print(f" ❌ Backend selection failed: {e}")
return False
return True
async def test_tool_schemas():
"""Test tool schema definitions"""
print("🔧 Testing tool schemas...")
try:
from hanzo_mcp.exact_tools import (
FmtArgs,
EditArgs,
LintArgs,
TestArgs,
BuildArgs,
EditArgs,
FmtArgs,
GuardArgs,
GuardRule,
LintArgs,
TargetSpec,
TestArgs,
)
# Test target spec
target = TargetSpec(target="ws", language="go", dry_run=True)
print(f" Target spec: {target.target}, {target.language}")
# Test edit args
edit = EditArgs(op="rename", new_name="NewName")
print(f" Edit args: {edit.op}, {edit.new_name}")
# Test guard rule
rule = GuardRule(
id="test-rule",
type="import",
glob="**/*.py",
forbid_import_prefix="forbidden"
forbid_import_prefix="forbidden",
)
print(f" Guard rule: {rule.id}, {rule.type}")
print(" ✅ All schemas working")
return True
except Exception as e:
print(f" ❌ Schema test failed: {e}")
return False
@@ -133,13 +134,13 @@ async def test_tool_schemas():
async def main():
print("🚀 Simple Test of Exact 6-Tool Implementation\n")
success = True
success &= await simple_test()
print()
success &= await test_tool_schemas()
print()
if success:
print("🎉 Basic functionality tests passed!")
print("\n✨ Implementation Features:")
@@ -153,9 +154,9 @@ async def main():
else:
print("❌ Some tests failed")
return 1
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit_code = asyncio.run(main())
+40 -39
View File
@@ -1,20 +1,20 @@
"""Final test to verify the complete system works as intended."""
import json
import asyncio
import json
from pathlib import Path
from hanzo_mcp.config import load_config, get_global_config_path
from hanzo_mcp.config import get_global_config_path, load_config
from hanzo_mcp.memory_service import PluginMemoryService
async def test_complete_system():
"""Test the complete system with all components working together."""
print("Testing complete system with all components...")
# Create a global config file with memory backend enabled
config_path = get_global_config_path()
# Create config with memory backend
memory_config = {
"enabled_backends": ["sqlite"],
@@ -22,125 +22,126 @@ async def test_complete_system():
"sqlite": {
"enabled": True,
"path": str(Path.home() / ".hanzo" / "memory.db"),
"settings": {
"auto_migrate": True,
"connection_timeout": 30
}
"settings": {"auto_migrate": True, "connection_timeout": 30},
}
},
"default_user_id": "hanzo-user",
"default_project_id": "hanzo-project"
"default_project_id": "hanzo-project",
}
# Save the config
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(memory_config, f, indent=2)
print(f"Created config with memory backend at: {config_path}")
# Load config using our system
loaded_config = load_config()
print(f"✓ Loaded config with enabled backends: {loaded_config.enabled_backends}")
# Test memory service initialization
print("\nInitializing memory service...")
service = PluginMemoryService()
await service.initialize(enabled_backends=loaded_config.enabled_backends)
print(f"✓ Active backends: {service.get_active_backends()}")
# Verify the memory backend is properly configured
print(f"✓ Available backends: {service.get_available_backends()}")
print(f"✓ Backend capabilities: {service.get_backend_capabilities('sqlite')}")
# Test memory operations
print("\nTesting memory operations...")
# Store multiple memories
test_memories = [
{
"content": "Hanzo MCP modular architecture enables flexible backend selection",
"metadata": {"type": "architecture", "domain": "mcp", "importance": 0.9}
"metadata": {"type": "architecture", "domain": "mcp", "importance": 0.9},
},
{
"content": "SQLite backend provides lightweight persistence with vector search",
"metadata": {"type": "backend", "domain": "storage", "importance": 0.8}
"metadata": {"type": "backend", "domain": "storage", "importance": 0.8},
},
{
"content": "Plugin system allows dynamic loading of memory backends",
"metadata": {"type": "feature", "domain": "modularity", "importance": 0.85}
}
"metadata": {"type": "feature", "domain": "modularity", "importance": 0.85},
},
]
memory_ids = []
for i, mem in enumerate(test_memories):
memory_id = await service.store_memory(
content=mem["content"],
metadata=mem["metadata"],
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id
project_id=loaded_config.default_project_id,
)
memory_ids.append(memory_id)
print(f"✓ Stored memory {i+1} with ID: {memory_id[:8]}...")
print(f"✓ Stored memory {i + 1} with ID: {memory_id[:8]}...")
# Test retrieval
print("\nTesting retrieval...")
retrieved = await service.retrieve_memory(
query="memory backend",
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id,
limit=10
limit=10,
)
print(f"✓ Retrieved {len(retrieved)} memories matching 'memory backend'")
# Test search
print("\nTesting search functionality...")
search_results = await service.search_memory(
query="modular architecture",
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id,
limit=5
limit=5,
)
print(f"✓ Found {len(search_results)} memories for 'modular architecture'")
# Display search results
for i, result in enumerate(search_results):
print(f" Result {i+1}: {result['content'][:60]}... (score: {result.get('similarity_score', 'N/A')})")
print(
f" Result {i + 1}: {result['content'][:60]}... (score: {result.get('similarity_score', 'N/A')})"
)
# Test capability queries
print("\nTesting capability queries...")
vector_backends = service.has_capability("vector_search")
persistence_backends = service.has_capability("persistence")
print(f"✓ Backends with vector search: {vector_backends}")
print(f"✓ Backends with persistence: {persistence_backends}")
# Test backend management
print("\nTesting backend management...")
print(f"✓ Current active backends: {service.get_active_backends()}")
# Try disabling and re-enabling
was_enabled = service.disable_backend("sqlite")
print(f"✓ SQLite disabled: {was_enabled}")
print(f"✓ Active backends after disable: {service.get_active_backends()}")
was_reenabled = service.enable_backend("sqlite")
print(f"✓ SQLite re-enabled: {was_reenabled}")
print(f"✓ Active backends after re-enable: {service.get_active_backends()}")
# Cleanup
print("\nCleaning up...")
await service.shutdown()
# Remove test config
if config_path.exists():
config_path.unlink()
print(f"✓ Removed test config: {config_path}")
print("\n🎉 Complete system test passed!")
print("✅ uvx hanzo-mcp[memory] would work with full memory backend support")
print("✅ Settings management works properly with ~/.config/hanzo/mcp-settings.json")
print(
"✅ Settings management works properly with ~/.config/hanzo/mcp-settings.json"
)
print("✅ Modular plugin architecture enables flexible backend selection")
print("✅ SQLite backend provides lightweight persistence with vector search")
if __name__ == "__main__":
asyncio.run(test_complete_system())
asyncio.run(test_complete_system())
+133 -129
View File
@@ -6,22 +6,22 @@ Test Runner for Exact 6-Tool Implementation
Test the exact tool specifications with real workspace scenarios.
"""
import asyncio
import json
import shutil
import asyncio
import tempfile
from pathlib import Path
from dataclasses import asdict
from pathlib import Path
from hanzo_mcp.exact_tools import (
FmtArgs,
EditArgs,
LintArgs,
TestArgs,
BuildArgs,
EditArgs,
FmtArgs,
GuardArgs,
GuardRule,
LintArgs,
TargetSpec,
TestArgs,
tools,
)
@@ -29,11 +29,11 @@ from hanzo_mcp.exact_tools import (
async def test_go_workspace_scenario():
"""Test Go workspace with go.work file"""
print("🔧 Testing Go workspace scenario...")
# Create temporary Go workspace
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create go.work file
go_work = workspace_dir / "go.work"
go_work.write_text("""go 1.21
@@ -43,14 +43,14 @@ use (
./cli
)
""")
# Create api module
api_dir = workspace_dir / "api"
api_dir.mkdir()
api_go_mod = api_dir / "go.mod"
api_go_mod.write_text("module github.com/luxfi/api\n\ngo 1.21\n")
api_main = api_dir / "main.go"
api_main.write_text("""package main
@@ -68,14 +68,14 @@ func main() {
http.ListenAndServe(":8080", nil)
}
""")
# Create cli module
cli_dir = workspace_dir / "cli"
cli_dir.mkdir()
cli_go_mod = cli_dir / "go.mod"
cli_go_mod.write_text("module github.com/luxfi/cli\n\ngo 1.21\n")
cli_main = cli_dir / "main.go"
cli_main.write_text("""package main
@@ -89,41 +89,43 @@ func main() {
GreetUser("World")
}
""")
# Test workspace detection
target_spec = TargetSpec(
target="ws",
root=str(workspace_dir)
)
target_spec = TargetSpec(target="ws", root=str(workspace_dir))
# Test fmt tool
fmt_result = await tools.fmt(target_spec, FmtArgs(opts={"local_prefix": "github.com/luxfi"}))
print(f" fmt result: {'✅' if fmt_result.ok else '❌'} {fmt_result.language_used}")
fmt_result = await tools.fmt(
target_spec, FmtArgs(opts={"local_prefix": "github.com/luxfi"})
)
print(
f" fmt result: {'✅' if fmt_result.ok else '❌'} {fmt_result.language_used}"
)
print(f" root: {fmt_result.root}")
print(f" backend: {fmt_result.backend_used}")
# Test edit tool - organize imports
edit_result = await tools.edit(target_spec, EditArgs(op="organize_imports"))
print(f" edit result: {'✅' if edit_result.ok else '❌'} {edit_result.backend_used}")
print(
f" edit result: {'✅' if edit_result.ok else '❌'} {edit_result.backend_used}"
)
# Test guard tool
guard_rules = [
GuardRule(
id="no-net-http-in-api",
type="import",
glob="api/*.go",
forbid_import_prefix="net/http"
forbid_import_prefix="net/http",
)
]
guard_result = await tools.guard(target_spec, GuardArgs(rules=guard_rules))
print(f" guard result: {'✅' if guard_result.ok else '❌'} violations: {len(guard_result.errors)}")
# Test specific package
pkg_target = TargetSpec(
target="pkg:./cli/...",
root=str(workspace_dir)
print(
f" guard result: {'✅' if guard_result.ok else '❌'} violations: {len(guard_result.errors)}"
)
# Test specific package
pkg_target = TargetSpec(target="pkg:./cli/...", root=str(workspace_dir))
test_result = await tools.test(pkg_target, TestArgs(opts={"dry_run": True}))
print(f" test result: {'✅' if test_result.ok else '❌'} {test_result.stdout}")
@@ -131,49 +133,52 @@ func main() {
async def test_typescript_project():
"""Test TypeScript project scenario"""
print("🔧 Testing TypeScript project scenario...")
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create package.json with workspaces
package_json = workspace_dir / "package.json"
package_json.write_text(json.dumps({
"name": "test-workspace",
"workspaces": ["packages/*"],
"scripts": {
"test": "jest",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0"
}
}, indent=2))
package_json.write_text(
json.dumps(
{
"name": "test-workspace",
"workspaces": ["packages/*"],
"scripts": {"test": "jest", "build": "tsc"},
"devDependencies": {"typescript": "^5.0.0", "jest": "^29.0.0"},
},
indent=2,
)
)
# Create TypeScript config
tsconfig = workspace_dir / "tsconfig.json"
tsconfig.write_text(json.dumps({
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": True
}
}, indent=2))
tsconfig.write_text(
json.dumps(
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": True,
}
},
indent=2,
)
)
# Create packages
packages_dir = workspace_dir / "packages"
packages_dir.mkdir()
# Core package
core_dir = packages_dir / "core"
core_dir.mkdir()
core_package = core_dir / "package.json"
core_package.write_text(json.dumps({
"name": "@test/core",
"version": "1.0.0"
}, indent=2))
core_package.write_text(
json.dumps({"name": "@test/core", "version": "1.0.0"}, indent=2)
)
core_index = core_dir / "index.ts"
core_index.write_text("""export interface User {
id: string;
@@ -186,35 +191,37 @@ export class UserService {
}
}
""")
# Test TypeScript workspace
target_spec = TargetSpec(
target="ws",
root=str(workspace_dir),
language="ts"
)
target_spec = TargetSpec(target="ws", root=str(workspace_dir), language="ts")
fmt_result = await tools.fmt(target_spec, FmtArgs())
print(f" fmt result: {'✅' if fmt_result.ok else '❌'} {fmt_result.backend_used}")
print(
f" fmt result: {'✅' if fmt_result.ok else '❌'} {fmt_result.backend_used}"
)
build_result = await tools.build(target_spec, BuildArgs(opts={"dry_run": True}))
print(f" build result: {'✅' if build_result.ok else '❌'} {build_result.stdout}")
print(
f" build result: {'✅' if build_result.ok else '❌'} {build_result.stdout}"
)
lint_result = await tools.lint(target_spec, LintArgs(opts={"dry_run": True}))
print(f" lint result: {'✅' if lint_result.ok else '❌'} {lint_result.backend_used}")
print(
f" lint result: {'✅' if lint_result.ok else '❌'} {lint_result.backend_used}"
)
async def test_target_resolution():
"""Test target resolution scenarios"""
print("🔧 Testing target resolution...")
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create simple Go project
go_mod = workspace_dir / "go.mod"
go_mod.write_text("module test\n\ngo 1.21\n")
main_go = workspace_dir / "main.go"
main_go.write_text("""package main
@@ -224,10 +231,10 @@ func main() {
fmt.Println("Hello")
}
""")
src_dir = workspace_dir / "src"
src_dir.mkdir()
helper_go = src_dir / "helper.go"
helper_go.write_text("""package src
@@ -235,33 +242,33 @@ func Helper() string {
return "helper"
}
""")
# Test different target types
test_cases = [
("file:" + str(main_go), "Single file"),
("dir:" + str(src_dir), "Directory"),
("ws", "Workspace"),
("pkg:.", "Current package")
("pkg:.", "Current package"),
]
for target, description in test_cases:
target_spec = TargetSpec(
target=target,
root=str(workspace_dir),
dry_run=True
target=target, root=str(workspace_dir), dry_run=True
)
result = await tools.fmt(target_spec, FmtArgs())
print(f" {description}: {'✅' if result.ok else '❌'} scope={len(result.scope_resolved)}")
print(
f" {description}: {'✅' if result.ok else '❌'} scope={len(result.scope_resolved)}"
)
async def test_composition_workflow():
"""Test tool composition workflow"""
print("🔧 Testing composition workflow...")
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create Python project
pyproject = workspace_dir / "pyproject.toml"
pyproject.write_text("""[build-system]
@@ -272,10 +279,10 @@ build-backend = "hatchling.build"
name = "test-project"
version = "0.1.0"
""")
src_dir = workspace_dir / "src"
src_dir.mkdir()
main_py = src_dir / "main.py"
main_py.write_text("""import os
import sys
@@ -291,29 +298,29 @@ def process_user(user_id: str) -> Optional[str]:
if __name__ == "__main__":
print(process_user("123"))
""")
# Composition workflow: edit -> fmt -> lint -> test
target_spec = TargetSpec(
target="file:" + str(main_py),
root=str(workspace_dir),
language="py"
target="file:" + str(main_py), root=str(workspace_dir), language="py"
)
# 1. Organize imports
print(" Step 1: Organize imports")
edit_result = await tools.edit(target_spec, EditArgs(op="organize_imports", dry_run=True))
edit_result = await tools.edit(
target_spec, EditArgs(op="organize_imports", dry_run=True)
)
print(f" {'✅' if edit_result.ok else '❌'} {edit_result.stdout}")
# 2. Format code
print(" Step 2: Format code")
fmt_result = await tools.fmt(target_spec, FmtArgs())
print(f" {'✅' if fmt_result.ok else '❌'} {fmt_result.backend_used}")
# 3. Lint code
print(" Step 3: Lint code")
lint_result = await tools.lint(target_spec, LintArgs(opts={"dry_run": True}))
print(f" {'✅' if lint_result.ok else '❌'} {lint_result.backend_used}")
# 4. Test code
print(" Step 4: Test code")
test_result = await tools.test(target_spec, TestArgs(opts={"dry_run": True}))
@@ -323,14 +330,14 @@ if __name__ == "__main__":
async def test_guard_rules():
"""Test guard rule scenarios"""
print("🔧 Testing guard rules...")
with tempfile.TemporaryDirectory() as temp_dir:
workspace_dir = Path(temp_dir)
# Create SDK structure
sdk_dir = workspace_dir / "sdk"
sdk_dir.mkdir()
# Bad import in SDK
sdk_file = sdk_dir / "bad.py"
sdk_file.write_text("""import node
@@ -339,11 +346,11 @@ from node.crypto import hash
def bad_function():
return node.process()
""")
# API contracts
api_dir = workspace_dir / "api"
api_dir.mkdir()
api_file = api_dir / "contract.py"
api_file.write_text("""import requests
from http.client import HTTPConnection
@@ -351,41 +358,38 @@ from http.client import HTTPConnection
def api_call():
return requests.get("http://example.com")
""")
# Generated files
generated_dir = workspace_dir / "api" / "pb"
generated_dir.mkdir(parents=True)
generated_file = generated_dir / "user_pb2.py"
generated_file.write_text("# Generated file - do not edit")
# Test guard rules
guard_rules = [
GuardRule(
id="no-node-in-sdk",
type="import",
glob="sdk/**/*.py",
forbid_import_prefix="node"
forbid_import_prefix="node",
),
GuardRule(
id="no-http-in-contracts",
id="no-http-in-contracts",
type="import",
glob="api/*.py",
forbid_import_prefix="requests"
forbid_import_prefix="requests",
),
GuardRule(
id="no-edits-in-generated",
type="generated",
glob="api/pb/**",
forbid_writes=True
)
forbid_writes=True,
),
]
target_spec = TargetSpec(
target="ws",
root=str(workspace_dir)
)
target_spec = TargetSpec(target="ws", root=str(workspace_dir))
guard_result = await tools.guard(target_spec, GuardArgs(rules=guard_rules))
print(f" guard result: {'✅' if guard_result.ok else '❌'}")
print(f" violations found: {len(guard_result.errors)}")
@@ -396,29 +400,29 @@ def api_call():
async def main():
"""Run all tests"""
print("🚀 Testing Exact 6-Tool Implementation\n")
try:
await test_go_workspace_scenario()
print()
await test_typescript_project()
await test_typescript_project()
print()
await test_target_resolution()
print()
await test_composition_workflow()
print()
await test_guard_rules()
print()
print("🎉 All tests completed!")
except Exception as e:
print(f"❌ Test failed: {e}")
raise
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())
+17 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Test memory integration in hanzo-mcp."""
import sys
import asyncio
import sys
from pathlib import Path
# Add parent to path
@@ -46,10 +46,18 @@ async def test_memory_integration():
# Check for markdown memories
projects = await client.list_projects()
markdown_project = next((p for p in projects if p.project_id == "markdown_import"), None)
markdown_project = next(
(p for p in projects if p.project_id == "markdown_import"), None
)
if markdown_project:
memory_count = len([m for m in client.memories.values() if m.get("project_id") == "markdown_import"])
memory_count = len(
[
m
for m in client.memories.values()
if m.get("project_id") == "markdown_import"
]
)
console.print(f"✅ Found {memory_count} memories from markdown files")
else:
console.print("ℹ️ No markdown memories found (first run)")
@@ -72,9 +80,13 @@ async def test_memory_integration():
except ImportError as e:
console.print(f"⚠️ Memory tools not available (expected): {e}")
console.print(" This is OK - memory tools are loaded dynamically when MCP server starts")
console.print(
" This is OK - memory tools are loaded dynamically when MCP server starts"
)
console.print("\n[bold green]✅ Memory integration test completed successfully![/bold green]")
console.print(
"\n[bold green]✅ Memory integration test completed successfully![/bold green]"
)
console.print(
"\nYour markdown files (LLM.md, CLAUDE.md, etc.) will be automatically loaded into memory when using hanzo-mcp tools."
)
+25 -28
View File
@@ -1,21 +1,21 @@
"""Test the memory backend with settings management."""
import json
import asyncio
import json
from pathlib import Path
from hanzo_mcp.config import load_config, save_global_config, get_global_config_path
from hanzo_mcp.config import get_global_config_path, load_config, save_global_config
from hanzo_mcp.memory_service import PluginMemoryService
async def test_memory_backend_with_settings():
"""Test the memory backend with settings management."""
print("Testing memory backend with settings management...")
# Create a global config file
config_path = get_global_config_path()
print(f"Global config path: {config_path}")
# Create sample config
sample_config = {
"enabled_backends": ["sqlite"],
@@ -23,91 +23,88 @@ async def test_memory_backend_with_settings():
"sqlite": {
"enabled": True,
"path": str(Path.home() / ".hanzo" / "memory.db"),
"settings": {
"auto_migrate": True,
"connection_timeout": 30
}
"settings": {"auto_migrate": True, "connection_timeout": 30},
}
},
"default_user_id": "test-user",
"default_project_id": "test-project"
"default_project_id": "test-project",
}
# Save the config
with open(config_path, 'w') as f:
with open(config_path, "w") as f:
json.dump(sample_config, f, indent=2)
print(f"Saved sample config to {config_path}")
# Load config using our system
loaded_config = load_config()
print(f"Loaded config enabled backends: {loaded_config.enabled_backends}")
print(f"Loaded config default user: {loaded_config.default_user_id}")
print(f"Loaded config default project: {loaded_config.default_project_id}")
# Test memory service with loaded config
print("\nTesting memory service with loaded config...")
service = PluginMemoryService()
# Initialize with the loaded config's settings
await service.initialize(enabled_backends=loaded_config.enabled_backends)
print(f"Active backends: {service.get_active_backends()}")
# Test memory operations
print("\nTesting memory operations...")
# Store a memory
metadata = {"type": "test", "source": "settings_test", "importance": 0.8}
memory_id = await service.store_memory(
content="This is a test memory stored using the settings-managed memory backend",
metadata=metadata,
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id
project_id=loaded_config.default_project_id,
)
print(f"Stored memory with ID: {memory_id}")
# Retrieve the memory
results = await service.retrieve_memory(
query="test memory",
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id,
limit=5
limit=5,
)
print(f"Retrieved {len(results)} memories")
if results:
print(f"First result content preview: {results[0]['content'][:50]}...")
print(f"Result metadata: {results[0]['metadata']}")
# Test search functionality
print("\nTesting search functionality...")
search_results = await service.search_memory(
query="settings-managed memory backend",
user_id=loaded_config.default_user_id,
project_id=loaded_config.default_project_id,
limit=5
limit=5,
)
print(f"Searched and found {len(search_results)} memories")
# Test backend management
print("\nTesting backend management...")
print(f"Available backends: {service.get_available_backends()}")
print(f"Active backends: {service.get_active_backends()}")
# Check capabilities
print(f"Backends with persistence: {service.has_capability('persistence')}")
print(f"Backends with embeddings: {service.has_capability('embeddings')}")
# Cleanup: shutdown the service
await service.shutdown()
print("\nService shut down successfully")
# Optionally, remove the test config file
if config_path.exists():
config_path.unlink()
print(f"Cleaned up config file: {config_path}")
print("\n✅ Memory backend with settings management test completed successfully!")
if __name__ == "__main__":
asyncio.run(test_memory_backend_with_settings())
asyncio.run(test_memory_backend_with_settings())
+24 -27
View File
@@ -4,99 +4,96 @@ import asyncio
import tempfile
from pathlib import Path
from hanzo_mcp.backends.sqlite_plugin import SQLiteBackendPlugin
from hanzo_mcp.config import get_default_config
from hanzo_mcp.memory_service import PluginMemoryService
from hanzo_mcp.plugin_interface import Capability
from hanzo_mcp.backends.sqlite_plugin import SQLiteBackendPlugin
async def test_modular_architecture():
"""Test the modular plugin architecture."""
print("Testing modular plugin architecture...")
# Test 1: Plugin interface and registry
print("\n1. Testing plugin interface and registry...")
# Create a SQLite plugin
plugin = SQLiteBackendPlugin()
print(f" Plugin name: {plugin.name}")
print(f" Plugin capabilities: {plugin.capabilities}")
# Initialize the plugin
await plugin.initialize()
print(" Plugin initialized successfully")
# Test 2: Memory service with plugin support
print("\n2. Testing memory service with plugin support...")
# Create memory service
service = PluginMemoryService()
print(f" Available backends: {service.get_available_backends()}")
# Initialize with default backends (SQLite)
await service.initialize()
print(f" Active backends: {service.get_active_backends()}")
# Test 3: Memory operations
print("\n3. Testing memory operations...")
# Store a memory
metadata = {"type": "test", "source": "modular_test"}
memory_id = await service.store_memory(
content="This is a test memory for the modular architecture",
metadata=metadata,
user_id="test-user",
project_id="test-project"
project_id="test-project",
)
print(f" Stored memory with ID: {memory_id}")
# Retrieve the memory
results = await service.retrieve_memory(
query="test memory",
user_id="test-user",
project_id="test-project",
limit=5
query="test memory", user_id="test-user", project_id="test-project", limit=5
)
print(f" Retrieved {len(results)} memories")
if results:
print(f" First result content preview: {results[0]['content'][:50]}...")
# Test 4: Capability queries
print("\n4. Testing capability queries...")
backends_with_vector_search = service.has_capability(Capability.VECTOR_SEARCH)
print(f" Backends with vector search: {backends_with_vector_search}")
backends_with_persistence = service.has_capability(Capability.PERSISTENCE)
print(f" Backends with persistence: {backends_with_persistence}")
# Test 5: Backend management
print("\n5. Testing backend management...")
# Try to disable and enable backends
sqlite_disabled = service.disable_backend("sqlite")
print(f" SQLite disabled: {sqlite_disabled}")
print(f" Active backends after disabling SQLite: {service.get_active_backends()}")
sqlite_enabled = service.enable_backend("sqlite")
print(f" SQLite enabled: {sqlite_enabled}")
print(f" Active backends after enabling SQLite: {service.get_active_backends()}")
# Test 6: Configuration system
print("\n6. Testing configuration system...")
config = get_default_config()
print(f" Default enabled backends: {config.enabled_backends}")
print(f" Default user ID: {config.default_user_id}")
print(f" Default project ID: {config.default_project_id}")
# Test 7: Shutdown
print("\n7. Testing shutdown...")
await service.shutdown()
print(" Service shut down successfully")
print("\n✅ All tests passed! Modular plugin architecture is working correctly.")
if __name__ == "__main__":
asyncio.run(test_modular_architecture())
asyncio.run(test_modular_architecture())
+1 -1
View File
@@ -2,6 +2,6 @@
__version__ = "0.1.0"
from .installer import install, uninstall, get_binary_path, is_installed
from .installer import install, uninstall, is_installed, get_binary_path
__all__ = ["install", "uninstall", "get_binary_path", "is_installed", "__version__"]
+4 -4
View File
@@ -1,18 +1,18 @@
"""CLI for hanzo-node installer and runner."""
import os
import subprocess
import sys
import subprocess
import click
from . import __version__
from .installer import (
install,
uninstall,
is_installed,
get_binary_path,
get_installed_version,
install,
is_installed,
uninstall,
)
+5 -5
View File
@@ -1,13 +1,13 @@
"""Cross-platform binary installer for hanzo-node."""
import os
import platform
import shutil
import stat
import sys
import stat
import shutil
import tarfile
import tempfile
import zipfile
import platform
import tempfile
from pathlib import Path
import httpx
@@ -260,7 +260,7 @@ def install(force: bool = False, version: str | None = None) -> Path:
# Check if in PATH
install_dir = str(get_install_dir())
if install_dir not in os.environ.get("PATH", ""):
print(f"\n add to PATH: export PATH=\"{install_dir}:$PATH\"")
print(f'\n add to PATH: export PATH="{install_dir}:$PATH"')
return binary_path
@@ -80,6 +80,7 @@ class LLMClient:
# Check if Ollama is installed and responsive
try:
import urllib.request
urllib.request.urlopen("http://localhost:11434/api/tags", timeout=1)
available.add(provider)
except Exception:
@@ -36,7 +36,7 @@ from .gemini_cli_tool import GeminiCLITool
# Export list for tool discovery
TOOLS = [
AgentTool, # Multi-agent orchestration
AgentTool, # Multi-agent orchestration
IChingTool, # I Ching wisdom
ReviewTool, # Code review
]
@@ -30,6 +30,7 @@ from pydantic import Field
from openai.types.chat import ChatCompletionMessageParam
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.fs import get_read_only_filesystem_tools
from hanzo_tools.core import (
BaseTool,
ToolContext,
@@ -38,7 +39,6 @@ from hanzo_tools.core import (
create_tool_context,
)
from hanzo_tools.jupyter import get_read_only_jupyter_tools
from hanzo_tools.fs import get_read_only_filesystem_tools
from .prompt import (
get_default_model,
@@ -378,6 +378,7 @@ Use 'agent --action call --agent-id {agent_id} --method <method> --args <args>'
"""Execute a single agent. Uses unified agent tool for actual execution."""
try:
from .unified_agent_tool import UnifiedAgentTool
agent_tool = UnifiedAgentTool()
result = await agent_tool.call(
tool_ctx,
File diff suppressed because it is too large Load Diff
@@ -23,19 +23,20 @@ Usage:
import json
import time
import asyncio
from typing import Any, Dict, List, Tuple, Optional
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Tuple, Any, Dict
from dataclasses import dataclass, asdict, field
from dataclasses import field, asdict, dataclass
from hanzo_async import append_file, write_file
from hanzo_async import write_file, append_file
from .agent_tool import AgentTool, Result, Telemetry
from .agent_tool import Result, AgentTool, Telemetry
@dataclass
class DatasetSample:
"""Single dataset sample with full telemetry."""
# Core fields
id: str
timestamp: str
@@ -187,18 +188,11 @@ class DatasetCollector:
async def collect_one(agent: str, prompt: str, idx: int) -> DatasetSample:
async with sem:
sample = await self.collect(
agent, prompt, timeout,
tags=tags,
metadata={"batch_index": idx}
)
print(f"[{idx+1}/{len(prompts)}] {agent}: {len(sample.response)} chars, ${sample.cost_usd:.4f}")
sample = await self.collect(agent, prompt, timeout, tags=tags, metadata={"batch_index": idx})
print(f"[{idx + 1}/{len(prompts)}] {agent}: {len(sample.response)} chars, ${sample.cost_usd:.4f}")
return sample
tasks = [
collect_one(agent, prompt, i)
for i, (agent, prompt) in enumerate(prompts)
]
tasks = [collect_one(agent, prompt, i) for i, (agent, prompt) in enumerate(prompts)]
return await asyncio.gather(*tasks)
@@ -279,10 +273,7 @@ async def build_dataset(
collector = DatasetCollector(output, auto_save=True)
# Create prompt-agent pairs, cycling through agents
pairs = [
(agents[i % len(agents)], prompt)
for i, prompt in enumerate(prompts)
]
pairs = [(agents[i % len(agents)], prompt) for i, prompt in enumerate(prompts)]
print(f"Collecting {len(prompts)} samples across {len(agents)} agents...")
start = time.time()
@@ -298,7 +289,7 @@ async def build_dataset(
print(f"Total tokens: {stats['total_input_tokens']}→{stats['total_output_tokens']}")
print(f"Cache reads: {stats['total_cache_read_tokens']}")
print(f"Avg latency: {stats['avg_latency_ms']:.0f}ms")
print(f"Success rate: {stats['success_rate']*100:.1f}%")
print(f"Success rate: {stats['success_rate'] * 100:.1f}%")
print(f"Time: {elapsed:.1f}s")
print(f"Agents: {stats['agents']}")
print(f"Models: {stats['models']}")
@@ -42,18 +42,36 @@ except ImportError:
class _RequiresHanzoAgents(type):
"""Raises ImportError on instantiation."""
def __call__(cls, *args, **kwargs):
raise ImportError(f"{cls.__name__} requires: pip install hanzo-agents")
class Agent(metaclass=_RequiresHanzoAgents): pass
class State(metaclass=_RequiresHanzoAgents): pass
class Network(metaclass=_RequiresHanzoAgents): pass
class Tool(metaclass=_RequiresHanzoAgents): pass
class History(metaclass=_RequiresHanzoAgents): pass
class ModelRegistry(metaclass=_RequiresHanzoAgents): pass
class InferenceResult(metaclass=_RequiresHanzoAgents): pass
class ToolCall(metaclass=_RequiresHanzoAgents): pass
class Router(metaclass=_RequiresHanzoAgents): pass
class Agent(metaclass=_RequiresHanzoAgents):
pass
class State(metaclass=_RequiresHanzoAgents):
pass
class Network(metaclass=_RequiresHanzoAgents):
pass
class Tool(metaclass=_RequiresHanzoAgents):
pass
class History(metaclass=_RequiresHanzoAgents):
pass
class ModelRegistry(metaclass=_RequiresHanzoAgents):
pass
class InferenceResult(metaclass=_RequiresHanzoAgents):
pass
class ToolCall(metaclass=_RequiresHanzoAgents):
pass
class Router(metaclass=_RequiresHanzoAgents):
pass
# Import optional components with fallbacks
@@ -68,15 +86,23 @@ except ImportError:
DeterministicRouter,
)
except ImportError:
class DeterministicRouter(metaclass=_RequiresHanzoAgents): pass
class LLMRouter(metaclass=_RequiresHanzoAgents): pass
class HybridRouter(metaclass=_RequiresHanzoAgents): pass
class DeterministicRouter(metaclass=_RequiresHanzoAgents):
pass
class LLMRouter(metaclass=_RequiresHanzoAgents):
pass
class HybridRouter(metaclass=_RequiresHanzoAgents):
pass
def _requires_hanzo_agents(name: str):
"""Create function that raises ImportError."""
def fn(*args, **kwargs):
raise ImportError(f"{name} requires: pip install hanzo-agents")
fn.__name__ = name
return fn
@@ -114,15 +140,23 @@ try:
OpenAICodexAgent,
)
except ImportError:
class ClaudeCodeAgent(metaclass=_RequiresHanzoAgents): pass
class OpenAICodexAgent(metaclass=_RequiresHanzoAgents): pass
class GeminiAgent(metaclass=_RequiresHanzoAgents): pass
class GrokAgent(metaclass=_RequiresHanzoAgents): pass
class ClaudeCodeAgent(metaclass=_RequiresHanzoAgents):
pass
class OpenAICodexAgent(metaclass=_RequiresHanzoAgents):
pass
class GeminiAgent(metaclass=_RequiresHanzoAgents):
pass
class GrokAgent(metaclass=_RequiresHanzoAgents):
pass
from hanzo_tools.fs import Edit, get_read_only_filesystem_tools
from hanzo_tools.core import BaseTool, PermissionManager, create_tool_context
from hanzo_tools.jupyter import get_read_only_jupyter_tools
from hanzo_tools.fs import Edit, get_read_only_filesystem_tools
from .agent_tool import MCPAgent
+52 -51
View File
@@ -34,50 +34,14 @@ MCP Tool Usage:
"""
# Models (structured data types)
from .models import (
AuthType,
Credential,
CredentialSource,
EffectiveCredential,
ProviderConfig,
ProviderStatus,
ProviderListResult,
Operation,
Parameter,
OperationListResult,
APICallResult,
ToolSchema,
ToolParameter,
)
# MCP Tool
from .api_tool import APITool
# Errors (structured errors with hints)
from .errors import (
APIError,
CredentialError,
OperationNotFoundError,
ProviderNotFoundError,
SpecNotLoadedError,
SpecLoadError,
AuthenticationError,
RateLimitError,
ValidationError,
)
# Storage (pluggable credential storage)
from .storage import (
CredentialStorage,
MemoryCredentialStorage,
FileCredentialStorage,
EnvironmentCredentialStorage,
ChainedCredentialStorage,
)
# Providers (provider configs and env var mappings)
from .providers import (
PROVIDER_CONFIGS,
ENV_VAR_MAPPINGS,
get_provider_config,
list_providers as list_provider_names,
# Main client (typed interface)
from .client import (
APIClient,
get_api_client,
reset_api_client,
)
# Credentials (credential management)
@@ -87,23 +51,60 @@ from .credentials import (
reset_credential_manager,
)
# Errors (structured errors with hints)
from .errors import (
APIError,
AuthenticationError,
CredentialError,
OperationNotFoundError,
ProviderNotFoundError,
RateLimitError,
SpecLoadError,
SpecNotLoadedError,
ValidationError,
)
from .models import (
APICallResult,
AuthType,
Credential,
CredentialSource,
EffectiveCredential,
Operation,
OperationListResult,
Parameter,
ProviderConfig,
ProviderListResult,
ProviderStatus,
ToolParameter,
ToolSchema,
)
# OpenAPI (spec parsing and caching)
from .openapi_client import (
OpenAPIClient,
SpecCache,
get_client,
clear_clients,
get_client,
)
# Main client (typed interface)
from .client import (
APIClient,
get_api_client,
reset_api_client,
# Providers (provider configs and env var mappings)
from .providers import (
ENV_VAR_MAPPINGS,
PROVIDER_CONFIGS,
get_provider_config,
)
from .providers import (
list_providers as list_provider_names,
)
# MCP Tool
from .api_tool import APITool
# Storage (pluggable credential storage)
from .storage import (
ChainedCredentialStorage,
CredentialStorage,
EnvironmentCredentialStorage,
FileCredentialStorage,
MemoryCredentialStorage,
)
# Tools list for entry point discovery - must be classes, not instances
TOOLS = [APITool]
+56 -58
View File
@@ -11,17 +11,17 @@ Provides a single tool interface for:
import json
import logging
from typing import Annotated, Any, Optional, final, override
from typing import Annotated, final, override
from pydantic import Field
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from pydantic import Field
from hanzo_tools.core import BaseTool, auto_timeout, create_tool_context
from .client import APIClient, get_api_client
from .models import APICallResult, OperationListResult, ProviderListResult
from .errors import APIError
from .models import APICallResult, OperationListResult, ProviderListResult
logger = logging.getLogger(__name__)
@@ -124,7 +124,7 @@ class APITool(BaseTool):
name = "api"
def __init__(self, client: Optional[APIClient] = None):
def __init__(self, client: APIClient | None = None):
"""Initialize with optional client."""
self._client = client
@@ -192,25 +192,25 @@ Examples:
self,
ctx: MCPContext,
action: str = "list",
provider: Optional[str] = None,
provider: str | None = None,
# Config params
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
account_id: Optional[str] = None,
base_url: Optional[str] = None,
api_key: str | None = None,
api_secret: str | None = None,
account_id: str | None = None,
base_url: str | None = None,
# Spec params
spec_url: Optional[str] = None,
spec_url: str | None = None,
force_refresh: bool = False,
# Operation params
operation: Optional[str] = None,
params: Optional[str] = None, # JSON string
body: Optional[str] = None, # JSON string
operation: str | None = None,
params: str | None = None, # JSON string
body: str | None = None, # JSON string
# Raw call params
method: str = "GET",
path: Optional[str] = None,
path: str | None = None,
# List/search params
search: Optional[str] = None,
tag: Optional[str] = None,
search: str | None = None,
tag: str | None = None,
configured_only: bool = False,
**kwargs,
) -> str:
@@ -223,9 +223,7 @@ Examples:
return await self._handle_list(provider, configured_only)
elif action == "config":
return await self._handle_config(
provider, api_key, api_secret, account_id, base_url
)
return await self._handle_config(provider, api_key, api_secret, account_id, base_url)
elif action == "delete":
return await self._handle_delete(provider)
@@ -266,7 +264,7 @@ Examples:
logger.exception(f"API tool error: {e}")
return f"Error: {e}"
async def _handle_list(self, provider: Optional[str], configured_only: bool) -> str:
async def _handle_list(self, provider: str | None, configured_only: bool) -> str:
"""List providers and their status."""
if provider:
# Show details for specific provider
@@ -295,11 +293,11 @@ Examples:
async def _handle_config(
self,
provider: Optional[str],
api_key: Optional[str],
api_secret: Optional[str],
account_id: Optional[str],
base_url: Optional[str],
provider: str | None,
api_key: str | None,
api_secret: str | None,
account_id: str | None,
base_url: str | None,
) -> str:
"""Configure credentials for a provider."""
if not provider:
@@ -318,7 +316,7 @@ Examples:
return f"Configured credentials for {provider}"
async def _handle_delete(self, provider: Optional[str]) -> str:
async def _handle_delete(self, provider: str | None) -> str:
"""Delete credentials for a provider."""
if not provider:
return "Error: --provider required for delete action"
@@ -329,8 +327,8 @@ Examples:
async def _handle_spec(
self,
provider: Optional[str],
spec_url: Optional[str],
provider: str | None,
spec_url: str | None,
force_refresh: bool,
) -> str:
"""Load or refresh OpenAPI spec."""
@@ -349,9 +347,9 @@ Examples:
async def _handle_ops(
self,
provider: Optional[str],
search: Optional[str],
tag: Optional[str],
provider: str | None,
search: str | None,
tag: str | None,
) -> str:
"""List operations for a provider."""
if not provider:
@@ -369,10 +367,10 @@ Examples:
async def _handle_call(
self,
provider: Optional[str],
operation: Optional[str],
params: Optional[str],
body: Optional[str],
provider: str | None,
operation: str | None,
params: str | None,
body: str | None,
) -> str:
"""Call an API operation."""
if not provider:
@@ -401,11 +399,11 @@ Examples:
async def _handle_raw(
self,
provider: Optional[str],
provider: str | None,
method: str,
path: Optional[str],
params: Optional[str],
body: Optional[str],
path: str | None,
params: str | None,
body: str | None,
) -> str:
"""Make a raw API request."""
if not provider:
@@ -433,7 +431,7 @@ Examples:
except Exception as e:
return f"Error making request: {e}"
async def _handle_search(self, query: Optional[str]) -> str:
async def _handle_search(self, query: str | None) -> str:
"""Search for APIs in public registries."""
if not query:
return "Error: --search <query> required for search action"
@@ -468,10 +466,10 @@ Examples:
async def _handle_register(
self,
name: Optional[str],
spec_url: Optional[str],
base_url: Optional[str],
api_key: Optional[str],
name: str | None,
spec_url: str | None,
base_url: str | None,
api_key: str | None,
auth_type: str,
) -> str:
"""Register a custom API provider."""
@@ -507,7 +505,7 @@ Examples:
except Exception as e:
return f"Error registering API: {e}"
async def _handle_overview(self, provider: Optional[str]) -> str:
async def _handle_overview(self, provider: str | None) -> str:
"""Get API overview."""
if not provider:
return "Error: --provider required for overview action"
@@ -541,7 +539,7 @@ Examples:
lines.extend(failed)
lines.append(f"\nTotal: {len(success)} loaded, {len(failed)} failed")
lines.append(f"Cache: ~/.hanzo/api/specs/")
lines.append("Cache: ~/.hanzo/api/specs/")
return "\n".join(lines)
@@ -562,27 +560,27 @@ Examples:
),
] = "list",
provider: Annotated[
Optional[str],
str | None,
Field(description="Provider name (e.g., cloudflare, github)"),
] = None,
api_key: Annotated[
Optional[str],
str | None,
Field(description="API key or token for config action"),
] = None,
api_secret: Annotated[
Optional[str],
str | None,
Field(description="API secret (if needed) for config action"),
] = None,
account_id: Annotated[
Optional[str],
str | None,
Field(description="Account/org ID for config action"),
] = None,
base_url: Annotated[
Optional[str],
str | None,
Field(description="Override base URL for provider"),
] = None,
spec_url: Annotated[
Optional[str],
str | None,
Field(description="URL to OpenAPI spec for spec action"),
] = None,
force_refresh: Annotated[
@@ -590,15 +588,15 @@ Examples:
Field(description="Force refresh cached spec"),
] = False,
operation: Annotated[
Optional[str],
str | None,
Field(description="Operation ID for call action"),
] = None,
params: Annotated[
Optional[str],
str | None,
Field(description="JSON parameters for call/raw action"),
] = None,
body: Annotated[
Optional[str],
str | None,
Field(description="JSON body for call/raw action"),
] = None,
method: Annotated[
@@ -606,15 +604,15 @@ Examples:
Field(description="HTTP method for raw action"),
] = "GET",
path: Annotated[
Optional[str],
str | None,
Field(description="URL path for raw action"),
] = None,
search: Annotated[
Optional[str],
str | None,
Field(description="Search filter for ops action"),
] = None,
tag: Annotated[
Optional[str],
str | None,
Field(description="Tag filter for ops action"),
] = None,
configured_only: Annotated[
@@ -8,7 +8,6 @@ Regenerate with: python scripts/generate_providers.py > hanzo_tools/api/apis_gur
from typing import Any
# =============================================================================
# APIs.guru Provider Configurations (with oapis.org enhancements)
# =============================================================================
@@ -294,7 +293,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"alertersystem": {
"display_name": "Alerter System API",
"description": "<p>This is the <a href=\"/\">Alerter System</a> API playground. More documentation is available at the <a href=\"/help/developers/\">API Help Center</",
"description": '<p>This is the <a href="/">Alerter System</a> API playground. More documentation is available at the <a href="/help/developers/">API Help Center</',
"spec_url": "https://api.apis.guru/v2/specs/alertersystem.com/1.6.0/openapi.json",
"base_url": "https://www.alertersystem.com/api/docs.json",
"env_vars": ["ALERTERSYSTEM_API_KEY", "ALERTERSYSTEM_TOKEN"],
@@ -804,7 +803,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"apiz-ebay-com-commerce-identity": {
"display_name": "Identity API",
"description": "<span class=\"tablenote\"><b>Note:</b> Not all the account related fields are returned for an authenticated user. The fields returned in the response ",
"description": '<span class="tablenote"><b>Note:</b> Not all the account related fields are returned for an authenticated user. The fields returned in the response ',
"spec_url": "https://api.apis.guru/v2/specs/apiz.ebay.com/commerce-identity/v1.1.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/commerce/identity/openapi/3/commerce_identity_v1_oas3.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
@@ -935,7 +934,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"autodealerdata": {
"display_name": "CIS Automotive API",
"description": "<div style=\"\">This API provides access to our Automotive Data. Use of this API is subject to our <a href=\"https://autodealerdata.com/tos\">Terms of",
"description": '<div style="">This API provides access to our Automotive Data. Use of this API is subject to our <a href="https://autodealerdata.com/tos">Terms of',
"spec_url": "https://api.apis.guru/v2/specs/autodealerdata.com/1.0/openapi.json",
"base_url": "https://api.autodealerdata.com/openapi.json",
"env_vars": ["AUTODEALERDATA_API_KEY", "AUTODEALERDATA_TOKEN"],
@@ -1071,7 +1070,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"bikewise": {
"display_name": "BikeWise API v2",
"description": "<p>This is an API for accessing information about bicycling related incidents. You can find the source code on <a href=\"https://github.com/bikeindex/",
"description": '<p>This is an API for accessing information about bicycling related incidents. You can find the source code on <a href="https://github.com/bikeindex/',
"spec_url": "https://api.apis.guru/v2/specs/bikewise.org/v2/openapi.json",
"base_url": "https://bikewise.org/api/v2/swagger_doc",
"env_vars": ["BIKEWISE_API_KEY", "BIKEWISE_TOKEN"],
@@ -1528,7 +1527,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"collegefootballdata": {
"display_name": "College Football Data API",
"description": "This is an API for accessing all sorts of college football data. Please note that API keys should be supplied with \"Bearer \" prepended (e.g. \"Beare",
"description": 'This is an API for accessing all sorts of college football data. Please note that API keys should be supplied with "Bearer " prepended (e.g. "Beare',
"spec_url": "https://api.apis.guru/v2/specs/collegefootballdata.com/4.4.12/openapi.json",
"base_url": "https://api.collegefootballdata.com/api-docs.json",
"env_vars": ["COLLEGEFOOTBALLDATA_API_KEY", "COLLEGEFOOTBALLDATA_TOKEN"],
@@ -1590,7 +1589,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"core-ac-uk": {
"display_name": "CORE API v2",
"description": "<p style=\"text-align: justify;\">You can use the CORE API to access the resources harvested and enriched by CORE. If you encounter any problems with ",
"description": '<p style="text-align: justify;">You can use the CORE API to access the resources harvested and enriched by CORE. If you encounter any problems with ',
"spec_url": "https://api.apis.guru/v2/specs/core.ac.uk/2.0/swagger.json",
"base_url": "http://core.ac.uk/api-v2/doc",
"env_vars": ["CORE_AC_UK_API_KEY", "CORE_AC_UK_TOKEN"],
@@ -1914,14 +1913,14 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"ebay-com-buy-deal": {
"display_name": "Deal API",
"description": "<span class=\"tablenote\"><b>Note:</b> This is a <a href=\"https://developer.ebay.com/api-docs/static/versioning.html#limited\" target=\"_blank\"> <im",
"description": '<span class="tablenote"><b>Note:</b> This is a <a href="https://developer.ebay.com/api-docs/static/versioning.html#limited" target="_blank"> <im',
"spec_url": "https://api.apis.guru/v2/specs/ebay.com/buy-deal/v1.3.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/buy/deal/openapi/3/buy_deal_v1_oas3.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
},
"ebay-com-buy-feed": {
"display_name": "Item Feed Service",
"description": "<span class=\"tablenote\"><b>Note:</b> This is a <a href=\"https://developer.ebay.com/api-docs/static/versioning.html#limited \" target=\"_blank\"> <i",
"description": '<span class="tablenote"><b>Note:</b> This is a <a href="https://developer.ebay.com/api-docs/static/versioning.html#limited " target="_blank"> <i',
"spec_url": "https://api.apis.guru/v2/specs/ebay.com/buy-feed/v1_beta.34.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/buy/feed/openapi/2/buy_feed_v1_beta_oas2.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
@@ -2005,14 +2004,14 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"ebay-com-sell-listing": {
"display_name": "Listing API",
"description": "<span class=\"tablenote\"><b>Note:</b> This is a <a href=\"https://developer.ebay.com/api-docs/static/versioning.html#limited\" target=\"_blank\"> <im",
"description": '<span class="tablenote"><b>Note:</b> This is a <a href="https://developer.ebay.com/api-docs/static/versioning.html#limited" target="_blank"> <im',
"spec_url": "https://api.apis.guru/v2/specs/ebay.com/sell-listing/v1_beta.3.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/sell/listing/openapi/3/sell_listing_v1_beta_oas3.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
},
"ebay-com-sell-logistics": {
"display_name": "Logistics API",
"description": "<span class=\"tablenote\"><b>Note:</b> This is a <a href=\"https://developer.ebay.com/api-docs/static/versioning.html#limited\" target=\"_blank\"> <im",
"description": '<span class="tablenote"><b>Note:</b> This is a <a href="https://developer.ebay.com/api-docs/static/versioning.html#limited" target="_blank"> <im',
"spec_url": "https://api.apis.guru/v2/specs/ebay.com/sell-logistics/v1_beta.0.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/sell/logistics/openapi/3/sell_logistics_v1_oas3.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
@@ -2033,7 +2032,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"ebay-com-sell-negotiation": {
"display_name": "Negotiation API",
"description": "The <b>Negotiations API</b> gives sellers the ability to proactively send discount offers to buyers who have shown an \"interest\" in their listings. ",
"description": 'The <b>Negotiations API</b> gives sellers the ability to proactively send discount offers to buyers who have shown an "interest" in their listings. ',
"spec_url": "https://api.apis.guru/v2/specs/ebay.com/sell-negotiation/v1.1.0/openapi.json",
"base_url": "https://developer.ebay.com/api-docs/master/sell/negotiation/openapi/3/sell_negotiation_v1_oas3.json",
"env_vars": ["EBAY_APP_ID", "EBAY_DEV_ID"],
@@ -2309,7 +2308,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"firstinspires": {
"display_name": "FRC Events",
"description": "<html><head></head><body><h1 id=\"overview\">Overview</h1> <p><em>FIRST</em>/FMS FRC Events API is a service to return relevant information about the ",
"description": '<html><head></head><body><h1 id="overview">Overview</h1> <p><em>FIRST</em>/FMS FRC Events API is a service to return relevant information about the ',
"spec_url": "https://api.apis.guru/v2/specs/firstinspires.org/1.0.0/openapi.json",
"base_url": "https://frc-api-docs.firstinspires.org/api/collections/13920602/TW6zHTEi?segregateAuth=true&versionTag=latest",
"env_vars": ["FIRSTINSPIRES_API_KEY", "FIRSTINSPIRES_TOKEN"],
@@ -2511,7 +2510,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"geneea": {
"display_name": "Geneea Natural Language Processing",
"description": "<div class=\"api-description\"> <h2>Authentication</h2> <p>For all calls, supply your API key. <a href=\"https://www.geneea.com/pricing\">Sign up to <",
"description": '<div class="api-description"> <h2>Authentication</h2> <p>For all calls, supply your API key. <a href="https://www.geneea.com/pricing">Sign up to <',
"spec_url": "https://api.apis.guru/v2/specs/geneea.com/1.0/swagger.json",
"base_url": "https://api.geneea.com/api-docs?group=s1",
"env_vars": ["GENEEA_API_KEY", "GENEEA_TOKEN"],
@@ -2817,7 +2816,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"gsa-gov": {
"display_name": "Discovery Market Research",
"description": "<p>This API drives the <a href=\"https://discovery.gsa.gov\">Discovery Market Research Tool</a>. It contains information on the vendors that are part ",
"description": '<p>This API drives the <a href="https://discovery.gsa.gov">Discovery Market Research Tool</a>. It contains information on the vendors that are part ',
"spec_url": "https://api.apis.guru/v2/specs/gsa.gov/0.1/swagger.json",
"base_url": "https://discovery.gsa.gov/docs/api-docs/",
"env_vars": ["GSA_GOV_API_KEY", "GSA_GOV_TOKEN"],
@@ -2911,7 +2910,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"hhs-gov": {
"display_name": "HHS Media Services API",
"description": "<div class=\"swagger-ui-wrap extraFooter\"><h3>Common Features / Behaviors</h3> <div class=\"features\"> <ul> <li><strong>* \"sort\" param:</strong> s",
"description": '<div class="swagger-ui-wrap extraFooter"><h3>Common Features / Behaviors</h3> <div class="features"> <ul> <li><strong>* "sort" param:</strong> s',
"spec_url": "https://api.apis.guru/v2/specs/hhs.gov/2/openapi.json",
"base_url": "https://api.digitalmedia.hhs.gov/swagger",
"env_vars": ["HHS_GOV_API_KEY", "HHS_GOV_TOKEN"],
@@ -3685,14 +3684,14 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"logoraisr": {
"display_name": "API docs | logoraisr.com",
"description": "<p style=\"font-size:110%;\">Dig into our logoraisr API reference documentation. We also offer an OpenAPI specification to allow easy integration into",
"description": '<p style="font-size:110%;">Dig into our logoraisr API reference documentation. We also offer an OpenAPI specification to allow easy integration into',
"spec_url": "https://api.apis.guru/v2/specs/logoraisr.com/v1/openapi.json",
"base_url": "https://docs.logoraisr.com/swagger.json",
"env_vars": ["LOGORAISR_API_KEY", "LOGORAISR_TOKEN"],
},
"loket-nl": {
"display_name": "Loket.nl API",
"description": "<span style=\"color:green\">**Is this your first time here? Please check out our [introduction to Loket (API)](./Introduction)**</span> **The initial ",
"description": '<span style="color:green">**Is this your first time here? Please check out our [introduction to Loket (API)](./Introduction)**</span> **The initial ',
"spec_url": "https://api.apis.guru/v2/specs/loket.nl/V2/openapi.json",
"base_url": "https://developer.loket.nl/swagger.json",
"env_vars": ["LOKET_NL_API_KEY", "LOKET_NL_TOKEN"],
@@ -4016,56 +4015,80 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
"description": "Autosuggest supplies search terms derived from a root text sent to the service. The terms Autosuggest supplies are related to the root text based on s",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-AutoSuggest/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/AutoSuggest/stable/v1.0/AutoSuggest.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_AUTOSUGGEST_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_AUTOSUGGEST_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_AUTOSUGGEST_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_AUTOSUGGEST_TOKEN",
],
},
"microsoft-com-cognitiveservices-computervision": {
"display_name": "Computer Vision Client",
"description": "The Computer Vision API provides state-of-the-art algorithms to process images and return information. For example, it can be used to determine if an ",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-ComputerVision/2.1/openapi.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/ComputerVision/stable/v2.1/ComputerVision.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_COMPUTERVISION_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_COMPUTERVISION_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_COMPUTERVISION_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_COMPUTERVISION_TOKEN",
],
},
"microsoft-com-cognitiveservices-customimagesearch": {
"display_name": "Custom Image Search Client",
"description": "The Bing Custom Image Search API lets you send an image search query to Bing and get back image search results customized to meet your custom search d",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-CustomImageSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/CustomImageSearch/stable/v1.0/CustomImageSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_CUSTOMIMAGESEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_CUSTOMIMAGESEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_CUSTOMIMAGESEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_CUSTOMIMAGESEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-customsearch": {
"display_name": "Custom Search Client",
"description": "The Bing Custom Search API lets you send a search query to Bing and get back search results customized to meet your custom search definition.",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-CustomSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/CustomWebSearch/stable/v1.0/CustomSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_CUSTOMSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_CUSTOMSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_CUSTOMSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_CUSTOMSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-entitysearch": {
"display_name": "Entity Search Client",
"description": "The Entity Search API lets you send a search query to Bing and get back search results that include entities and places. Place results include restaur",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-EntitySearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/EntitySearch/stable/v1.0/EntitySearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_ENTITYSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_ENTITYSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_ENTITYSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_ENTITYSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-imagesearch": {
"display_name": "Image Search Client",
"description": "The Image Search API lets you send a search query to Bing and get back a list of relevant images. This section provides technical details about the qu",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-ImageSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/ImageSearch/stable/v1.0/ImageSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_IMAGESEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_IMAGESEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_IMAGESEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_IMAGESEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-localsearch": {
"display_name": "Local Search Client",
"description": "The Local Search client lets you send a search query to Bing and get back search results that include local businesses such as restaurants, hotels, re",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-LocalSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/LocalSearch/stable/v1.0/LocalSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_LOCALSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_LOCALSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_LOCALSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_LOCALSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-newssearch": {
"display_name": "News Search Client",
"description": "The News Search API lets you send a search query to Bing and get back a list of news that are relevant to the search query. This section provides tech",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-NewsSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/NewsSearch/stable/v1.0/NewsSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_NEWSSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_NEWSSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_NEWSSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_NEWSSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-ocr": {
"display_name": "Computer Vision Client",
@@ -4078,41 +4101,59 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
"display_name": "Custom Vision Prediction Client",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-Prediction/3.0/openapi.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/CustomVision/Prediction/stable/v3.0/Prediction.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_PREDICTION_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_PREDICTION_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_PREDICTION_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_PREDICTION_TOKEN",
],
},
"microsoft-com-cognitiveservices-spellcheck": {
"display_name": "Spell Check Client",
"description": "The Spell Check API - V7 lets you check a text string for spelling and grammar errors.",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-SpellCheck/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/SpellCheck/stable/v1.0/SpellCheck.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_SPELLCHECK_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_SPELLCHECK_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_SPELLCHECK_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_SPELLCHECK_TOKEN",
],
},
"microsoft-com-cognitiveservices-training": {
"display_name": "Custom Vision Training Client",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-Training/3.2/openapi.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/CustomVision/Training/stable/v3.2/Training.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_TRAINING_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_TRAINING_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_TRAINING_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_TRAINING_TOKEN",
],
},
"microsoft-com-cognitiveservices-videosearch": {
"display_name": "Video Search Client",
"description": "The Video Search API lets you search on Bing for video that are relevant to the user's search query, for insights about a video or for videos that are",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-VideoSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/VideoSearch/stable/v1.0/VideoSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_VIDEOSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_VIDEOSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_VIDEOSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_VIDEOSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-visualsearch": {
"display_name": "Visual Search Client",
"description": "Visual Search API lets you discover insights about an image such as visually similar images, shopping sources, and related searches. The API can also ",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-VisualSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/VisualSearch/preview/v1.0/VisualSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_VISUALSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_VISUALSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_VISUALSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_VISUALSEARCH_TOKEN",
],
},
"microsoft-com-cognitiveservices-websearch": {
"display_name": "Web Search Client",
"description": "The Web Search API lets you send a search query to Bing and get back search results that include links to webpages, images, and more.",
"spec_url": "https://api.apis.guru/v2/specs/microsoft.com/cognitiveservices-WebSearch/1.0/swagger.json",
"base_url": "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cognitiveservices/data-plane/WebSearch/stable/v1.0/WebSearch.json",
"env_vars": ["MICROSOFT_COM_COGNITIVESERVICES_WEBSEARCH_API_KEY", "MICROSOFT_COM_COGNITIVESERVICES_WEBSEARCH_TOKEN"],
"env_vars": [
"MICROSOFT_COM_COGNITIVESERVICES_WEBSEARCH_API_KEY",
"MICROSOFT_COM_COGNITIVESERVICES_WEBSEARCH_TOKEN",
],
},
"microsoft-com-graph": {
"display_name": "OData Service for namespace microsoft.graph",
@@ -4254,7 +4295,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"nbg-gr": {
"display_name": "Account and Transaction API Specification - UK",
"description": "## Functionality at a glance The NBG \"UK OPB - Account and Transaction v3.1.5\" API follows the [UK Open Banking Specification v3.1.5](https://openba",
"description": '## Functionality at a glance The NBG "UK OPB - Account and Transaction v3.1.5" API follows the [UK Open Banking Specification v3.1.5](https://openba',
"spec_url": "https://api.apis.guru/v2/specs/nbg.gr/v3.1.5/openapi.json",
"base_url": "https://developer.nbg.gr/api.gateway/publicportal/sites/default/files/2020-09/NBGSwagger-account-and_transaction_api_specification_uk-v3.1.5-swagger%20%284%29.yaml",
"env_vars": ["NBG_GR_API_KEY", "NBG_GR_TOKEN"],
@@ -4303,14 +4344,14 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"neowsapp": {
"display_name": "NeoWs - (Near Earth Object Web Service)",
"description": "A web service for near earth objects. All the data is from the <a href=\"http://neo.jpl.nasa.gov/\" target=\"_blank\">NASA JPL Asteroid team</a>. NeoW",
"description": 'A web service for near earth objects. All the data is from the <a href="http://neo.jpl.nasa.gov/" target="_blank">NASA JPL Asteroid team</a>. NeoW',
"spec_url": "https://api.apis.guru/v2/specs/neowsapp.com/1.0/openapi.json",
"base_url": "http://www.neowsapp.com/api-docs",
"env_vars": ["NEOWSAPP_API_KEY", "NEOWSAPP_TOKEN"],
},
"netatmo": {
"display_name": "Netatmo",
"description": "<h3>Welcome to the Netatmo swagger on-line documentation !</h3> This site is a complement to the official <a href=\"https://dev.netatmo.com/\">Netatmo",
"description": '<h3>Welcome to the Netatmo swagger on-line documentation !</h3> This site is a complement to the official <a href="https://dev.netatmo.com/">Netatmo',
"spec_url": "https://api.apis.guru/v2/specs/netatmo.net/1.1.5/openapi.json",
"base_url": "https://raw.githubusercontent.com/cbornet/netatmo-swagger-decl/master/spec/swagger.yaml",
"env_vars": ["NETATMO_API_KEY", "NETATMO_TOKEN"],
@@ -4359,7 +4400,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"nexmo-com-application": {
"display_name": "Nexmo Application API",
"description": "<div class=\"Vlt-callout Vlt-callout--critical\"> <i></i> <div class=\"Vlt-callout__content\"> <h4>Applications V1 is deprecated</h4> This version of ",
"description": '<div class="Vlt-callout Vlt-callout--critical"> <i></i> <div class="Vlt-callout__content"> <h4>Applications V1 is deprecated</h4> This version of ',
"spec_url": "https://api.apis.guru/v2/specs/nexmo.com/application/1.0.2/openapi.json",
"base_url": "https://raw.githubusercontent.com/nexmo/api-specification/master/definitions/application.yml",
"env_vars": ["NEXMO_API_KEY", "NEXMO_API_SECRET"],
@@ -4583,7 +4624,10 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
"description": "Query our database of federal and state laws and incentives for alternative fuels and vehicles, air quality, fuel efficiency, and other transportation",
"spec_url": "https://api.apis.guru/v2/specs/nrel.gov/transportation-incentives-laws/0.1.0/openapi.json",
"base_url": "http://developer.nrel.gov/docs/transportation/transportation-incentives-laws-v1/spec.yml",
"env_vars": ["NREL_GOV_TRANSPORTATION_INCENTIVES_LAWS_API_KEY", "NREL_GOV_TRANSPORTATION_INCENTIVES_LAWS_TOKEN"],
"env_vars": [
"NREL_GOV_TRANSPORTATION_INCENTIVES_LAWS_API_KEY",
"NREL_GOV_TRANSPORTATION_INCENTIVES_LAWS_TOKEN",
],
},
"nrm-se-georg": {
"display_name": "Georg API",
@@ -4793,28 +4837,40 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
"description": "Swagger for Account and Transaction API Specification",
"spec_url": "https://api.apis.guru/v2/specs/openbanking.org.uk/account-info-openapi/3.1.7/openapi.json",
"base_url": "https://raw.githubusercontent.com/OpenBankingUK/read-write-api-specs/master/dist/openapi/account-info-openapi.yaml",
"env_vars": ["OPENBANKING_ORG_UK_ACCOUNT_INFO_OPENAPI_API_KEY", "OPENBANKING_ORG_UK_ACCOUNT_INFO_OPENAPI_TOKEN"],
"env_vars": [
"OPENBANKING_ORG_UK_ACCOUNT_INFO_OPENAPI_API_KEY",
"OPENBANKING_ORG_UK_ACCOUNT_INFO_OPENAPI_TOKEN",
],
},
"openbanking-org-uk-confirmation-funds-openapi": {
"display_name": "Confirmation of Funds API Specification",
"description": "Swagger for Confirmation of Funds API Specification",
"spec_url": "https://api.apis.guru/v2/specs/openbanking.org.uk/confirmation-funds-openapi/3.1.7/openapi.json",
"base_url": "https://raw.githubusercontent.com/OpenBankingUK/read-write-api-specs/master/dist/openapi/confirmation-funds-openapi.yaml",
"env_vars": ["OPENBANKING_ORG_UK_CONFIRMATION_FUNDS_OPENAPI_API_KEY", "OPENBANKING_ORG_UK_CONFIRMATION_FUNDS_OPENAPI_TOKEN"],
"env_vars": [
"OPENBANKING_ORG_UK_CONFIRMATION_FUNDS_OPENAPI_API_KEY",
"OPENBANKING_ORG_UK_CONFIRMATION_FUNDS_OPENAPI_TOKEN",
],
},
"openbanking-org-uk-event-notifications-openapi": {
"display_name": "Event Notification API Specification - TPP Endpoints",
"description": "Swagger for Event Notification API Specification - TPP Endpoints",
"spec_url": "https://api.apis.guru/v2/specs/openbanking.org.uk/event-notifications-openapi/3.1.7/openapi.json",
"base_url": "https://raw.githubusercontent.com/OpenBankingUK/read-write-api-specs/master/dist/openapi/event-notifications-openapi.yaml",
"env_vars": ["OPENBANKING_ORG_UK_EVENT_NOTIFICATIONS_OPENAPI_API_KEY", "OPENBANKING_ORG_UK_EVENT_NOTIFICATIONS_OPENAPI_TOKEN"],
"env_vars": [
"OPENBANKING_ORG_UK_EVENT_NOTIFICATIONS_OPENAPI_API_KEY",
"OPENBANKING_ORG_UK_EVENT_NOTIFICATIONS_OPENAPI_TOKEN",
],
},
"openbanking-org-uk-payment-initiation-openapi": {
"display_name": "Payment Initiation API",
"description": "Swagger for Payment Initiation API Specification",
"spec_url": "https://api.apis.guru/v2/specs/openbanking.org.uk/payment-initiation-openapi/3.1.7/openapi.json",
"base_url": "https://raw.githubusercontent.com/OpenBankingUK/read-write-api-specs/master/dist/openapi/payment-initiation-openapi.yaml",
"env_vars": ["OPENBANKING_ORG_UK_PAYMENT_INITIATION_OPENAPI_API_KEY", "OPENBANKING_ORG_UK_PAYMENT_INITIATION_OPENAPI_TOKEN"],
"env_vars": [
"OPENBANKING_ORG_UK_PAYMENT_INITIATION_OPENAPI_API_KEY",
"OPENBANKING_ORG_UK_PAYMENT_INITIATION_OPENAPI_TOKEN",
],
},
"openbankingproject-ch": {
"display_name": "Swiss NextGen Banking API-Framework",
@@ -5572,7 +5628,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"rumble-run": {
"display_name": "Rumble API (deprecated)",
"description": "<p><b><i>Rumble Network Discovery is now runZero. <a href=\"https://www.runzero.com/blog/introducing-runzero/\">Read the announcement</a></i></b>.</p>",
"description": '<p><b><i>Rumble Network Discovery is now runZero. <a href="https://www.runzero.com/blog/introducing-runzero/">Read the announcement</a></i></b>.</p>',
"spec_url": "https://api.apis.guru/v2/specs/rumble.run/2.15.0/openapi.json",
"base_url": "https://raw.githubusercontent.com/RumbleDiscovery/rumble-api/main/rumble-api.yml",
"env_vars": ["RUMBLE_RUN_API_KEY", "RUMBLE_RUN_TOKEN"],
@@ -6881,7 +6937,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"unicourt": {
"display_name": "UniCourt Enterprise APIs",
"description": "<button><a href=\"/enterpriseapi/download/UniCourt-Enterprise-API-Spec.yaml\" >Download UniCourt Enterprise APIs Specification</a></button>",
"description": '<button><a href="/enterpriseapi/download/UniCourt-Enterprise-API-Spec.yaml" >Download UniCourt Enterprise APIs Specification</a></button>',
"spec_url": "https://api.apis.guru/v2/specs/unicourt.com/1.0.0/openapi.json",
"base_url": "https://docs.unicourt.com/enterpriseapi/download/UniCourt-Enterprise-API-Spec.yaml",
"env_vars": ["UNICOURT_API_KEY", "UNICOURT_TOKEN"],
@@ -7318,7 +7374,10 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
"description": "API for issuers to save and manage Google Wallet Objects.",
"spec_url": "https://api.apis.guru/v2/specs/walletobjects.googleapis.com/pay-passes/v1/openapi.json",
"base_url": "https://walletobjects.googleapis.com/$discovery/rest?version=v1",
"env_vars": ["WALLETOBJECTS_GOOGLEAPIS_COM_PAY_PASSES_API_KEY", "WALLETOBJECTS_GOOGLEAPIS_COM_PAY_PASSES_TOKEN"],
"env_vars": [
"WALLETOBJECTS_GOOGLEAPIS_COM_PAY_PASSES_API_KEY",
"WALLETOBJECTS_GOOGLEAPIS_COM_PAY_PASSES_TOKEN",
],
},
"walmart-com-inventory": {
"display_name": "Inventory Management",
@@ -7652,7 +7711,7 @@ APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {
},
"zapier-com-nla": {
"display_name": "Zapier Natural Language Actions (NLA) API - Beta",
"description": "<img src=\"https://cdn.zappy.app/945f9bf9e44126873952ec5113949c3f.png\" width=\"100\" /> ## Hi, there! Welcome to the **Zapier Natural Language Action",
"description": '<img src="https://cdn.zappy.app/945f9bf9e44126873952ec5113949c3f.png" width="100" /> ## Hi, there! Welcome to the **Zapier Natural Language Action',
"spec_url": "https://api.apis.guru/v2/specs/zapier.com/nla/1.0.0/openapi.json",
"base_url": "https://nla.zapier.com/api/v1/openapi.json",
"env_vars": ["ZAPIER_COM_NLA_API_KEY", "ZAPIER_COM_NLA_TOKEN"],
+65 -76
View File
@@ -27,30 +27,24 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
from typing import Any
from .credentials import CredentialManager, get_credential_manager
from .errors import (
ProviderNotFoundError,
)
from .models import (
APICallResult,
Credential,
CredentialSource,
EffectiveCredential,
Operation,
OperationListResult,
ProviderListResult,
ProviderStatus,
ToolSchema,
ToolParameter,
ToolSchema,
)
from .credentials import CredentialManager, get_credential_manager, reset_credential_manager
from .openapi_client import OpenAPIClient, SpecCache, get_client, clear_clients
from .providers import PROVIDER_CONFIGS, get_provider_config, list_providers as _list_provider_names
from .errors import (
APIError,
CredentialError,
OperationNotFoundError,
ProviderNotFoundError,
SpecNotLoadedError,
)
from .openapi_client import OpenAPIClient, SpecCache
from .providers import PROVIDER_CONFIGS
logger = logging.getLogger(__name__)
@@ -79,8 +73,8 @@ class APIClient:
def __init__(
self,
config_dir: Optional[Path] = None,
credential_manager: Optional[CredentialManager] = None,
config_dir: Path | None = None,
credential_manager: CredentialManager | None = None,
):
"""Initialize API client.
@@ -162,10 +156,10 @@ class APIClient:
async def config(
self,
provider: str,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
account_id: Optional[str] = None,
base_url: Optional[str] = None,
api_key: str | None = None,
api_secret: str | None = None,
account_id: str | None = None,
base_url: str | None = None,
**extra,
) -> None:
"""Configure credentials for a provider.
@@ -229,7 +223,7 @@ class APIClient:
async def spec(
self,
provider: str,
spec_url: Optional[str] = None,
spec_url: str | None = None,
force_refresh: bool = False,
) -> int:
"""Load or refresh OpenAPI spec for a provider.
@@ -272,7 +266,7 @@ class APIClient:
openapi_client = await self._get_openapi_client(provider)
return openapi_client.has_spec()
async def spec_age(self, provider: str) -> Optional[float]:
async def spec_age(self, provider: str) -> float | None:
"""Get age of cached spec in seconds.
Args:
@@ -304,11 +298,11 @@ class APIClient:
async def ops(
self,
provider: str,
search: Optional[str] = None,
tag: Optional[str] = None,
method: Optional[str] = None,
path_contains: Optional[str] = None,
operation_id_prefix: Optional[str] = None,
search: str | None = None,
tag: str | None = None,
method: str | None = None,
path_contains: str | None = None,
operation_id_prefix: str | None = None,
include_deprecated: bool = False,
) -> OperationListResult:
"""List available operations for a provider.
@@ -382,9 +376,9 @@ class APIClient:
self,
provider: str,
operation_id: str,
params: Optional[dict[str, Any]] = None,
body: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
dry_run: bool = False,
) -> APICallResult:
"""Call an API operation by ID.
@@ -437,9 +431,9 @@ class APIClient:
provider: str,
method: str,
path: str,
params: Optional[dict[str, Any]] = None,
body: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
dry_run: bool = False,
) -> APICallResult:
"""Make a raw HTTP request.
@@ -694,7 +688,7 @@ class APIClient:
# Search openapisearch.com
try:
response = await http.get(
f"https://openapisearch.com/api/search",
"https://openapisearch.com/api/search",
params={"q": query},
)
if response.status_code == 200:
@@ -705,9 +699,7 @@ class APIClient:
# Fallback: search handmade OpenAPIs on GitHub
try:
response = await http.get(
"https://api.github.com/repos/janwilmake/handmade-openapis/contents"
)
response = await http.get("https://api.github.com/repos/janwilmake/handmade-openapis/contents")
if response.status_code == 200:
files = response.json()
results = []
@@ -715,11 +707,13 @@ class APIClient:
if f["name"].endswith(".json"):
name = f["name"].replace(".json", "")
if query.lower() in name.lower():
results.append({
"id": name,
"name": name.replace("-", " ").title(),
"spec_url": f["download_url"],
})
results.append(
{
"id": name,
"name": name.replace("-", " ").title(),
"spec_url": f["download_url"],
}
)
return results
except Exception as e:
logger.warning(f"GitHub fallback search failed: {e}")
@@ -729,12 +723,12 @@ class APIClient:
async def register(
self,
name: str,
spec_url: Optional[str] = None,
base_url: Optional[str] = None,
spec_url: str | None = None,
base_url: str | None = None,
auth_type: str = "bearer",
auth_header: str = "Authorization",
auth_prefix: str = "Bearer",
api_key: Optional[str] = None,
api_key: str | None = None,
) -> int:
"""Register a custom API provider dynamically.
@@ -765,7 +759,6 @@ class APIClient:
)
"""
from .models import AuthType, ProviderConfig
from .providers import PROVIDER_CONFIGS
# Create dynamic provider config
auth_type_enum = AuthType(auth_type) if auth_type in [e.value for e in AuthType] else AuthType.BEARER
@@ -826,22 +819,20 @@ class APIClient:
try:
full_op = openapi_client.get_operation(op.operation_id)
# Build query params string
query_params = [
f"{p.name}={p.schema_type}"
for p in full_op.parameters
if p.location.value == "query"
]
query_params = [f"{p.name}={p.schema_type}" for p in full_op.parameters if p.location.value == "query"]
query_string = f"?{'&'.join(query_params)}" if query_params else ""
except Exception:
query_string = ""
items.append({
"operation_id": op.operation_id,
"method": op.method,
"path": op.path,
"query_string": query_string,
"summary": op.summary or "",
})
items.append(
{
"operation_id": op.operation_id,
"method": op.method,
"path": op.path,
"query_string": query_string,
"summary": op.summary or "",
}
)
# Check if we need minified output (>10k chars ≈ 2500 tokens)
is_large = len(str(items)) > 10000
@@ -860,11 +851,11 @@ class APIClient:
for item in items:
if is_large:
# Super compact: just operation_id and summary
summary_part = f" - {item['summary']}" if item['summary'] else ""
summary_part = f" - {item['summary']}" if item["summary"] else ""
lines.append(f"- {item['operation_id']}{summary_part}")
else:
# Compact: include method and path
summary_part = f" - {item['summary']}" if item['summary'] else ""
summary_part = f" - {item['summary']}" if item["summary"] else ""
lines.append(
f"- {item['operation_id']}: {item['method']} {item['path']}{item['query_string']}{summary_part}"
)
@@ -920,14 +911,10 @@ class APIClient:
await client.preload_specs(["cloudflare", "github", "openai"])
"""
import asyncio
from .providers import PROVIDER_CONFIGS
# Determine which providers to preload
if providers is None:
providers = [
name for name, config in PROVIDER_CONFIGS.items()
if config.spec_url
]
providers = [name for name, config in PROVIDER_CONFIGS.items() if config.spec_url]
results: dict[str, int] = {}
semaphore = asyncio.Semaphore(concurrent)
@@ -951,7 +938,7 @@ class APIClient:
return results
@classmethod
async def preload_common(cls) -> "APIClient":
async def preload_common(cls) -> APIClient:
"""Create a client with common specs preloaded.
Returns a client with cloudflare, github, stripe, openai, notion
@@ -962,14 +949,16 @@ class APIClient:
# Now all common specs are cached
"""
client = cls()
await client.preload_specs([
"cloudflare",
"github",
"stripe",
"openai",
"notion",
"groq",
])
await client.preload_specs(
[
"cloudflare",
"github",
"stripe",
"openai",
"notion",
"groq",
]
)
return client
# =========================================================================
@@ -982,7 +971,7 @@ class APIClient:
await client.close()
self._clients.clear()
async def __aenter__(self) -> "APIClient":
async def __aenter__(self) -> APIClient:
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -993,7 +982,7 @@ class APIClient:
# Module-level singleton
# =============================================================================
_default_client: Optional[APIClient] = None
_default_client: APIClient | None = None
def get_api_client() -> APIClient:
@@ -11,18 +11,17 @@ from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Optional
from .errors import CredentialError
from .models import Credential, CredentialSource, EffectiveCredential, ProviderConfig, ProviderStatus
from .providers import PROVIDER_CONFIGS, ENV_VAR_MAPPINGS, get_env_vars, get_provider_config
from .providers import ENV_VAR_MAPPINGS, PROVIDER_CONFIGS, get_env_vars, get_provider_config
from .storage import (
CredentialStorage,
ChainedCredentialStorage,
FileCredentialStorage,
CredentialStorage,
EnvironmentCredentialStorage,
FileCredentialStorage,
MemoryCredentialStorage,
)
from .errors import CredentialError
logger = logging.getLogger(__name__)
@@ -54,8 +53,8 @@ class CredentialManager:
def __init__(
self,
config_dir: Optional[Path] = None,
storage: Optional[CredentialStorage] = None,
config_dir: Path | None = None,
storage: CredentialStorage | None = None,
):
"""Initialize credential manager.
@@ -73,10 +72,12 @@ class CredentialManager:
self._storage = storage
else:
file_path = self.config_dir / "credentials.json"
self._storage = ChainedCredentialStorage([
FileCredentialStorage(file_path), # Persistent storage
EnvironmentCredentialStorage(ENV_VAR_MAPPINGS), # Read-only fallback
])
self._storage = ChainedCredentialStorage(
[
FileCredentialStorage(file_path), # Persistent storage
EnvironmentCredentialStorage(ENV_VAR_MAPPINGS), # Read-only fallback
]
)
# Separate override storage for context manager
self._overrides = MemoryCredentialStorage()
@@ -157,10 +158,10 @@ class CredentialManager:
async def set_credential(
self,
provider: str,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
account_id: Optional[str] = None,
base_url: Optional[str] = None,
api_key: str | None = None,
api_secret: str | None = None,
account_id: str | None = None,
base_url: str | None = None,
**extra,
) -> None:
"""Store a credential.
@@ -268,13 +269,13 @@ class CredentialManager:
return effective.credential
def get_provider_config(self, provider: str) -> Optional[ProviderConfig]:
def get_provider_config(self, provider: str) -> ProviderConfig | None:
"""Get provider configuration."""
return get_provider_config(provider)
# Singleton instance
_credential_manager: Optional[CredentialManager] = None
_credential_manager: CredentialManager | None = None
def get_credential_manager() -> CredentialManager:
+33 -33
View File
@@ -6,7 +6,7 @@ making it easy for agents to understand and recover from errors.
from __future__ import annotations
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel, Field
@@ -16,14 +16,14 @@ class APIErrorInfo(BaseModel):
error_type: str = Field(description="Error category")
message: str = Field(description="Human-readable error message")
provider: Optional[str] = Field(default=None, description="Provider involved")
operation_id: Optional[str] = Field(default=None, description="Operation involved")
status_code: Optional[int] = Field(default=None, description="HTTP status code")
provider: str | None = Field(default=None, description="Provider involved")
operation_id: str | None = Field(default=None, description="Operation involved")
status_code: int | None = Field(default=None, description="HTTP status code")
missing_fields: list[str] = Field(default_factory=list, description="Required fields that are missing")
hint: Optional[str] = Field(default=None, description="Suggested fix")
hint: str | None = Field(default=None, description="Suggested fix")
env_vars: list[str] = Field(default_factory=list, description="Environment variables to set")
config_command: Optional[str] = Field(default=None, description="CLI command to fix")
details: Optional[dict[str, Any]] = Field(default=None, description="Additional error details")
config_command: str | None = Field(default=None, description="CLI command to fix")
details: dict[str, Any] | None = Field(default=None, description="Additional error details")
class APIError(Exception):
@@ -33,15 +33,15 @@ class APIError(Exception):
self,
message: str,
error_type: str = "api_error",
provider: Optional[str] = None,
operation_id: Optional[str] = None,
status_code: Optional[int] = None,
provider: str | None = None,
operation_id: str | None = None,
status_code: int | None = None,
body: Any = None,
hint: Optional[str] = None,
missing_fields: Optional[list[str]] = None,
env_vars: Optional[list[str]] = None,
config_command: Optional[str] = None,
details: Optional[dict[str, Any]] = None,
hint: str | None = None,
missing_fields: list[str] | None = None,
env_vars: list[str] | None = None,
config_command: str | None = None,
details: dict[str, Any] | None = None,
):
self.message = message
self.error_type = error_type
@@ -91,8 +91,8 @@ class CredentialError(APIError):
self,
message: str,
provider: str,
missing_fields: Optional[list[str]] = None,
env_vars: Optional[list[str]] = None,
missing_fields: list[str] | None = None,
env_vars: list[str] | None = None,
):
config_cmd = f'api config --provider {provider} --api_key "YOUR_KEY"'
hint = f"Configure credentials for {provider}"
@@ -111,7 +111,7 @@ class CredentialError(APIError):
class ProviderNotFoundError(APIError):
"""Provider is not recognized."""
def __init__(self, provider: str, available_providers: Optional[list[str]] = None):
def __init__(self, provider: str, available_providers: list[str] | None = None):
hint = None
if available_providers:
hint = f"Available providers: {', '.join(available_providers[:10])}"
@@ -132,7 +132,7 @@ class OperationNotFoundError(APIError):
self,
operation_id: str,
provider: str,
similar_operations: Optional[list[str]] = None,
similar_operations: list[str] | None = None,
):
hint = None
if similar_operations:
@@ -170,8 +170,8 @@ class SpecParseError(APIError):
self,
message: str,
provider: str,
path_in_spec: Optional[str] = None,
parse_error: Optional[str] = None,
path_in_spec: str | None = None,
parse_error: str | None = None,
):
details = {}
if path_in_spec:
@@ -196,8 +196,8 @@ class ParameterValidationError(APIError):
message: str,
provider: str,
operation_id: str,
missing_params: Optional[list[str]] = None,
invalid_params: Optional[dict[str, str]] = None,
missing_params: list[str] | None = None,
invalid_params: dict[str, str] | None = None,
):
details = {}
if missing_params:
@@ -230,8 +230,8 @@ class NetworkError(APIError):
self,
message: str,
provider: str,
url: Optional[str] = None,
original_error: Optional[Exception] = None,
url: str | None = None,
original_error: Exception | None = None,
):
details = {}
if url:
@@ -254,7 +254,7 @@ class RateLimitError(APIError):
def __init__(
self,
provider: str,
retry_after: Optional[int] = None,
retry_after: int | None = None,
status_code: int = 429,
):
hint = "Rate limit exceeded"
@@ -278,7 +278,7 @@ class AuthenticationError(APIError):
self,
provider: str,
status_code: int = 401,
env_vars: Optional[list[str]] = None,
env_vars: list[str] | None = None,
):
super().__init__(
message=f"Authentication failed for {provider}",
@@ -297,8 +297,8 @@ class SpecLoadError(APIError):
def __init__(
self,
provider: str,
spec_url: Optional[str] = None,
original_error: Optional[Exception] = None,
spec_url: str | None = None,
original_error: Exception | None = None,
):
message = f"Failed to load OpenAPI spec for {provider}"
if spec_url:
@@ -325,11 +325,11 @@ class ValidationError(APIError):
def __init__(
self,
message: str,
provider: Optional[str] = None,
operation_id: Optional[str] = None,
field: Optional[str] = None,
provider: str | None = None,
operation_id: str | None = None,
field: str | None = None,
value: Any = None,
expected: Optional[str] = None,
expected: str | None = None,
):
details = {}
if field:
+23 -24
View File
@@ -11,10 +11,9 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, ConfigDict
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Enums
@@ -61,10 +60,10 @@ class Credential(BaseModel):
model_config = ConfigDict(frozen=False)
provider: str = Field(description="Provider name")
api_key: Optional[str] = Field(default=None, description="API key or token")
api_secret: Optional[str] = Field(default=None, description="API secret (if needed)")
account_id: Optional[str] = Field(default=None, description="Account/organization ID")
base_url: Optional[str] = Field(default=None, description="Custom base URL override")
api_key: str | None = Field(default=None, description="API key or token")
api_secret: str | None = Field(default=None, description="API secret (if needed)")
account_id: str | None = Field(default=None, description="Account/organization ID")
base_url: str | None = Field(default=None, description="Custom base URL override")
extra: dict[str, Any] = Field(default_factory=dict, description="Provider-specific fields")
@property
@@ -80,7 +79,7 @@ class EffectiveCredential(BaseModel):
credential: Credential
source: CredentialSource = Field(description="Where the credential was resolved from")
env_var_used: Optional[str] = Field(default=None, description="Environment variable used (if any)")
env_var_used: str | None = Field(default=None, description="Environment variable used (if any)")
@property
def has_credentials(self) -> bool:
@@ -99,7 +98,7 @@ class ProviderConfig(BaseModel):
auth_header: str = Field(default="Authorization", description="Header name for auth")
auth_prefix: str = Field(default="Bearer", description="Prefix for auth value")
auth_query_param: str = Field(default="api_key", description="Query param for auth")
spec_url: Optional[str] = Field(default=None, description="URL to OpenAPI spec")
spec_url: str | None = Field(default=None, description="URL to OpenAPI spec")
env_vars: list[str] = Field(default_factory=list, description="Environment variables to check")
extra_headers: dict[str, str] = Field(default_factory=dict, description="Additional headers")
@@ -112,11 +111,11 @@ class ProviderStatus(BaseModel):
name: str
display_name: str
configured: bool = Field(description="Whether credentials are available")
source: Optional[CredentialSource] = Field(default=None, description="Credential source")
source: CredentialSource | None = Field(default=None, description="Credential source")
base_url: str
has_spec: bool = Field(description="Whether OpenAPI spec is available")
spec_cached: bool = Field(default=False, description="Whether spec is cached locally")
spec_age_seconds: Optional[float] = Field(default=None, description="Age of cached spec")
spec_age_seconds: float | None = Field(default=None, description="Age of cached spec")
# =============================================================================
@@ -136,7 +135,7 @@ class Parameter(BaseModel):
description: str = Field(default="", description="Parameter description")
default: Any = Field(default=None, description="Default value")
enum: list[Any] = Field(default_factory=list, description="Allowed values")
json_schema: Optional[dict[str, Any]] = Field(default=None, description="Full JSON schema")
json_schema: dict[str, Any] | None = Field(default=None, description="Full JSON schema")
class Operation(BaseModel):
@@ -150,9 +149,9 @@ class Operation(BaseModel):
summary: str = Field(default="", description="Short description")
description: str = Field(default="", description="Detailed description")
parameters: list[Parameter] = Field(default_factory=list, description="Operation parameters")
request_body_schema: Optional[dict[str, Any]] = Field(default=None, description="Request body JSON schema")
request_body_schema: dict[str, Any] | None = Field(default=None, description="Request body JSON schema")
request_body_required: bool = Field(default=False, description="Whether request body is required")
response_schema: Optional[dict[str, Any]] = Field(default=None, description="Response JSON schema")
response_schema: dict[str, Any] | None = Field(default=None, description="Response JSON schema")
tags: list[str] = Field(default_factory=list, description="Operation tags")
deprecated: bool = Field(default=False, description="Whether operation is deprecated")
@@ -201,7 +200,7 @@ class OperationSummary(BaseModel):
deprecated: bool = False
@classmethod
def from_operation(cls, op: Operation) -> "OperationSummary":
def from_operation(cls, op: Operation) -> OperationSummary:
return cls(
operation_id=op.operation_id,
method=op.method,
@@ -226,13 +225,13 @@ class APICallResult(BaseModel):
status_code: int = Field(description="HTTP status code")
headers: dict[str, str] = Field(default_factory=dict, description="Response headers")
body: Any = Field(default=None, description="Response body (parsed JSON or raw text)")
raw_body: Optional[str] = Field(default=None, description="Raw response body")
elapsed_ms: Optional[float] = Field(default=None, description="Request duration in milliseconds")
request_id: Optional[str] = Field(default=None, description="Request ID from headers")
raw_body: str | None = Field(default=None, description="Raw response body")
elapsed_ms: float | None = Field(default=None, description="Request duration in milliseconds")
request_id: str | None = Field(default=None, description="Request ID from headers")
# Metadata
provider: str = Field(description="Provider that was called")
operation_id: Optional[str] = Field(default=None, description="Operation ID (if using spec)")
operation_id: str | None = Field(default=None, description="Operation ID (if using spec)")
method: str = Field(description="HTTP method used")
url: str = Field(description="Full URL that was called")
@@ -272,9 +271,9 @@ class SpecCacheEntry(BaseModel):
provider: str
spec: dict[str, Any]
fetched_at: datetime
etag: Optional[str] = None
last_modified: Optional[str] = None
source_url: Optional[str] = None
etag: str | None = None
last_modified: str | None = None
source_url: str | None = None
@property
def age_seconds(self) -> float:
@@ -302,7 +301,7 @@ class ToolParameter(BaseModel):
description: str
required: bool = False
default: Any = None
enum: Optional[list[str]] = None
enum: list[str] | None = None
class ToolSchema(BaseModel):
@@ -375,7 +374,7 @@ class OperationListResult(BaseModel):
provider: str
operations: list[OperationSummary]
total_count: int
filter_applied: Optional[str] = None
filter_applied: str | None = None
def by_tag(self) -> dict[str, list[OperationSummary]]:
"""Group operations by tag."""
@@ -13,32 +13,18 @@ import json
import logging
import re
import time
import hashlib
from datetime import datetime
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urljoin, urlencode
from typing import Any
from urllib.parse import urlencode, urljoin
import aiofiles
import httpx
import yaml
import aiofiles
from .models import (
APICallResult,
Credential,
Operation,
OperationSummary,
OperationListResult,
Parameter,
ParameterLocation,
ProviderConfig,
SpecCacheEntry,
)
from .credentials import CredentialManager, get_credential_manager
from .providers import get_provider_config
from .errors import (
APIError,
AuthenticationError,
NetworkError,
OperationNotFoundError,
@@ -47,6 +33,17 @@ from .errors import (
SpecNotLoadedError,
SpecParseError,
)
from .models import (
APICallResult,
Credential,
Operation,
OperationListResult,
OperationSummary,
Parameter,
ParameterLocation,
SpecCacheEntry,
)
from .providers import get_provider_config
logger = logging.getLogger(__name__)
@@ -54,7 +51,7 @@ logger = logging.getLogger(__name__)
class SpecCache:
"""Smart OpenAPI spec caching with ETag support."""
def __init__(self, cache_dir: Optional[Path] = None):
def __init__(self, cache_dir: Path | None = None):
"""Initialize spec cache.
Args:
@@ -81,7 +78,7 @@ class SpecCache:
return True
return self._cache_path(provider).exists()
def spec_age(self, provider: str) -> Optional[float]:
def spec_age(self, provider: str) -> float | None:
"""Get age of cached spec in seconds."""
if provider in self._memory_cache:
return self._memory_cache[provider].age_seconds
@@ -98,7 +95,7 @@ class SpecCache:
return True
return age > max_age
async def get(self, provider: str) -> Optional[SpecCacheEntry]:
async def get(self, provider: str) -> SpecCacheEntry | None:
"""Get cached spec entry."""
# Check memory cache
if provider in self._memory_cache:
@@ -110,7 +107,7 @@ class SpecCache:
if cache_path.exists():
try:
async with aiofiles.open(cache_path, "r") as f:
async with aiofiles.open(cache_path) as f:
spec = json.loads(await f.read())
# Load metadata
@@ -120,7 +117,7 @@ class SpecCache:
fetched_at = datetime.fromtimestamp(cache_path.stat().st_mtime)
if meta_path.exists():
async with aiofiles.open(meta_path, "r") as f:
async with aiofiles.open(meta_path) as f:
meta = json.loads(await f.read())
etag = meta.get("etag")
last_modified = meta.get("last_modified")
@@ -149,9 +146,9 @@ class SpecCache:
self,
provider: str,
spec: dict,
etag: Optional[str] = None,
last_modified: Optional[str] = None,
source_url: Optional[str] = None,
etag: str | None = None,
last_modified: str | None = None,
source_url: str | None = None,
) -> SpecCacheEntry:
"""Cache a spec."""
self._ensure_dir()
@@ -204,9 +201,9 @@ class OpenAPIClient:
def __init__(
self,
provider: str,
base_url: Optional[str] = None,
credential_manager: Optional[CredentialManager] = None,
spec_cache: Optional[SpecCache] = None,
base_url: str | None = None,
credential_manager: CredentialManager | None = None,
spec_cache: SpecCache | None = None,
):
"""Initialize OpenAPI client.
@@ -218,7 +215,7 @@ class OpenAPIClient:
"""
self.provider = provider
self._base_url_override = base_url
self._spec: Optional[dict] = None
self._spec: dict | None = None
self._operations: dict[str, Operation] = {}
self._parsed = False
@@ -228,7 +225,7 @@ class OpenAPIClient:
self.config = get_provider_config(provider)
# HTTP client
self._client: Optional[httpx.AsyncClient] = None
self._client: httpx.AsyncClient | None = None
@property
def base_url(self) -> str:
@@ -268,7 +265,7 @@ class OpenAPIClient:
"""Check if spec is available (loaded or cached)."""
return self._spec is not None or self.spec_cache.has_spec(self.provider)
def spec_age(self) -> Optional[float]:
def spec_age(self) -> float | None:
"""Get age of spec in seconds."""
return self.spec_cache.spec_age(self.provider)
@@ -357,7 +354,7 @@ class OpenAPIClient:
original_error=e,
)
async def load_spec(self, spec_source: Optional[str] = None) -> None:
async def load_spec(self, spec_source: str | None = None) -> None:
"""Load OpenAPI spec from various sources.
Args:
@@ -422,7 +419,7 @@ class OpenAPIClient:
async def _load_spec_from_file(self, path: str) -> None:
"""Load spec from local file."""
try:
async with aiofiles.open(path, "r") as f:
async with aiofiles.open(path) as f:
content = await f.read()
if path.endswith((".yaml", ".yml")):
@@ -507,7 +504,7 @@ class OpenAPIClient:
op_data: dict,
common_params: list,
seen_ids: set[str],
) -> Optional[Operation]:
) -> Operation | None:
"""Parse a single operation."""
# Generate unique operation ID
op_id = op_data.get("operationId")
@@ -599,7 +596,7 @@ class OpenAPIClient:
deprecated=op_data.get("deprecated", False),
)
def _resolve_ref(self, ref: str) -> Optional[dict]:
def _resolve_ref(self, ref: str) -> dict | None:
"""Resolve a JSON reference in the spec."""
if not ref.startswith("#/"):
return None
@@ -623,11 +620,11 @@ class OpenAPIClient:
def list_operations(
self,
search: Optional[str] = None,
tag: Optional[str] = None,
method: Optional[str] = None,
path_contains: Optional[str] = None,
operation_id_prefix: Optional[str] = None,
search: str | None = None,
tag: str | None = None,
method: str | None = None,
path_contains: str | None = None,
operation_id_prefix: str | None = None,
include_deprecated: bool = False,
) -> OperationListResult:
"""List available operations with filtering.
@@ -796,9 +793,9 @@ class OpenAPIClient:
async def call(
self,
operation_id: str,
params: Optional[dict[str, Any]] = None,
body: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
dry_run: bool = False,
) -> APICallResult:
"""Call an API operation.
@@ -874,9 +871,9 @@ class OpenAPIClient:
self,
method: str,
path: str,
params: Optional[dict[str, Any]] = None,
body: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
dry_run: bool = False,
) -> APICallResult:
"""Make a raw API call without using operation definitions.
@@ -929,8 +926,8 @@ class OpenAPIClient:
method: str,
url: str,
headers: dict[str, str],
body: Optional[dict[str, Any]] = None,
operation_id: Optional[str] = None,
body: dict[str, Any] | None = None,
operation_id: str | None = None,
) -> APICallResult:
"""Make the actual HTTP request."""
client = await self._get_client()
@@ -994,7 +991,7 @@ class OpenAPIClient:
self,
operation: Operation,
params: dict[str, Any],
body: Optional[dict[str, Any]],
body: dict[str, Any] | None,
) -> None:
"""Validate parameters against operation schema."""
missing = []
@@ -1026,7 +1023,7 @@ class OpenAPIClient:
_clients: dict[str, OpenAPIClient] = {}
async def get_client(provider: str, spec_url: Optional[str] = None) -> OpenAPIClient:
async def get_client(provider: str, spec_url: str | None = None) -> OpenAPIClient:
"""Get or create an OpenAPI client for a provider.
Args:
@@ -452,11 +452,7 @@ def get_env_vars(provider: str) -> list[str]:
def list_providers() -> list[str]:
"""List all known provider names (1100+ providers)."""
return sorted(
set(PROVIDER_CONFIGS.keys())
| set(ENV_VAR_MAPPINGS.keys())
| set(APIS_GURU_PROVIDERS.keys())
)
return sorted(set(PROVIDER_CONFIGS.keys()) | set(ENV_VAR_MAPPINGS.keys()) | set(APIS_GURU_PROVIDERS.keys()))
def list_providers_with_specs() -> list[str]:
+10 -11
View File
@@ -14,7 +14,6 @@ import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
import aiofiles
@@ -31,7 +30,7 @@ class CredentialStorage(ABC):
"""
@abstractmethod
async def get(self, provider: str) -> Optional[Credential]:
async def get(self, provider: str) -> Credential | None:
"""Retrieve credential for a provider.
Args:
@@ -83,7 +82,7 @@ class FileCredentialStorage(CredentialStorage):
credentials, consider using a proper secrets manager.
"""
def __init__(self, path: Optional[Path] = None):
def __init__(self, path: Path | None = None):
"""Initialize file storage.
Args:
@@ -119,7 +118,7 @@ class FileCredentialStorage(CredentialStorage):
if self.path.exists():
try:
async with aiofiles.open(self.path, "r") as f:
async with aiofiles.open(self.path) as f:
content = await f.read()
data = json.loads(content)
@@ -156,7 +155,7 @@ class FileCredentialStorage(CredentialStorage):
# Set restrictive permissions
self.path.chmod(0o600)
async def get(self, provider: str) -> Optional[Credential]:
async def get(self, provider: str) -> Credential | None:
await self._load()
return self._cache.get(provider)
@@ -185,7 +184,7 @@ class EnvironmentCredentialStorage(CredentialStorage):
provider-specific mappings.
"""
def __init__(self, env_mappings: Optional[dict[str, list[str]]] = None):
def __init__(self, env_mappings: dict[str, list[str]] | None = None):
"""Initialize environment storage.
Args:
@@ -195,7 +194,7 @@ class EnvironmentCredentialStorage(CredentialStorage):
self.env_mappings = env_mappings or ENV_VAR_MAPPINGS
async def get(self, provider: str) -> Optional[Credential]:
async def get(self, provider: str) -> Credential | None:
env_vars = self.env_mappings.get(provider, [])
for var in env_vars:
@@ -235,7 +234,7 @@ class MemoryCredentialStorage(CredentialStorage):
def __init__(self):
self._credentials: dict[str, Credential] = {}
async def get(self, provider: str) -> Optional[Credential]:
async def get(self, provider: str) -> Credential | None:
return self._credentials.get(provider)
async def set(self, credential: Credential) -> None:
@@ -263,7 +262,7 @@ class ChainedCredentialStorage(CredentialStorage):
3. Environment variables
"""
def __init__(self, stores: Optional[list[CredentialStorage]] = None):
def __init__(self, stores: list[CredentialStorage] | None = None):
"""Initialize chained storage.
Args:
@@ -278,7 +277,7 @@ class ChainedCredentialStorage(CredentialStorage):
]
self.stores = stores
async def get(self, provider: str) -> Optional[Credential]:
async def get(self, provider: str) -> Credential | None:
"""Get credential from first store that has it."""
for store in self.stores:
cred = await store.get(provider)
@@ -286,7 +285,7 @@ class ChainedCredentialStorage(CredentialStorage):
return cred
return None
async def get_with_source(self, provider: str) -> tuple[Optional[Credential], Optional[CredentialStorage]]:
async def get_with_source(self, provider: str) -> tuple[Credential | None, CredentialStorage | None]:
"""Get credential and its source store."""
for store in self.stores:
cred = await store.get(provider)
+225 -208
View File
@@ -1,170 +1,185 @@
#!/usr/bin/env python3
"""Generate provider configurations from APIs.guru with LLM-optimized descriptions from oapis.org."""
import asyncio
import json
import re
import sys
from pathlib import Path
import urllib.request
from concurrent.futures import ThreadPoolExecutor
# Common env var patterns
ENV_VAR_PATTERNS = {
'stripe': ['STRIPE_API_KEY', 'STRIPE_SECRET_KEY'],
'twilio': ['TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN'],
'github': ['GITHUB_TOKEN', 'GH_TOKEN'],
'gitlab': ['GITLAB_TOKEN'],
'slack': ['SLACK_TOKEN', 'SLACK_BOT_TOKEN'],
'discord': ['DISCORD_TOKEN', 'DISCORD_BOT_TOKEN'],
'shopify': ['SHOPIFY_API_KEY', 'SHOPIFY_ACCESS_TOKEN'],
'hubspot': ['HUBSPOT_API_KEY', 'HUBSPOT_ACCESS_TOKEN'],
'mailchimp': ['MAILCHIMP_API_KEY'],
'sendgrid': ['SENDGRID_API_KEY'],
'mailgun': ['MAILGUN_API_KEY'],
'contentful': ['CONTENTFUL_ACCESS_TOKEN'],
'airtable': ['AIRTABLE_API_KEY'],
'notion': ['NOTION_API_KEY', 'NOTION_TOKEN'],
'figma': ['FIGMA_TOKEN', 'FIGMA_ACCESS_TOKEN'],
'asana': ['ASANA_TOKEN', 'ASANA_ACCESS_TOKEN'],
'trello': ['TRELLO_API_KEY', 'TRELLO_TOKEN'],
'jira': ['JIRA_TOKEN', 'JIRA_API_TOKEN'],
'confluence': ['CONFLUENCE_TOKEN'],
'bitbucket': ['BITBUCKET_TOKEN'],
'dropbox': ['DROPBOX_ACCESS_TOKEN'],
'box': ['BOX_ACCESS_TOKEN'],
'spotify': ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET'],
'youtube': ['YOUTUBE_API_KEY'],
'twitter': ['TWITTER_API_KEY', 'TWITTER_BEARER_TOKEN'],
'linkedin': ['LINKEDIN_ACCESS_TOKEN'],
'facebook': ['FACEBOOK_ACCESS_TOKEN'],
'instagram': ['INSTAGRAM_ACCESS_TOKEN'],
'pinterest': ['PINTEREST_ACCESS_TOKEN'],
'reddit': ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET'],
'zendesk': ['ZENDESK_API_TOKEN'],
'freshdesk': ['FRESHDESK_API_KEY'],
'intercom': ['INTERCOM_ACCESS_TOKEN'],
'mixpanel': ['MIXPANEL_TOKEN'],
'segment': ['SEGMENT_WRITE_KEY'],
'amplitude': ['AMPLITUDE_API_KEY'],
'plaid': ['PLAID_CLIENT_ID', 'PLAID_SECRET'],
'square': ['SQUARE_ACCESS_TOKEN'],
'paypal': ['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET'],
'braintree': ['BRAINTREE_MERCHANT_ID'],
'quickbooks': ['QUICKBOOKS_CLIENT_ID'],
'xero': ['XERO_CLIENT_ID'],
'salesforce': ['SALESFORCE_ACCESS_TOKEN'],
'pipedrive': ['PIPEDRIVE_API_TOKEN'],
'zoho': ['ZOHO_ACCESS_TOKEN'],
'monday': ['MONDAY_API_KEY'],
'clickup': ['CLICKUP_API_KEY'],
'linear': ['LINEAR_API_KEY'],
'vercel': ['VERCEL_TOKEN'],
'netlify': ['NETLIFY_AUTH_TOKEN'],
'heroku': ['HEROKU_API_KEY'],
'digitalocean': ['DIGITALOCEAN_TOKEN', 'DO_TOKEN'],
'linode': ['LINODE_TOKEN'],
'vultr': ['VULTR_API_KEY'],
'cloudflare': ['CLOUDFLARE_API_TOKEN', 'CF_API_TOKEN'],
'datadog': ['DD_API_KEY', 'DATADOG_API_KEY'],
'newrelic': ['NEW_RELIC_API_KEY'],
'sentry': ['SENTRY_AUTH_TOKEN'],
'pagerduty': ['PAGERDUTY_API_KEY'],
'opsgenie': ['OPSGENIE_API_KEY'],
'splunk': ['SPLUNK_TOKEN'],
'elasticsearch': ['ELASTIC_API_KEY'],
'algolia': ['ALGOLIA_API_KEY'],
'meilisearch': ['MEILISEARCH_API_KEY'],
'typesense': ['TYPESENSE_API_KEY'],
'twitch': ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET'],
'ebay': ['EBAY_APP_ID', 'EBAY_DEV_ID'],
'etsy': ['ETSY_API_KEY'],
'walmart': ['WALMART_CLIENT_ID'],
'bestbuy': ['BESTBUY_API_KEY'],
'yelp': ['YELP_API_KEY'],
'foursquare': ['FOURSQUARE_API_KEY'],
'tripadvisor': ['TRIPADVISOR_API_KEY'],
'airbnb': ['AIRBNB_API_KEY'],
'uber': ['UBER_ACCESS_TOKEN'],
'lyft': ['LYFT_ACCESS_TOKEN'],
'doordash': ['DOORDASH_API_KEY'],
'postmates': ['POSTMATES_API_KEY'],
'mapbox': ['MAPBOX_ACCESS_TOKEN'],
'here': ['HERE_API_KEY'],
'tomtom': ['TOMTOM_API_KEY'],
'openweathermap': ['OPENWEATHERMAP_API_KEY'],
'weatherapi': ['WEATHERAPI_KEY'],
'newsapi': ['NEWSAPI_KEY'],
'nytimes': ['NYTIMES_API_KEY'],
'guardian': ['GUARDIAN_API_KEY'],
'giphy': ['GIPHY_API_KEY'],
'unsplash': ['UNSPLASH_ACCESS_KEY'],
'pexels': ['PEXELS_API_KEY'],
'cloudinary': ['CLOUDINARY_API_KEY'],
'imgix': ['IMGIX_API_KEY'],
'uploadcare': ['UPLOADCARE_PUBLIC_KEY'],
'filestack': ['FILESTACK_API_KEY'],
'agora': ['AGORA_APP_ID'],
'vonage': ['VONAGE_API_KEY', 'NEXMO_API_KEY'],
'messagebird': ['MESSAGEBIRD_API_KEY'],
'bandwidth': ['BANDWIDTH_API_TOKEN'],
'telnyx': ['TELNYX_API_KEY'],
'apilayer': ['APILAYER_API_KEY'],
'currencylayer': ['CURRENCYLAYER_API_KEY'],
'exchangerate': ['EXCHANGERATE_API_KEY'],
'coinbase': ['COINBASE_API_KEY'],
'binance': ['BINANCE_API_KEY'],
'kraken': ['KRAKEN_API_KEY'],
'alchemy': ['ALCHEMY_API_KEY'],
'infura': ['INFURA_PROJECT_ID'],
'moralis': ['MORALIS_API_KEY'],
'thegraph': ['THEGRAPH_API_KEY'],
'openai': ['OPENAI_API_KEY'],
'anthropic': ['ANTHROPIC_API_KEY'],
'cohere': ['COHERE_API_KEY'],
'huggingface': ['HF_TOKEN', 'HUGGINGFACE_TOKEN'],
'replicate': ['REPLICATE_API_TOKEN'],
'stability': ['STABILITY_API_KEY'],
'deepl': ['DEEPL_API_KEY'],
'ably': ['ABLY_API_KEY'],
'pusher': ['PUSHER_APP_KEY'],
'pubnub': ['PUBNUB_SUBSCRIBE_KEY'],
'firebase': ['FIREBASE_API_KEY'],
'supabase': ['SUPABASE_API_KEY'],
'mongodb': ['MONGODB_API_KEY'],
'redis': ['REDIS_API_KEY'],
'cockroachdb': ['COCKROACHDB_API_KEY'],
'planetscale': ['PLANETSCALE_TOKEN'],
'neon': ['NEON_API_KEY'],
'upstash': ['UPSTASH_API_KEY'],
'fauna': ['FAUNA_SECRET'],
'adyen': ['ADYEN_API_KEY'],
'sportsdata': ['SPORTSDATA_API_KEY'],
'amadeus': ['AMADEUS_API_KEY', 'AMADEUS_API_SECRET'],
'nexmo': ['NEXMO_API_KEY', 'NEXMO_API_SECRET'],
'mastercard': ['MASTERCARD_API_KEY'],
'hubapi': ['HUBSPOT_API_KEY'],
'apideck': ['APIDECK_API_KEY'],
'codat': ['CODAT_API_KEY'],
'deutschebahn': ['DEUTSCHEBAHN_API_KEY'],
'rapidapi': ['RAPIDAPI_KEY'],
'whapi': ['WHAPI_TOKEN'],
'vtex': ['VTEX_APP_KEY', 'VTEX_APP_TOKEN'],
'interzoid': ['INTERZOID_API_KEY'],
"stripe": ["STRIPE_API_KEY", "STRIPE_SECRET_KEY"],
"twilio": ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"],
"github": ["GITHUB_TOKEN", "GH_TOKEN"],
"gitlab": ["GITLAB_TOKEN"],
"slack": ["SLACK_TOKEN", "SLACK_BOT_TOKEN"],
"discord": ["DISCORD_TOKEN", "DISCORD_BOT_TOKEN"],
"shopify": ["SHOPIFY_API_KEY", "SHOPIFY_ACCESS_TOKEN"],
"hubspot": ["HUBSPOT_API_KEY", "HUBSPOT_ACCESS_TOKEN"],
"mailchimp": ["MAILCHIMP_API_KEY"],
"sendgrid": ["SENDGRID_API_KEY"],
"mailgun": ["MAILGUN_API_KEY"],
"contentful": ["CONTENTFUL_ACCESS_TOKEN"],
"airtable": ["AIRTABLE_API_KEY"],
"notion": ["NOTION_API_KEY", "NOTION_TOKEN"],
"figma": ["FIGMA_TOKEN", "FIGMA_ACCESS_TOKEN"],
"asana": ["ASANA_TOKEN", "ASANA_ACCESS_TOKEN"],
"trello": ["TRELLO_API_KEY", "TRELLO_TOKEN"],
"jira": ["JIRA_TOKEN", "JIRA_API_TOKEN"],
"confluence": ["CONFLUENCE_TOKEN"],
"bitbucket": ["BITBUCKET_TOKEN"],
"dropbox": ["DROPBOX_ACCESS_TOKEN"],
"box": ["BOX_ACCESS_TOKEN"],
"spotify": ["SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET"],
"youtube": ["YOUTUBE_API_KEY"],
"twitter": ["TWITTER_API_KEY", "TWITTER_BEARER_TOKEN"],
"linkedin": ["LINKEDIN_ACCESS_TOKEN"],
"facebook": ["FACEBOOK_ACCESS_TOKEN"],
"instagram": ["INSTAGRAM_ACCESS_TOKEN"],
"pinterest": ["PINTEREST_ACCESS_TOKEN"],
"reddit": ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"],
"zendesk": ["ZENDESK_API_TOKEN"],
"freshdesk": ["FRESHDESK_API_KEY"],
"intercom": ["INTERCOM_ACCESS_TOKEN"],
"mixpanel": ["MIXPANEL_TOKEN"],
"segment": ["SEGMENT_WRITE_KEY"],
"amplitude": ["AMPLITUDE_API_KEY"],
"plaid": ["PLAID_CLIENT_ID", "PLAID_SECRET"],
"square": ["SQUARE_ACCESS_TOKEN"],
"paypal": ["PAYPAL_CLIENT_ID", "PAYPAL_CLIENT_SECRET"],
"braintree": ["BRAINTREE_MERCHANT_ID"],
"quickbooks": ["QUICKBOOKS_CLIENT_ID"],
"xero": ["XERO_CLIENT_ID"],
"salesforce": ["SALESFORCE_ACCESS_TOKEN"],
"pipedrive": ["PIPEDRIVE_API_TOKEN"],
"zoho": ["ZOHO_ACCESS_TOKEN"],
"monday": ["MONDAY_API_KEY"],
"clickup": ["CLICKUP_API_KEY"],
"linear": ["LINEAR_API_KEY"],
"vercel": ["VERCEL_TOKEN"],
"netlify": ["NETLIFY_AUTH_TOKEN"],
"heroku": ["HEROKU_API_KEY"],
"digitalocean": ["DIGITALOCEAN_TOKEN", "DO_TOKEN"],
"linode": ["LINODE_TOKEN"],
"vultr": ["VULTR_API_KEY"],
"cloudflare": ["CLOUDFLARE_API_TOKEN", "CF_API_TOKEN"],
"datadog": ["DD_API_KEY", "DATADOG_API_KEY"],
"newrelic": ["NEW_RELIC_API_KEY"],
"sentry": ["SENTRY_AUTH_TOKEN"],
"pagerduty": ["PAGERDUTY_API_KEY"],
"opsgenie": ["OPSGENIE_API_KEY"],
"splunk": ["SPLUNK_TOKEN"],
"elasticsearch": ["ELASTIC_API_KEY"],
"algolia": ["ALGOLIA_API_KEY"],
"meilisearch": ["MEILISEARCH_API_KEY"],
"typesense": ["TYPESENSE_API_KEY"],
"twitch": ["TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET"],
"ebay": ["EBAY_APP_ID", "EBAY_DEV_ID"],
"etsy": ["ETSY_API_KEY"],
"walmart": ["WALMART_CLIENT_ID"],
"bestbuy": ["BESTBUY_API_KEY"],
"yelp": ["YELP_API_KEY"],
"foursquare": ["FOURSQUARE_API_KEY"],
"tripadvisor": ["TRIPADVISOR_API_KEY"],
"airbnb": ["AIRBNB_API_KEY"],
"uber": ["UBER_ACCESS_TOKEN"],
"lyft": ["LYFT_ACCESS_TOKEN"],
"doordash": ["DOORDASH_API_KEY"],
"postmates": ["POSTMATES_API_KEY"],
"mapbox": ["MAPBOX_ACCESS_TOKEN"],
"here": ["HERE_API_KEY"],
"tomtom": ["TOMTOM_API_KEY"],
"openweathermap": ["OPENWEATHERMAP_API_KEY"],
"weatherapi": ["WEATHERAPI_KEY"],
"newsapi": ["NEWSAPI_KEY"],
"nytimes": ["NYTIMES_API_KEY"],
"guardian": ["GUARDIAN_API_KEY"],
"giphy": ["GIPHY_API_KEY"],
"unsplash": ["UNSPLASH_ACCESS_KEY"],
"pexels": ["PEXELS_API_KEY"],
"cloudinary": ["CLOUDINARY_API_KEY"],
"imgix": ["IMGIX_API_KEY"],
"uploadcare": ["UPLOADCARE_PUBLIC_KEY"],
"filestack": ["FILESTACK_API_KEY"],
"agora": ["AGORA_APP_ID"],
"vonage": ["VONAGE_API_KEY", "NEXMO_API_KEY"],
"messagebird": ["MESSAGEBIRD_API_KEY"],
"bandwidth": ["BANDWIDTH_API_TOKEN"],
"telnyx": ["TELNYX_API_KEY"],
"apilayer": ["APILAYER_API_KEY"],
"currencylayer": ["CURRENCYLAYER_API_KEY"],
"exchangerate": ["EXCHANGERATE_API_KEY"],
"coinbase": ["COINBASE_API_KEY"],
"binance": ["BINANCE_API_KEY"],
"kraken": ["KRAKEN_API_KEY"],
"alchemy": ["ALCHEMY_API_KEY"],
"infura": ["INFURA_PROJECT_ID"],
"moralis": ["MORALIS_API_KEY"],
"thegraph": ["THEGRAPH_API_KEY"],
"openai": ["OPENAI_API_KEY"],
"anthropic": ["ANTHROPIC_API_KEY"],
"cohere": ["COHERE_API_KEY"],
"huggingface": ["HF_TOKEN", "HUGGINGFACE_TOKEN"],
"replicate": ["REPLICATE_API_TOKEN"],
"stability": ["STABILITY_API_KEY"],
"deepl": ["DEEPL_API_KEY"],
"ably": ["ABLY_API_KEY"],
"pusher": ["PUSHER_APP_KEY"],
"pubnub": ["PUBNUB_SUBSCRIBE_KEY"],
"firebase": ["FIREBASE_API_KEY"],
"supabase": ["SUPABASE_API_KEY"],
"mongodb": ["MONGODB_API_KEY"],
"redis": ["REDIS_API_KEY"],
"cockroachdb": ["COCKROACHDB_API_KEY"],
"planetscale": ["PLANETSCALE_TOKEN"],
"neon": ["NEON_API_KEY"],
"upstash": ["UPSTASH_API_KEY"],
"fauna": ["FAUNA_SECRET"],
"adyen": ["ADYEN_API_KEY"],
"sportsdata": ["SPORTSDATA_API_KEY"],
"amadeus": ["AMADEUS_API_KEY", "AMADEUS_API_SECRET"],
"nexmo": ["NEXMO_API_KEY", "NEXMO_API_SECRET"],
"mastercard": ["MASTERCARD_API_KEY"],
"hubapi": ["HUBSPOT_API_KEY"],
"apideck": ["APIDECK_API_KEY"],
"codat": ["CODAT_API_KEY"],
"deutschebahn": ["DEUTSCHEBAHN_API_KEY"],
"rapidapi": ["RAPIDAPI_KEY"],
"whapi": ["WHAPI_TOKEN"],
"vtex": ["VTEX_APP_KEY", "VTEX_APP_TOKEN"],
"interzoid": ["INTERZOID_API_KEY"],
}
# Skip cloud-specific APIs that need special auth handling
SKIP_PREFIXES = [
'amazonaws', 'azure', 'googleapis', 'google.',
'apisetu', 'parliament', 'gov.', 'opto22',
'windows', 'o365', 'microsofthealth',
"amazonaws",
"azure",
"googleapis",
"google.",
"apisetu",
"parliament",
"gov.",
"opto22",
"windows",
"o365",
"microsofthealth",
]
# Popular APIs with oapis.org LLM-friendly descriptions
# These get priority loading and better descriptions
OAPIS_POPULAR = [
'github', 'stripe', 'twilio', 'slack', 'notion', 'discord',
'shopify', 'spotify', 'twitter', 'openai', 'anthropic',
"github",
"stripe",
"twilio",
"slack",
"notion",
"discord",
"shopify",
"spotify",
"twitter",
"openai",
"anthropic",
]
@@ -175,21 +190,21 @@ def get_env_vars(name: str) -> list[str]:
if key in name_lower:
return vars
# Generate default pattern
clean = re.sub(r'[^a-z0-9]', '_', name_lower).upper()
return [f'{clean}_API_KEY', f'{clean}_TOKEN']
clean = re.sub(r"[^a-z0-9]", "_", name_lower).upper()
return [f"{clean}_API_KEY", f"{clean}_TOKEN"]
def clean_name(name: str) -> str:
"""Clean API name for use as provider ID."""
# Remove version suffixes
name = re.sub(r':\d+.*$', '', name)
name = re.sub(r":\d+.*$", "", name)
# Replace dots and colons with hyphens
name = name.replace('.', '-').replace(':', '-')
name = name.replace(".", "-").replace(":", "-")
# Remove common suffixes
name = re.sub(r'-com$|-io$|-net$|-org$|-local$', '', name)
name = re.sub(r"-com$|-io$|-net$|-org$|-local$", "", name)
# Clean up
name = re.sub(r'[^a-z0-9-]', '', name.lower())
name = re.sub(r'-+', '-', name).strip('-')
name = re.sub(r"[^a-z0-9-]", "", name.lower())
name = re.sub(r"-+", "-", name).strip("-")
return name
@@ -200,7 +215,7 @@ def fetch_oapis_slop(name: str) -> dict | None:
"""
try:
url = f"https://oapis.org/slop/{name}"
req = urllib.request.Request(url, headers={'User-Agent': 'hanzo-tools-api/0.2'})
req = urllib.request.Request(url, headers={"User-Agent": "hanzo-tools-api/0.2"})
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read().decode()
@@ -208,21 +223,21 @@ def fetch_oapis_slop(name: str) -> dict | None:
result = {}
# Extract base URL (usually on a line with https://)
base_match = re.search(r'Base URL:\s*(https?://[^\s]+)', content, re.IGNORECASE)
base_match = re.search(r"Base URL:\s*(https?://[^\s]+)", content, re.IGNORECASE)
if not base_match:
base_match = re.search(r'\*\*Base URL\*\*:\s*(https?://[^\s]+)', content, re.IGNORECASE)
base_match = re.search(r"\*\*Base URL\*\*:\s*(https?://[^\s]+)", content, re.IGNORECASE)
if base_match:
result['base_url'] = base_match.group(1).rstrip('/')
result["base_url"] = base_match.group(1).rstrip("/")
# Extract endpoint count
count_match = re.search(r'(\d+)\s+endpoints?', content, re.IGNORECASE)
count_match = re.search(r"(\d+)\s+endpoints?", content, re.IGNORECASE)
if count_match:
result['endpoints_count'] = int(count_match.group(1))
result["endpoints_count"] = int(count_match.group(1))
# Extract description (first paragraph after title)
desc_match = re.search(r'^#[^\n]+\n+([^#\n][^\n]+)', content, re.MULTILINE)
desc_match = re.search(r"^#[^\n]+\n+([^#\n][^\n]+)", content, re.MULTILINE)
if desc_match:
result['description'] = desc_match.group(1).strip()[:200]
result["description"] = desc_match.group(1).strip()[:200]
return result if result else None
except Exception:
@@ -274,17 +289,17 @@ def main():
if skip:
continue
preferred = info.get('preferred', '')
if not preferred or preferred not in info.get('versions', {}):
preferred = info.get("preferred", "")
if not preferred or preferred not in info.get("versions", {}):
continue
version_info = info['versions'][preferred]
spec_url = version_info.get('swaggerUrl', '')
version_info = info["versions"][preferred]
spec_url = version_info.get("swaggerUrl", "")
if not spec_url:
continue
api_info = version_info.get('info', {})
title = api_info.get('title', name)
api_info = version_info.get("info", {})
title = api_info.get("title", name)
clean = clean_name(name)
if clean in seen_names or len(clean) < 2:
@@ -292,11 +307,11 @@ def main():
seen_names.add(clean)
# Get base URL from x-origin
base_url = ''
if 'x-origin' in api_info:
x_origin = api_info['x-origin']
base_url = ""
if "x-origin" in api_info:
x_origin = api_info["x-origin"]
if isinstance(x_origin, list) and x_origin:
base_url = x_origin[0].get('url', '')
base_url = x_origin[0].get("url", "")
# Check for oapis.org enhanced data
oapis_key = None
@@ -305,81 +320,83 @@ def main():
oapis_key = popular
break
description = api_info.get('description', '')[:200] if api_info.get('description') else ''
description = api_info.get("description", "")[:200] if api_info.get("description") else ""
endpoints_count = None
if oapis_key and oapis_key in oapis_data:
oapis_info = oapis_data[oapis_key]
if oapis_info.get('base_url'):
base_url = oapis_info['base_url']
if oapis_info.get('description'):
description = oapis_info['description']
if oapis_info.get('endpoints_count'):
endpoints_count = oapis_info['endpoints_count']
if oapis_info.get("base_url"):
base_url = oapis_info["base_url"]
if oapis_info.get("description"):
description = oapis_info["description"]
if oapis_info.get("endpoints_count"):
endpoints_count = oapis_info["endpoints_count"]
# Clean strings - remove newlines, escape quotes, limit length
def clean_str(s: str, max_len: int = 150) -> str:
if not s:
return ""
# Replace various whitespace with single space
s = ' '.join(s.split())
s = " ".join(s.split())
# Escape backslashes first, then quotes
s = s.replace('\\', '\\\\').replace('"', '\\"')
s = s.replace("\\", "\\\\").replace('"', '\\"')
return s[:max_len]
apis.append({
'name': clean,
'title': clean_str(title, 60),
'description': clean_str(description, 150),
'spec_url': spec_url,
'base_url': base_url,
'env_vars': get_env_vars(clean),
'endpoints_count': endpoints_count,
})
apis.append(
{
"name": clean,
"title": clean_str(title, 60),
"description": clean_str(description, 150),
"spec_url": spec_url,
"base_url": base_url,
"env_vars": get_env_vars(clean),
"endpoints_count": endpoints_count,
}
)
# Sort by name
apis.sort(key=lambda x: x['name'])
apis.sort(key=lambda x: x["name"])
# Generate Python code
print('"""Auto-generated provider configurations from APIs.guru + oapis.org.')
print()
print(f'Generated {len(apis)} provider configurations.')
print('Includes LLM-optimized descriptions from oapis.org for popular APIs.')
print(f"Generated {len(apis)} provider configurations.")
print("Includes LLM-optimized descriptions from oapis.org for popular APIs.")
print()
print('Regenerate with: python scripts/generate_providers.py > hanzo_tools/api/apis_guru_providers.py')
print("Regenerate with: python scripts/generate_providers.py > hanzo_tools/api/apis_guru_providers.py")
print('"""')
print()
print('from typing import Any')
print("from typing import Any")
print()
print()
print('# =============================================================================')
print('# APIs.guru Provider Configurations (with oapis.org enhancements)')
print('# =============================================================================')
print("# =============================================================================")
print("# APIs.guru Provider Configurations (with oapis.org enhancements)")
print("# =============================================================================")
print()
print('APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {')
print("APIS_GURU_PROVIDERS: dict[str, dict[str, Any]] = {")
for api in apis:
env_str = ', '.join(f'"{v}"' for v in api['env_vars'][:2])
env_str = ", ".join(f'"{v}"' for v in api["env_vars"][:2])
print(f' "{api["name"]}": {{')
print(f' "display_name": "{api["title"]}",')
if api['description']:
if api["description"]:
print(f' "description": "{api["description"][:150]}",')
print(f' "spec_url": "{api["spec_url"]}",')
if api['base_url']:
safe_url = api['base_url'].replace('"', '\\"')
if api["base_url"]:
safe_url = api["base_url"].replace('"', '\\"')
print(f' "base_url": "{safe_url}",')
print(f' "env_vars": [{env_str}],')
if api['endpoints_count']:
if api["endpoints_count"]:
print(f' "endpoints_count": {api["endpoints_count"]},')
print(' },')
print(" },")
print('}')
print("}")
print()
print()
print(f'# Total: {len(apis)} providers')
print(f"# Total: {len(apis)} providers")
print(f"\nGenerated {len(apis)} providers", file=sys.stderr)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+48 -11
View File
@@ -8,8 +8,8 @@ Usage:
python scripts/preload_specs.py github stripe # Download specific specs
"""
import asyncio
import argparse
import asyncio
import sys
from pathlib import Path
@@ -17,28 +17,65 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from hanzo_tools.api.client import APIClient
from hanzo_tools.api.providers import list_providers_with_specs, get_provider_config
from hanzo_tools.api.providers import get_provider_config, list_providers_with_specs
# Top 50 most popular APIs to pre-cache
POPULAR_APIS = [
# AI/ML
"openai", "anthropic", "together", "groq", "mistral", "cohere", "replicate",
"openai",
"anthropic",
"together",
"groq",
"mistral",
"cohere",
"replicate",
# Developer
"github", "gitlab", "bitbucket", "vercel", "netlify", "fly", "railway", "render",
"github",
"gitlab",
"bitbucket",
"vercel",
"netlify",
"fly",
"railway",
"render",
# Cloud
"cloudflare", "digitalocean", "hetzner", "linode", "vultr",
"cloudflare",
"digitalocean",
"hetzner",
"linode",
"vultr",
# Payment
"stripe", "paypal", "adyen-com-accountservice", "square",
"stripe",
"paypal",
"adyen-com-accountservice",
"square",
# Communication
"twilio", "sendgrid", "resend", "slack", "discord",
"twilio",
"sendgrid",
"resend",
"slack",
"discord",
# Databases
"supabase", "neon", "planetscale", "mongodb", "redis",
"supabase",
"neon",
"planetscale",
"mongodb",
"redis",
# Search
"algolia", "elasticsearch", "meilisearch", "typesense",
"algolia",
"elasticsearch",
"meilisearch",
"typesense",
# Monitoring
"datadog", "sentry", "newrelic",
"datadog",
"sentry",
"newrelic",
# Other popular
"notion", "airtable", "hubspot", "shopify", "jira",
"notion",
"airtable",
"hubspot",
"shopify",
"jira",
]
+10 -11
View File
@@ -1,29 +1,26 @@
"""Tests for hanzo-tools-api v0.2.0."""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import AsyncMock, patch
import pytest
from hanzo_tools.api import (
ENV_VAR_MAPPINGS,
PROVIDER_CONFIGS,
APIClient,
APITool,
Credential,
CredentialManager,
OpenAPIClient,
SpecCache,
get_credential_manager,
reset_credential_manager,
PROVIDER_CONFIGS,
ENV_VAR_MAPPINGS,
)
from hanzo_tools.api.models import AuthType
from hanzo_tools.api.storage import (
MemoryCredentialStorage,
ChainedCredentialStorage,
MemoryCredentialStorage,
)
@@ -89,10 +86,12 @@ class TestCredentialManager:
from hanzo_tools.api.storage import EnvironmentCredentialStorage
env_mappings = {"testprov": ["TESTPROV_API_KEY"]}
storage = ChainedCredentialStorage([
MemoryCredentialStorage(),
EnvironmentCredentialStorage(env_mappings),
])
storage = ChainedCredentialStorage(
[
MemoryCredentialStorage(),
EnvironmentCredentialStorage(env_mappings),
]
)
cred_manager = CredentialManager(
config_dir=temp_config_dir,
@@ -9,9 +9,9 @@ Integration with Hanzo Browser Extension:
No manual setup required - just install the extension and use hanzo-mcp.
"""
import os
import asyncio
import logging
import os
import threading
from typing import Optional
@@ -32,9 +32,9 @@ logger = logging.getLogger(__name__)
# CDP Bridge for browser extension integration
try:
from hanzo_tools.browser.cdp_bridge_server import (
CDPBridgeServer,
CDPBridgeClient,
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
CDPBridgeClient,
CDPBridgeServer,
)
except ImportError:
CDP_BRIDGE_AVAILABLE = False
@@ -133,10 +133,7 @@ def start_cdp_bridge(
try:
_cdp_bridge_thread = threading.Thread(
target=_run_cdp_bridge_server,
args=(host, port),
daemon=True,
name="cdp-bridge-server"
target=_run_cdp_bridge_server, args=(host, port), daemon=True, name="cdp-bridge-server"
)
_cdp_bridge_thread.start()
logger.info(f"CDP bridge started on ws://{host}:{port}")
@@ -153,10 +150,7 @@ def stop_cdp_bridge() -> None:
if _cdp_bridge_loop is not None:
try:
if _cdp_bridge_server is not None:
asyncio.run_coroutine_threadsafe(
_cdp_bridge_server.stop(),
_cdp_bridge_loop
)
asyncio.run_coroutine_threadsafe(_cdp_bridge_server.stop(), _cdp_bridge_loop)
except Exception:
pass
@@ -63,11 +63,9 @@ async def _check_extension() -> bool:
"""Check if Hanzo browser extension is connected."""
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
"http://localhost:9224/status",
timeout=aiohttp.ClientTimeout(total=1)
) as resp:
async with session.get("http://localhost:9224/status", timeout=aiohttp.ClientTimeout(total=1)) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("connected", False)
@@ -80,15 +78,14 @@ async def _extension_command(action: str, **kwargs) -> Optional[dict]:
"""Send command to Hanzo browser extension."""
try:
import aiohttp
# Filter out None values
payload = {"action": action}
payload.update({k: v for k, v in kwargs.items() if v is not None})
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost:9224",
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
"http://localhost:9224", json=payload, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
return await resp.json()
@@ -732,8 +729,7 @@ CATEGORIES:
extension_actions = {"navigate", "screenshot", "click", "fill", "evaluate", "tabs", "status"}
if action in extension_actions:
ext_result = await _extension_command(
action, url=url, selector=sel, text=text, value=value,
code=code, expression=code, full_page=full_page
action, url=url, selector=sel, text=text, value=value, code=code, expression=code, full_page=full_page
)
if ext_result is not None and "error" not in ext_result:
# Normalize response
@@ -745,9 +741,9 @@ CATEGORIES:
if not PLAYWRIGHT_AVAILABLE:
return {
"error": "Browser extension not connected and Playwright not installed. "
"Either connect the Hanzo browser extension or install Playwright: "
"pip install playwright && playwright install chromium",
"action": action
"Either connect the Hanzo browser extension or install Playwright: "
"pip install playwright && playwright install chromium",
"action": action,
}
pool = await BrowserPool.get_instance()
@@ -25,19 +25,19 @@ Environment Variables:
HANZO_CDP_HTTP_PORT: Port for the HTTP API (default: 9224)
"""
import asyncio
import json
import logging
import os
import json
import time
import uuid
from dataclasses import dataclass, field
import asyncio
import logging
from typing import Any, Callable, Optional
from dataclasses import field, dataclass
try:
import websockets
from websockets.server import serve as ws_serve
from websockets.server import WebSocketServerProtocol
from websockets.server import WebSocketServerProtocol, serve as ws_serve
WEBSOCKETS_AVAILABLE = True
except ImportError:
WEBSOCKETS_AVAILABLE = False
@@ -46,6 +46,7 @@ except ImportError:
# Try to import aiohttp for HTTP API server
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
@@ -57,6 +58,7 @@ logger = logging.getLogger(__name__)
@dataclass
class ExtensionClient:
"""Represents a connected browser extension client."""
client_id: str
websocket: WebSocketServerProtocol
browser: str = "unknown"
@@ -115,10 +117,7 @@ class CDPBridgeServer:
if not self.extension_clients:
return None
# Return the client with most recent last_active timestamp
return max(
self.extension_clients.keys(),
key=lambda cid: self.extension_clients[cid].last_active
)
return max(self.extension_clients.keys(), key=lambda cid: self.extension_clients[cid].last_active)
@property
def default_client(self) -> Optional[ExtensionClient]:
@@ -129,10 +128,7 @@ class CDPBridgeServer:
async def start(self) -> None:
"""Start the WebSocket and HTTP servers."""
if not WEBSOCKETS_AVAILABLE:
raise ImportError(
"websockets package required for CDP bridge. "
"Install with: pip install websockets"
)
raise ImportError("websockets package required for CDP bridge. Install with: pip install websockets")
# Start WebSocket server for browser extensions
self._server = await ws_serve(
@@ -157,11 +153,7 @@ class CDPBridgeServer:
self._http_runner = web.AppRunner(app)
await self._http_runner.setup()
self._http_server = web.TCPSite(
self._http_runner,
self.host,
self.http_port
)
self._http_server = web.TCPSite(self._http_runner, self.host, self.http_port)
await self._http_server.start()
logger.info(f"CDP Bridge HTTP API started on http://{self.host}:{self.http_port}")
@@ -173,17 +165,20 @@ class CDPBridgeServer:
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
},
)
async def _http_status(self, request: "web.Request") -> "web.Response":
"""HTTP endpoint for status check."""
connected = len(self.extension_clients) > 0
return web.json_response({
"connected": connected,
"clients": len(self.extension_clients),
"default_client_id": self.default_client_id,
}, headers={"Access-Control-Allow-Origin": "*"})
return web.json_response(
{
"connected": connected,
"clients": len(self.extension_clients),
"default_client_id": self.default_client_id,
},
headers={"Access-Control-Allow-Origin": "*"},
)
async def _http_command(self, request: "web.Request") -> "web.Response":
"""HTTP endpoint for sending commands to browser extension."""
@@ -191,9 +186,7 @@ class CDPBridgeServer:
data = await request.json()
except Exception as e:
return web.json_response(
{"error": f"Invalid JSON: {e}"},
status=400,
headers={"Access-Control-Allow-Origin": "*"}
{"error": f"Invalid JSON: {e}"}, status=400, headers={"Access-Control-Allow-Origin": "*"}
)
# Resolve target client
@@ -203,11 +196,15 @@ class CDPBridgeServer:
)
if not target_client:
return web.json_response({
"error": "No browser extension connected"
return web.json_response(
{
"error": "No browser extension connected"
if not self.extension_clients
else f"Client not found: {data.get('client_id') or data.get('target_id')}"
}, status=503, headers={"Access-Control-Allow-Origin": "*"})
},
status=503,
headers={"Access-Control-Allow-Origin": "*"},
)
# Update target client's last_active
target_client.last_active = time.time()
@@ -224,34 +221,49 @@ class CDPBridgeServer:
self.pending_requests[request_id] = future
try:
await target_client.websocket.send(json.dumps({
"id": request_id,
"method": method,
"params": params,
}))
await target_client.websocket.send(
json.dumps(
{
"id": request_id,
"method": method,
"params": params,
}
)
)
# Wait for response with timeout
response = await asyncio.wait_for(future, timeout=30.0)
# Return result
return web.json_response({
"success": True,
"client_id": target_client.client_id,
"result": response.get("result", {}),
}, headers={"Access-Control-Allow-Origin": "*"})
return web.json_response(
{
"success": True,
"client_id": target_client.client_id,
"result": response.get("result", {}),
},
headers={"Access-Control-Allow-Origin": "*"},
)
except asyncio.TimeoutError:
self.pending_requests.pop(request_id, None)
return web.json_response({
"error": "Request timeout",
"client_id": target_client.client_id,
}, status=504, headers={"Access-Control-Allow-Origin": "*"})
return web.json_response(
{
"error": "Request timeout",
"client_id": target_client.client_id,
},
status=504,
headers={"Access-Control-Allow-Origin": "*"},
)
except Exception as e:
self.pending_requests.pop(request_id, None)
return web.json_response({
"error": str(e),
}, status=500, headers={"Access-Control-Allow-Origin": "*"})
return web.json_response(
{
"error": str(e),
},
status=500,
headers={"Access-Control-Allow-Origin": "*"},
)
def _action_to_method(self, action: str) -> str:
"""Map HTTP action to CDP method."""
@@ -332,27 +344,32 @@ class CDPBridgeServer:
self.extension_clients[client_id] = client
self._ws_to_client_id[websocket] = client_id
logger.info(
f"Browser extension registered: {client_id} "
f"({client.browser}/{client.profile})"
)
logger.info(f"Browser extension registered: {client_id} ({client.browser}/{client.profile})")
# Send back the assigned client_id
await websocket.send(json.dumps({
"type": "registered",
"client_id": client_id,
}))
await websocket.send(
json.dumps(
{
"type": "registered",
"client_id": client_id,
}
)
)
# Notify MCP clients
for mcp in self.mcp_clients:
await mcp.send(json.dumps({
"type": "provider_connected",
"client_id": client_id,
"browser": client.browser,
"profile": client.profile,
"capabilities": client.capabilities,
"total_clients": len(self.extension_clients),
}))
await mcp.send(
json.dumps(
{
"type": "provider_connected",
"client_id": client_id,
"browser": client.browser,
"profile": client.profile,
"capabilities": client.capabilities,
"total_clients": len(self.extension_clients),
}
)
)
elif role == "mcp-client":
# This is hanzo-mcp or another MCP tool
@@ -360,12 +377,16 @@ class CDPBridgeServer:
logger.info("MCP client connected")
# Send status with all connected clients
await websocket.send(json.dumps({
"type": "status",
"connected": len(self.extension_clients) > 0,
"clients": [c.to_dict() for c in self.extension_clients.values()],
"default_client_id": self.default_client_id,
}))
await websocket.send(
json.dumps(
{
"type": "status",
"connected": len(self.extension_clients) > 0,
"clients": [c.to_dict() for c in self.extension_clients.values()],
"default_client_id": self.default_client_id,
}
)
)
# Handle subsequent messages
async for message in websocket:
@@ -383,11 +404,15 @@ class CDPBridgeServer:
# Notify MCP clients
for mcp in self.mcp_clients:
try:
await mcp.send(json.dumps({
"type": "provider_disconnected",
"client_id": client_id,
"remaining_clients": len(self.extension_clients),
}))
await mcp.send(
json.dumps(
{
"type": "provider_disconnected",
"client_id": client_id,
"remaining_clients": len(self.extension_clients),
}
)
)
except Exception:
pass
@@ -463,15 +488,19 @@ class CDPBridgeServer:
if not target_client:
# No extension connected or specified client not found
await sender.send(json.dumps({
"id": data.get("id"),
"error": {
"code": -32000,
"message": "No browser extension connected"
if not self.extension_clients
else f"Client not found: {data.get('client_id') or data.get('target_id')}"
}
}))
await sender.send(
json.dumps(
{
"id": data.get("id"),
"error": {
"code": -32000,
"message": "No browser extension connected"
if not self.extension_clients
else f"Client not found: {data.get('client_id') or data.get('target_id')}",
},
}
)
)
return
# Update target client's last_active
@@ -495,22 +524,20 @@ class CDPBridgeServer:
except asyncio.TimeoutError:
self.pending_requests.pop(request_id, None)
await sender.send(json.dumps({
"id": request_id,
"error": {
"code": -32001,
"message": f"Request timeout (client: {target_client.client_id})"
}
}))
await sender.send(
json.dumps(
{
"id": request_id,
"error": {
"code": -32001,
"message": f"Request timeout (client: {target_client.client_id})",
},
}
)
)
except Exception as e:
self.pending_requests.pop(request_id, None)
await sender.send(json.dumps({
"id": request_id,
"error": {
"code": -32603,
"message": str(e)
}
}))
await sender.send(json.dumps({"id": request_id, "error": {"code": -32603, "message": str(e)}}))
def _next_request_id(self) -> int:
"""Generate next request ID."""
@@ -553,14 +580,12 @@ class CDPBridgeClient:
try:
import websockets
uri = f"ws://{self.host}:{self.port}/cdp"
self._websocket = await websockets.connect(uri)
# Register as MCP client
await self._websocket.send(json.dumps({
"type": "register",
"role": "mcp-client"
}))
await self._websocket.send(json.dumps({"type": "register", "role": "mcp-client"}))
# Wait for status response
status_msg = await self._websocket.recv()
@@ -597,12 +622,14 @@ class CDPBridgeClient:
# Handle status updates
if data.get("type") == "provider_connected":
# New client connected
self._clients.append({
"client_id": data.get("client_id"),
"browser": data.get("browser"),
"profile": data.get("profile"),
"capabilities": data.get("capabilities"),
})
self._clients.append(
{
"client_id": data.get("client_id"),
"browser": data.get("browser"),
"profile": data.get("profile"),
"capabilities": data.get("capabilities"),
}
)
elif data.get("type") == "provider_disconnected":
# Client disconnected
cid = data.get("client_id")
@@ -653,11 +680,7 @@ class CDPBridgeClient:
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[request_id] = future
payload = {
"id": request_id,
"method": method,
"params": params or {}
}
payload = {"id": request_id, "method": method, "params": params or {}}
# Add routing hints
if client_id:
@@ -33,7 +33,4 @@ class TestBrowserTool:
def test_has_description(self, tool):
assert tool.description
assert (
"browser" in tool.description.lower()
or "playwright" in tool.description.lower()
)
assert "browser" in tool.description.lower() or "playwright" in tool.description.lower()
@@ -1,3 +1,4 @@
# Namespace package
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
@@ -17,27 +17,24 @@ Representation: Text → AST → Patch
Scope: File → Package → Repo
"""
import os
import json
import hashlib
import difflib
from typing import Any, ClassVar, Literal
from pathlib import Path
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, Literal
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
BaseTool,
ToolError,
InvalidParamsError,
NotFoundError,
Paging,
ToolError,
content_hash,
)
# Language detection by extension
LANG_MAP = {
".py": "python",
@@ -76,6 +73,7 @@ LANG_MAP = {
@dataclass
class TransformSpec:
"""Specification for code transformation."""
kind: Literal["rename", "extract", "inline", "move", "codemod", "generate"]
# For rename
@@ -158,6 +156,7 @@ All operations are PURE - safe to cache and parallelize.
if self._tree_sitter is None:
try:
import tree_sitter_languages
self._tree_sitter = tree_sitter_languages
except ImportError:
raise ToolError(
@@ -213,8 +212,18 @@ All operations are PURE - safe to cache and parallelize.
# Symbol node types by language
symbol_types = {
"python": ["function_definition", "class_definition", "assignment"],
"javascript": ["function_declaration", "class_declaration", "variable_declaration", "lexical_declaration"],
"typescript": ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration"],
"javascript": [
"function_declaration",
"class_declaration",
"variable_declaration",
"lexical_declaration",
],
"typescript": [
"function_declaration",
"class_declaration",
"interface_declaration",
"type_alias_declaration",
],
"go": ["function_declaration", "method_declaration", "type_declaration"],
"rust": ["function_item", "struct_item", "enum_item", "impl_item", "trait_item"],
}
@@ -236,15 +245,17 @@ All operations are PURE - safe to cache and parallelize.
break
if name:
symbols.append({
"name": name,
"kind": node_type,
"range": {
"start": node["start"],
"end": node["end"],
},
"parent": parent_name or None,
})
symbols.append(
{
"name": name,
"kind": node_type,
"range": {
"start": node["start"],
"end": node["end"],
},
"parent": parent_name or None,
}
)
parent_name = name
for child in node.get("children", []):
@@ -272,6 +283,7 @@ All operations are PURE - safe to cache and parallelize.
def _apply_rename(self, text: str, old_name: str, new_name: str) -> tuple[str, int]:
"""Apply simple rename transformation. Returns (new_text, count)."""
import re
# Word-boundary rename to avoid partial matches
pattern = rf"\b{re.escape(old_name)}\b"
new_text, count = re.subn(pattern, new_name, text)
@@ -280,6 +292,7 @@ All operations are PURE - safe to cache and parallelize.
def _apply_codemod(self, text: str, match: str, replace: str) -> tuple[str, int]:
"""Apply pattern-based codemod. Returns (new_text, count)."""
import re
try:
new_text, count = re.subn(match, replace, text)
return new_text, count
@@ -342,6 +355,7 @@ All operations are PURE - safe to cache and parallelize.
Effect: PURE
"""
def extract_text(node: dict) -> str:
if "text" in node:
return node["text"]
@@ -577,7 +591,9 @@ All operations are PURE - safe to cache and parallelize.
summary_parts.append("(truncated for summary)")
return {
"summary": " | ".join(summary_parts) if summary_parts else "No content to summarize",
"summary": " | ".join(summary_parts)
if summary_parts
else "No content to summarize",
"risks": risks,
"next_actions": next_actions,
}
@@ -40,12 +40,12 @@ from hanzo_tools.core import BaseTool, ToolRegistry, PermissionManager
# HIP-0300: Single unified 'ui' tool
from .ui_tool import UiTool, ui_tool
# Backward compatibility (deprecated)
from .computer_tool import ComputerTool
from .screen_tool import ScreenTool, ScreenConfig, screen_tool
# Internal utilities (used by screen_tool)
from .media_tool import MediaLimits, MediaResult, ActivitySegment, media_tool
from .screen_tool import ScreenTool, ScreenConfig, screen_tool
# Backward compatibility (deprecated)
from .computer_tool import ComputerTool
# Export list for tool discovery - single ui tool (HIP-0300)
TOOLS = [UiTool]
@@ -19,8 +19,8 @@ import sys
import json
import time
import base64
import asyncio
import shutil
import asyncio
import tempfile
import subprocess
from typing import Any, Literal, Optional, Annotated, final, override
@@ -43,32 +43,33 @@ IS_WINDOWS = PLATFORM == "win32"
QUARTZ_AVAILABLE = False
if IS_MACOS:
try:
import Quartz
import AppKit
import Quartz
from Quartz import (
CGEventPost,
CGMainDisplayID,
CGDisplayPixelsHigh,
CGDisplayPixelsWide,
CGEventCreateMouseEvent,
CGEventCreateKeyboardEvent,
CGEventCreateScrollWheelEvent,
CGEventPost,
CGDisplayPixelsWide,
CGDisplayPixelsHigh,
CGMainDisplayID,
kCGEventLeftMouseDown,
kCGEventLeftMouseUp,
kCGEventRightMouseDown,
kCGEventRightMouseUp,
kCGEventOtherMouseDown,
kCGEventOtherMouseUp,
kCGEventMouseMoved,
kCGEventLeftMouseDragged,
kCGEventRightMouseDragged,
kCGEventOtherMouseDragged,
kCGMouseButtonLeft,
kCGMouseButtonRight,
kCGMouseButtonCenter,
kCGHIDEventTap,
kCGEventMouseMoved,
kCGMouseButtonLeft,
kCGEventLeftMouseUp,
kCGMouseButtonRight,
kCGEventOtherMouseUp,
kCGEventRightMouseUp,
kCGMouseButtonCenter,
kCGEventLeftMouseDown,
kCGEventOtherMouseDown,
kCGEventRightMouseDown,
kCGScrollEventUnitLine,
kCGEventLeftMouseDragged,
kCGEventOtherMouseDragged,
kCGEventRightMouseDragged,
)
QUARTZ_AVAILABLE = True
except ImportError:
QUARTZ_AVAILABLE = False
@@ -85,6 +86,7 @@ WIN32_AVAILABLE = False
if IS_WINDOWS:
try:
import ctypes
WIN32_AVAILABLE = True
except ImportError:
WIN32_AVAILABLE = False
@@ -97,33 +99,104 @@ _EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="computer_")
# macOS key codes
KEY_CODES = {
'a': 0x00, 's': 0x01, 'd': 0x02, 'f': 0x03, 'h': 0x04, 'g': 0x05,
'z': 0x06, 'x': 0x07, 'c': 0x08, 'v': 0x09, 'b': 0x0b, 'q': 0x0c,
'w': 0x0d, 'e': 0x0e, 'r': 0x0f, 'y': 0x10, 't': 0x11,
'1': 0x12, '2': 0x13, '3': 0x14, '4': 0x15, '5': 0x17, '6': 0x16,
'7': 0x1a, '8': 0x1c, '9': 0x19, '0': 0x1d,
'-': 0x1b, '=': 0x18, '[': 0x21, ']': 0x1e, '\\': 0x2a,
';': 0x29, "'": 0x27, '`': 0x32, ',': 0x2b, '.': 0x2f, '/': 0x2c,
'o': 0x1f, 'u': 0x20, 'i': 0x22, 'p': 0x23, 'l': 0x25, 'j': 0x26,
'k': 0x28, 'n': 0x2d, 'm': 0x2e,
' ': 0x31, 'space': 0x31,
'return': 0x24, 'enter': 0x24, '\n': 0x24, '\r': 0x24,
'tab': 0x30, '\t': 0x30,
'backspace': 0x33, '\b': 0x33,
'escape': 0x35, 'esc': 0x35,
'command': 0x37, 'cmd': 0x37,
'shift': 0x38, 'shiftleft': 0x38, 'shiftright': 0x3c,
'capslock': 0x39,
'option': 0x3a, 'alt': 0x3a, 'optionleft': 0x3a, 'altleft': 0x3a,
'optionright': 0x3d, 'altright': 0x3d,
'control': 0x3b, 'ctrl': 0x3b, 'ctrlleft': 0x3b, 'ctrlright': 0x3e,
'fn': 0x3f,
'f1': 0x7a, 'f2': 0x78, 'f3': 0x63, 'f4': 0x76, 'f5': 0x60,
'f6': 0x61, 'f7': 0x62, 'f8': 0x64, 'f9': 0x65, 'f10': 0x6d,
'f11': 0x67, 'f12': 0x6f,
'home': 0x73, 'end': 0x77, 'pageup': 0x74, 'pagedown': 0x79,
'delete': 0x75, 'del': 0x75,
'left': 0x7b, 'right': 0x7c, 'down': 0x7d, 'up': 0x7e,
"a": 0x00,
"s": 0x01,
"d": 0x02,
"f": 0x03,
"h": 0x04,
"g": 0x05,
"z": 0x06,
"x": 0x07,
"c": 0x08,
"v": 0x09,
"b": 0x0B,
"q": 0x0C,
"w": 0x0D,
"e": 0x0E,
"r": 0x0F,
"y": 0x10,
"t": 0x11,
"1": 0x12,
"2": 0x13,
"3": 0x14,
"4": 0x15,
"5": 0x17,
"6": 0x16,
"7": 0x1A,
"8": 0x1C,
"9": 0x19,
"0": 0x1D,
"-": 0x1B,
"=": 0x18,
"[": 0x21,
"]": 0x1E,
"\\": 0x2A,
";": 0x29,
"'": 0x27,
"`": 0x32,
",": 0x2B,
".": 0x2F,
"/": 0x2C,
"o": 0x1F,
"u": 0x20,
"i": 0x22,
"p": 0x23,
"l": 0x25,
"j": 0x26,
"k": 0x28,
"n": 0x2D,
"m": 0x2E,
" ": 0x31,
"space": 0x31,
"return": 0x24,
"enter": 0x24,
"\n": 0x24,
"\r": 0x24,
"tab": 0x30,
"\t": 0x30,
"backspace": 0x33,
"\b": 0x33,
"escape": 0x35,
"esc": 0x35,
"command": 0x37,
"cmd": 0x37,
"shift": 0x38,
"shiftleft": 0x38,
"shiftright": 0x3C,
"capslock": 0x39,
"option": 0x3A,
"alt": 0x3A,
"optionleft": 0x3A,
"altleft": 0x3A,
"optionright": 0x3D,
"altright": 0x3D,
"control": 0x3B,
"ctrl": 0x3B,
"ctrlleft": 0x3B,
"ctrlright": 0x3E,
"fn": 0x3F,
"f1": 0x7A,
"f2": 0x78,
"f3": 0x63,
"f4": 0x76,
"f5": 0x60,
"f6": 0x61,
"f7": 0x62,
"f8": 0x64,
"f9": 0x65,
"f10": 0x6D,
"f11": 0x67,
"f12": 0x6F,
"home": 0x73,
"end": 0x77,
"pageup": 0x74,
"pagedown": 0x79,
"delete": 0x75,
"del": 0x75,
"left": 0x7B,
"right": 0x7C,
"down": 0x7D,
"up": 0x7E,
}
SHIFT_CHARS = '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'
@@ -132,22 +205,74 @@ SHIFT_CHARS = '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'
def _get_vk_code(key: str) -> int | None:
"""Get Windows virtual key code."""
VK_CODES = {
'a': 0x41, 'b': 0x42, 'c': 0x43, 'd': 0x44, 'e': 0x45, 'f': 0x46,
'g': 0x47, 'h': 0x48, 'i': 0x49, 'j': 0x4A, 'k': 0x4B, 'l': 0x4C,
'm': 0x4D, 'n': 0x4E, 'o': 0x4F, 'p': 0x50, 'q': 0x51, 'r': 0x52,
's': 0x53, 't': 0x54, 'u': 0x55, 'v': 0x56, 'w': 0x57, 'x': 0x58,
'y': 0x59, 'z': 0x5A,
'0': 0x30, '1': 0x31, '2': 0x32, '3': 0x33, '4': 0x34,
'5': 0x35, '6': 0x36, '7': 0x37, '8': 0x38, '9': 0x39,
'return': 0x0D, 'enter': 0x0D, 'tab': 0x09, 'space': 0x20,
'backspace': 0x08, 'escape': 0x1B, 'esc': 0x1B,
'shift': 0x10, 'ctrl': 0x11, 'control': 0x11, 'alt': 0x12,
'left': 0x25, 'up': 0x26, 'right': 0x27, 'down': 0x28,
'delete': 0x2E, 'home': 0x24, 'end': 0x23,
'pageup': 0x21, 'pagedown': 0x22,
'f1': 0x70, 'f2': 0x71, 'f3': 0x72, 'f4': 0x73, 'f5': 0x74,
'f6': 0x75, 'f7': 0x76, 'f8': 0x77, 'f9': 0x78, 'f10': 0x79,
'f11': 0x7A, 'f12': 0x7B,
"a": 0x41,
"b": 0x42,
"c": 0x43,
"d": 0x44,
"e": 0x45,
"f": 0x46,
"g": 0x47,
"h": 0x48,
"i": 0x49,
"j": 0x4A,
"k": 0x4B,
"l": 0x4C,
"m": 0x4D,
"n": 0x4E,
"o": 0x4F,
"p": 0x50,
"q": 0x51,
"r": 0x52,
"s": 0x53,
"t": 0x54,
"u": 0x55,
"v": 0x56,
"w": 0x57,
"x": 0x58,
"y": 0x59,
"z": 0x5A,
"0": 0x30,
"1": 0x31,
"2": 0x32,
"3": 0x33,
"4": 0x34,
"5": 0x35,
"6": 0x36,
"7": 0x37,
"8": 0x38,
"9": 0x39,
"return": 0x0D,
"enter": 0x0D,
"tab": 0x09,
"space": 0x20,
"backspace": 0x08,
"escape": 0x1B,
"esc": 0x1B,
"shift": 0x10,
"ctrl": 0x11,
"control": 0x11,
"alt": 0x12,
"left": 0x25,
"up": 0x26,
"right": 0x27,
"down": 0x28,
"delete": 0x2E,
"home": 0x24,
"end": 0x23,
"pageup": 0x21,
"pagedown": 0x22,
"f1": 0x70,
"f2": 0x71,
"f3": 0x72,
"f4": 0x73,
"f5": 0x74,
"f6": 0x75,
"f7": 0x76,
"f8": 0x77,
"f9": 0x78,
"f10": 0x79,
"f11": 0x7A,
"f12": 0x7B,
}
return VK_CODES.get(key.lower())
@@ -166,7 +291,7 @@ class NativeControl:
"xdotool": XDOTOOL_AVAILABLE,
"scrot": SCROT_AVAILABLE,
"win32": WIN32_AVAILABLE,
}
},
}
@staticmethod
@@ -177,19 +302,21 @@ class NativeControl:
return int(loc.x), int(CGDisplayPixelsHigh(0) - loc.y)
elif IS_LINUX and XDOTOOL_AVAILABLE:
result = subprocess.run(
["xdotool", "getmouselocation", "--shell"],
capture_output=True, text=True, timeout=2
["xdotool", "getmouselocation", "--shell"], capture_output=True, text=True, timeout=2
)
vals = dict(line.split("=") for line in result.stdout.strip().split("\n") if "=" in line)
return int(vals.get("X", 0)), int(vals.get("Y", 0))
elif IS_WINDOWS and WIN32_AVAILABLE:
class POINT(ctypes.Structure):
_fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
pt = POINT()
ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
return pt.x, pt.y
else:
import pyautogui
return pyautogui.position()
@staticmethod
@@ -215,6 +342,7 @@ class NativeControl:
)
else:
import pyautogui
return pyautogui.size()
@staticmethod
@@ -240,7 +368,8 @@ class NativeControl:
btn_map = {"left": "1", "middle": "2", "right": "3"}
subprocess.run(
["xdotool", "mousemove", str(x), str(y), "click", btn_map.get(button, "1")],
capture_output=True, timeout=2
capture_output=True,
timeout=2,
)
elif IS_WINDOWS and WIN32_AVAILABLE:
ctypes.windll.user32.SetCursorPos(x, y)
@@ -255,6 +384,7 @@ class NativeControl:
ctypes.windll.user32.mouse_event(0x0040, 0, 0, 0, 0)
else:
import pyautogui
pyautogui.click(x, y, button=button)
@staticmethod
@@ -262,8 +392,7 @@ class NativeControl:
"""Double click."""
if IS_LINUX and XDOTOOL_AVAILABLE:
subprocess.run(
["xdotool", "mousemove", str(x), str(y), "click", "--repeat", "2", "1"],
capture_output=True, timeout=2
["xdotool", "mousemove", str(x), str(y), "click", "--repeat", "2", "1"], capture_output=True, timeout=2
)
else:
NativeControl.click(x, y)
@@ -281,6 +410,7 @@ class NativeControl:
ctypes.windll.user32.SetCursorPos(x, y)
else:
import pyautogui
pyautogui.moveTo(x, y, _pause=False)
@staticmethod
@@ -312,12 +442,23 @@ class NativeControl:
NativeControl._send_mouse_event_macos(up_type, end_x, end_y, btn)
elif IS_LINUX and XDOTOOL_AVAILABLE:
btn_map = {"left": "1", "middle": "2", "right": "3"}
subprocess.run([
"xdotool", "mousemove", str(start_x), str(start_y),
"mousedown", btn_map.get(button, "1"),
"mousemove", str(end_x), str(end_y),
"mouseup", btn_map.get(button, "1"),
], capture_output=True, timeout=5)
subprocess.run(
[
"xdotool",
"mousemove",
str(start_x),
str(start_y),
"mousedown",
btn_map.get(button, "1"),
"mousemove",
str(end_x),
str(end_y),
"mouseup",
btn_map.get(button, "1"),
],
capture_output=True,
timeout=5,
)
elif IS_WINDOWS and WIN32_AVAILABLE:
ctypes.windll.user32.SetCursorPos(start_x, start_y)
ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0)
@@ -330,6 +471,7 @@ class NativeControl:
ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0)
else:
import pyautogui
pyautogui.moveTo(start_x, start_y, _pause=False)
pyautogui.drag(end_x - start_x, end_y - start_y, _pause=False)
@@ -350,6 +492,7 @@ class NativeControl:
ctypes.windll.user32.mouse_event(0x0800, 0, 0, amount * 120, 0)
else:
import pyautogui
pyautogui.scroll(amount, _pause=False)
@staticmethod
@@ -373,6 +516,7 @@ class NativeControl:
ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
else:
import pyautogui
pyautogui.keyDown(key_lower, _pause=False)
@staticmethod
@@ -390,6 +534,7 @@ class NativeControl:
ctypes.windll.user32.keybd_event(vk, 0, 0x0002, 0)
else:
import pyautogui
pyautogui.keyUp(key_lower, _pause=False)
@staticmethod
@@ -420,15 +565,16 @@ class NativeControl:
subprocess.run(["xdotool", "type", "--", char], capture_output=True, timeout=2)
elif IS_MACOS and QUARTZ_AVAILABLE:
if char in SHIFT_CHARS:
NativeControl.key_down('shift')
NativeControl.key_down("shift")
key = char.lower() if char.isalpha() else char
if key in KEY_CODES:
NativeControl.press(key)
NativeControl.key_up('shift')
NativeControl.key_up("shift")
elif char.lower() in KEY_CODES:
NativeControl.press(char.lower())
else:
import pyautogui
pyautogui.typewrite(char, _pause=False)
@staticmethod
@@ -438,7 +584,8 @@ class NativeControl:
if interval > 0:
subprocess.run(
["xdotool", "type", "--delay", str(int(interval * 1000)), "--", text],
capture_output=True, timeout=30
capture_output=True,
timeout=30,
)
else:
subprocess.run(["xdotool", "type", "--", text], capture_output=True, timeout=30)
@@ -472,6 +619,7 @@ class NativeControl:
elif IS_WINDOWS or IS_LINUX:
from PIL import ImageGrab
if region and len(region) == 4:
x, y, w, h = region
img = ImageGrab.grab(bbox=(x, y, x + w, y + h))
@@ -499,7 +647,7 @@ class NativeControl:
return True
except subprocess.TimeoutExpired:
pass
# If that fails, try to find app by partial name match
search_script = f'''
tell application "System Events"
@@ -521,8 +669,7 @@ class NativeControl:
return False
elif IS_LINUX and XDOTOOL_AVAILABLE:
result = subprocess.run(
["xdotool", "search", "--name", title, "windowactivate"],
capture_output=True, timeout=10
["xdotool", "search", "--name", title, "windowactivate"], capture_output=True, timeout=10
)
return result.returncode == 0
elif IS_WINDOWS and WIN32_AVAILABLE:
@@ -537,7 +684,7 @@ class NativeControl:
def get_active_window() -> dict:
"""Get active window info."""
if IS_MACOS:
script = '''
script = """
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
@@ -551,7 +698,7 @@ class NativeControl:
return appName & "|" & "" & "|0|0|0|0"
end try
end tell
'''
"""
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
parts = result.stdout.strip().split("|")
@@ -566,8 +713,7 @@ class NativeControl:
}
elif IS_LINUX and XDOTOOL_AVAILABLE:
result = subprocess.run(
["xdotool", "getactivewindow", "getwindowname"],
capture_output=True, text=True, timeout=5
["xdotool", "getactivewindow", "getwindowname"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return {"title": result.stdout.strip()}
@@ -612,14 +758,16 @@ class NativeControl:
parts = line.split("\x1f")
if len(parts) >= 6:
try:
windows.append({
"app": parts[0],
"title": parts[1],
"x": int(parts[2]),
"y": int(parts[3]),
"width": int(parts[4]),
"height": int(parts[5]),
})
windows.append(
{
"app": parts[0],
"title": parts[1],
"x": int(parts[2]),
"y": int(parts[3]),
"width": int(parts[4]),
"height": int(parts[5]),
}
)
except (ValueError, IndexError):
# Skip windows with parsing errors
continue
@@ -629,36 +777,67 @@ class NativeControl:
Action = Literal[
# Mouse
"click", "double_click", "right_click", "middle_click",
"move", "move_relative", "drag", "drag_relative", "scroll",
"click",
"double_click",
"right_click",
"middle_click",
"move",
"move_relative",
"drag",
"drag_relative",
"scroll",
# Touch (mobile emulation)
"tap", "swipe", "pinch",
"tap",
"swipe",
"pinch",
# Keyboard
"type", "write", "press", "key_down", "key_up", "hotkey",
"type",
"write",
"press",
"key_down",
"key_up",
"hotkey",
# Screen capture
"screenshot", "screenshot_region", "capture",
"screenshot",
"screenshot_region",
"capture",
# Screen recording (consolidated from ScreenTool)
"session", # ONE-SHOT: record → analyze → compress → return for Claude
"record", # Start background recording
"stop", # Stop recording and get compressed frames
"analyze", # Analyze existing video file
"session", # ONE-SHOT: record → analyze → compress → return for Claude
"record", # Start background recording
"stop", # Stop recording and get compressed frames
"analyze", # Analyze existing video file
# Image location
"locate", "locate_all", "locate_center",
"wait_for_image", "wait_while_image",
"locate",
"locate_all",
"locate_center",
"wait_for_image",
"wait_while_image",
# Pixel
"pixel", "pixel_matches",
"pixel",
"pixel_matches",
# Window
"get_active_window", "list_windows", "focus_window",
"get_active_window",
"list_windows",
"focus_window",
# Screen info
"get_screens", "screen_size", "current_screen",
"get_screens",
"screen_size",
"current_screen",
# Region helpers
"define_region", "region_screenshot", "region_locate",
"define_region",
"region_screenshot",
"region_locate",
# Timing
"sleep", "countdown", "set_pause", "set_failsafe",
"sleep",
"countdown",
"set_pause",
"set_failsafe",
# Batch
"batch",
# Info
"info", "position", "status",
"info",
"position",
"status",
]
@@ -694,6 +873,7 @@ class ComputerTool(BaseTool):
"""Lazy load pyautogui for fallback/advanced features."""
if self._pyautogui is None:
import pyautogui
pyautogui.FAILSAFE = self._failsafe
pyautogui.PAUSE = self._pause
self._pyautogui = pyautogui
@@ -703,6 +883,7 @@ class ComputerTool(BaseTool):
"""Lazy load PIL."""
if self._pil is None:
from PIL import Image
self._pil = Image
return self._pil
@@ -710,10 +891,10 @@ class ComputerTool(BaseTool):
@override
def description(self) -> str:
platform_info = NativeControl.get_platform_info()
backends = ', '.join(k for k, v in platform_info['backends'].items() if v) or 'pyautogui'
backends = ", ".join(k for k, v in platform_info["backends"].items() if v) or "pyautogui"
return f"""Control local computer with native API acceleration.
PLATFORM: {platform_info['platform']}
PLATFORM: {platform_info["platform"]}
BACKENDS: {backends}
MOUSE (< 5ms native):
@@ -929,34 +1110,38 @@ Examples:
# Screen actions
elif action == "screenshot" or action == "screenshot_region":
data = await run(NativeControl.screenshot_native, region)
# If name is provided, save to file instead of returning base64
if name:
# Expand ~ and make absolute path
file_path = os.path.expanduser(name)
if not os.path.isabs(file_path):
file_path = os.path.join(tempfile.gettempdir(), name)
if not file_path.endswith('.png'):
file_path += '.png'
with open(file_path, 'wb') as f:
if not file_path.endswith(".png"):
file_path += ".png"
with open(file_path, "wb") as f:
f.write(data)
return json.dumps({
return json.dumps(
{
"success": True,
"format": "png",
"size": len(data),
"path": file_path,
}
)
# Otherwise return base64 (large output warning)
b64 = base64.b64encode(data).decode()
return json.dumps(
{
"success": True,
"format": "png",
"size": len(data),
"path": file_path,
})
# Otherwise return base64 (large output warning)
b64 = base64.b64encode(data).decode()
return json.dumps({
"success": True,
"format": "png",
"size": len(data),
"base64": b64,
})
"base64": b64,
}
)
# Image location (uses pyautogui)
elif action == "locate":
@@ -1035,10 +1220,12 @@ Examples:
elif action == "current_screen":
pos = NativeControl.mouse_position()
size = NativeControl.screen_size()
return json.dumps({
"size": {"width": size[0], "height": size[1]},
"mouse": {"x": pos[0], "y": pos[1]},
})
return json.dumps(
{
"size": {"width": size[0], "height": size[1]},
"mouse": {"x": pos[0], "y": pos[1]},
}
)
# Region helpers
elif action == "define_region":
@@ -1134,26 +1321,30 @@ Examples:
results.append({"index": i, "action": act_type, "error": str(e)})
elapsed = time.time() - start
return json.dumps({
"success": True,
"count": len(results),
"elapsed_ms": round(elapsed * 1000, 2),
"results": results,
})
return json.dumps(
{
"success": True,
"count": len(results),
"elapsed_ms": round(elapsed * 1000, 2),
"results": results,
}
)
# Info
elif action == "info":
pos = NativeControl.mouse_position()
size = NativeControl.screen_size()
platform_info = NativeControl.get_platform_info()
return json.dumps({
"screen": {"width": size[0], "height": size[1]},
"mouse": {"x": pos[0], "y": pos[1]},
"platform": platform_info,
"pause": self._pause,
"failsafe": self._failsafe,
"regions": list(self._defined_regions.keys()),
})
return json.dumps(
{
"screen": {"width": size[0], "height": size[1]},
"mouse": {"x": pos[0], "y": pos[1]},
"platform": platform_info,
"pause": self._pause,
"failsafe": self._failsafe,
"regions": list(self._defined_regions.keys()),
}
)
elif action == "position":
pos = NativeControl.mouse_position()
@@ -1181,11 +1372,18 @@ Examples:
location = pg.locateOnScreen(str(path), **kwargs)
if location:
center = pg.center(location)
return json.dumps({
"found": True,
"center": {"x": center.x, "y": center.y},
"box": {"left": location.left, "top": location.top, "width": location.width, "height": location.height},
})
return json.dumps(
{
"found": True,
"center": {"x": center.x, "y": center.y},
"box": {
"left": location.left,
"top": location.top,
"width": location.width,
"height": location.height,
},
}
)
return json.dumps({"found": False})
except Exception as e:
return json.dumps({"error": str(e)})
@@ -1203,10 +1401,12 @@ Examples:
results = []
for loc in locations:
center = pg.center(loc)
results.append({
"center": {"x": center.x, "y": center.y},
"box": {"left": loc.left, "top": loc.top, "width": loc.width, "height": loc.height},
})
results.append(
{
"center": {"x": center.x, "y": center.y},
"box": {"left": loc.left, "top": loc.top, "width": loc.width, "height": loc.height},
}
)
return json.dumps({"found": len(results), "locations": results})
except Exception as e:
return json.dumps({"error": str(e)})
@@ -1236,7 +1436,9 @@ Examples:
result = await run(self._locate_center, str(path), confidence)
data = json.loads(result) if result.startswith("{") else {}
if data.get("found"):
return json.dumps({"found": True, "x": data["x"], "y": data["y"], "elapsed": round(time.time() - start, 2)})
return json.dumps(
{"found": True, "x": data["x"], "y": data["y"], "elapsed": round(time.time() - start, 2)}
)
await asyncio.sleep(0.1)
return json.dumps({"found": False, "timeout": timeout})
@@ -1268,11 +1470,13 @@ Examples:
screenshot = pg.screenshot(region=(x, y, 1, 1))
pixel = screenshot.getpixel((0, 0))
matches = all(abs(pixel[i] - color[i]) <= tolerance for i in range(3))
return json.dumps({
"matches": matches,
"expected": {"r": color[0], "g": color[1], "b": color[2]},
"actual": {"r": pixel[0], "g": pixel[1], "b": pixel[2]},
})
return json.dumps(
{
"matches": matches,
"expected": {"r": color[0], "g": color[1], "b": color[2]},
"actual": {"r": pixel[0], "g": pixel[1], "b": pixel[2]},
}
)
except Exception as e:
return json.dumps({"error": str(e)})
@@ -1281,18 +1485,22 @@ Examples:
try:
result = subprocess.run(
["system_profiler", "SPDisplaysDataType", "-json"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
data = json.loads(result.stdout)
displays = []
for gpu in data.get("SPDisplaysDataType", []):
for disp in gpu.get("spdisplays_ndrvs", []):
displays.append({
"name": disp.get("_name", "Unknown"),
"resolution": disp.get("_spdisplays_resolution", "Unknown"),
"main": disp.get("spdisplays_main") == "spdisplays_yes",
})
displays.append(
{
"name": disp.get("_name", "Unknown"),
"resolution": disp.get("_spdisplays_resolution", "Unknown"),
"main": disp.get("spdisplays_main") == "spdisplays_yes",
}
)
return json.dumps(displays)
except Exception:
pass
@@ -1339,13 +1547,31 @@ Examples:
return await tool_self.call(
ctx,
action=action,
x=x, y=y, dx=dx, dy=dy, end_x=end_x, end_y=end_y,
text=text, key=key, keys=keys,
button=button, amount=amount, duration=duration, interval=interval,
region=region, clear=clear,
image_path=image_path, confidence=confidence, timeout=timeout,
color=tuple(color) if color else None, tolerance=tolerance,
title=title, use_regex=use_regex,
name=name, width=width, height=height,
value=value, actions=actions,
x=x,
y=y,
dx=dx,
dy=dy,
end_x=end_x,
end_y=end_y,
text=text,
key=key,
keys=keys,
button=button,
amount=amount,
duration=duration,
interval=interval,
region=region,
clear=clear,
image_path=image_path,
confidence=confidence,
timeout=timeout,
color=tuple(color) if color else None,
tolerance=tolerance,
title=title,
use_regex=use_regex,
name=name,
width=width,
height=height,
value=value,
actions=actions,
)
@@ -29,17 +29,17 @@ Environment configuration:
- HANZO_MEDIA_ACTIVITY_THRESHOLD: Activity detection sensitivity (default: 0.02)
"""
import os
import io
import os
import sys
import json
import base64
import asyncio
import hashlib
import mimetypes
from typing import Any, Union, Literal, Optional, Annotated, final, override
from pathlib import Path
from typing import Any, Literal, Optional, Annotated, final, override, Union
from dataclasses import dataclass, field
from dataclasses import field, dataclass
from concurrent.futures import ThreadPoolExecutor
from pydantic import Field
@@ -129,6 +129,7 @@ class MediaLimits:
@dataclass
class MediaResult:
"""Result of media processing."""
success: bool
images: list[dict] = field(default_factory=list)
total_size: int = 0
@@ -153,13 +154,13 @@ def _get_image_info(data: bytes) -> dict:
info = {"size": len(data), "format": "unknown"}
# Detect format from magic bytes
if data[:8] == b'\x89PNG\r\n\x1a\n':
if data[:8] == b"\x89PNG\r\n\x1a\n":
info["format"] = "png"
elif data[:2] == b'\xff\xd8':
elif data[:2] == b"\xff\xd8":
info["format"] = "jpeg"
elif data[:6] in (b'GIF87a', b'GIF89a'):
elif data[:6] in (b"GIF87a", b"GIF89a"):
info["format"] = "gif"
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
info["format"] = "webp"
return info
@@ -200,15 +201,15 @@ def _resize_image(
# Convert to JPEG if requested or if PNG is too large
if force_jpeg or (output_format == "PNG" and len(data) > 500_000):
output_format = "JPEG"
if img.mode in ('RGBA', 'P', 'LA'):
if img.mode in ("RGBA", "P", "LA"):
# Convert transparency to white background
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
elif img.mode != "RGB":
img = img.convert("RGB")
# Save to buffer
buffer = io.BytesIO()
@@ -225,8 +226,8 @@ def _resize_image(
img.save(buffer, format=output_format, **save_kwargs)
except Exception:
# Fallback to JPEG
if img.mode != 'RGB':
img = img.convert('RGB')
if img.mode != "RGB":
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=quality, optimize=True)
output_format = "JPEG"
@@ -249,17 +250,21 @@ def _extract_video_frames(
max_size: int = 768,
) -> list[tuple[bytes, dict]]:
"""Extract frames from video file."""
import subprocess
import tempfile
import subprocess
frames = []
# Get video duration
probe_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_path,
]
try:
@@ -282,13 +287,19 @@ def _extract_video_frames(
try:
cmd = [
"ffmpeg", "-y",
"-ss", str(timestamp),
"-i", video_path,
"-frames:v", "1",
"-vf", f"scale='min({max_size},iw)':min'({max_size},ih)':force_original_aspect_ratio=decrease",
"-q:v", "2",
tmp_path
"ffmpeg",
"-y",
"-ss",
str(timestamp),
"-i",
video_path,
"-frames:v",
"1",
"-vf",
f"scale='min({max_size},iw)':min'({max_size},ih)':force_original_aspect_ratio=decrease",
"-q:v",
"2",
tmp_path,
]
subprocess.run(cmd, capture_output=True, timeout=10)
@@ -296,12 +307,17 @@ def _extract_video_frames(
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
with open(tmp_path, "rb") as f:
data = f.read()
frames.append((data, {
"timestamp_ms": int(timestamp * 1000),
"frame_index": i,
"size": len(data),
"format": "jpeg",
}))
frames.append(
(
data,
{
"timestamp_ms": int(timestamp * 1000),
"frame_index": i,
"size": len(data),
"format": "jpeg",
},
)
)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
@@ -312,6 +328,7 @@ def _extract_video_frames(
@dataclass
class ActivitySegment:
"""A detected activity segment in video."""
start_ms: int
end_ms: int
activity_score: float # 0-1, how much activity
@@ -332,18 +349,24 @@ def _detect_scene_changes(
# Use FFmpeg scene detection filter
cmd = [
"ffprobe", "-v", "quiet",
"-show_entries", "frame=pts_time",
"-select_streams", "v:0",
"-of", "csv=p=0",
"-f", "lavfi",
"ffprobe",
"-v",
"quiet",
"-show_entries",
"frame=pts_time",
"-select_streams",
"v:0",
"-of",
"csv=p=0",
"-f",
"lavfi",
f"movie={video_path},select='gt(scene,{threshold})'",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
timestamps = []
for line in result.stdout.strip().split('\n'):
for line in result.stdout.strip().split("\n"):
if line:
try:
ts = float(line)
@@ -367,14 +390,14 @@ def _compute_frame_difference(
difference_ratio is 0-1 representing % of pixels that changed.
"""
try:
from PIL import Image, ImageChops
import numpy as np
from PIL import Image, ImageChops
except ImportError:
return 0.0, False
try:
img1 = Image.open(io.BytesIO(frame1_data)).convert('L') # Grayscale
img2 = Image.open(io.BytesIO(frame2_data)).convert('L')
img1 = Image.open(io.BytesIO(frame1_data)).convert("L") # Grayscale
img2 = Image.open(io.BytesIO(frame2_data)).convert("L")
# Ensure same size
if img1.size != img2.size:
@@ -412,18 +435,22 @@ def _analyze_video_activity(
2. Frame differencing (PIL)
3. Activity clustering
"""
import subprocess
import tempfile
import subprocess
segments = []
keyframe_times = []
# Get video duration
probe_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_path,
]
try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
@@ -453,13 +480,19 @@ def _analyze_video_activity(
try:
cmd = [
"ffmpeg", "-y",
"-ss", str(timestamp),
"-i", video_path,
"-frames:v", "1",
"-vf", "scale=320:-1", # Small for comparison
"-q:v", "5",
tmp_path
"ffmpeg",
"-y",
"-ss",
str(timestamp),
"-i",
video_path,
"-frames:v",
"1",
"-vf",
"scale=320:-1", # Small for comparison
"-q:v",
"5",
tmp_path,
]
subprocess.run(cmd, capture_output=True, timeout=5)
@@ -468,14 +501,14 @@ def _analyze_video_activity(
frame_data = f.read()
if prev_frame is not None:
diff_ratio, has_activity = _compute_frame_difference(
prev_frame, frame_data, activity_threshold
)
diff_ratio, has_activity = _compute_frame_difference(prev_frame, frame_data, activity_threshold)
if has_activity:
activity_frames.append({
"timestamp": timestamp,
"diff_ratio": diff_ratio,
})
activity_frames.append(
{
"timestamp": timestamp,
"diff_ratio": diff_ratio,
}
)
if timestamp not in keyframe_times:
keyframe_times.append(timestamp)
@@ -494,12 +527,14 @@ def _analyze_video_activity(
for frame in activity_frames[1:]:
# If gap > 1 second, start new segment
if frame["timestamp"] - current_segment_start > 1.0 + segment_frame_count * sample_interval:
segments.append(ActivitySegment(
start_ms=int(current_segment_start * 1000),
end_ms=int((current_segment_start + segment_frame_count * sample_interval) * 1000),
activity_score=current_segment_score / segment_frame_count,
frame_count=segment_frame_count,
))
segments.append(
ActivitySegment(
start_ms=int(current_segment_start * 1000),
end_ms=int((current_segment_start + segment_frame_count * sample_interval) * 1000),
activity_score=current_segment_score / segment_frame_count,
frame_count=segment_frame_count,
)
)
current_segment_start = frame["timestamp"]
current_segment_score = frame["diff_ratio"]
segment_frame_count = 1
@@ -508,12 +543,14 @@ def _analyze_video_activity(
segment_frame_count += 1
# Last segment
segments.append(ActivitySegment(
start_ms=int(current_segment_start * 1000),
end_ms=int((current_segment_start + segment_frame_count * sample_interval) * 1000),
activity_score=current_segment_score / segment_frame_count,
frame_count=segment_frame_count,
))
segments.append(
ActivitySegment(
start_ms=int(current_segment_start * 1000),
end_ms=int((current_segment_start + segment_frame_count * sample_interval) * 1000),
activity_score=current_segment_score / segment_frame_count,
frame_count=segment_frame_count,
)
)
# Sort and deduplicate keyframe times
keyframe_times = sorted(set(keyframe_times))
@@ -532,8 +569,8 @@ def _extract_activity_frames(
Optimized for minimal payload size.
"""
import subprocess
import tempfile
import subprocess
frames = []
@@ -549,13 +586,19 @@ def _extract_activity_frames(
try:
cmd = [
"ffmpeg", "-y",
"-ss", str(timestamp),
"-i", video_path,
"-frames:v", "1",
"-vf", f"scale='min({max_size},iw)':'min({max_size},ih)':force_original_aspect_ratio=decrease",
"-q:v", str(max(1, min(31, (100 - quality) // 3))), # FFmpeg quality 1-31
tmp_path
"ffmpeg",
"-y",
"-ss",
str(timestamp),
"-i",
video_path,
"-frames:v",
"1",
"-vf",
f"scale='min({max_size},iw)':'min({max_size},ih)':force_original_aspect_ratio=decrease",
"-q:v",
str(max(1, min(31, (100 - quality) // 3))), # FFmpeg quality 1-31
tmp_path,
]
subprocess.run(cmd, capture_output=True, timeout=10)
@@ -563,12 +606,17 @@ def _extract_activity_frames(
with open(tmp_path, "rb") as f:
data = f.read()
frames.append((data, {
"timestamp_ms": int(timestamp * 1000),
"timestamp_sec": round(timestamp, 2),
"size": len(data),
"format": "jpeg",
}))
frames.append(
(
data,
{
"timestamp_ms": int(timestamp * 1000),
"timestamp_sec": round(timestamp, 2),
"size": len(data),
"format": "jpeg",
},
)
)
finally:
if os.path.exists(tmp_path):
@@ -601,11 +649,16 @@ def _compress_session(
# If no activity detected, sample evenly
if not keyframe_times:
import subprocess
probe_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
video_path
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_path,
]
try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
@@ -646,20 +699,20 @@ def _compress_session(
Action = Literal[
# Image operations
"load", # Load single image
"load_batch", # Load multiple images
"optimize", # Optimize images for Claude
"resize", # Resize images
"info", # Get image info
"load", # Load single image
"load_batch", # Load multiple images
"optimize", # Optimize images for Claude
"resize", # Resize images
"info", # Get image info
# Video operations
"extract_frames", # Extract frames from video
"extract_frames", # Extract frames from video
# Activity detection & slicing (NEW)
"analyze", # Detect activity segments in video
"slice", # Extract frames at activity points only
"analyze", # Detect activity segments in video
"slice", # Extract frames at activity points only
"compress_session", # Full pipeline for computer use sessions
# Configuration
"limits", # Get/set limits
"status", # Get current status
"limits", # Get/set limits
"status", # Get current status
]
@@ -831,17 +884,14 @@ EXAMPLES:
# Enforce max images limit
if len(paths) > self.limits.max_images:
result.warnings.append(
f"Requested {len(paths)} images, limited to {self.limits.max_images}"
)
paths = paths[:self.limits.max_images]
result.warnings.append(f"Requested {len(paths)} images, limited to {self.limits.max_images}")
paths = paths[: self.limits.max_images]
for path in paths:
# Check payload limit
if result.total_size >= self.limits.max_payload_bytes:
result.warnings.append(
f"Payload limit ({self.limits.max_payload_mb}MB) reached, "
f"stopped at {result.total_count} images"
f"Payload limit ({self.limits.max_payload_mb}MB) reached, stopped at {result.total_count} images"
)
break
@@ -853,19 +903,19 @@ EXAMPLES:
# Check if this image would exceed payload
if result.total_size + len(data) > self.limits.max_payload_bytes:
result.warnings.append(
f"Skipping {path}: would exceed payload limit"
)
result.warnings.append(f"Skipping {path}: would exceed payload limit")
continue
result.images.append({
"path": path,
"size": len(data),
"format": info.get("format", "unknown"),
"dimensions": info.get("new_dimensions", info.get("original_dimensions")),
"base64": base64.b64encode(data).decode(),
**{k: v for k, v in info.items() if k not in ("path", "base64")},
})
result.images.append(
{
"path": path,
"size": len(data),
"format": info.get("format", "unknown"),
"dimensions": info.get("new_dimensions", info.get("original_dimensions")),
"base64": base64.b64encode(data).decode(),
**{k: v for k, v in info.items() if k not in ("path", "base64")},
}
)
result.total_size += len(data)
result.total_count += 1
@@ -908,23 +958,25 @@ EXAMPLES:
try:
if action == "status":
return json.dumps({
"limits": {
"max_images": self.limits.max_images,
"max_payload_mb": self.limits.max_payload_mb,
"max_resolution": self.limits.max_resolution,
"optimal_size": self.limits.optimal_size,
"jpeg_quality": self.limits.jpeg_quality,
"max_frames": self.limits.max_frames,
},
"env_vars": {
"HANZO_MEDIA_MAX_IMAGES": os.environ.get("HANZO_MEDIA_MAX_IMAGES"),
"HANZO_MEDIA_MAX_PAYLOAD_MB": os.environ.get("HANZO_MEDIA_MAX_PAYLOAD_MB"),
"HANZO_MEDIA_MAX_RESOLUTION": os.environ.get("HANZO_MEDIA_MAX_RESOLUTION"),
"HANZO_MEDIA_OPTIMAL_SIZE": os.environ.get("HANZO_MEDIA_OPTIMAL_SIZE"),
"HANZO_MEDIA_JPEG_QUALITY": os.environ.get("HANZO_MEDIA_JPEG_QUALITY"),
},
})
return json.dumps(
{
"limits": {
"max_images": self.limits.max_images,
"max_payload_mb": self.limits.max_payload_mb,
"max_resolution": self.limits.max_resolution,
"optimal_size": self.limits.optimal_size,
"jpeg_quality": self.limits.jpeg_quality,
"max_frames": self.limits.max_frames,
},
"env_vars": {
"HANZO_MEDIA_MAX_IMAGES": os.environ.get("HANZO_MEDIA_MAX_IMAGES"),
"HANZO_MEDIA_MAX_PAYLOAD_MB": os.environ.get("HANZO_MEDIA_MAX_PAYLOAD_MB"),
"HANZO_MEDIA_MAX_RESOLUTION": os.environ.get("HANZO_MEDIA_MAX_RESOLUTION"),
"HANZO_MEDIA_OPTIMAL_SIZE": os.environ.get("HANZO_MEDIA_OPTIMAL_SIZE"),
"HANZO_MEDIA_JPEG_QUALITY": os.environ.get("HANZO_MEDIA_JPEG_QUALITY"),
},
}
)
elif action == "limits":
# Update limits if provided
@@ -935,16 +987,18 @@ EXAMPLES:
if max_resolution is not None:
self.limits.max_resolution = min(max_resolution, 4096) # Hard cap
return json.dumps({
"success": True,
"limits": {
"max_images": self.limits.max_images,
"max_payload_mb": self.limits.max_payload_mb,
"max_resolution": self.limits.max_resolution,
"optimal_size": self.limits.optimal_size,
"jpeg_quality": self.limits.jpeg_quality,
},
})
return json.dumps(
{
"success": True,
"limits": {
"max_images": self.limits.max_images,
"max_payload_mb": self.limits.max_payload_mb,
"max_resolution": self.limits.max_resolution,
"optimal_size": self.limits.optimal_size,
"jpeg_quality": self.limits.jpeg_quality,
},
}
)
elif action == "load":
if not path:
@@ -955,15 +1009,17 @@ EXAMPLES:
if data is None:
return json.dumps({"success": False, **info})
return json.dumps({
"success": True,
"path": path,
"size": len(data),
"format": info.get("format", "unknown"),
"dimensions": info.get("new_dimensions", info.get("original_dimensions")),
"base64": base64.b64encode(data).decode(),
**{k: v for k, v in info.items() if k not in ("path", "base64")},
})
return json.dumps(
{
"success": True,
"path": path,
"size": len(data),
"format": info.get("format", "unknown"),
"dimensions": info.get("new_dimensions", info.get("original_dimensions")),
"base64": base64.b64encode(data).decode(),
**{k: v for k, v in info.items() if k not in ("path", "base64")},
}
)
elif action == "load_batch":
if not paths:
@@ -1011,15 +1067,17 @@ EXAMPLES:
result.warnings.append(f"Skipping {source}: would exceed limit")
continue
result.images.append({
"index": i,
"source": source,
"size": len(optimized),
"format": info.get("format", "jpeg"),
"dimensions": info.get("new_dimensions"),
"compression_ratio": round(info.get("original_size", len(data)) / len(optimized), 2),
"base64": base64.b64encode(optimized).decode(),
})
result.images.append(
{
"index": i,
"source": source,
"size": len(optimized),
"format": info.get("format", "jpeg"),
"dimensions": info.get("new_dimensions"),
"compression_ratio": round(info.get("original_size", len(data)) / len(optimized), 2),
"base64": base64.b64encode(optimized).decode(),
}
)
result.total_size += len(optimized)
result.total_count += 1
@@ -1058,13 +1116,15 @@ EXAMPLES:
False,
)
result.images.append({
"index": i,
"size": len(resized),
"dimensions": info.get("new_dimensions"),
"format": info.get("format"),
"base64": base64.b64encode(resized).decode(),
})
result.images.append(
{
"index": i,
"size": len(resized),
"dimensions": info.get("new_dimensions"),
"format": info.get("format"),
"base64": base64.b64encode(resized).decode(),
}
)
result.total_size += len(resized)
result.total_count += 1
@@ -1099,22 +1159,26 @@ EXAMPLES:
result.warnings.append("Payload limit reached, stopped extraction")
break
result.images.append({
"frame_index": info["frame_index"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
})
result.images.append(
{
"frame_index": info["frame_index"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
}
)
result.total_size += len(data)
result.total_count += 1
return json.dumps({
**result.to_dict(),
"source": path,
"interval_ms": interval_ms,
"frames_extracted": result.total_count,
})
return json.dumps(
{
**result.to_dict(),
"source": path,
"interval_ms": interval_ms,
"frames_extracted": result.total_count,
}
)
elif action == "info":
if not path:
@@ -1137,6 +1201,7 @@ EXAMPLES:
# Get image dimensions if possible
try:
from PIL import Image
with Image.open(path) as img:
info["dimensions"] = img.size
info["format"] = img.format
@@ -1149,11 +1214,21 @@ EXAMPLES:
info["type"] = "video"
try:
import subprocess
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"format=duration:stream=width,height,r_frame_rate",
"-of", "json", path],
capture_output=True, text=True, timeout=10
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration:stream=width,height,r_frame_rate",
"-of",
"json",
path,
],
capture_output=True,
text=True,
timeout=10,
)
if probe.returncode == 0:
probe_data = json.loads(probe.stdout)
@@ -1192,27 +1267,29 @@ EXAMPLES:
scene_threshold,
)
return json.dumps({
"success": True,
"source": path,
"activity_segments": [
{
"start_ms": s.start_ms,
"end_ms": s.end_ms,
"duration_ms": s.end_ms - s.start_ms,
"activity_score": round(s.activity_score, 4),
"frame_count": s.frame_count,
}
for s in segments
],
"keyframe_count": len(keyframe_times),
"keyframe_times_sec": [round(t, 2) for t in keyframe_times],
"settings": {
"activity_threshold": activity_threshold,
"scene_threshold": scene_threshold,
"max_duration": max_duration,
},
})
return json.dumps(
{
"success": True,
"source": path,
"activity_segments": [
{
"start_ms": s.start_ms,
"end_ms": s.end_ms,
"duration_ms": s.end_ms - s.start_ms,
"activity_score": round(s.activity_score, 4),
"frame_count": s.frame_count,
}
for s in segments
],
"keyframe_count": len(keyframe_times),
"keyframe_times_sec": [round(t, 2) for t in keyframe_times],
"settings": {
"activity_threshold": activity_threshold,
"scene_threshold": scene_threshold,
"max_duration": max_duration,
},
}
)
elif action == "slice":
if not path:
@@ -1258,26 +1335,30 @@ EXAMPLES:
result.warnings.append("Payload limit reached")
break
result.images.append({
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
})
result.images.append(
{
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
}
)
result.total_size += len(data)
result.total_count += 1
return json.dumps({
**result.to_dict(),
"source": path,
"activity_segments": len(segments),
"keyframes_detected": len(keyframe_times),
"compression_settings": {
"max_size": slice_max_size,
"quality": slice_quality,
},
})
return json.dumps(
{
**result.to_dict(),
"source": path,
"activity_segments": len(segments),
"keyframes_detected": len(keyframe_times),
"compression_settings": {
"max_size": slice_max_size,
"quality": slice_quality,
},
}
)
elif action == "compress_session":
if not path:
@@ -1314,28 +1395,32 @@ EXAMPLES:
result.warnings.append("Payload limit reached")
break
result.images.append({
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
})
result.images.append(
{
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"format": info["format"],
"base64": base64.b64encode(data).decode(),
}
)
result.total_size += len(data)
result.total_count += 1
return json.dumps({
**result.to_dict(),
**metadata,
"activity_segments": [
{
"start_ms": s.start_ms,
"end_ms": s.end_ms,
"activity_score": round(s.activity_score, 4),
}
for s in segments
],
})
return json.dumps(
{
**result.to_dict(),
**metadata,
"activity_segments": [
{
"start_ms": s.start_ms,
"end_ms": s.end_ms,
"activity_score": round(s.activity_score, 4),
}
for s in segments
],
}
)
else:
return json.dumps({"error": f"Unknown action: {action}"})
@@ -19,8 +19,8 @@ Typical usage:
# Claude can interpret the computer use session
"""
import os
import io
import os
import sys
import json
import time
@@ -30,7 +30,7 @@ import tempfile
import subprocess
from typing import Any, Literal, Optional, Annotated, final, override
from pathlib import Path
from dataclasses import dataclass, field
from dataclasses import field, dataclass
from concurrent.futures import ThreadPoolExecutor
from pydantic import Field
@@ -43,10 +43,10 @@ from .media_tool import (
MediaLimits,
MediaResult,
ActivitySegment,
_resize_image,
_compress_session,
_analyze_video_activity,
_extract_activity_frames,
_compress_session,
_resize_image,
)
# Thread pool for blocking operations
@@ -139,14 +139,22 @@ def _record_screen(
cmd = [
"ffmpeg",
"-y",
"-f", "avfoundation",
"-framerate", str(fps),
"-i", "1:none", # Screen capture
"-t", str(duration),
"-c:v", "libx264",
"-crf", crf,
"-preset", "ultrafast",
"-pix_fmt", "yuv420p",
"-f",
"avfoundation",
"-framerate",
str(fps),
"-i",
"1:none", # Screen capture
"-t",
str(duration),
"-c:v",
"libx264",
"-crf",
crf,
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
]
if region and len(region) == 4:
@@ -186,12 +194,14 @@ def _process_recording_for_claude(
# Convert to serializable format
frames_data = []
for data, info in frames:
frames_data.append({
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"base64": base64.b64encode(data).decode(),
})
frames_data.append(
{
"timestamp_sec": info["timestamp_sec"],
"timestamp_ms": info["timestamp_ms"],
"size": len(data),
"base64": base64.b64encode(data).decode(),
}
)
segments_data = [
{
@@ -206,13 +216,13 @@ def _process_recording_for_claude(
Action = Literal[
"capture", # Single screenshot
"record", # Start recording (background)
"stop", # Stop recording
"status", # Recording status
"session", # ONE-SHOT: record → analyze → compress → return
"analyze", # Analyze existing video file
"info", # System info
"capture", # Single screenshot
"record", # Start recording (background)
"stop", # Stop recording
"status", # Recording status
"session", # ONE-SHOT: record → analyze → compress → return
"analyze", # Analyze existing video file
"info", # System info
]
@@ -363,19 +373,21 @@ EXAMPLES:
try:
if action == "info":
return json.dumps({
"platform": sys.platform,
"has_ffmpeg": self._check_ffmpeg(),
"has_screencapture": self._check_screencapture(),
"recording": self._recording,
"config": {
"default_duration": self.config.default_duration,
"max_duration": self.config.max_duration,
"target_frames": self.config.target_frames,
"max_size": self.config.max_size,
"jpeg_quality": self.config.jpeg_quality,
},
})
return json.dumps(
{
"platform": sys.platform,
"has_ffmpeg": self._check_ffmpeg(),
"has_screencapture": self._check_screencapture(),
"recording": self._recording,
"config": {
"default_duration": self.config.default_duration,
"max_duration": self.config.max_duration,
"target_frames": self.config.target_frames,
"max_size": self.config.max_size,
"jpeg_quality": self.config.jpeg_quality,
},
}
)
elif action == "capture":
# Single screenshot
@@ -403,13 +415,15 @@ EXAMPLES:
format_type = "png"
info = {}
return json.dumps({
"success": True,
"format": format_type,
"size": len(data),
"dimensions": info.get("new_dimensions"),
"base64": base64.b64encode(data).decode(),
})
return json.dumps(
{
"success": True,
"format": format_type,
"size": len(data),
"dimensions": info.get("new_dimensions"),
"base64": base64.b64encode(data).decode(),
}
)
elif action == "session":
# ONE-SHOT: record → analyze → compress → return
@@ -446,22 +460,24 @@ EXAMPLES:
total_size = sum(f["size"] for f in frames_data)
return json.dumps({
"success": True,
"action": "session",
"duration_seconds": duration,
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_bytes": total_size,
"total_size_kb": round(total_size / 1024, 1),
"compression_settings": {
"target_frames": target_frames,
"max_size": max_size,
"quality": jpeg_quality,
},
"metadata": metadata,
})
return json.dumps(
{
"success": True,
"action": "session",
"duration_seconds": duration,
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_bytes": total_size,
"total_size_kb": round(total_size / 1024, 1),
"compression_settings": {
"target_frames": target_frames,
"max_size": max_size,
"quality": jpeg_quality,
},
"metadata": metadata,
}
)
finally:
# Cleanup temp file
@@ -486,14 +502,22 @@ EXAMPLES:
cmd = [
"ffmpeg",
"-y",
"-f", "avfoundation",
"-framerate", str(fps),
"-i", "1:none",
"-t", str(duration),
"-c:v", "libx264",
"-crf", crf,
"-preset", "ultrafast",
"-pix_fmt", "yuv420p",
"-f",
"avfoundation",
"-framerate",
str(fps),
"-i",
"1:none",
"-t",
str(duration),
"-c:v",
"libx264",
"-crf",
crf,
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
]
if region and len(region) == 4:
@@ -512,12 +536,14 @@ EXAMPLES:
self._recording = True
self._start_time = time.time()
return json.dumps({
"success": True,
"recording": True,
"duration": duration,
"output": self._output_file,
})
return json.dumps(
{
"success": True,
"recording": True,
"duration": duration,
"output": self._output_file,
}
)
elif action == "stop":
# Stop recording and process
@@ -562,15 +588,17 @@ EXAMPLES:
total_size = sum(f["size"] for f in frames_data)
return json.dumps({
"success": True,
"duration_seconds": round(actual_duration, 2),
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_kb": round(total_size / 1024, 1),
"metadata": metadata,
})
return json.dumps(
{
"success": True,
"duration_seconds": round(actual_duration, 2),
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_kb": round(total_size / 1024, 1),
"metadata": metadata,
}
)
finally:
os.unlink(video_path)
@@ -606,15 +634,17 @@ EXAMPLES:
total_size = sum(f["size"] for f in frames_data)
return json.dumps({
"success": True,
"source": path,
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_kb": round(total_size / 1024, 1),
"metadata": metadata,
})
return json.dumps(
{
"success": True,
"source": path,
"frames": frames_data,
"activity_segments": segments_data,
"total_frames": len(frames_data),
"total_size_kb": round(total_size / 1024, 1),
"metadata": metadata,
}
)
else:
return json.dumps({"error": f"Unknown action: {action}"})
@@ -19,22 +19,22 @@ from hanzo_tools.core.base import (
)
from hanzo_tools.core.types import MCPResourceDocument
from hanzo_tools.core.context import ToolContext, create_tool_context
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.permissions import PermissionManager
from hanzo_tools.core.id_tool import IdTool, id_tool
from hanzo_tools.core.unified import (
Range,
Paging,
BaseTool, # HIP-0300 unified tool - use this
ActionHandler,
ErrorCode,
ToolError,
ActionHandler,
ConflictError,
NotFoundError,
InvalidParamsError,
Paging,
Range,
content_hash,
file_uri,
ErrorCode,
content_hash,
)
from hanzo_tools.core.id_tool import IdTool, id_tool
from hanzo_tools.core.decorators import auto_timeout
from hanzo_tools.core.permissions import PermissionManager
__all__ = [
# HIP-0300 Base class - use this for new tools
@@ -19,22 +19,21 @@ All operations are deterministic and side-effect free.
import os
import json
import hashlib
import aiofiles
from typing import Any, ClassVar
from pathlib import Path
from urllib.parse import quote
import aiofiles
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core.unified import BaseTool
from hanzo_tools.core.unified import (
BaseTool,
ToolError,
NotFoundError,
InvalidParamsError,
)
# Supported hash algorithms
HASH_ALGORITHMS = ["sha256", "sha512", "sha1", "md5", "blake2b", "blake2s"]
@@ -17,12 +17,12 @@ Design principles:
Reference: HIP-0300 Unified MCP Tools Architecture
"""
import inspect
import json
import hashlib
import inspect
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable, ClassVar, Literal, TypeVar, get_type_hints
from typing import Any, Literal, TypeVar, Callable, ClassVar, get_type_hints
from dataclasses import field, dataclass
from collections.abc import Awaitable
from mcp.server import FastMCP
@@ -30,22 +30,22 @@ from mcp.server.fastmcp import Context as MCPContext
from .base import BaseTool as _BaseToolABC
# Error codes for structured error handling
ErrorCode = Literal[
"UNKNOWN_ACTION", # Action not found
"INVALID_PARAMS", # Invalid parameters
"NOT_FOUND", # Resource not found
"CONFLICT", # Precondition failed (e.g., base_hash mismatch)
"PERMISSION_DENIED", # Access denied
"TIMEOUT", # Operation timed out
"INTERNAL_ERROR", # Unexpected error
"UNKNOWN_ACTION", # Action not found
"INVALID_PARAMS", # Invalid parameters
"NOT_FOUND", # Resource not found
"CONFLICT", # Precondition failed (e.g., base_hash mismatch)
"PERMISSION_DENIED", # Access denied
"TIMEOUT", # Operation timed out
"INTERNAL_ERROR", # Unexpected error
]
@dataclass
class Paging:
"""Pagination info for large results."""
cursor: str | None = None
more: bool = False
total: int | None = None
@@ -54,6 +54,7 @@ class Paging:
@dataclass
class ToolError(Exception):
"""Structured error for tool operations."""
code: ErrorCode
message: str
details: dict[str, Any] = field(default_factory=dict)
@@ -61,6 +62,7 @@ class ToolError(Exception):
class ConflictError(ToolError):
"""Precondition failed (e.g., base_hash mismatch)."""
def __init__(self, message: str, expected: str | None = None, actual: str | None = None):
details = {}
if expected:
@@ -72,6 +74,7 @@ class ConflictError(ToolError):
class NotFoundError(ToolError):
"""Resource not found."""
def __init__(self, message: str, uri: str | None = None):
details = {"uri": uri} if uri else {}
super().__init__(code="NOT_FOUND", message=message, details=details)
@@ -79,6 +82,7 @@ class NotFoundError(ToolError):
class InvalidParamsError(ToolError):
"""Invalid parameters."""
def __init__(self, message: str, param: str | None = None, expected: str | None = None):
details = {}
if param:
@@ -91,6 +95,7 @@ class InvalidParamsError(ToolError):
@dataclass
class ActionHandler:
"""Metadata for a registered action handler."""
name: str
handler: Callable[..., Awaitable[Any]]
description: str
@@ -162,7 +167,7 @@ class BaseTool(_BaseToolABC):
raise ToolError(
code="UNKNOWN_ACTION",
message=f"Unknown action: {action_name}",
details={"available": list(self._handlers.keys())}
details={"available": list(self._handlers.keys())},
)
return {"action": action_name, "schema": handler.schema or {}}
@@ -199,6 +204,7 @@ class BaseTool(_BaseToolABC):
Returns:
Decorator function
"""
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
# Auto-generate schema from type hints if not provided
auto_schema = schema
@@ -213,6 +219,7 @@ class BaseTool(_BaseToolABC):
examples=examples or [],
)
return fn
return decorator
def _generate_schema(self, fn: Callable) -> dict[str, Any]:
@@ -371,6 +378,7 @@ class BaseTool(_BaseToolABC):
# Utility functions for composability
def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
"""Generate content hash for file identity."""
if isinstance(content, str):
@@ -383,6 +391,7 @@ def content_hash(content: str | bytes, algorithm: str = "sha256") -> str:
def file_uri(path: str) -> str:
"""Convert path to file:// URI."""
from pathlib import Path
abs_path = Path(path).resolve()
return f"file://{abs_path}"
@@ -393,6 +402,7 @@ class Range:
0-based line and column numbers.
"""
start_line: int
start_col: int = 0
end_line: int | None = None
+12 -12
View File
@@ -56,9 +56,15 @@ class TestToolPackages:
assert len(TOOLS) == 9
names = {t.name for t in TOOLS}
expected = {
"recall_memories", "create_memories", "update_memories",
"delete_memories", "manage_memories", "recall_facts",
"store_facts", "summarize_to_memory", "manage_knowledge_bases"
"recall_memories",
"create_memories",
"update_memories",
"delete_memories",
"manage_memories",
"recall_facts",
"store_facts",
"summarize_to_memory",
"manage_knowledge_bases",
}
assert names == expected
@@ -267,18 +273,14 @@ class TestToolAsync:
"""Verify all tool .call() methods are async."""
import importlib
all_packages = REQUIRED_PACKAGES + [
p for p in OPTIONAL_PACKAGES if _module_installed(p)
]
all_packages = REQUIRED_PACKAGES + [p for p in OPTIONAL_PACKAGES if _module_installed(p)]
for pkg_name in all_packages:
pkg = importlib.import_module(pkg_name)
tools = getattr(pkg, "TOOLS", [])
for tool in tools:
if hasattr(tool, "call"):
assert asyncio.iscoroutinefunction(
tool.call
), f"{pkg_name}.{tool.name}.call() is not async"
assert asyncio.iscoroutinefunction(tool.call), f"{pkg_name}.{tool.name}.call() is not async"
class TestTotalToolCount:
@@ -311,9 +313,7 @@ class TestTotalToolCount:
pkg = importlib.import_module(pkg_name)
tools = getattr(pkg, "TOOLS", [])
actual = len(tools)
assert (
actual == expected_count
), f"{pkg_name}: expected {expected_count} tools, got {actual}"
assert actual == expected_count, f"{pkg_name}: expected {expected_count} tools, got {actual}"
total += actual
# Required tools: 38 (7+12+1+9+1+2+1+1+1+3)
@@ -31,11 +31,11 @@ from .sql_stats import SqlStatsTool
from .sql_search import SqlSearchTool
from .graph_query import GraphQueryTool
from .graph_stats import GraphStatsTool
from .memory_tool import MemoryTool
from .graph_remove import GraphRemoveTool
from .graph_search import GraphSearchTool
from .database_manager import DatabaseManager
from .memory_tool import MemoryTool
from .memory_manager import MemoryManager
from .database_manager import DatabaseManager
# Export list for tool discovery
TOOLS = [
@@ -1,28 +1,28 @@
"""Initialize default memory structure for new projects."""
import os
from pathlib import Path
from typing import Optional
from pathlib import Path
def init_global_memory():
"""Initialize global memory structure in ~/.hanzo/memory/."""
hanzo_dir = Path.home() / ".hanzo"
memory_dir = hanzo_dir / "memory"
# Create directories
memory_dir.mkdir(parents=True, exist_ok=True)
# Template directory
template_dir = Path(__file__).parent.parent.parent / "templates"
# Copy templates if they don't exist
templates = [
("global_rules.md", "rules.md"),
("user_preferences.md", "user_preferences.md"),
("coding_standards.md", "coding_standards.md"),
]
for template_file, target_file in templates:
target_path = memory_dir / target_file
if not target_path.exists():
@@ -30,7 +30,7 @@ def init_global_memory():
if template_path.exists():
target_path.write_text(template_path.read_text())
print(f"Created global memory file: {target_path}")
print(f"Global memory initialized at: {memory_dir}")
@@ -38,18 +38,18 @@ def init_project_memory(project_path: Optional[str] = None):
"""Initialize project memory structure in project/.hanzo/memory/."""
if not project_path:
project_path = os.getcwd()
project_path = Path(project_path)
memory_dir = project_path / ".hanzo" / "memory"
# Create directories
memory_dir.mkdir(parents=True, exist_ok=True)
sessions_dir = memory_dir / "sessions"
sessions_dir.mkdir(exist_ok=True)
# Template directory
template_dir = Path(__file__).parent.parent.parent / "templates"
# Create architecture.md if it doesn't exist
arch_file = memory_dir / "architecture.md"
if not arch_file.exists():
@@ -58,11 +58,10 @@ def init_project_memory(project_path: Optional[str] = None):
content = template_path.read_text()
# Customize for this project
project_name = project_path.name
content = content.replace("# Project Architecture Decisions",
f"# {project_name} Architecture Decisions")
content = content.replace("# Project Architecture Decisions", f"# {project_name} Architecture Decisions")
arch_file.write_text(content)
print(f"Created project architecture file: {arch_file}")
# Create patterns.md template
patterns_file = memory_dir / "patterns.md"
if not patterns_file.exists():
@@ -118,7 +117,7 @@ except SpecificError as e:
"""
patterns_file.write_text(patterns_content)
print(f"Created project patterns file: {patterns_file}")
# Create README for memory system
readme_file = memory_dir / "README.md"
if not readme_file.exists():
@@ -152,14 +151,15 @@ These files are automatically indexed in `.hanzo/db/memory.db` for fast full-tex
"""
readme_file.write_text(readme_content)
print(f"Created memory README: {readme_file}")
print(f"Project memory initialized at: {memory_dir}")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
init_project_memory(sys.argv[1])
else:
init_global_memory()
init_project_memory()
init_project_memory()
@@ -10,7 +10,7 @@ Combines:
import os
import json
import sqlite3
from typing import Dict, List, Optional, Any, Tuple
from typing import Any, Dict, List, Tuple, Optional
from pathlib import Path
from datetime import datetime
@@ -24,21 +24,21 @@ class MemoryManager:
self.permission_manager = permission_manager
self.global_memory_dir = Path.home() / ".hanzo" / "memory"
self.global_memory_dir.mkdir(parents=True, exist_ok=True)
def _get_project_memory_dir(self, project_path: str) -> Path:
"""Get project memory directory."""
project_path = Path(project_path)
memory_dir = project_path / ".hanzo" / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
return memory_dir
def _get_project_db_path(self, project_path: str) -> Path:
"""Get project database path."""
project_path = Path(project_path)
db_dir = project_path / ".hanzo" / "db"
db_dir.mkdir(parents=True, exist_ok=True)
return db_dir / "memory.db"
def _get_global_db_path(self) -> Path:
"""Get global database path."""
db_dir = Path.home() / ".hanzo" / "db"
@@ -49,7 +49,7 @@ class MemoryManager:
"""Initialize memory database with FTS5 and vector support."""
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Enable sqlite-vec extension if available
try:
conn.enable_load_extension(True)
@@ -57,7 +57,7 @@ class MemoryManager:
has_vector = True
except (sqlite3.Error, AttributeError):
has_vector = False
# Create markdown files table
conn.execute("""
CREATE TABLE IF NOT EXISTS markdown_files (
@@ -70,7 +70,7 @@ class MemoryManager:
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create FTS5 index for full-text search
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS markdown_fts USING fts5(
@@ -79,7 +79,7 @@ class MemoryManager:
content_rowid='id'
)
""")
# Create memories table for structured data
conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
@@ -93,7 +93,7 @@ class MemoryManager:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create FTS5 index for memories
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
@@ -102,7 +102,7 @@ class MemoryManager:
content_rowid='id'
)
""")
if has_vector:
# Create vector embeddings table
conn.execute("""
@@ -115,14 +115,14 @@ class MemoryManager:
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create vector index
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_index USING vec0(
embedding float[384] -- BGE small model dimensions
)
""")
# Create triggers to keep FTS in sync
conn.execute("""
CREATE TRIGGER IF NOT EXISTS markdown_files_ai
@@ -131,7 +131,7 @@ class MemoryManager:
VALUES (new.id, new.path, new.content, new.category, new.scope);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS markdown_files_ad
AFTER DELETE ON markdown_files BEGIN
@@ -139,7 +139,7 @@ class MemoryManager:
VALUES('delete', old.id, old.path, old.content, old.category, old.scope);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS markdown_files_au
AFTER UPDATE ON markdown_files BEGIN
@@ -149,7 +149,7 @@ class MemoryManager:
VALUES (new.id, new.path, new.content, new.category, new.scope);
END
""")
# Similar triggers for memories
conn.execute("""
CREATE TRIGGER IF NOT EXISTS memories_ai
@@ -158,7 +158,7 @@ class MemoryManager:
VALUES (new.id, new.content, new.category, new.metadata);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS memories_ad
AFTER DELETE ON memories BEGIN
@@ -166,7 +166,7 @@ class MemoryManager:
VALUES('delete', old.id, old.content, old.category, old.metadata);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS memories_au
AFTER UPDATE ON memories BEGIN
@@ -176,7 +176,7 @@ class MemoryManager:
VALUES (new.id, new.content, new.category, new.metadata);
END
""")
conn.commit()
return conn
@@ -190,14 +190,15 @@ class MemoryManager:
project_path = os.getcwd()
memory_dir = self._get_project_memory_dir(project_path)
full_path = memory_dir / file_path
if not full_path.exists():
return None
return full_path.read_text()
def write_markdown_file(self, file_path: str, content: str, scope: str = "project",
project_path: str = None, category: str = None) -> bool:
def write_markdown_file(
self, file_path: str, content: str, scope: str = "project", project_path: str = None, category: str = None
) -> bool:
"""Write markdown memory file and index in database."""
if scope == "global":
full_path = self.global_memory_dir / file_path
@@ -208,75 +209,95 @@ class MemoryManager:
memory_dir = self._get_project_memory_dir(project_path)
full_path = memory_dir / file_path
db_path = self._get_project_db_path(project_path)
# Ensure directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
full_path.write_text(content)
# Update database index
try:
conn = self._init_memory_db(db_path)
conn.execute("""
conn.execute(
"""
INSERT OR REPLACE INTO markdown_files
(path, content, category, scope, modified_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (str(file_path), content, category, scope))
""",
(str(file_path), content, category, scope),
)
conn.commit()
conn.close()
except Exception as e:
print(f"Warning: Failed to index markdown file: {e}")
return True
def append_markdown_file(self, file_path: str, content: str, scope: str = "project",
project_path: str = None, category: str = None) -> bool:
def append_markdown_file(
self, file_path: str, content: str, scope: str = "project", project_path: str = None, category: str = None
) -> bool:
"""Append to markdown memory file."""
existing = self.read_markdown_file(file_path, scope, project_path) or ""
# Add timestamp and newlines
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if existing:
new_content = f"{existing}\n\n## {timestamp}\n\n{content}"
else:
new_content = f"# {file_path}\n\n## {timestamp}\n\n{content}"
return self.write_markdown_file(file_path, new_content, scope, project_path, category)
def list_markdown_files(self, scope: str = "both", project_path: str = None) -> List[Dict[str, Any]]:
"""List all markdown memory files."""
files = []
if scope in ("global", "both"):
global_files = list(self.global_memory_dir.rglob("*.md"))
files.extend([{
"path": str(f.relative_to(self.global_memory_dir)),
"full_path": str(f),
"scope": "global",
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat()
} for f in global_files])
files.extend(
[
{
"path": str(f.relative_to(self.global_memory_dir)),
"full_path": str(f),
"scope": "global",
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in global_files
]
)
if scope in ("project", "both"):
if not project_path:
project_path = os.getcwd()
memory_dir = self._get_project_memory_dir(project_path)
project_files = list(memory_dir.rglob("*.md"))
files.extend([{
"path": str(f.relative_to(memory_dir)),
"full_path": str(f),
"scope": "project",
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat()
} for f in project_files])
files.extend(
[
{
"path": str(f.relative_to(memory_dir)),
"full_path": str(f),
"scope": "project",
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in project_files
]
)
return sorted(files, key=lambda x: x["modified"], reverse=True)
# Structured memory operations
def create_memory(self, content: str, category: str = None, importance: int = 5,
metadata: Dict[str, Any] = None, scope: str = "project",
project_path: str = None) -> int:
def create_memory(
self,
content: str,
category: str = None,
importance: int = 5,
metadata: Dict[str, Any] = None,
scope: str = "project",
project_path: str = None,
) -> int:
"""Create structured memory record."""
if scope == "global":
db_path = self._get_global_db_path()
@@ -284,41 +305,45 @@ class MemoryManager:
if not project_path:
project_path = os.getcwd()
db_path = self._get_project_db_path(project_path)
conn = self._init_memory_db(db_path)
cursor = conn.execute("""
cursor = conn.execute(
"""
INSERT INTO memories (content, category, importance, metadata, scope)
VALUES (?, ?, ?, ?, ?)
""", (content, category, importance, json.dumps(metadata) if metadata else None, scope))
""",
(content, category, importance, json.dumps(metadata) if metadata else None, scope),
)
memory_id = cursor.lastrowid
conn.commit()
conn.close()
return memory_id
# Search operations
def search_memories(self, query: str, scope: str = "both", project_path: str = None,
search_type: str = "fulltext", limit: int = 10) -> List[Dict[str, Any]]:
def search_memories(
self, query: str, scope: str = "both", project_path: str = None, search_type: str = "fulltext", limit: int = 10
) -> List[Dict[str, Any]]:
"""Search across all memory types."""
results = []
# Search markdown files
markdown_results = self._search_markdown_fulltext(query, scope, project_path, limit)
results.extend([{**r, "source": "markdown"} for r in markdown_results])
# Search structured memories
# Search structured memories
memory_results = self._search_structured_memories(query, scope, project_path, limit)
results.extend([{**r, "source": "memory"} for r in memory_results])
# Vector search available when sqlite-vec extension installed
# Sort by relevance/recency
return sorted(results, key=lambda x: x.get("score", 0), reverse=True)[:limit]
def _search_markdown_fulltext(self, query: str, scope: str, project_path: str, limit: int) -> List[Dict[str, Any]]:
"""Search markdown files using FTS5."""
results = []
dbs = []
if scope in ("global", "both"):
dbs.append(("global", self._get_global_db_path()))
@@ -326,14 +351,15 @@ class MemoryManager:
if not project_path:
project_path = os.getcwd()
dbs.append(("project", self._get_project_db_path(project_path)))
for scope_name, db_path in dbs:
if not db_path.exists():
continue
try:
conn = self._init_memory_db(db_path)
cursor = conn.execute("""
cursor = conn.execute(
"""
SELECT m.*, f.rank,
snippet(markdown_fts, 1, '<mark>', '</mark>', '...', 64) as snippet
FROM markdown_fts f
@@ -341,31 +367,37 @@ class MemoryManager:
WHERE markdown_fts MATCH ?
ORDER BY f.rank
LIMIT ?
""", (query, limit))
""",
(query, limit),
)
for row in cursor.fetchall():
results.append({
"id": row["id"],
"path": row["path"],
"content": row["content"][:500] + "..." if len(row["content"]) > 500 else row["content"],
"snippet": row["snippet"],
"category": row["category"],
"scope": scope_name,
"score": -row["rank"], # FTS5 rank is negative
"created_at": row["created_at"],
"modified_at": row["modified_at"]
})
results.append(
{
"id": row["id"],
"path": row["path"],
"content": row["content"][:500] + "..." if len(row["content"]) > 500 else row["content"],
"snippet": row["snippet"],
"category": row["category"],
"scope": scope_name,
"score": -row["rank"], # FTS5 rank is negative
"created_at": row["created_at"],
"modified_at": row["modified_at"],
}
)
conn.close()
except Exception as e:
print(f"Warning: FTS search failed for {scope_name}: {e}")
return results
def _search_structured_memories(self, query: str, scope: str, project_path: str, limit: int) -> List[Dict[str, Any]]:
def _search_structured_memories(
self, query: str, scope: str, project_path: str, limit: int
) -> List[Dict[str, Any]]:
"""Search structured memories using FTS5."""
results = []
dbs = []
if scope in ("global", "both"):
dbs.append(("global", self._get_global_db_path()))
@@ -373,14 +405,15 @@ class MemoryManager:
if not project_path:
project_path = os.getcwd()
dbs.append(("project", self._get_project_db_path(project_path)))
for scope_name, db_path in dbs:
if not db_path.exists():
continue
try:
conn = self._init_memory_db(db_path)
cursor = conn.execute("""
cursor = conn.execute(
"""
SELECT m.*, f.rank,
snippet(memories_fts, 0, '<mark>', '</mark>', '...', 64) as snippet
FROM memories_fts f
@@ -388,26 +421,30 @@ class MemoryManager:
WHERE memories_fts MATCH ?
ORDER BY f.rank
LIMIT ?
""", (query, limit))
""",
(query, limit),
)
for row in cursor.fetchall():
results.append({
"id": row["id"],
"content": row["content"],
"snippet": row["snippet"],
"category": row["category"],
"importance": row["importance"],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
"scope": scope_name,
"score": -row["rank"],
"created_at": row["created_at"],
"updated_at": row["updated_at"]
})
results.append(
{
"id": row["id"],
"content": row["content"],
"snippet": row["snippet"],
"category": row["category"],
"importance": row["importance"],
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
"scope": scope_name,
"score": -row["rank"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
conn.close()
except Exception as e:
print(f"Warning: Memory search failed for {scope_name}: {e}")
return results
# Vector operations (requires sqlite-vec extension)
@@ -416,15 +453,22 @@ class MemoryManager:
# Requires: pip install fastembed
try:
from fastembed import TextEmbedding
embedding_model = TextEmbedding(model_name=model)
embeddings = list(embedding_model.embed([text]))
return embeddings[0].tolist() if embeddings else None
except ImportError:
return None # fastembed not installed
def add_embedding(self, source_table: str, source_id: int, embedding: List[float],
model: str = "bge-small-en-v1.5", scope: str = "project",
project_path: str = None) -> bool:
def add_embedding(
self,
source_table: str,
source_id: int,
embedding: List[float],
model: str = "bge-small-en-v1.5",
scope: str = "project",
project_path: str = None,
) -> bool:
"""Add vector embedding to database."""
if scope == "global":
db_path = self._get_global_db_path()
@@ -432,28 +476,34 @@ class MemoryManager:
if not project_path:
project_path = os.getcwd()
db_path = self._get_project_db_path(project_path)
try:
conn = self._init_memory_db(db_path)
# Check if sqlite-vec is available
cursor = conn.execute("SELECT name FROM pragma_table_info('vec_index')")
if not cursor.fetchall():
conn.close()
return False
# Store embedding
conn.execute("""
conn.execute(
"""
INSERT OR REPLACE INTO embeddings
(source_table, source_id, embedding, model)
VALUES (?, ?, ?, ?)
""", (source_table, source_id, json.dumps(embedding), model))
""",
(source_table, source_id, json.dumps(embedding), model),
)
# Add to vector index
conn.execute("""
conn.execute(
"""
INSERT INTO vec_index (embedding) VALUES (?)
""", (json.dumps(embedding),))
""",
(json.dumps(embedding),),
)
conn.commit()
conn.close()
return True
@@ -461,8 +511,9 @@ class MemoryManager:
print(f"Warning: Vector embedding failed: {e}")
return False
def vector_search(self, query_embedding: List[float], scope: str = "both",
project_path: str = None, limit: int = 10) -> List[Dict[str, Any]]:
def vector_search(
self, query_embedding: List[float], scope: str = "both", project_path: str = None, limit: int = 10
) -> List[Dict[str, Any]]:
"""Search using vector similarity. Requires sqlite-vec extension."""
results = []
dbs = []
@@ -485,21 +536,26 @@ class MemoryManager:
continue
# Vector similarity search using sqlite-vec
cursor = conn.execute("""
cursor = conn.execute(
"""
SELECT e.source_table, e.source_id, vec_distance(v.embedding, ?) as distance
FROM vec_index v
JOIN embeddings e ON e.id = v.rowid
ORDER BY distance ASC
LIMIT ?
""", (json.dumps(query_embedding), limit))
""",
(json.dumps(query_embedding), limit),
)
for row in cursor.fetchall():
results.append({
"source_table": row["source_table"],
"source_id": row["source_id"],
"distance": row["distance"],
"scope": scope_name,
})
results.append(
{
"source_table": row["source_table"],
"source_id": row["source_id"],
"distance": row["distance"],
"scope": scope_name,
}
)
conn.close()
except Exception as e:
print(f"Warning: Vector search failed for {scope_name}: {e}")
@@ -513,52 +569,52 @@ class MemoryManager:
"markdown_files": {"global": 0, "project": 0},
"structured_memories": {"global": 0, "project": 0},
"vector_embeddings": {"global": 0, "project": 0},
"total_size_bytes": 0
"total_size_bytes": 0,
}
# Count markdown files
if scope in ("global", "both"):
global_files = list(self.global_memory_dir.rglob("*.md"))
stats["markdown_files"]["global"] = len(global_files)
stats["total_size_bytes"] += sum(f.stat().st_size for f in global_files)
if scope in ("project", "both") and project_path:
memory_dir = self._get_project_memory_dir(project_path)
if memory_dir.exists():
project_files = list(memory_dir.rglob("*.md"))
stats["markdown_files"]["project"] = len(project_files)
stats["total_size_bytes"] += sum(f.stat().st_size for f in project_files)
# Count database records
dbs = []
if scope in ("global", "both"):
dbs.append(("global", self._get_global_db_path()))
if scope in ("project", "both") and project_path:
dbs.append(("project", self._get_project_db_path(project_path)))
for scope_name, db_path in dbs:
if not db_path.exists():
continue
try:
conn = self._init_memory_db(db_path)
# Count memories
cursor = conn.execute("SELECT COUNT(*) FROM memories")
stats["structured_memories"][scope_name] = cursor.fetchone()[0]
# Count embeddings
# Count embeddings
try:
cursor = conn.execute("SELECT COUNT(*) FROM embeddings")
stats["vector_embeddings"][scope_name] = cursor.fetchone()[0]
except sqlite3.Error:
stats["vector_embeddings"][scope_name] = 0
# Add DB file size
stats["total_size_bytes"] += db_path.stat().st_size
conn.close()
except Exception as e:
print(f"Warning: Failed to get stats for {scope_name}: {e}")
return stats
return stats
@@ -149,7 +149,7 @@ memory --action stats
# Extract action
action = params.get("action", "search")
# Get current working directory for project context
project_path = os.getcwd()
@@ -170,8 +170,10 @@ memory --action stats
elif action == "stats":
return await self._handle_stats(params, project_path, tool_ctx)
else:
return f"Error: Unknown action '{action}'. Valid actions: read, write, append, search, create, list, stats"
return (
f"Error: Unknown action '{action}'. Valid actions: read, write, append, search, create, list, stats"
)
except Exception as e:
await tool_ctx.error(f"Memory operation failed: {str(e)}")
return f"Error: {str(e)}"
@@ -183,21 +185,21 @@ memory --action stats
return "Error: file_path required for read action"
scope = params.get("scope", "project")
content = self.memory_manager.read_markdown_file(file_path, scope, project_path)
if content is None:
return f"Error: File '{file_path}' not found in {scope} scope"
await tool_ctx.info(f"Read {scope} memory file: {file_path}")
return f"=== {file_path} ({scope}) ===\n\n{content}"
async def _handle_write(self, params: Dict[str, Any], project_path: str, tool_ctx) -> str:
"""Write markdown memory file."""
file_path = params.get("file_path")
content = params.get("content")
if not file_path:
return "Error: file_path required for write action"
if not content:
@@ -205,11 +207,9 @@ memory --action stats
scope = params.get("scope", "project")
category = params.get("category")
success = self.memory_manager.write_markdown_file(
file_path, content, scope, project_path, category
)
success = self.memory_manager.write_markdown_file(file_path, content, scope, project_path, category)
if success:
await tool_ctx.info(f"Wrote {scope} memory file: {file_path}")
return f"Successfully wrote {scope} memory file: {file_path}"
@@ -220,7 +220,7 @@ memory --action stats
"""Append to markdown memory file."""
file_path = params.get("file_path")
content = params.get("content")
if not file_path:
return "Error: file_path required for append action"
if not content:
@@ -228,11 +228,9 @@ memory --action stats
scope = params.get("scope", "project")
category = params.get("category")
success = self.memory_manager.append_markdown_file(
file_path, content, scope, project_path, category
)
success = self.memory_manager.append_markdown_file(file_path, content, scope, project_path, category)
if success:
await tool_ctx.info(f"Appended to {scope} memory file: {file_path}")
return f"Successfully appended to {scope} memory file: {file_path}"
@@ -248,29 +246,27 @@ memory --action stats
scope = params.get("scope", "both")
search_type = params.get("search_type", "fulltext")
limit = params.get("limit", 10)
results = self.memory_manager.search_memories(
query, scope, project_path, search_type, limit
)
results = self.memory_manager.search_memories(query, scope, project_path, search_type, limit)
if not results:
return f"No memories found for query: '{query}'"
# Format results
output = [f"=== Memory Search Results for '{query}' ==="]
output.append(f"Found {len(results)} results (scope: {scope})\n")
for i, result in enumerate(results, 1):
source = result["source"]
scope_name = result["scope"]
if source == "markdown":
output.append(f"{i}. [{scope_name}] {result['path']} (markdown)")
if "snippet" in result:
output.append(f" {result['snippet']}")
output.append(f" Category: {result.get('category', 'none')}")
output.append(f" Modified: {result['modified_at']}")
elif source == "memory":
output.append(f"{i}. [{scope_name}] Memory #{result['id']} (structured)")
if "snippet" in result:
@@ -278,11 +274,11 @@ memory --action stats
output.append(f" Category: {result.get('category', 'none')}")
output.append(f" Importance: {result['importance']}")
output.append(f" Created: {result['created_at']}")
output.append("")
await tool_ctx.info(f"Found {len(results)} memories for: {query}")
return "\n".join(output)
async def _handle_create(self, params: Dict[str, Any], project_path: str, tool_ctx) -> str:
@@ -294,85 +290,83 @@ memory --action stats
scope = params.get("scope", "project")
category = params.get("category")
importance = params.get("importance", 5)
memory_id = self.memory_manager.create_memory(
content, category, importance, None, scope, project_path
)
memory_id = self.memory_manager.create_memory(content, category, importance, None, scope, project_path)
await tool_ctx.info(f"Created {scope} memory: #{memory_id}")
return f"Successfully created {scope} memory #{memory_id}\nContent: {content[:100]}..."
async def _handle_list(self, params: Dict[str, Any], project_path: str, tool_ctx) -> str:
"""List memory files and records."""
scope = params.get("scope", "both")
# List markdown files
markdown_files = self.memory_manager.list_markdown_files(scope, project_path)
output = [f"=== Memory Files ({scope} scope) ==="]
if markdown_files:
output.append("\nMarkdown Files:")
for file_info in markdown_files:
size_kb = file_info["size"] / 1024
output.append(
f" [{file_info['scope']}] {file_info['path']} "
f"({size_kb:.1f}KB, {file_info['modified']})"
f" [{file_info['scope']}] {file_info['path']} ({size_kb:.1f}KB, {file_info['modified']})"
)
else:
output.append("\nNo markdown files found")
# Get stats for structured memories
stats = self.memory_manager.get_memory_stats(scope, project_path)
output.append(f"\nStructured Memories:")
if scope in ("global", "both"):
output.append(f" Global: {stats['structured_memories']['global']} records")
if scope in ("project", "both"):
output.append(f" Project: {stats['structured_memories']['project']} records")
total_size_mb = stats["total_size_bytes"] / 1024 / 1024
output.append(f"\nTotal size: {total_size_mb:.2f}MB")
return "\n".join(output)
async def _handle_stats(self, params: Dict[str, Any], project_path: str, tool_ctx) -> str:
"""Get detailed memory statistics."""
scope = params.get("scope", "both")
stats = self.memory_manager.get_memory_stats(scope, project_path)
output = [f"=== Memory System Statistics ({scope} scope) ==="]
output.append(f"Project: {project_path}\n")
# Markdown files
output.append("Markdown Files:")
output.append(f" Global: {stats['markdown_files']['global']} files")
output.append(f" Project: {stats['markdown_files']['project']} files")
# Structured memories
output.append("\nStructured Memories:")
output.append(f" Global: {stats['structured_memories']['global']} records")
output.append(f" Project: {stats['structured_memories']['project']} records")
# Vector embeddings
output.append("\nVector Embeddings:")
output.append(f" Global: {stats['vector_embeddings']['global']} embeddings")
output.append(f" Project: {stats['vector_embeddings']['project']} embeddings")
# Storage size
total_size_mb = stats["total_size_bytes"] / 1024 / 1024
output.append(f"\nTotal Storage: {total_size_mb:.2f}MB")
# Features status
output.append("\nFeature Status:")
output.append(" ✓ Markdown files")
output.append(" ✓ SQLite FTS5 full-text search")
# Check sqlite-vec availability
try:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True)
conn.load_extension("vec0")
@@ -380,9 +374,9 @@ memory --action stats
conn.close()
except Exception:
output.append(" ✗ sqlite-vec vector search (not available)")
return "\n".join(output)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
pass
@@ -134,16 +134,17 @@ Note: Use sql_search for text search operations."""
# Check if query is read-only using token-based parsing
if read_only:
import re
# Remove comments and string literals to prevent bypass
clean_query = re.sub(r'--.*$', '', query, flags=re.MULTILINE) # Remove -- comments
clean_query = re.sub(r'/\*.*?\*/', '', clean_query, flags=re.DOTALL) # Remove /* */ comments
clean_query = re.sub(r"--.*$", "", query, flags=re.MULTILINE) # Remove -- comments
clean_query = re.sub(r"/\*.*?\*/", "", clean_query, flags=re.DOTALL) # Remove /* */ comments
clean_query = re.sub(r"'[^']*'", "''", clean_query) # Neutralize string literals
clean_query = re.sub(r'"[^"]*"', '""', clean_query) # Neutralize identifiers
# Check for write operations using word boundaries
write_keywords = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "REPLACE"]
for keyword in write_keywords:
if re.search(rf'\b{keyword}\b', clean_query, re.IGNORECASE):
if re.search(rf"\b{keyword}\b", clean_query, re.IGNORECASE):
return (
f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
)
+29 -24
View File
@@ -6,9 +6,9 @@ Downloads and installs sqlite-vec for vector search capabilities.
import os
import sys
import urllib.request
import platform
import sqlite3
import platform
import urllib.request
from pathlib import Path
@@ -16,7 +16,7 @@ def get_sqlite_vec_url():
"""Get the appropriate sqlite-vec download URL for this platform."""
system = platform.system().lower()
machine = platform.machine().lower()
# Map platform to sqlite-vec release names
if system == "darwin":
if machine in ("x86_64", "amd64"):
@@ -29,8 +29,10 @@ def get_sqlite_vec_url():
elif machine in ("arm64", "aarch64"):
return "https://github.com/asg017/sqlite-vec/releases/latest/download/sqlite-vec-v0.1.0-alpha.7-linux-aarch64.tar.gz"
elif system == "windows":
return "https://github.com/asg017/sqlite-vec/releases/latest/download/sqlite-vec-v0.1.0-alpha.7-windows-x86_64.zip"
return (
"https://github.com/asg017/sqlite-vec/releases/latest/download/sqlite-vec-v0.1.0-alpha.7-windows-x86_64.zip"
)
raise RuntimeError(f"Unsupported platform: {system} {machine}")
@@ -39,36 +41,38 @@ def download_sqlite_vec():
try:
url = get_sqlite_vec_url()
print(f"Downloading sqlite-vec from: {url}")
# Create extension directory
ext_dir = Path(__file__).parent / "extensions"
ext_dir.mkdir(exist_ok=True)
# Download
filename = url.split("/")[-1]
local_path = ext_dir / filename
urllib.request.urlretrieve(url, local_path)
print(f"Downloaded: {local_path}")
# Extract
if filename.endswith(".tar.gz"):
import tarfile
with tarfile.open(local_path, 'r:gz') as tar:
with tarfile.open(local_path, "r:gz") as tar:
tar.extractall(ext_dir)
elif filename.endswith(".zip"):
import zipfile
with zipfile.ZipFile(local_path, 'r') as zip_ref:
with zipfile.ZipFile(local_path, "r") as zip_ref:
zip_ref.extractall(ext_dir)
# Find the extension file
for ext_file in ext_dir.rglob("vec0.*"):
if ext_file.suffix in (".so", ".dll", ".dylib"):
print(f"Found extension: {ext_file}")
return ext_file
raise RuntimeError("Could not find vec0 extension after extraction")
except Exception as e:
print(f"Failed to download sqlite-vec: {e}")
return None
@@ -80,21 +84,21 @@ def test_sqlite_vec(extension_path):
conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True)
conn.load_extension(str(extension_path))
# Test basic functionality
conn.execute("CREATE VIRTUAL TABLE test_vec USING vec0(embedding float[3])")
conn.execute("INSERT INTO test_vec (embedding) VALUES ('[1,2,3]')")
result = conn.execute("SELECT * FROM test_vec").fetchone()
conn.close()
if result:
print("✓ sqlite-vec extension is working correctly")
return True
else:
print("✗ sqlite-vec extension test failed")
return False
except Exception as e:
print(f"✗ sqlite-vec extension test failed: {e}")
return False
@@ -103,7 +107,7 @@ def test_sqlite_vec(extension_path):
def install_sqlite_vec():
"""Install sqlite-vec extension."""
print("Setting up sqlite-vec extension for vector search...")
# Check if already available
try:
conn = sqlite3.connect(":memory:")
@@ -114,24 +118,25 @@ def install_sqlite_vec():
return True
except Exception:
pass # Extension not available, will download
# Download and install
extension_path = download_sqlite_vec()
if extension_path and test_sqlite_vec(extension_path):
# Create symlink or copy to standard location
try:
import shutil
target_dir = Path.home() / ".hanzo" / "extensions"
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / extension_path.name
shutil.copy2(extension_path, target_path)
print(f"✓ Installed sqlite-vec to: {target_path}")
# Add to environment
print("\nTo use sqlite-vec, add this to your environment:")
print(f"export SQLITE_VEC_PATH={target_path}")
return True
except Exception as e:
print(f"Warning: Failed to install extension: {e}")
@@ -145,4 +150,4 @@ def install_sqlite_vec():
if __name__ == "__main__":
success = install_sqlite_vec()
sys.exit(0 if success else 1)
sys.exit(0 if success else 1)
+57 -70
View File
@@ -10,184 +10,170 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "hanzo_tools"))
from hanzo_tools.core import PermissionManager
from hanzo_tools.database.memory_manager import MemoryManager
from hanzo_tools.database.memory_tool import MemoryTool
from hanzo_tools.database.memory_manager import MemoryManager
async def test_memory_system():
"""Test the hybrid memory system."""
print("🧠 Testing Hybrid Memory System")
print("=" * 50)
# Create permission manager (allow all for testing)
permission_manager = PermissionManager(allowed_paths=["/"])
# Create memory manager
memory_manager = MemoryManager(permission_manager)
# Create memory tool
memory_tool = MemoryTool(permission_manager)
# Test project path
test_project = Path("/tmp/test_hanzo_project")
test_project.mkdir(exist_ok=True)
os.chdir(test_project)
print(f"📁 Test project: {test_project}")
# Mock MCP context (simplified)
class MockContext:
pass
ctx = MockContext()
# Test 1: Initialize memory structure
print("\n1. 📋 Initialize memory structure...")
from hanzo_tools.database.init_memory import init_project_memory, init_global_memory
from hanzo_tools.database.init_memory import init_global_memory, init_project_memory
try:
init_global_memory()
init_project_memory(str(test_project))
print("✓ Memory structure initialized")
except Exception as e:
print(f"✗ Initialization failed: {e}")
# Test 2: Write memory files
print("\n2. ✏️ Write memory files...")
try:
# Global memory
result = await memory_tool.call(ctx,
result = await memory_tool.call(
ctx,
action="write",
file_path="test_rules.md",
file_path="test_rules.md",
content="# Test Rules\n\nThis is a test rule file.",
scope="global",
category="test"
category="test",
)
print(f"✓ Global write: {result}")
# Project memory
result = await memory_tool.call(ctx,
result = await memory_tool.call(
ctx,
action="write",
file_path="test_architecture.md",
content="# Test Architecture\n\nThis describes the test architecture.",
scope="project",
category="architecture"
category="architecture",
)
print(f"✓ Project write: {result}")
except Exception as e:
print(f"✗ Write failed: {e}")
# Test 3: Read memory files
print("\n3. 📖 Read memory files...")
try:
result = await memory_tool.call(ctx,
action="read",
file_path="test_rules.md",
scope="global"
)
result = await memory_tool.call(ctx, action="read", file_path="test_rules.md", scope="global")
print("✓ Global read successful")
print(f"Content preview: {result[:100]}...")
result = await memory_tool.call(ctx,
action="read",
file_path="test_architecture.md",
scope="project"
)
result = await memory_tool.call(ctx, action="read", file_path="test_architecture.md", scope="project")
print("✓ Project read successful")
print(f"Content preview: {result[:100]}...")
except Exception as e:
print(f"✗ Read failed: {e}")
# Test 4: Append to memory files
print("\n4. ➕ Append to memory files...")
try:
result = await memory_tool.call(ctx,
result = await memory_tool.call(
ctx,
action="append",
file_path="sessions/test_session.md",
content="This is a test session entry with important insights.",
scope="project",
category="session"
category="session",
)
print(f"✓ Append: {result}")
except Exception as e:
print(f"✗ Append failed: {e}")
# Test 5: Create structured memories
print("\n5. 🗃️ Create structured memories...")
try:
result = await memory_tool.call(ctx,
result = await memory_tool.call(
ctx,
action="create",
content="This is a test structured memory with high importance",
category="test",
importance=8,
scope="project"
scope="project",
)
print(f"✓ Create structured: {result}")
except Exception as e:
print(f"✗ Create failed: {e}")
# Test 6: Search memories
print("\n6. 🔍 Search memories...")
try:
result = await memory_tool.call(ctx,
action="search",
content="test",
scope="both",
limit=5
)
result = await memory_tool.call(ctx, action="search", content="test", scope="both", limit=5)
print("✓ Search successful")
print(f"Results preview: {result[:300]}...")
except Exception as e:
print(f"✗ Search failed: {e}")
# Test 7: List memory files
print("\n7. 📋 List memory files...")
try:
result = await memory_tool.call(ctx,
action="list",
scope="both"
)
result = await memory_tool.call(ctx, action="list", scope="both")
print("✓ List successful")
print(f"List preview: {result[:200]}...")
except Exception as e:
print(f"✗ List failed: {e}")
# Test 8: Get statistics
print("\n8. 📊 Get memory statistics...")
try:
result = await memory_tool.call(ctx,
action="stats",
scope="both"
)
result = await memory_tool.call(ctx, action="stats", scope="both")
print("✓ Stats successful")
print(f"Stats preview: {result[:300]}...")
except Exception as e:
print(f"✗ Stats failed: {e}")
# Test 9: Test database features
print("\n9. 🗄️ Test database features...")
try:
# Test FTS5 search
results = memory_manager.search_memories("test architecture", "both", str(test_project))
print(f"✓ FTS5 search found {len(results)} results")
# Get stats
stats = memory_manager.get_memory_stats("both", str(test_project))
print(f"✓ Database stats: {stats['markdown_files']} markdown files, {stats['structured_memories']} memories")
# Test sqlite-vec availability
try:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.enable_load_extension(True)
conn.load_extension("vec0")
@@ -195,15 +181,16 @@ async def test_memory_system():
print("✓ sqlite-vec extension available")
except:
print("⚠️ sqlite-vec extension not available (optional)")
except Exception as e:
print(f"✗ Database test failed: {e}")
print("\n" + "=" * 50)
print("🎉 Memory system test completed!")
# Cleanup
import shutil
try:
shutil.rmtree(test_project)
print(f"🧹 Cleaned up test project: {test_project}")
@@ -212,4 +199,4 @@ async def test_memory_system():
if __name__ == "__main__":
asyncio.run(test_memory_system())
asyncio.run(test_memory_system())
@@ -28,8 +28,6 @@ Usage:
"""
# HIP-0300: Unified fs tool
from hanzo_tools.fs.fs_tool import FsTool, fs_tool
# Legacy tools (backward compatibility)
from hanzo_tools.fs.ast import ASTTool
from hanzo_tools.fs.edit import EditTool
@@ -38,6 +36,7 @@ from hanzo_tools.fs.read import ReadTool
from hanzo_tools.fs.tree import TreeTool
from hanzo_tools.fs.write import WriteTool
from hanzo_tools.fs.search import SearchTool
from hanzo_tools.fs.fs_tool import FsTool, fs_tool
# Backwards compatibility aliases
Edit = EditTool
+76 -56
View File
@@ -17,30 +17,31 @@ import re
import json
import fnmatch
import hashlib
import aiofiles
from typing import Any, Optional, ClassVar
from enum import Enum
from typing import Any, ClassVar, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field
from enum import Enum
from dataclasses import field, dataclass
import aiofiles
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import (
Paging,
BaseTool,
PermissionManager,
ConflictError,
NotFoundError,
PermissionManager,
InvalidParamsError,
Paging,
content_hash,
file_uri,
content_hash,
)
class PatchOp(str, Enum):
"""Patch operation type (Rust parity)."""
ADD = "add"
UPDATE = "update"
DELETE = "delete"
@@ -49,6 +50,7 @@ class PatchOp(str, Enum):
@dataclass
class PatchHunk:
"""A single hunk in a patch (Rust parity)."""
context: str = "" # @@ header context
old_lines: list[str] = field(default_factory=list)
new_lines: list[str] = field(default_factory=list)
@@ -57,6 +59,7 @@ class PatchHunk:
@dataclass
class PatchFile:
"""A single file operation in a patch (Rust parity)."""
op: PatchOp
path: str
hunks: list[PatchHunk] = field(default_factory=list)
@@ -254,7 +257,7 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
# Apply offset/limit by lines
lines = content.splitlines(keepends=True)
total_lines = len(lines)
selected = lines[offset:offset + limit]
selected = lines[offset : offset + limit]
# Format with line numbers
output_lines = []
@@ -394,12 +397,14 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
continue
rel_path = entry.relative_to(path)
entries.append({
"name": str(rel_path),
"uri": file_uri(str(entry)),
"is_dir": entry.is_dir(),
"size": entry.stat().st_size if entry.is_file() else None,
})
entries.append(
{
"name": str(rel_path),
"uri": file_uri(str(entry)),
"is_dir": entry.is_dir(),
"size": entry.stat().st_size if entry.is_file() else None,
}
)
count += 1
if entry.is_dir() and current_depth < depth:
@@ -420,7 +425,7 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
"cursor": next_cursor,
"more": has_more,
"total": total,
}
},
}
@self.action("apply_patch", "Edit file with precondition")
@@ -563,12 +568,14 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
async with aiofiles.open(path, "w", encoding="utf-8") as f:
await f.write(pf.content)
results.append({
"op": "add",
"path": str(path),
"hash": self._compute_hash(pf.content),
"success": True,
})
results.append(
{
"op": "add",
"path": str(path),
"hash": self._compute_hash(pf.content),
"success": True,
}
)
elif pf.op == PatchOp.UPDATE:
# Update existing file
@@ -592,38 +599,46 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
async with aiofiles.open(path, "w", encoding="utf-8") as f:
await f.write(content)
results.append({
"op": "update",
"path": str(path),
"hash": self._compute_hash(content),
"hunks_applied": len(pf.hunks),
"success": True,
})
results.append(
{
"op": "update",
"path": str(path),
"hash": self._compute_hash(content),
"hunks_applied": len(pf.hunks),
"success": True,
}
)
elif pf.op == PatchOp.DELETE:
# Delete file
if not path.exists():
results.append({
"op": "delete",
"path": str(path),
"success": True,
"message": "File already deleted",
})
results.append(
{
"op": "delete",
"path": str(path),
"success": True,
"message": "File already deleted",
}
)
else:
path.unlink()
results.append({
"op": "delete",
"path": str(path),
"success": True,
})
results.append(
{
"op": "delete",
"path": str(path),
"success": True,
}
)
except Exception as e:
results.append({
"op": pf.op.value,
"path": str(path),
"success": False,
"error": str(e),
})
results.append(
{
"op": pf.op.value,
"path": str(path),
"success": False,
"error": str(e),
}
)
return {
"results": results,
@@ -680,11 +695,13 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
data = json.loads(line)
if data.get("type") == "match":
match_data = data["data"]
matches.append({
"uri": file_uri(match_data["path"]["text"]),
"line": match_data["line_number"],
"text": match_data["lines"]["text"].strip(),
})
matches.append(
{
"uri": file_uri(match_data["path"]["text"]),
"line": match_data["line_number"],
"text": match_data["lines"]["text"].strip(),
}
)
except json.JSONDecodeError:
continue
@@ -702,11 +719,13 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
async for line in f:
line_num += 1
if regex.search(line):
matches.append({
"uri": file_uri(str(file_path)),
"line": line_num,
"text": line.strip()[:200],
})
matches.append(
{
"uri": file_uri(str(file_path)),
"line": line_num,
"text": line.strip()[:200],
}
)
if len(matches) >= limit:
return
except (PermissionError, IsADirectoryError):
@@ -731,7 +750,7 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
"paging": {
"cursor": next_cursor,
"more": has_more,
}
},
}
@self.action("mkdir", "Create directory")
@@ -774,6 +793,7 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
path.unlink()
else:
import shutil
shutil.rmtree(path)
return {"uri": file_uri(str(path)), "removed": True}
+1 -1
View File
@@ -3,9 +3,9 @@
from typing import Any, Optional, Annotated
from pathlib import Path
from hanzo_async import read_lines
from pydantic import Field
from mcp.server import FastMCP
from hanzo_async import read_lines
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout
+1 -1
View File
@@ -5,9 +5,9 @@ import asyncio
from typing import Optional, Annotated
from pathlib import Path
from hanzo_async import read_file
from pydantic import Field
from mcp.server import FastMCP
from hanzo_async import read_file
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, auto_timeout
+1 -1
View File
@@ -3,9 +3,9 @@
from typing import Optional, Annotated
from pathlib import Path
from hanzo_async import write_file, mkdir
from pydantic import Field
from mcp.server import FastMCP
from hanzo_async import mkdir, write_file
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import FileSystemTool, PermissionManager, auto_timeout

Some files were not shown because too many files have changed in this diff Show More