VEGA tool host: 22 API-first developer tools
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# VEGA — API-first utility/developer tool host
|
||||||
|
|
||||||
|
22 deterministic developer tools, each exposed as one REST endpoint.
|
||||||
|
Phase 1 of the paid-MCP toolbox (wired into Hyperion + Omninexus).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
python3 -m venv venv && ./venv/bin/pip install flask
|
||||||
|
./venv/bin/python app.py
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
GET /health
|
||||||
|
GET /api/tools
|
||||||
|
GET|POST /api/<tool>?<params>
|
||||||
|
|
||||||
|
Public: https://vega.thetempleofdoom.com
|
||||||
338
app.py
Normal file
338
app.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
VEGA — API-first utility/developer tool host.
|
||||||
|
|
||||||
|
Every capability is a plain function registered in TOOLS and auto-exposed as:
|
||||||
|
GET /api/tools -> catalog {name: {label, category, params, desc}}
|
||||||
|
GET /api/<tool>?<params> -> run one tool, return JSON {tool, result}
|
||||||
|
POST /api/<tool> -> same, params as JSON body
|
||||||
|
GET /health -> liveness + tool count
|
||||||
|
|
||||||
|
Design goals:
|
||||||
|
- API-first: the web UI (later) and the MCP wrapper (later) both consume these
|
||||||
|
exact endpoints — no separate logic paths.
|
||||||
|
- Deterministic + stdlib-only for the core: no keys, no network, fast, safe.
|
||||||
|
- One registry -> N surfaces (REST now, MCP + Hyperion metering next).
|
||||||
|
|
||||||
|
This is the Phase-1 slice (developer tools). Later phases add internet/infra
|
||||||
|
(re-using osint.py's 454), files/media, monitoring, and AI/LLM categories.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
import uuid as _uuid
|
||||||
|
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
TOOLS = {}
|
||||||
|
|
||||||
|
|
||||||
|
def tool(name, category, params, desc):
|
||||||
|
"""Register a function as a tool. params = list of input key names."""
|
||||||
|
def deco(fn):
|
||||||
|
TOOLS[name] = {"fn": fn, "category": category, "params": params,
|
||||||
|
"desc": desc, "label": name.replace("_", " ").title()}
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON / data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool("json_format", "data", ["json"], "Validate + pretty-print JSON.")
|
||||||
|
def json_format(json="{}"):
|
||||||
|
d = json_loads(json)
|
||||||
|
return {"valid": True, "pretty": json.dumps(d, indent=2, sort_keys=True),
|
||||||
|
"type": type(d).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("json_to_csv", "data", ["json"], "Flatten a JSON array of objects to CSV.")
|
||||||
|
def json_to_csv(json="[]"):
|
||||||
|
d = json_loads(json)
|
||||||
|
if not isinstance(d, list):
|
||||||
|
return {"error": "expected a JSON array of objects"}
|
||||||
|
out = io.StringIO()
|
||||||
|
w = csv.DictWriter(out, fieldnames=sorted({k for o in d if isinstance(o, dict) for k in o}))
|
||||||
|
w.writeheader()
|
||||||
|
for o in d:
|
||||||
|
if isinstance(o, dict):
|
||||||
|
w.writerow(o)
|
||||||
|
return {"csv": out.getvalue()}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("json_diff", "data", ["a", "b"], "Diff two JSON values (added/removed/changed keys).")
|
||||||
|
def json_diff(a="{}", b="{}"):
|
||||||
|
da, db = json_loads(a), json_loads(b)
|
||||||
|
return diff(da, db)
|
||||||
|
|
||||||
|
|
||||||
|
@tool("jwt_decode", "data", ["token"], "Decode a JWT header+payload (no signature verification).")
|
||||||
|
def jwt_decode(token=""):
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return {"error": "not a JWT (need header.payload.signature)"}
|
||||||
|
out = {}
|
||||||
|
for name, p in (("header", parts[0]), ("payload", parts[1])):
|
||||||
|
try:
|
||||||
|
pad = p + "=" * (-len(p) % 4)
|
||||||
|
out[name] = json.loads(base64.urlsafe_b64decode(pad).decode("utf-8", "replace"))
|
||||||
|
except Exception as e:
|
||||||
|
out[name] = {"error": str(e)}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# encoding / crypto / ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool("hash", "encoding", ["text", "algo"],
|
||||||
|
"Hash a string. algo = md5/sha1/sha256/sha512.")
|
||||||
|
def hash_text(text="", algo="sha256"):
|
||||||
|
h = hashlib.new(algo if algo in hashlib.algorithms_available else "sha256")
|
||||||
|
h.update(text.encode())
|
||||||
|
return {"algo": h.name, "hex": h.hexdigest(), "bytes": h.digest_size}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("base64_encode", "encoding", ["text"], "Base64-encode text.")
|
||||||
|
def b64_encode(text=""):
|
||||||
|
return {"encoded": base64.b64encode(text.encode()).decode()}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("base64_decode", "encoding", ["data"], "Base64-decode (returns text + hex).")
|
||||||
|
def b64_decode(data=""):
|
||||||
|
raw = base64.b64decode(data + "=" * (-len(data) % 4))
|
||||||
|
return {"text": raw.decode("utf-8", "replace"), "hex": raw.hex()}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("url_encode", "encoding", ["text"], "Percent-encode a string.")
|
||||||
|
def url_encode(text=""):
|
||||||
|
return {"encoded": quote(text, safe="")}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("url_decode", "encoding", ["data"], "Percent-decode a string.")
|
||||||
|
def url_decode(data=""):
|
||||||
|
return {"decoded": unquote(data)}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("url_parse", "encoding", ["url"], "Parse a URL into scheme/host/path/query/fragment.")
|
||||||
|
def url_parse(url=""):
|
||||||
|
p = urlparse(url)
|
||||||
|
return {"scheme": p.scheme, "netloc": p.netloc, "host": p.hostname,
|
||||||
|
"port": p.port, "path": p.path, "query": parse_qs(p.query),
|
||||||
|
"fragment": p.fragment}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("uuid", "ids", [], "Generate a random UUID v4.")
|
||||||
|
def uuid_gen():
|
||||||
|
return {"uuid": str(_uuid.uuid4()), "hex": _uuid.uuid4().hex}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("hex_to_text", "encoding", ["hex"], "Decode hex bytes to text.")
|
||||||
|
def hex_to_text(hex=""):
|
||||||
|
try:
|
||||||
|
raw = bytes.fromhex(hex)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
return {"text": raw.decode("utf-8", "replace"), "bytes": len(raw)}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("text_to_hex", "encoding", ["text"], "Encode text to hex.")
|
||||||
|
def text_to_hex(text=""):
|
||||||
|
return {"hex": text.encode().hex()}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# text / regex / string utils
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool("regex_test", "text", ["pattern", "text"], "Test a regex; return matches + groups.")
|
||||||
|
def regex_test(pattern="", text=""):
|
||||||
|
try:
|
||||||
|
rx = re.compile(pattern)
|
||||||
|
except re.error as e:
|
||||||
|
return {"error": f"bad regex: {e}"}
|
||||||
|
matches = [{"match": m.group(0), "groups": list(m.groups()), "span": m.span()}
|
||||||
|
for m in rx.finditer(text)]
|
||||||
|
return {"valid": True, "count": len(matches), "matches": matches[:50]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("cron_explain", "text", ["expr"], "Explain a 5-field cron expression.")
|
||||||
|
def cron_explain(expr=""):
|
||||||
|
fields = expr.split()
|
||||||
|
if len(fields) != 5:
|
||||||
|
return {"error": "need 5 fields: min hour dom mon dow"}
|
||||||
|
names = ["minute", "hour", "day-of-month", "month", "day-of-week"]
|
||||||
|
return {names[i]: f for i, f in enumerate(fields)}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("slugify", "text", ["text"], "Slugify text (lowercase, dashes, ascii).")
|
||||||
|
def slugify(text=""):
|
||||||
|
s = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||||
|
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
|
||||||
|
return {"slug": s}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("case_convert", "text", ["text", "to"],
|
||||||
|
"Convert case: camel/snake/kebab/pascal/title/upper/lower.")
|
||||||
|
def case_convert(text="", to="snake"):
|
||||||
|
words = [w for w in re.split(r"[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])", text) if w]
|
||||||
|
if to == "camel":
|
||||||
|
return {"result": words[0].lower() + "".join(w.capitalize() for w in words[1:]) if words else ""}
|
||||||
|
if to == "pascal":
|
||||||
|
return {"result": "".join(w.capitalize() for w in words)}
|
||||||
|
if to == "snake":
|
||||||
|
return {"result": "_".join(w.lower() for w in words)}
|
||||||
|
if to == "kebab":
|
||||||
|
return {"result": "-".join(w.lower() for w in words)}
|
||||||
|
if to == "upper":
|
||||||
|
return {"result": text.upper()}
|
||||||
|
if to == "lower":
|
||||||
|
return {"result": text.lower()}
|
||||||
|
if to == "title":
|
||||||
|
return {"result": text.title()}
|
||||||
|
return {"error": f"unknown 'to': {to}"}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("string_metrics", "text", ["text"], "Length/word/line/char counts + Shannon entropy.")
|
||||||
|
def string_metrics(text=""):
|
||||||
|
if not text:
|
||||||
|
return {"chars": 0, "words": 0, "lines": 0, "bytes": 0, "entropy": 0.0}
|
||||||
|
import math
|
||||||
|
freq = {}
|
||||||
|
for c in text:
|
||||||
|
freq[c] = freq.get(c, 0) + 1
|
||||||
|
n = len(text)
|
||||||
|
entropy = -sum((v / n) * math.log2(v / n) for v in freq.values())
|
||||||
|
return {"chars": n, "words": len(text.split()), "lines": text.count("\n") + 1,
|
||||||
|
"bytes": len(text.encode()), "unique_chars": len(freq),
|
||||||
|
"entropy": round(entropy, 3)}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("html_decode", "text", ["html"], "Decode HTML entities.")
|
||||||
|
def html_decode(html=""):
|
||||||
|
return {"text": html.unescape(html)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# numbers / time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tool("base_convert", "numbers", ["value", "from_base", "to_base"],
|
||||||
|
"Convert a number between bases 2-36.")
|
||||||
|
def base_convert(value="", from_base="10", to_base="16"):
|
||||||
|
try:
|
||||||
|
n = int(value, int(from_base))
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "invalid value for base"}
|
||||||
|
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
|
if not 2 <= int(to_base) <= 36:
|
||||||
|
return {"error": "to_base must be 2-36"}
|
||||||
|
if n == 0:
|
||||||
|
return {"result": "0"}
|
||||||
|
neg = n < 0
|
||||||
|
n = abs(n)
|
||||||
|
out = ""
|
||||||
|
while n:
|
||||||
|
out = chars[n % int(to_base)] + out
|
||||||
|
n //= int(to_base)
|
||||||
|
return {"result": ("-" if neg else "") + out,
|
||||||
|
"decimal": int(value, int(from_base))}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("epoch", "time", ["ts"], "Convert a unix epoch to human-readable UTC + relative.")
|
||||||
|
def epoch(ts=""):
|
||||||
|
try:
|
||||||
|
t = int(float(ts))
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "invalid epoch"}
|
||||||
|
return {"utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(t)),
|
||||||
|
"local": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t)),
|
||||||
|
"age_seconds": int(time.time() - t)}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("now", "time", [], "Current unix time (UTC).")
|
||||||
|
def now():
|
||||||
|
t = time.time()
|
||||||
|
return {"epoch": int(t), "utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(t))}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def json_loads(s):
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"invalid JSON: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def diff(a, b, path=""):
|
||||||
|
out = {"added": [], "removed": [], "changed": []}
|
||||||
|
if type(a) is not type(b):
|
||||||
|
out["changed"].append(path or "<root>")
|
||||||
|
return out
|
||||||
|
if isinstance(a, dict):
|
||||||
|
for k in set(a) | set(b):
|
||||||
|
p = f"{path}.{k}" if path else k
|
||||||
|
if k not in b:
|
||||||
|
out["removed"].append(p)
|
||||||
|
elif k not in a:
|
||||||
|
out["added"].append(p)
|
||||||
|
elif a[k] != b[k]:
|
||||||
|
d = diff(a[k], b[k], p)
|
||||||
|
out["added"] += d["added"]; out["removed"] += d["removed"]; out["changed"] += d["changed"]
|
||||||
|
elif isinstance(a, list):
|
||||||
|
if len(a) != len(b):
|
||||||
|
out["changed"].append(f"{path or '<root>'}[length {len(a)}->{len(b)}]")
|
||||||
|
for i, (x, y) in enumerate(zip(a, b)):
|
||||||
|
if x != y:
|
||||||
|
d = diff(x, y, f"{path}[{i}]")
|
||||||
|
out["added"] += d["added"]; out["removed"] += d["removed"]; out["changed"] += d["changed"]
|
||||||
|
elif a != b:
|
||||||
|
out["changed"].append(path or "<root>")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True, "tools": len(TOOLS),
|
||||||
|
"categories": sorted({t["category"] for t in TOOLS.values()})})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tools")
|
||||||
|
def api_catalog():
|
||||||
|
return jsonify({name: {"label": t["label"], "category": t["category"],
|
||||||
|
"params": t["params"], "desc": t["desc"]}
|
||||||
|
for name, t in TOOLS.items()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/<name>", methods=["GET", "POST"])
|
||||||
|
def api_run(name):
|
||||||
|
if name not in TOOLS:
|
||||||
|
return jsonify({"error": f"unknown tool: {name}"}), 404
|
||||||
|
t = TOOLS[name]
|
||||||
|
if request.method == "POST":
|
||||||
|
args = request.get_json(silent=True) or {}
|
||||||
|
else:
|
||||||
|
args = {k: v for k, v in request.args.items()}
|
||||||
|
args = {k: args.get(k, "") for k in t["params"]}
|
||||||
|
try:
|
||||||
|
result = t["fn"](**args)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"tool": name, "error": f"{type(e).__name__}: {e}"}), 400
|
||||||
|
if isinstance(result, dict) and "error" in result:
|
||||||
|
return jsonify({"tool": name, **result}), 200
|
||||||
|
return jsonify({"tool": name, "result": result})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080)
|
||||||
Reference in New Issue
Block a user