mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Authentication Models
|
||||
# =============================================================================
|
||||
|
||||
+279
-192
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user