commit fa48844fb3674501cf4f033a7faeca3104653780 Author: tomasz-syn-grzegorza Date: Wed Jul 1 13:31:03 2026 +0000 Add shared workspace skills for Cursor and Hermes. Introduce gitnexus-search skill with REST CLI for remote GitNexus queries by repo name and prompt. Co-authored-by: Cursor diff --git a/README.md b/README.md new file mode 100644 index 0000000..86c537f --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Workspace skills (`/home/test-user/cursor/skills`) + +Wspólne skille dla **Cursor** i **Hermes** w ekosystemie agency-ai. + +## Skille + +| Skill | Wywołanie | Opis | +|-------|-----------|------| +| [gitnexus-search](gitnexus-search/SKILL.md) | `gitnexus-search ` | Przeszukiwanie zdalnego GitNexus po nazwie repo | + +## Cursor + +Skills w tym katalogu nie są automatycznie widoczne w Cursor — utwórz symlink: + +```bash +mkdir -p ~/.cursor/skills +ln -sfn /home/test-user/cursor/skills/gitnexus-search ~/.cursor/skills/gitnexus-search +``` + +Opcjonalnie cały katalog: + +```bash +ln -sfn /home/test-user/cursor/skills ~/.cursor/skills-workspace +``` + +Wymagany MCP GitNexus w `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "gitnexus": { + "url": "https://gitnexus.mobile.agency-ai.dev/api/mcp" + } + } +} +``` + +Po symlinku: **Reload Window** + nowa sesja chatu. + +## Hermes (m3gan) + +W promptcie systemowym lub `INSTALL-AGENT.md` podaj ścieżkę bezwzględną do skilla: + +```text +/home/test-user/cursor/skills/gitnexus-search/SKILL.md +``` + +Hermes używa REST (skrypt), gdy MCP niedostępny: + +```bash +python3 /home/test-user/cursor/skills/gitnexus-search/scripts/gitnexus-search.py \ + one-gateway "deploy_runner" +``` + +Więcej: [one-gateway/docs/hermes/GITNEXUS-CLIENT.md](../one-gateway/docs/hermes/GITNEXUS-CLIENT.md). + +## Przykład + +```text +gitnexus-search ai-lawyer-srvr routes_paperless admin +gitnexus-search gateway gitnexus_workspace_dir +``` diff --git a/gitnexus-search/SKILL.md b/gitnexus-search/SKILL.md new file mode 100644 index 0000000..b2eb87e --- /dev/null +++ b/gitnexus-search/SKILL.md @@ -0,0 +1,82 @@ +--- +name: gitnexus-search +description: >- + Przeszukuje zdalny GitNexus po nazwie repo i promptcie użytkownika. + Użyj gdy użytkownik wywołuje skill gitnexus-search, @gitnexus-search, + lub podaje repo + zapytanie do grafu kodu (search, symbole, flow). +disable-model-invocation: true +--- + +# gitnexus-search + +Jawne wyszukiwanie w zdalnym GitNexus (`https://gitnexus.mobile.agency-ai.dev`). Działa w **Cursor** (MCP) i **Hermes** (REST). + +## Format wejścia + +```text +gitnexus-search +``` + +- Pierwszy token po nazwie skilla = **repo** +- **Cała reszta linii** = prompt wyszukiwania (może zawierać spacje) + +Przykłady: + +```text +gitnexus-search one-gateway gitnexus_workspace_dir +gitnexus-search ai-lawyer-srvr routes_paperless admin flow +gitnexus-search gateway deploy_runner install path +``` + +## Aliasy repo + +| Użytkownik wpisuje | Repo GitNexus | Ścieżka MCP (przy konflikcie) | +|--------------------|---------------|-------------------------------| +| `one-gateway`, `gateway` | `one-gateway` | `/data/gitnexus/repos/one-gateway` | +| `ai-lawyer-srvr`, `ai-lawyer` | `ai-lawyer-srvr` | `/data/gitnexus/repos/ai-lawyer-srvr` | + +Nieznane repo → `list_repos` / `GET /api/repos` i pokaż dostępne nazwy. + +## Wykonanie — Cursor (preferowane) + +Gdy MCP `gitnexus` jest Connected: + +1. `list_repos` — potwierdź, że repo istnieje i ma `stats.files > 0` +2. `query` z argumentami: + - `search_query`: prompt użytkownika + - `repo`: znormalizowana nazwa (lub ścieżka bezwzględna z tabeli) + - `limit`: 10 +3. Jeśli prompt sugeruje impact/zależności — opcjonalnie jeden `context` lub `impact` na top symbolu z wyników + +## Wykonanie — Hermes / fallback + +Gdy MCP niedostępny — **od razu** REST, bez pytania użytkownika: + +```bash +python3 /home/test-user/cursor/skills/gitnexus-search/scripts/gitnexus-search.py \ + "" +``` + +Opcjonalnie: `--limit N`, `--json`. + +## Format odpowiedzi + +1. **Nagłówek:** repo + prompt (echo) +2. **Tabela** top 5–10 trafień: rank, filePath, score, kluczowe symbole/nodeIds +3. **Synteza:** 1–2 zdania — co wyniki sugerują w architekturze +4. **Następny krok:** `context`/`impact` (MCP), otwarcie pliku lokalnie, lub doprecyzowanie promptu + +## Błędy + +| Sytuacja | Akcja | +|----------|-------| +| Repo nieznane | Lista z `/api/repos` | +| Indeks pusty (`files: 0`) | Wskazówka: re-index (`gitnexus-index-remote.py` w projekcie) | +| MCP Error / brak narzędzi | REST przez `gitnexus-search.py` | +| HTTP / timeout | `curl -fsS $GITNEXUS_SERVER_URL/api/health` | + +## Powiązane + +- Skrypt: [`scripts/gitnexus-search.py`](scripts/gitnexus-search.py) +- Hermes: [`one-gateway/docs/hermes/GITNEXUS-CLIENT.md`](../../one-gateway/docs/hermes/GITNEXUS-CLIENT.md) +- Workspace skills: [`../README.md`](../README.md) diff --git a/gitnexus-search/scripts/gitnexus-search.py b/gitnexus-search/scripts/gitnexus-search.py new file mode 100755 index 0000000..79ce865 --- /dev/null +++ b/gitnexus-search/scripts/gitnexus-search.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Universal GitNexus search CLI for Cursor (fallback) and Hermes.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + +DEFAULT_SERVER = "https://gitnexus.mobile.agency-ai.dev" + +REPO_ALIASES: dict[str, str] = { + "gateway": "one-gateway", + "one-gateway": "one-gateway", + "ai-lawyer": "ai-lawyer-srvr", + "ai-lawyer-srvr": "ai-lawyer-srvr", +} + +MCP_PATHS: dict[str, str] = { + "one-gateway": "/data/gitnexus/repos/one-gateway", + "ai-lawyer-srvr": "/data/gitnexus/repos/ai-lawyer-srvr", +} + + +def base_url() -> str: + return os.environ.get("GITNEXUS_SERVER_URL", DEFAULT_SERVER).rstrip("/") + + +def normalize_repo(name: str) -> str: + key = name.strip().lower() + if key in REPO_ALIASES: + return REPO_ALIASES[key] + return name.strip() + + +def request(method: str, path: str, body: dict | None = None) -> object: + url = f"{base_url()}{path}" + headers = {"Accept": "application/json"} + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + raw = resp.read().decode("utf-8") + if not raw.strip(): + return {} + return json.loads(raw) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"HTTP {exc.code} {exc.reason}: {detail}", file=sys.stderr) + raise SystemExit(1) from exc + except urllib.error.URLError as exc: + print(f"Request failed: {exc.reason}", file=sys.stderr) + raise SystemExit(1) from exc + + +def list_repos() -> list[dict]: + repos = request("GET", "/api/repos") + if not isinstance(repos, list): + raise SystemExit(f"Unexpected /api/repos response: {repos!r}") + return repos + + +def resolve_repo(name: str) -> dict: + canonical = normalize_repo(name) + matches = [r for r in list_repos() if r.get("name") == canonical] + if not matches: + available = sorted({r.get("name", "") for r in list_repos() if r.get("name")}) + print(f"Unknown repo: {name!r} (normalized: {canonical!r})", file=sys.stderr) + print(f"Available: {', '.join(available) or '(none)'}", file=sys.stderr) + raise SystemExit(1) + + with_files = [r for r in matches if (r.get("stats") or {}).get("files", 0) > 0] + if with_files: + return with_files[0] + if len(matches) == 1: + print( + f"WARN: repo {canonical!r} is indexed but has 0 files — re-index may be needed", + file=sys.stderr, + ) + return matches[0] + print(f"Ambiguous repo {canonical!r}: multiple entries, none with files", file=sys.stderr) + for r in matches: + print(f" - path={r.get('path')}", file=sys.stderr) + raise SystemExit(1) + + +def search(repo_name: str, query: str, limit: int) -> dict: + repo_entry = resolve_repo(repo_name) + canonical = repo_entry.get("name", normalize_repo(repo_name)) + payload = {"query": query, "repo": canonical, "limit": limit} + result = request("POST", "/api/search", payload) + if not isinstance(result, dict): + raise SystemExit(f"Unexpected /api/search response: {result!r}") + result["_repo"] = canonical + result["_repo_path"] = repo_entry.get("path", "") + result["_mcp_path"] = MCP_PATHS.get(canonical, repo_entry.get("path", "")) + return result + + +def format_text(result: dict) -> str: + lines = [ + f"GitNexus search", + f"Server: {base_url()}", + f"Repo: {result.get('_repo')} ({result.get('_repo_path')})", + f"MCP path: {result.get('_mcp_path')}", + "", + ] + results = result.get("results") or [] + if not results: + lines.append("No results.") + return "\n".join(lines) + + lines.append(f"Top {len(results)} hits:") + lines.append("") + for item in results: + rank = item.get("rank", "?") + path = item.get("filePath", "?") + score = item.get("score", 0) + nodes = item.get("nodeIds") or [] + sym = ", ".join(n.split(":")[-1] for n in nodes[:3]) if nodes else "-" + lines.append(f" {rank}. {path} (score={score:.2f})") + if sym != "-": + lines.append(f" symbols: {sym}") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Search GitNexus remote index by repo name and query", + ) + parser.add_argument("repo", help="Repository name or alias (e.g. one-gateway, ai-lawyer)") + parser.add_argument("query", nargs="+", help="Search prompt (words after repo)") + parser.add_argument("--limit", type=int, default=10, help="Max results (default: 10)") + parser.add_argument("--json", action="store_true", help="Output raw JSON") + parser.add_argument( + "--server", + default=None, + help="Override GITNEXUS_SERVER_URL", + ) + args = parser.parse_args() + + if args.server: + os.environ["GITNEXUS_SERVER_URL"] = args.server.rstrip("/") + + query = " ".join(args.query).strip() + if not query: + print("Query cannot be empty", file=sys.stderr) + raise SystemExit(1) + + result = search(args.repo, query, args.limit) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(format_text(result)) + + +if __name__ == "__main__": + main()