fiveclaw-agent 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fiveclaw_agent-1.0.0/PKG-INFO +100 -0
- fiveclaw_agent-1.0.0/README.md +89 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent/__init__.py +2 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent/config.py +89 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent/local.py +422 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent/remote.py +56 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent/server.py +302 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/PKG-INFO +100 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/SOURCES.txt +13 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/dependency_links.txt +1 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/entry_points.txt +2 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/requires.txt +4 -0
- fiveclaw_agent-1.0.0/fiveclaw_agent.egg-info/top_level.txt +1 -0
- fiveclaw_agent-1.0.0/pyproject.toml +24 -0
- fiveclaw_agent-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fiveclaw-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local MCP agent for FiveClaw — FiveM AI development tools
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastmcp>=3.0
|
|
9
|
+
Provides-Extra: ssh
|
|
10
|
+
Requires-Dist: paramiko>=3.0; extra == "ssh"
|
|
11
|
+
|
|
12
|
+
# fiveclaw-agent
|
|
13
|
+
|
|
14
|
+
Local MCP agent for [FiveClaw](https://fiveclaw.com) — AI-powered FiveM development tools.
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
`fiveclaw-agent` runs on your machine alongside your FiveM server. It handles local operations (file search, logs, MySQL, txAdmin, SSH deploy) and relays AI analysis requests to the FiveClaw platform using your API key.
|
|
19
|
+
|
|
20
|
+
**Local tools** (run on your machine):
|
|
21
|
+
- Resource map generation and querying
|
|
22
|
+
- File search across Lua/JS resources
|
|
23
|
+
- Lua syntax checking
|
|
24
|
+
- Log reading
|
|
25
|
+
- MySQL query execution
|
|
26
|
+
- txAdmin server control (start/stop/restart resources, send console commands)
|
|
27
|
+
- SSH deployment to remote servers
|
|
28
|
+
- Persistent context memory
|
|
29
|
+
|
|
30
|
+
**Cloud tools** (powered by FiveClaw, gated by your plan):
|
|
31
|
+
- Resource validation and health checks
|
|
32
|
+
- Anti-pattern and duplicate code detection
|
|
33
|
+
- Export/event flow tracing
|
|
34
|
+
- AI code review and test generation
|
|
35
|
+
- FiveM native docs search
|
|
36
|
+
- Pattern library (scaffold new resources/features)
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python 3.10+
|
|
41
|
+
- A [FiveClaw](https://fiveclaw.com) account with an active subscription
|
|
42
|
+
- A FiveClaw API key (generate one at [fiveclaw.com/dashboard/keys](https://fiveclaw.com/dashboard/keys))
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install fiveclaw-agent
|
|
48
|
+
|
|
49
|
+
# With SSH deployment support
|
|
50
|
+
pip install fiveclaw-agent[ssh]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Setup
|
|
54
|
+
|
|
55
|
+
1. **Create a `.env` file** in your FiveM project root:
|
|
56
|
+
|
|
57
|
+
```env
|
|
58
|
+
FIVECLAW_API_KEY=fc_live_your_key_here
|
|
59
|
+
|
|
60
|
+
# Optional — auto-detected if not set
|
|
61
|
+
# FIVEM_PROJECT_ROOT=/path/to/your/server
|
|
62
|
+
|
|
63
|
+
# Optional SSH deployment
|
|
64
|
+
# FIVEM_SSH_HOST=1.2.3.4
|
|
65
|
+
# FIVEM_SSH_USER=root
|
|
66
|
+
# FIVEM_SSH_KEY_PATH=~/.ssh/id_rsa
|
|
67
|
+
# FIVEM_REMOTE_RESOURCES=/server-data/resources
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
2. **Add to your Claude config** (`claude_desktop_config.json` or `.mcp.json`):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"fiveclaw": {
|
|
76
|
+
"url": "http://localhost:5200/mcp"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
3. **Start the agent** in your project directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
fiveclaw
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The agent runs on `http://localhost:5200/mcp` and stays running while you work. Restart it anytime without restarting Claude.
|
|
89
|
+
|
|
90
|
+
4. **Verify** by asking Claude: *"Run mcp_health to check my FiveClaw connection."*
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
SSH, txAdmin, and MySQL settings can also be configured on the [FiveClaw dashboard](https://fiveclaw.com/dashboard/server) — the agent fetches them automatically using your API key, so you don't need to duplicate them in `.env`.
|
|
95
|
+
|
|
96
|
+
## Links
|
|
97
|
+
|
|
98
|
+
- [FiveClaw Dashboard](https://fiveclaw.com/dashboard)
|
|
99
|
+
- [Setup Guide](https://fiveclaw.com/dashboard/download)
|
|
100
|
+
- [Pricing](https://fiveclaw.com/pricing)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# fiveclaw-agent
|
|
2
|
+
|
|
3
|
+
Local MCP agent for [FiveClaw](https://fiveclaw.com) — AI-powered FiveM development tools.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
`fiveclaw-agent` runs on your machine alongside your FiveM server. It handles local operations (file search, logs, MySQL, txAdmin, SSH deploy) and relays AI analysis requests to the FiveClaw platform using your API key.
|
|
8
|
+
|
|
9
|
+
**Local tools** (run on your machine):
|
|
10
|
+
- Resource map generation and querying
|
|
11
|
+
- File search across Lua/JS resources
|
|
12
|
+
- Lua syntax checking
|
|
13
|
+
- Log reading
|
|
14
|
+
- MySQL query execution
|
|
15
|
+
- txAdmin server control (start/stop/restart resources, send console commands)
|
|
16
|
+
- SSH deployment to remote servers
|
|
17
|
+
- Persistent context memory
|
|
18
|
+
|
|
19
|
+
**Cloud tools** (powered by FiveClaw, gated by your plan):
|
|
20
|
+
- Resource validation and health checks
|
|
21
|
+
- Anti-pattern and duplicate code detection
|
|
22
|
+
- Export/event flow tracing
|
|
23
|
+
- AI code review and test generation
|
|
24
|
+
- FiveM native docs search
|
|
25
|
+
- Pattern library (scaffold new resources/features)
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.10+
|
|
30
|
+
- A [FiveClaw](https://fiveclaw.com) account with an active subscription
|
|
31
|
+
- A FiveClaw API key (generate one at [fiveclaw.com/dashboard/keys](https://fiveclaw.com/dashboard/keys))
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install fiveclaw-agent
|
|
37
|
+
|
|
38
|
+
# With SSH deployment support
|
|
39
|
+
pip install fiveclaw-agent[ssh]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
1. **Create a `.env` file** in your FiveM project root:
|
|
45
|
+
|
|
46
|
+
```env
|
|
47
|
+
FIVECLAW_API_KEY=fc_live_your_key_here
|
|
48
|
+
|
|
49
|
+
# Optional — auto-detected if not set
|
|
50
|
+
# FIVEM_PROJECT_ROOT=/path/to/your/server
|
|
51
|
+
|
|
52
|
+
# Optional SSH deployment
|
|
53
|
+
# FIVEM_SSH_HOST=1.2.3.4
|
|
54
|
+
# FIVEM_SSH_USER=root
|
|
55
|
+
# FIVEM_SSH_KEY_PATH=~/.ssh/id_rsa
|
|
56
|
+
# FIVEM_REMOTE_RESOURCES=/server-data/resources
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
2. **Add to your Claude config** (`claude_desktop_config.json` or `.mcp.json`):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"fiveclaw": {
|
|
65
|
+
"url": "http://localhost:5200/mcp"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
3. **Start the agent** in your project directory:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
fiveclaw
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The agent runs on `http://localhost:5200/mcp` and stays running while you work. Restart it anytime without restarting Claude.
|
|
78
|
+
|
|
79
|
+
4. **Verify** by asking Claude: *"Run mcp_health to check my FiveClaw connection."*
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
SSH, txAdmin, and MySQL settings can also be configured on the [FiveClaw dashboard](https://fiveclaw.com/dashboard/server) — the agent fetches them automatically using your API key, so you don't need to duplicate them in `.env`.
|
|
84
|
+
|
|
85
|
+
## Links
|
|
86
|
+
|
|
87
|
+
- [FiveClaw Dashboard](https://fiveclaw.com/dashboard)
|
|
88
|
+
- [Setup Guide](https://fiveclaw.com/dashboard/download)
|
|
89
|
+
- [Pricing](https://fiveclaw.com/pricing)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration for the FiveClaw Agent."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_env_file():
|
|
9
|
+
for candidate in [Path.cwd() / ".env", Path(__file__).parent.parent / ".env"]:
|
|
10
|
+
if candidate.exists():
|
|
11
|
+
with open(candidate) as f:
|
|
12
|
+
for line in f:
|
|
13
|
+
line = line.strip()
|
|
14
|
+
if line and not line.startswith("#") and "=" in line:
|
|
15
|
+
key, value = line.split("=", 1)
|
|
16
|
+
if key not in os.environ:
|
|
17
|
+
os.environ[key] = value
|
|
18
|
+
break
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Config:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
load_env_file()
|
|
24
|
+
|
|
25
|
+
self.api_key = os.getenv("FIVECLAW_API_KEY", "")
|
|
26
|
+
self.api_url = os.getenv("FIVECLAW_API_URL", "https://fiveclaw.gg").rstrip("/")
|
|
27
|
+
|
|
28
|
+
if not self.api_key:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
"FIVECLAW_API_KEY is not set.\n"
|
|
31
|
+
"Add it to your .env file: FIVECLAW_API_KEY=fc_live_...\n"
|
|
32
|
+
"Get your key at https://fiveclaw.gg/dashboard/keys"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Local project root (auto-detected or explicit)
|
|
36
|
+
env_root = os.getenv("FIVEM_PROJECT_ROOT", "")
|
|
37
|
+
if env_root and Path(env_root).exists():
|
|
38
|
+
self.project_root = Path(env_root)
|
|
39
|
+
else:
|
|
40
|
+
self.project_root = self._detect_project_root()
|
|
41
|
+
|
|
42
|
+
resources_override = os.getenv("FIVEM_RESOURCES_DIR", "")
|
|
43
|
+
self.resources_dir = (
|
|
44
|
+
Path(resources_override)
|
|
45
|
+
if resources_override
|
|
46
|
+
else self.project_root / "resources"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.logs_dir = self.project_root / "logs"
|
|
50
|
+
self.context_dir = self.project_root / ".fiveclaw" / "context"
|
|
51
|
+
self.context_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# SSH (for deploy_resource)
|
|
54
|
+
self.ssh = {
|
|
55
|
+
"host": os.getenv("FIVEM_SSH_HOST", ""),
|
|
56
|
+
"port": int(os.getenv("FIVEM_SSH_PORT", "22")),
|
|
57
|
+
"user": os.getenv("FIVEM_SSH_USER", ""),
|
|
58
|
+
"key_path": os.getenv("FIVEM_SSH_KEY", ""),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# MySQL (direct local connection)
|
|
62
|
+
self.mysql = {
|
|
63
|
+
"host": os.getenv("MYSQL_HOST", "127.0.0.1"),
|
|
64
|
+
"port": int(os.getenv("MYSQL_PORT", "3306")),
|
|
65
|
+
"user": os.getenv("MYSQL_USER", ""),
|
|
66
|
+
"password": os.getenv("MYSQL_PASSWORD", ""),
|
|
67
|
+
"database": os.getenv("MYSQL_DATABASE", ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# txAdmin
|
|
71
|
+
self.txadmin_url = os.getenv("TXADMIN_URL", "http://localhost:40120")
|
|
72
|
+
self.txadmin_token = os.getenv("TXADMIN_TOKEN", "")
|
|
73
|
+
|
|
74
|
+
def has_ssh(self) -> bool:
|
|
75
|
+
return bool(self.ssh["host"] and self.ssh["user"])
|
|
76
|
+
|
|
77
|
+
def has_mysql(self) -> bool:
|
|
78
|
+
return bool(self.mysql["user"] and self.mysql["database"])
|
|
79
|
+
|
|
80
|
+
def _detect_project_root(self) -> Path:
|
|
81
|
+
check = Path.cwd()
|
|
82
|
+
for _ in range(10):
|
|
83
|
+
if (check / "resources").is_dir() and list((check / "resources").glob("*/fxmanifest.lua")):
|
|
84
|
+
return check
|
|
85
|
+
parent = check.parent
|
|
86
|
+
if parent == check:
|
|
87
|
+
break
|
|
88
|
+
check = parent
|
|
89
|
+
return Path.cwd()
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local tool implementations — file I/O, SSH, MySQL, txAdmin.
|
|
3
|
+
These run entirely on the user's machine. No source logic is sent to the VPS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─── File collection helpers ──────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def collect_resource_files(resources_dir: Path, resource_name: Optional[str] = None) -> dict[str, str]:
|
|
20
|
+
"""
|
|
21
|
+
Collect Lua (and JS/TS) source files from one or all resources.
|
|
22
|
+
Returns {relative_path: content} — content capped at 50 KB per file.
|
|
23
|
+
"""
|
|
24
|
+
MAX_FILE = 50_000
|
|
25
|
+
files: dict[str, str] = {}
|
|
26
|
+
|
|
27
|
+
dirs = (
|
|
28
|
+
[resources_dir / resource_name]
|
|
29
|
+
if resource_name
|
|
30
|
+
else [d for d in resources_dir.iterdir() if d.is_dir()]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
for rdir in dirs:
|
|
34
|
+
if not rdir.exists():
|
|
35
|
+
continue
|
|
36
|
+
for ext in ("*.lua", "*.js", "*.ts"):
|
|
37
|
+
for f in rdir.rglob(ext):
|
|
38
|
+
try:
|
|
39
|
+
content = f.read_text(errors="ignore")
|
|
40
|
+
if len(content) > MAX_FILE:
|
|
41
|
+
content = content[:MAX_FILE] + "\n-- [truncated]"
|
|
42
|
+
rel = str(f.relative_to(resources_dir.parent))
|
|
43
|
+
files[rel] = content
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
return files
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─── RepoMap ──────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
class RepoMapTool:
|
|
53
|
+
def __init__(self, config: Config):
|
|
54
|
+
self.config = config
|
|
55
|
+
self._cache_file = config.context_dir / "repomap.json"
|
|
56
|
+
|
|
57
|
+
async def generate(self) -> str:
|
|
58
|
+
if not self.config.resources_dir.exists():
|
|
59
|
+
return json.dumps({"error": f"Resources directory not found: {self.config.resources_dir}"})
|
|
60
|
+
|
|
61
|
+
resources = {}
|
|
62
|
+
for rdir in self.config.resources_dir.iterdir():
|
|
63
|
+
if not rdir.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
info: dict = {"name": rdir.name, "files": [], "exports": [], "events": []}
|
|
66
|
+
for f in rdir.rglob("*.lua"):
|
|
67
|
+
info["files"].append(str(f.relative_to(rdir)))
|
|
68
|
+
try:
|
|
69
|
+
text = f.read_text(errors="ignore")
|
|
70
|
+
info["exports"] += re.findall(r'exports\[[\'"](.*?)[\'"]\]', text)
|
|
71
|
+
info["events"] += re.findall(r'RegisterNetEvent\([\'"]([^\'"]+)[\'"]', text)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
resources[rdir.name] = info
|
|
75
|
+
|
|
76
|
+
result = {"resources": resources, "count": len(resources)}
|
|
77
|
+
self._cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
self._cache_file.write_text(json.dumps(result, indent=2))
|
|
79
|
+
return json.dumps({"success": True, "resources_count": len(resources)})
|
|
80
|
+
|
|
81
|
+
async def query(self, query_type: str, filter: Optional[str] = None) -> str:
|
|
82
|
+
if not self._cache_file.exists():
|
|
83
|
+
return json.dumps({"error": "Run repomap_generate first."})
|
|
84
|
+
data = json.loads(self._cache_file.read_text())
|
|
85
|
+
resources = data.get("resources", {})
|
|
86
|
+
|
|
87
|
+
if filter:
|
|
88
|
+
resources = {k: v for k, v in resources.items() if filter.lower() in k.lower()}
|
|
89
|
+
|
|
90
|
+
if query_type == "exports":
|
|
91
|
+
out = {k: v.get("exports", []) for k, v in resources.items()}
|
|
92
|
+
elif query_type == "events":
|
|
93
|
+
out = {k: v.get("events", []) for k, v in resources.items()}
|
|
94
|
+
elif query_type == "files":
|
|
95
|
+
out = {k: v.get("files", []) for k, v in resources.items()}
|
|
96
|
+
else:
|
|
97
|
+
out = resources
|
|
98
|
+
|
|
99
|
+
return json.dumps(out, indent=2)
|
|
100
|
+
|
|
101
|
+
async def show(self) -> str:
|
|
102
|
+
if not self._cache_file.exists():
|
|
103
|
+
return json.dumps({"error": "Run repomap_generate first."})
|
|
104
|
+
return self._cache_file.read_text()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─── Search / File info ───────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
class FileTool:
|
|
110
|
+
def __init__(self, config: Config):
|
|
111
|
+
self.config = config
|
|
112
|
+
|
|
113
|
+
async def search(self, pattern: str, path: Optional[str] = None) -> str:
|
|
114
|
+
search_path = Path(path) if path else self.config.resources_dir
|
|
115
|
+
if not search_path.exists():
|
|
116
|
+
return json.dumps({"error": f"Path not found: {search_path}"})
|
|
117
|
+
|
|
118
|
+
matches = []
|
|
119
|
+
for ext in ("*.lua", "*.js", "*.ts", "*.json"):
|
|
120
|
+
for f in search_path.rglob(ext):
|
|
121
|
+
try:
|
|
122
|
+
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
|
123
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
124
|
+
matches.append({
|
|
125
|
+
"file": str(f.relative_to(search_path)),
|
|
126
|
+
"line": i,
|
|
127
|
+
"content": line.strip()[:200],
|
|
128
|
+
})
|
|
129
|
+
if len(matches) >= 100:
|
|
130
|
+
return json.dumps({"matches": matches, "truncated": True})
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
return json.dumps({"matches": matches, "count": len(matches)})
|
|
135
|
+
|
|
136
|
+
async def file_info(self, file_path: str) -> str:
|
|
137
|
+
p = Path(file_path)
|
|
138
|
+
if not p.exists():
|
|
139
|
+
p = self.config.project_root / file_path
|
|
140
|
+
if not p.exists():
|
|
141
|
+
return json.dumps({"error": f"File not found: {file_path}"})
|
|
142
|
+
|
|
143
|
+
stat = p.stat()
|
|
144
|
+
try:
|
|
145
|
+
lines = len(p.read_text(errors="ignore").splitlines())
|
|
146
|
+
except Exception:
|
|
147
|
+
lines = None
|
|
148
|
+
|
|
149
|
+
return json.dumps({
|
|
150
|
+
"path": str(p),
|
|
151
|
+
"size_bytes": stat.st_size,
|
|
152
|
+
"lines": lines,
|
|
153
|
+
"modified": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
async def syntax_check(self, file_path: str) -> str:
|
|
157
|
+
p = Path(file_path)
|
|
158
|
+
if not p.exists():
|
|
159
|
+
p = self.config.resources_dir / file_path
|
|
160
|
+
if not p.exists():
|
|
161
|
+
return json.dumps({"error": f"File not found: {file_path}"})
|
|
162
|
+
|
|
163
|
+
result = subprocess.run(["luac", "-p", str(p)], capture_output=True, text=True)
|
|
164
|
+
if result.returncode == 0:
|
|
165
|
+
return json.dumps({"valid": True, "file": str(p)})
|
|
166
|
+
return json.dumps({"valid": False, "error": result.stderr.strip()})
|
|
167
|
+
|
|
168
|
+
async def read_logs(self, lines: int = 100, pattern: Optional[str] = None) -> str:
|
|
169
|
+
if not self.config.logs_dir.exists():
|
|
170
|
+
return json.dumps({"error": f"Logs directory not found: {self.config.logs_dir}"})
|
|
171
|
+
|
|
172
|
+
log_files = sorted(self.config.logs_dir.glob("*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
173
|
+
if not log_files:
|
|
174
|
+
return json.dumps({"error": "No log files found."})
|
|
175
|
+
|
|
176
|
+
latest = log_files[0]
|
|
177
|
+
all_lines = latest.read_text(errors="ignore").splitlines()[-lines:]
|
|
178
|
+
if pattern:
|
|
179
|
+
all_lines = [l for l in all_lines if re.search(pattern, l, re.IGNORECASE)]
|
|
180
|
+
|
|
181
|
+
return json.dumps({"file": latest.name, "lines": all_lines, "count": len(all_lines)})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─── MySQL ────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
class MySQLTool:
|
|
187
|
+
def __init__(self, config: Config):
|
|
188
|
+
self.config = config
|
|
189
|
+
|
|
190
|
+
async def query(self, query: str) -> str:
|
|
191
|
+
if not self.config.has_mysql():
|
|
192
|
+
return json.dumps({
|
|
193
|
+
"error": "MySQL not configured.",
|
|
194
|
+
"setup": "Set MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE in your .env",
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
result = subprocess.run(["which", "mysql"], capture_output=True)
|
|
198
|
+
if result.returncode != 0:
|
|
199
|
+
return json.dumps({"error": "mysql client not installed."})
|
|
200
|
+
|
|
201
|
+
db = self.config.mysql
|
|
202
|
+
cmd = [
|
|
203
|
+
"mysql",
|
|
204
|
+
"-h", db["host"],
|
|
205
|
+
"-P", str(db["port"]),
|
|
206
|
+
"-u", db["user"],
|
|
207
|
+
f"-p{db['password']}",
|
|
208
|
+
"-N", "-e", query, db["database"],
|
|
209
|
+
]
|
|
210
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
211
|
+
if result.returncode != 0:
|
|
212
|
+
return json.dumps({"error": result.stderr.strip()})
|
|
213
|
+
|
|
214
|
+
rows = [line.split("\t") for line in result.stdout.strip().splitlines() if line]
|
|
215
|
+
return json.dumps({"success": True, "rows": rows, "count": len(rows), "database": db["database"]})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ─── txAdmin ──────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
class TxAdminTool:
|
|
221
|
+
def __init__(self, config: Config):
|
|
222
|
+
self.config = config
|
|
223
|
+
|
|
224
|
+
def _headers(self) -> dict:
|
|
225
|
+
h = {"Content-Type": "application/json"}
|
|
226
|
+
if self.config.txadmin_token:
|
|
227
|
+
h["X-TxAdmin-Token"] = self.config.txadmin_token
|
|
228
|
+
return h
|
|
229
|
+
|
|
230
|
+
async def server_status(self) -> str:
|
|
231
|
+
import urllib.request, urllib.error
|
|
232
|
+
try:
|
|
233
|
+
req = urllib.request.Request(
|
|
234
|
+
f"{self.config.txadmin_url}/api/server/status",
|
|
235
|
+
headers=self._headers(),
|
|
236
|
+
)
|
|
237
|
+
with urllib.request.urlopen(req, timeout=5) as r:
|
|
238
|
+
return r.read().decode()
|
|
239
|
+
except Exception as e:
|
|
240
|
+
return json.dumps({"error": str(e), "hint": f"Is txAdmin running at {self.config.txadmin_url}?"})
|
|
241
|
+
|
|
242
|
+
async def resource_control(self, action: str, resource_name: str) -> str:
|
|
243
|
+
import urllib.request
|
|
244
|
+
payload = json.dumps({"action": action, "resource": resource_name}).encode()
|
|
245
|
+
req = urllib.request.Request(
|
|
246
|
+
f"{self.config.txadmin_url}/api/server-control/command",
|
|
247
|
+
data=payload,
|
|
248
|
+
headers=self._headers(),
|
|
249
|
+
method="POST",
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
253
|
+
return r.read().decode()
|
|
254
|
+
except Exception as e:
|
|
255
|
+
return json.dumps({"error": str(e)})
|
|
256
|
+
|
|
257
|
+
async def server_console(self, command: str) -> str:
|
|
258
|
+
import urllib.request
|
|
259
|
+
payload = json.dumps({"command": command}).encode()
|
|
260
|
+
req = urllib.request.Request(
|
|
261
|
+
f"{self.config.txadmin_url}/api/server-control/command",
|
|
262
|
+
data=payload,
|
|
263
|
+
headers=self._headers(),
|
|
264
|
+
method="POST",
|
|
265
|
+
)
|
|
266
|
+
try:
|
|
267
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
268
|
+
return r.read().decode()
|
|
269
|
+
except Exception as e:
|
|
270
|
+
return json.dumps({"error": str(e)})
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ─── Deploy ───────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
class DeployTool:
|
|
276
|
+
def __init__(self, config: Config):
|
|
277
|
+
self.config = config
|
|
278
|
+
|
|
279
|
+
async def deploy(self, resource_name: str, target: str = "production") -> str:
|
|
280
|
+
source = self.config.resources_dir / resource_name
|
|
281
|
+
if not source.exists():
|
|
282
|
+
return json.dumps({"error": f"Resource not found: {resource_name}"})
|
|
283
|
+
|
|
284
|
+
if self.config.has_ssh():
|
|
285
|
+
return await self._deploy_ssh(resource_name, source)
|
|
286
|
+
|
|
287
|
+
# Local copy
|
|
288
|
+
remote_res = os.getenv("FIVEM_REMOTE_RESOURCES_DIR", str(self.config.resources_dir))
|
|
289
|
+
target_path = (
|
|
290
|
+
Path(remote_res) / resource_name
|
|
291
|
+
if target in ("production", "txdata")
|
|
292
|
+
else Path(target) / resource_name
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
import shutil
|
|
296
|
+
backup_dir = self.config.project_root / "backups" / "deploy"
|
|
297
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
backup_path = backup_dir / f"{resource_name}_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
299
|
+
|
|
300
|
+
if target_path.exists():
|
|
301
|
+
shutil.copytree(target_path, backup_path)
|
|
302
|
+
shutil.rmtree(target_path)
|
|
303
|
+
|
|
304
|
+
shutil.copytree(source, target_path)
|
|
305
|
+
return json.dumps({
|
|
306
|
+
"success": True, "resource": resource_name,
|
|
307
|
+
"target": str(target_path), "method": "local",
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
async def _deploy_ssh(self, resource_name: str, source: Path) -> str:
|
|
311
|
+
try:
|
|
312
|
+
import paramiko
|
|
313
|
+
except ImportError:
|
|
314
|
+
return json.dumps({"error": "paramiko not installed. Run: pip install fiveclaw-agent[ssh]"})
|
|
315
|
+
|
|
316
|
+
remote_res = os.getenv("FIVEM_REMOTE_RESOURCES_DIR", "")
|
|
317
|
+
if not remote_res:
|
|
318
|
+
return json.dumps({"error": "FIVEM_REMOTE_RESOURCES_DIR not set."})
|
|
319
|
+
|
|
320
|
+
ssh_cfg = self.config.ssh
|
|
321
|
+
remote_target = f"{remote_res.rstrip('/')}/{resource_name}"
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
client = paramiko.SSHClient()
|
|
325
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
326
|
+
kw: dict = {"hostname": ssh_cfg["host"], "port": ssh_cfg["port"], "username": ssh_cfg["user"]}
|
|
327
|
+
if ssh_cfg.get("key_path"):
|
|
328
|
+
kw["key_filename"] = ssh_cfg["key_path"]
|
|
329
|
+
client.connect(**kw, timeout=15)
|
|
330
|
+
sftp = client.open_sftp()
|
|
331
|
+
|
|
332
|
+
def _upload(local: Path, remote: str):
|
|
333
|
+
try: sftp.mkdir(remote)
|
|
334
|
+
except OSError: pass
|
|
335
|
+
for item in local.iterdir():
|
|
336
|
+
r = f"{remote}/{item.name}"
|
|
337
|
+
if item.is_dir(): _upload(item, r)
|
|
338
|
+
else: sftp.put(str(item), r)
|
|
339
|
+
|
|
340
|
+
client.exec_command(f"rm -rf '{remote_target}'")[1].channel.recv_exit_status()
|
|
341
|
+
_upload(source, remote_target)
|
|
342
|
+
sftp.close(); client.close()
|
|
343
|
+
return json.dumps({"success": True, "resource": resource_name, "target": remote_target,
|
|
344
|
+
"host": ssh_cfg["host"], "method": "ssh"})
|
|
345
|
+
except Exception as e:
|
|
346
|
+
return json.dumps({"error": f"SSH deploy failed: {e}"})
|
|
347
|
+
|
|
348
|
+
async def backup(self, resource_name: str) -> str:
|
|
349
|
+
source = self.config.resources_dir / resource_name
|
|
350
|
+
if not source.exists():
|
|
351
|
+
return json.dumps({"error": f"Resource not found: {resource_name}"})
|
|
352
|
+
|
|
353
|
+
import shutil
|
|
354
|
+
backup_dir = self.config.project_root / "backups" / "resources"
|
|
355
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
357
|
+
dest = backup_dir / f"{resource_name}_{ts}"
|
|
358
|
+
shutil.copytree(source, dest)
|
|
359
|
+
return json.dumps({"success": True, "backup": str(dest), "timestamp": ts})
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ─── Context memory ───────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
class ContextTool:
|
|
365
|
+
def __init__(self, config: Config):
|
|
366
|
+
self._file = config.context_dir / "knowledge.json"
|
|
367
|
+
self._ensure()
|
|
368
|
+
|
|
369
|
+
def _ensure(self):
|
|
370
|
+
self._file.parent.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
if not self._file.exists():
|
|
372
|
+
self._file.write_text(json.dumps({"facts": {}, "history": []}))
|
|
373
|
+
|
|
374
|
+
def _load(self) -> dict:
|
|
375
|
+
return json.loads(self._file.read_text())
|
|
376
|
+
|
|
377
|
+
def _save(self, data: dict):
|
|
378
|
+
self._file.write_text(json.dumps(data, indent=2))
|
|
379
|
+
|
|
380
|
+
async def remember(self, key: str, value: str, category: str = "general") -> str:
|
|
381
|
+
data = self._load()
|
|
382
|
+
data["facts"][key] = {"value": value, "category": category, "ts": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
383
|
+
self._save(data)
|
|
384
|
+
return json.dumps({"saved": key})
|
|
385
|
+
|
|
386
|
+
async def recall(self, key: Optional[str] = None) -> str:
|
|
387
|
+
data = self._load()
|
|
388
|
+
if key:
|
|
389
|
+
return json.dumps(data["facts"].get(key, {"error": "Not found"}))
|
|
390
|
+
return json.dumps(data["facts"], indent=2)
|
|
391
|
+
|
|
392
|
+
async def search(self, query: str) -> str:
|
|
393
|
+
data = self._load()
|
|
394
|
+
q = query.lower()
|
|
395
|
+
hits = {k: v for k, v in data["facts"].items()
|
|
396
|
+
if q in k.lower() or q in str(v.get("value", "")).lower()}
|
|
397
|
+
return json.dumps(hits, indent=2)
|
|
398
|
+
|
|
399
|
+
async def forget(self, key: str) -> str:
|
|
400
|
+
data = self._load()
|
|
401
|
+
if key in data["facts"]:
|
|
402
|
+
del data["facts"][key]
|
|
403
|
+
self._save(data)
|
|
404
|
+
return json.dumps({"deleted": key})
|
|
405
|
+
return json.dumps({"error": f"Key not found: {key}"})
|
|
406
|
+
|
|
407
|
+
async def record(self, summary: str, tags: str) -> str:
|
|
408
|
+
data = self._load()
|
|
409
|
+
data.setdefault("history", []).append({
|
|
410
|
+
"summary": summary, "tags": tags.split(","),
|
|
411
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
412
|
+
})
|
|
413
|
+
data["history"] = data["history"][-200:]
|
|
414
|
+
self._save(data)
|
|
415
|
+
return json.dumps({"recorded": True})
|
|
416
|
+
|
|
417
|
+
async def history(self, limit: int = 10) -> str:
|
|
418
|
+
data = self._load()
|
|
419
|
+
return json.dumps(data.get("history", [])[-limit:], indent=2)
|
|
420
|
+
|
|
421
|
+
async def show(self) -> str:
|
|
422
|
+
return self._file.read_text()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""HTTP client — sends tool calls to the FiveClaw VPS for processing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RemoteClient:
|
|
10
|
+
"""Authenticated relay to the FiveClaw VPS intelligence API."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, api_key: str, api_url: str):
|
|
13
|
+
self.api_key = api_key
|
|
14
|
+
self.api_url = api_url
|
|
15
|
+
|
|
16
|
+
def call(self, tool: str, params: dict, files: dict[str, str] | None = None) -> str:
|
|
17
|
+
"""
|
|
18
|
+
POST to /api/mcp/tools with the tool name, params, and optional file content.
|
|
19
|
+
Returns the raw JSON string result.
|
|
20
|
+
"""
|
|
21
|
+
payload = {
|
|
22
|
+
"tool": tool,
|
|
23
|
+
"params": params,
|
|
24
|
+
"files": files or {},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
data = json.dumps(payload).encode()
|
|
28
|
+
req = urllib.request.Request(
|
|
29
|
+
f"{self.api_url}/api/mcp/tools",
|
|
30
|
+
data=data,
|
|
31
|
+
headers={
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
34
|
+
},
|
|
35
|
+
method="POST",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
40
|
+
return resp.read().decode()
|
|
41
|
+
except urllib.error.HTTPError as e:
|
|
42
|
+
body = e.read().decode()
|
|
43
|
+
try:
|
|
44
|
+
err = json.loads(body)
|
|
45
|
+
except Exception:
|
|
46
|
+
err = {"error": body}
|
|
47
|
+
|
|
48
|
+
if e.code == 401:
|
|
49
|
+
return json.dumps({"error": "Invalid or expired API key. Check your FIVECLAW_API_KEY."})
|
|
50
|
+
if e.code == 402:
|
|
51
|
+
return json.dumps({"error": "Subscription required. Visit https://fiveclaw.gg/pricing"})
|
|
52
|
+
if e.code == 403:
|
|
53
|
+
return json.dumps({"error": err.get("error", "Access denied — your plan may not include this tool.")})
|
|
54
|
+
return json.dumps({"error": f"API error {e.code}: {err.get('error', body[:200])}"})
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return json.dumps({"error": f"Could not reach FiveClaw API: {str(e)}"})
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FiveClaw Agent — local MCP server.
|
|
3
|
+
|
|
4
|
+
Users run this on their machine and add it to their Claude Desktop config:
|
|
5
|
+
{ "fiveclaw": { "url": "http://localhost:5200/mcp" } }
|
|
6
|
+
|
|
7
|
+
Local tools (file I/O, SSH, MySQL, txAdmin) run on the user's machine.
|
|
8
|
+
Intelligence tools (analysis, AI review, docs, patterns) relay to the FiveClaw VPS.
|
|
9
|
+
The VPS logic is never distributed — only results come back.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .local import RepoMapTool, FileTool, MySQLTool, TxAdminTool, DeployTool, ContextTool, collect_resource_files
|
|
18
|
+
from .remote import RemoteClient
|
|
19
|
+
|
|
20
|
+
# ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
config = Config()
|
|
24
|
+
except RuntimeError as e:
|
|
25
|
+
import sys
|
|
26
|
+
print(f"[FiveClaw Agent] Configuration error:\n{e}", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
remote = RemoteClient(config.api_key, config.api_url)
|
|
30
|
+
repomap = RepoMapTool(config)
|
|
31
|
+
files = FileTool(config)
|
|
32
|
+
mysql = MySQLTool(config)
|
|
33
|
+
txadmin = TxAdminTool(config)
|
|
34
|
+
deploy = DeployTool(config)
|
|
35
|
+
context = ContextTool(config)
|
|
36
|
+
|
|
37
|
+
server = FastMCP("fiveclaw-agent")
|
|
38
|
+
|
|
39
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def _resource_files(resource: Optional[str] = None) -> dict:
|
|
42
|
+
"""Collect local source files to send to the VPS for analysis."""
|
|
43
|
+
return collect_resource_files(config.resources_dir, resource)
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# LOCAL TOOLS — run on the user's machine
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
@server.tool()
|
|
50
|
+
async def repomap_generate() -> str:
|
|
51
|
+
"""Scan your FiveM resources directory and build a map of all resources,
|
|
52
|
+
their files, exports, and event handlers. Run this first."""
|
|
53
|
+
return await repomap.generate()
|
|
54
|
+
|
|
55
|
+
@server.tool()
|
|
56
|
+
async def repomap_query(query_type: str, filter: Optional[str] = None) -> str:
|
|
57
|
+
"""Query the resource map. query_type: 'exports', 'events', 'files', or 'all'."""
|
|
58
|
+
return await repomap.query(query_type, filter)
|
|
59
|
+
|
|
60
|
+
@server.tool()
|
|
61
|
+
async def repomap_show() -> str:
|
|
62
|
+
"""Show the full resource map JSON."""
|
|
63
|
+
return await repomap.show()
|
|
64
|
+
|
|
65
|
+
@server.tool()
|
|
66
|
+
async def tool_search(pattern: str, path: Optional[str] = None) -> str:
|
|
67
|
+
"""Search for a regex pattern across all Lua/JS files in your resources."""
|
|
68
|
+
return await files.search(pattern, path)
|
|
69
|
+
|
|
70
|
+
@server.tool()
|
|
71
|
+
async def tool_file_info(file_path: str) -> str:
|
|
72
|
+
"""Get file size, line count, and last modified time."""
|
|
73
|
+
return await files.file_info(file_path)
|
|
74
|
+
|
|
75
|
+
@server.tool()
|
|
76
|
+
async def tool_syntax_check(file_path: str) -> str:
|
|
77
|
+
"""Check Lua syntax using luac. Requires luac installed locally."""
|
|
78
|
+
return await files.syntax_check(file_path)
|
|
79
|
+
|
|
80
|
+
@server.tool()
|
|
81
|
+
async def read_latest_logs(lines: int = 100, pattern: Optional[str] = None) -> str:
|
|
82
|
+
"""Read the latest FiveM server log file."""
|
|
83
|
+
return await files.read_logs(lines, pattern)
|
|
84
|
+
|
|
85
|
+
@server.tool()
|
|
86
|
+
async def tool_mysql_query(query: str) -> str:
|
|
87
|
+
"""Execute a SQL query against your locally configured MySQL database."""
|
|
88
|
+
return await mysql.query(query)
|
|
89
|
+
|
|
90
|
+
@server.tool()
|
|
91
|
+
async def tool_server_status() -> str:
|
|
92
|
+
"""Check FiveM server status via your txAdmin panel."""
|
|
93
|
+
return await txadmin.server_status()
|
|
94
|
+
|
|
95
|
+
@server.tool()
|
|
96
|
+
async def tool_resource_control(action: str, resource_name: str) -> str:
|
|
97
|
+
"""Start, stop, or restart a resource via txAdmin. action: 'start'|'stop'|'restart'."""
|
|
98
|
+
return await txadmin.resource_control(action, resource_name)
|
|
99
|
+
|
|
100
|
+
@server.tool()
|
|
101
|
+
async def tool_server_console(command: str) -> str:
|
|
102
|
+
"""Send a raw command to the FiveM server console via txAdmin."""
|
|
103
|
+
return await txadmin.server_console(command)
|
|
104
|
+
|
|
105
|
+
@server.tool()
|
|
106
|
+
async def deploy_resource(resource_name: str, target: str = "production") -> str:
|
|
107
|
+
"""Deploy a resource to your FiveM server.
|
|
108
|
+
Uses SSH if FIVEM_SSH_HOST is configured, otherwise local file copy.
|
|
109
|
+
target: 'production' or an absolute path."""
|
|
110
|
+
return await deploy.deploy(resource_name, target)
|
|
111
|
+
|
|
112
|
+
@server.tool()
|
|
113
|
+
async def backup_resource(resource_name: str) -> str:
|
|
114
|
+
"""Create a timestamped backup of a resource in your project's backups/ folder."""
|
|
115
|
+
return await deploy.backup(resource_name)
|
|
116
|
+
|
|
117
|
+
# Context memory
|
|
118
|
+
@server.tool()
|
|
119
|
+
async def context_remember(key: str, value: str, category: str = "general") -> str:
|
|
120
|
+
"""Save a fact to persistent local memory."""
|
|
121
|
+
return await context.remember(key, value, category)
|
|
122
|
+
|
|
123
|
+
@server.tool()
|
|
124
|
+
async def context_recall(key: Optional[str] = None) -> str:
|
|
125
|
+
"""Recall a saved fact, or list all facts if no key given."""
|
|
126
|
+
return await context.recall(key)
|
|
127
|
+
|
|
128
|
+
@server.tool()
|
|
129
|
+
async def context_search(query: str) -> str:
|
|
130
|
+
"""Search your saved facts."""
|
|
131
|
+
return await context.search(query)
|
|
132
|
+
|
|
133
|
+
@server.tool()
|
|
134
|
+
async def context_forget(key: str) -> str:
|
|
135
|
+
"""Delete a saved fact."""
|
|
136
|
+
return await context.forget(key)
|
|
137
|
+
|
|
138
|
+
@server.tool()
|
|
139
|
+
async def context_record(summary: str, tags: str) -> str:
|
|
140
|
+
"""Record a session note (comma-separated tags)."""
|
|
141
|
+
return await context.record(summary, tags)
|
|
142
|
+
|
|
143
|
+
@server.tool()
|
|
144
|
+
async def context_history(limit: int = 10) -> str:
|
|
145
|
+
"""Show recent session history."""
|
|
146
|
+
return await context.history(limit)
|
|
147
|
+
|
|
148
|
+
# =============================================================================
|
|
149
|
+
# REMOTE TOOLS — relay to FiveClaw VPS (your logic stays server-side)
|
|
150
|
+
# =============================================================================
|
|
151
|
+
|
|
152
|
+
@server.tool()
|
|
153
|
+
async def tool_validate_resource(resource_name: str) -> str:
|
|
154
|
+
"""Validate a FiveM resource — checks manifest, syntax, structure, and best practices."""
|
|
155
|
+
return remote.call("validate_resource", {"resource_name": resource_name},
|
|
156
|
+
files=_resource_files(resource_name))
|
|
157
|
+
|
|
158
|
+
@server.tool()
|
|
159
|
+
async def resource_health_check(resource_name: str) -> str:
|
|
160
|
+
"""Comprehensive health check: manifest, syntax, NUI build, TODOs, dependencies."""
|
|
161
|
+
return remote.call("resource_health_check", {"resource_name": resource_name},
|
|
162
|
+
files=_resource_files(resource_name))
|
|
163
|
+
|
|
164
|
+
@server.tool()
|
|
165
|
+
async def detect_anti_patterns(resource: Optional[str] = None) -> str:
|
|
166
|
+
"""Detect common FiveM anti-patterns: busy waits, missing locals, performance issues."""
|
|
167
|
+
return remote.call("detect_anti_patterns", {"resource": resource},
|
|
168
|
+
files=_resource_files(resource))
|
|
169
|
+
|
|
170
|
+
@server.tool()
|
|
171
|
+
async def detect_duplicate_code(min_lines: int = 10, resource: Optional[str] = None) -> str:
|
|
172
|
+
"""Find duplicate or copy-pasted code blocks across your resources."""
|
|
173
|
+
return remote.call("detect_duplicate_code", {"min_lines": min_lines, "resource": resource},
|
|
174
|
+
files=_resource_files(resource))
|
|
175
|
+
|
|
176
|
+
@server.tool()
|
|
177
|
+
async def find_exports(export_name: Optional[str] = None, resource: Optional[str] = None) -> str:
|
|
178
|
+
"""Find where exports are defined and called across your resources."""
|
|
179
|
+
return remote.call("find_exports", {"export_name": export_name, "resource": resource},
|
|
180
|
+
files=_resource_files(resource))
|
|
181
|
+
|
|
182
|
+
@server.tool()
|
|
183
|
+
async def find_event_handlers(event_name: Optional[str] = None, resource: Optional[str] = None) -> str:
|
|
184
|
+
"""Find RegisterNetEvent and AddEventHandler calls."""
|
|
185
|
+
return remote.call("find_event_handlers", {"event_name": event_name, "resource": resource},
|
|
186
|
+
files=_resource_files(resource))
|
|
187
|
+
|
|
188
|
+
@server.tool()
|
|
189
|
+
async def find_triggers(pattern: Optional[str] = None, resource: Optional[str] = None) -> str:
|
|
190
|
+
"""Find TriggerServerEvent and TriggerClientEvent calls."""
|
|
191
|
+
return remote.call("find_triggers", {"pattern": pattern, "resource": resource},
|
|
192
|
+
files=_resource_files(resource))
|
|
193
|
+
|
|
194
|
+
@server.tool()
|
|
195
|
+
async def nui_build_check(resource_name: str) -> str:
|
|
196
|
+
"""Check if a NUI resource needs to be rebuilt."""
|
|
197
|
+
return remote.call("nui_build_check", {"resource_name": resource_name},
|
|
198
|
+
files=_resource_files(resource_name))
|
|
199
|
+
|
|
200
|
+
@server.tool()
|
|
201
|
+
async def test_resource(resource_name: str, mode: str = "auto") -> str:
|
|
202
|
+
"""Test a FiveM resource using the FiveClaw test engine. mode: 'auto' or 'analyze'."""
|
|
203
|
+
return remote.call("test_resource", {"resource_name": resource_name, "mode": mode},
|
|
204
|
+
files=_resource_files(resource_name))
|
|
205
|
+
|
|
206
|
+
@server.tool()
|
|
207
|
+
async def test_generate(resource_name: str) -> str:
|
|
208
|
+
"""Generate test cases for a resource without running them."""
|
|
209
|
+
return remote.call("test_generate", {"resource_name": resource_name},
|
|
210
|
+
files=_resource_files(resource_name))
|
|
211
|
+
|
|
212
|
+
@server.tool()
|
|
213
|
+
async def trace_event_flow(event_name: str, max_depth: int = 3) -> str:
|
|
214
|
+
"""Trace how an event flows through your resources."""
|
|
215
|
+
return remote.call("trace_event_flow", {"event_name": event_name, "max_depth": max_depth},
|
|
216
|
+
files=_resource_files())
|
|
217
|
+
|
|
218
|
+
@server.tool()
|
|
219
|
+
async def analyze_export_usage(caller: str, target: str, export_name: Optional[str] = None) -> str:
|
|
220
|
+
"""Analyze how a resource uses another resource's exports."""
|
|
221
|
+
return remote.call("analyze_export_usage",
|
|
222
|
+
{"caller": caller, "target": target, "export_name": export_name},
|
|
223
|
+
files=_resource_files())
|
|
224
|
+
|
|
225
|
+
@server.tool()
|
|
226
|
+
async def mysql_visualize_schema() -> str:
|
|
227
|
+
"""Visualize your database schema as an ASCII diagram."""
|
|
228
|
+
return remote.call("mysql_visualize_schema", {})
|
|
229
|
+
|
|
230
|
+
@server.tool()
|
|
231
|
+
async def pattern_list() -> str:
|
|
232
|
+
"""List all available FiveM code patterns in the FiveClaw pattern library."""
|
|
233
|
+
return remote.call("pattern_list", {})
|
|
234
|
+
|
|
235
|
+
@server.tool()
|
|
236
|
+
async def pattern_show(pattern_name: str) -> str:
|
|
237
|
+
"""Show the full code for a pattern from the FiveClaw library."""
|
|
238
|
+
return remote.call("pattern_show", {"pattern_name": pattern_name})
|
|
239
|
+
|
|
240
|
+
@server.tool()
|
|
241
|
+
async def pattern_apply(pattern_name: str, variables_json: str,
|
|
242
|
+
output_dir: Optional[str] = None, dry_run: bool = False) -> str:
|
|
243
|
+
"""Apply a FiveClaw code pattern to scaffold a new resource or feature."""
|
|
244
|
+
return remote.call("pattern_apply",
|
|
245
|
+
{"pattern_name": pattern_name, "variables_json": variables_json,
|
|
246
|
+
"output_dir": output_dir, "dry_run": dry_run})
|
|
247
|
+
|
|
248
|
+
@server.tool()
|
|
249
|
+
async def fivem_docs(query: str) -> str:
|
|
250
|
+
"""Search the FiveM native documentation, scripting guides, and framework references."""
|
|
251
|
+
return remote.call("fivem_docs", {"query": query})
|
|
252
|
+
|
|
253
|
+
@server.tool()
|
|
254
|
+
async def fivem_native(native_name: str) -> str:
|
|
255
|
+
"""Get detailed information about a FiveM native function."""
|
|
256
|
+
return remote.call("fivem_native", {"native_name": native_name})
|
|
257
|
+
|
|
258
|
+
@server.tool()
|
|
259
|
+
async def mcp_health() -> str:
|
|
260
|
+
"""Check agent status and verify your FiveClaw API key is valid."""
|
|
261
|
+
import json as _json
|
|
262
|
+
result = remote.call("mcp_health", {})
|
|
263
|
+
try:
|
|
264
|
+
data = _json.loads(result)
|
|
265
|
+
data["agent_version"] = "1.0.0"
|
|
266
|
+
data["project_root"] = str(config.project_root)
|
|
267
|
+
data["resources_dir"] = str(config.resources_dir)
|
|
268
|
+
data["ssh_configured"] = config.has_ssh()
|
|
269
|
+
data["mysql_configured"] = config.has_mysql()
|
|
270
|
+
return _json.dumps(data, indent=2)
|
|
271
|
+
except Exception:
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
# =============================================================================
|
|
275
|
+
# Entry point
|
|
276
|
+
# =============================================================================
|
|
277
|
+
|
|
278
|
+
def main():
|
|
279
|
+
import signal, sys
|
|
280
|
+
|
|
281
|
+
def _stop(sig, frame):
|
|
282
|
+
print("\n[FiveClaw Agent] Shutting down.", file=sys.stderr)
|
|
283
|
+
sys.exit(0)
|
|
284
|
+
|
|
285
|
+
signal.signal(signal.SIGINT, _stop)
|
|
286
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
287
|
+
|
|
288
|
+
transport = os.getenv("TRANSPORT", "streamable-http")
|
|
289
|
+
host = os.getenv("HOST", "127.0.0.1") # localhost only by default
|
|
290
|
+
port = int(os.getenv("PORT", "5200"))
|
|
291
|
+
|
|
292
|
+
print(f"[FiveClaw Agent] Starting on {transport}://{host}:{port}/mcp", file=sys.stderr)
|
|
293
|
+
print(f"[FiveClaw Agent] Project root: {config.project_root}", file=sys.stderr)
|
|
294
|
+
|
|
295
|
+
if transport == "streamable-http":
|
|
296
|
+
server.run(transport="streamable-http", host=host, port=port)
|
|
297
|
+
else:
|
|
298
|
+
server.run(transport="stdio")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fiveclaw-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local MCP agent for FiveClaw — FiveM AI development tools
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastmcp>=3.0
|
|
9
|
+
Provides-Extra: ssh
|
|
10
|
+
Requires-Dist: paramiko>=3.0; extra == "ssh"
|
|
11
|
+
|
|
12
|
+
# fiveclaw-agent
|
|
13
|
+
|
|
14
|
+
Local MCP agent for [FiveClaw](https://fiveclaw.com) — AI-powered FiveM development tools.
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
`fiveclaw-agent` runs on your machine alongside your FiveM server. It handles local operations (file search, logs, MySQL, txAdmin, SSH deploy) and relays AI analysis requests to the FiveClaw platform using your API key.
|
|
19
|
+
|
|
20
|
+
**Local tools** (run on your machine):
|
|
21
|
+
- Resource map generation and querying
|
|
22
|
+
- File search across Lua/JS resources
|
|
23
|
+
- Lua syntax checking
|
|
24
|
+
- Log reading
|
|
25
|
+
- MySQL query execution
|
|
26
|
+
- txAdmin server control (start/stop/restart resources, send console commands)
|
|
27
|
+
- SSH deployment to remote servers
|
|
28
|
+
- Persistent context memory
|
|
29
|
+
|
|
30
|
+
**Cloud tools** (powered by FiveClaw, gated by your plan):
|
|
31
|
+
- Resource validation and health checks
|
|
32
|
+
- Anti-pattern and duplicate code detection
|
|
33
|
+
- Export/event flow tracing
|
|
34
|
+
- AI code review and test generation
|
|
35
|
+
- FiveM native docs search
|
|
36
|
+
- Pattern library (scaffold new resources/features)
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Python 3.10+
|
|
41
|
+
- A [FiveClaw](https://fiveclaw.com) account with an active subscription
|
|
42
|
+
- A FiveClaw API key (generate one at [fiveclaw.com/dashboard/keys](https://fiveclaw.com/dashboard/keys))
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install fiveclaw-agent
|
|
48
|
+
|
|
49
|
+
# With SSH deployment support
|
|
50
|
+
pip install fiveclaw-agent[ssh]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Setup
|
|
54
|
+
|
|
55
|
+
1. **Create a `.env` file** in your FiveM project root:
|
|
56
|
+
|
|
57
|
+
```env
|
|
58
|
+
FIVECLAW_API_KEY=fc_live_your_key_here
|
|
59
|
+
|
|
60
|
+
# Optional — auto-detected if not set
|
|
61
|
+
# FIVEM_PROJECT_ROOT=/path/to/your/server
|
|
62
|
+
|
|
63
|
+
# Optional SSH deployment
|
|
64
|
+
# FIVEM_SSH_HOST=1.2.3.4
|
|
65
|
+
# FIVEM_SSH_USER=root
|
|
66
|
+
# FIVEM_SSH_KEY_PATH=~/.ssh/id_rsa
|
|
67
|
+
# FIVEM_REMOTE_RESOURCES=/server-data/resources
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
2. **Add to your Claude config** (`claude_desktop_config.json` or `.mcp.json`):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"fiveclaw": {
|
|
76
|
+
"url": "http://localhost:5200/mcp"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
3. **Start the agent** in your project directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
fiveclaw
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The agent runs on `http://localhost:5200/mcp` and stays running while you work. Restart it anytime without restarting Claude.
|
|
89
|
+
|
|
90
|
+
4. **Verify** by asking Claude: *"Run mcp_health to check my FiveClaw connection."*
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
SSH, txAdmin, and MySQL settings can also be configured on the [FiveClaw dashboard](https://fiveclaw.com/dashboard/server) — the agent fetches them automatically using your API key, so you don't need to duplicate them in `.env`.
|
|
95
|
+
|
|
96
|
+
## Links
|
|
97
|
+
|
|
98
|
+
- [FiveClaw Dashboard](https://fiveclaw.com/dashboard)
|
|
99
|
+
- [Setup Guide](https://fiveclaw.com/dashboard/download)
|
|
100
|
+
- [Pricing](https://fiveclaw.com/pricing)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
fiveclaw_agent/__init__.py
|
|
4
|
+
fiveclaw_agent/config.py
|
|
5
|
+
fiveclaw_agent/local.py
|
|
6
|
+
fiveclaw_agent/remote.py
|
|
7
|
+
fiveclaw_agent/server.py
|
|
8
|
+
fiveclaw_agent.egg-info/PKG-INFO
|
|
9
|
+
fiveclaw_agent.egg-info/SOURCES.txt
|
|
10
|
+
fiveclaw_agent.egg-info/dependency_links.txt
|
|
11
|
+
fiveclaw_agent.egg-info/entry_points.txt
|
|
12
|
+
fiveclaw_agent.egg-info/requires.txt
|
|
13
|
+
fiveclaw_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fiveclaw_agent
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fiveclaw-agent"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Local MCP agent for FiveClaw — FiveM AI development tools"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"fastmcp>=3.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
ssh = ["paramiko>=3.0"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
fiveclaw = "fiveclaw_agent.server:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["."]
|
|
24
|
+
include = ["fiveclaw_agent*"]
|