zai-coding-gateway 0.1.0__py3-none-any.whl
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.
- zai_coding_gateway/__init__.py +3 -0
- zai_coding_gateway/client.py +93 -0
- zai_coding_gateway/errors.py +63 -0
- zai_coding_gateway/logging_utils.py +32 -0
- zai_coding_gateway/main.py +38 -0
- zai_coding_gateway/server.py +120 -0
- zai_coding_gateway/tools/__init__.py +10 -0
- zai_coding_gateway/tools/file_io.py +228 -0
- zai_coding_gateway/tools/solve_in_workspace.py +761 -0
- zai_coding_gateway-0.1.0.dist-info/METADATA +176 -0
- zai_coding_gateway-0.1.0.dist-info/RECORD +13 -0
- zai_coding_gateway-0.1.0.dist-info/WHEEL +4 -0
- zai_coding_gateway-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Обёртка над OpenAI-клиентом для Z.AI Coding Plan endpoint."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from openai import OpenAI
|
|
7
|
+
|
|
8
|
+
from zai_coding_gateway.errors import QuotaExceededError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_quota_error(exc: Exception) -> bool:
|
|
12
|
+
"""Определяет по исключению, что это исчерпание квоты (429 / quota / rate limit)."""
|
|
13
|
+
status = getattr(exc, "status_code", None)
|
|
14
|
+
if status == 429:
|
|
15
|
+
return True
|
|
16
|
+
msg = str(exc).lower()
|
|
17
|
+
if "429" in msg or "quota" in msg or "rate limit" in msg or "rate_limit" in msg:
|
|
18
|
+
return True
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.z.ai/api/coding/paas/v4"
|
|
22
|
+
DEFAULT_WORK_MODEL = "glm-4.7"
|
|
23
|
+
DEFAULT_FAST_MODEL = "glm-4.5-air"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_env(key: str, default: str | None = None) -> str | None:
|
|
27
|
+
return os.environ.get(key) or default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_effective_base_url() -> str:
|
|
31
|
+
"""Возвращает URL, который будет использоваться для запросов (из env или дефолт). Для проверки биллинга."""
|
|
32
|
+
return (_get_env("ZAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + "/"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_work_model() -> str:
|
|
36
|
+
"""Модель для основного цикла сессии solve_in_workspace. По умолчанию glm-4.7."""
|
|
37
|
+
v = (_get_env("ZAI_WORK_MODEL") or "").strip()
|
|
38
|
+
return v or DEFAULT_WORK_MODEL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_fast_model() -> str:
|
|
42
|
+
"""Модель для консолидации и суммаризации в solve_in_workspace. По умолчанию glm-4.5-air."""
|
|
43
|
+
v = (_get_env("ZAI_FAST_MODEL") or "").strip()
|
|
44
|
+
return v or DEFAULT_FAST_MODEL
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_client() -> OpenAI:
|
|
48
|
+
"""Создаёт OpenAI-клиент с base_url и api_key из ENV. Ключ только из окружения."""
|
|
49
|
+
api_key = _get_env("ZAI_API_KEY")
|
|
50
|
+
if not api_key:
|
|
51
|
+
raise ValueError("ZAI_API_KEY не задан. Укажите ключ в env (например в mcp.json).")
|
|
52
|
+
base_url = get_effective_base_url()
|
|
53
|
+
return OpenAI(api_key=api_key, base_url=base_url)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def chat_completions(
|
|
57
|
+
client: OpenAI,
|
|
58
|
+
messages: list[dict[str, Any]],
|
|
59
|
+
model: str,
|
|
60
|
+
response_format: dict[str, Any] | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Вызов chat/completions. При исчерпании квоты выбрасывает QuotaExceededError.
|
|
64
|
+
model: передавать get_work_model() или get_fast_model() в зависимости от сценария.
|
|
65
|
+
"""
|
|
66
|
+
kwargs: dict[str, Any] = {"model": model, "messages": messages, "stream": False}
|
|
67
|
+
if response_format is not None:
|
|
68
|
+
kwargs["response_format"] = response_format
|
|
69
|
+
try:
|
|
70
|
+
resp = client.chat.completions.create(**kwargs)
|
|
71
|
+
except QuotaExceededError:
|
|
72
|
+
raise
|
|
73
|
+
except Exception as e: # noqa: BLE001
|
|
74
|
+
if _is_quota_error(e):
|
|
75
|
+
raise QuotaExceededError("QuotaExceeded: wait for refresh cycle") from e
|
|
76
|
+
raise
|
|
77
|
+
return _completion_to_dict(resp)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _completion_to_dict(resp: Any) -> dict[str, Any]:
|
|
81
|
+
"""Преобразует объект Completion в dict для единообразного разбора."""
|
|
82
|
+
return {
|
|
83
|
+
"choices": [
|
|
84
|
+
{
|
|
85
|
+
"message": {
|
|
86
|
+
"content": c.message.content,
|
|
87
|
+
"role": getattr(c.message, "role", "assistant"),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for c in (resp.choices or [])
|
|
91
|
+
],
|
|
92
|
+
"usage": getattr(resp, "usage", None),
|
|
93
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Доменные исключения для ZAI Coding Gateway."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ZAIGatewayError(Exception):
|
|
5
|
+
"""Базовое исключение gateway."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuotaExceededError(ZAIGatewayError):
|
|
11
|
+
"""Исчерпана квота Coding Plan. Ожидать обновления цикла (~5 ч)."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InstructionTooLongError(ZAIGatewayError):
|
|
17
|
+
"""Текстовый ввод (инструкция/задача/контекст) превышает допустимый лимит. Использовать file_paths для контекста."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileOutsideProjectError(ZAIGatewayError):
|
|
23
|
+
"""Путь к файлу вне корня проекта (ZAI_PROJECT_ROOT)."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectRootNotSetError(ZAIGatewayError):
|
|
29
|
+
"""
|
|
30
|
+
Не удалось определить корень проекта: ZAI_PROJECT_ROOT не задан,
|
|
31
|
+
MCP roots/list не поддерживается клиентом (например Cursor), cwd не подходит.
|
|
32
|
+
Задайте ZAI_PROJECT_ROOT в конфиге MCP (env) — путь к папке проекта.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FilePathsRequiredError(ZAIGatewayError):
|
|
39
|
+
"""Для задачи требуются file_paths; список пуст или не передан."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SuggestedChangesOutsideSessionError(ZAIGatewayError):
|
|
45
|
+
"""Путь в suggested_changes не входит в множество файлов сессии."""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DiffNotApplicableError(ZAIGatewayError):
|
|
51
|
+
"""Дифф не применим к текущему содержимому файла."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SessionNotFoundError(ZAIGatewayError):
|
|
57
|
+
"""Сессия workspace не найдена (уже очищена или неверный session_id)."""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ~3000 токенов: ориентир ~4 символа на токен → 12000 символов
|
|
63
|
+
MAX_INSTRUCTION_CHARS = 12_000
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Структурированное логирование в stderr (JSON, одна строка на событие)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
ToolInvocationStatus = Literal["success", "quota_exceeded", "error"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def log_tool_invocation(
|
|
11
|
+
tool: str,
|
|
12
|
+
model: str,
|
|
13
|
+
latency_ms: float,
|
|
14
|
+
status: ToolInvocationStatus,
|
|
15
|
+
extra: dict[str, Any] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Пишет в stderr одну строку JSON: tool, model, latency, status.
|
|
19
|
+
stdout не трогаем — занят MCP при stdio.
|
|
20
|
+
"""
|
|
21
|
+
payload: dict[str, Any] = {
|
|
22
|
+
"event": "tool_invocation",
|
|
23
|
+
"tool": tool,
|
|
24
|
+
"model": model,
|
|
25
|
+
"latency_ms": round(latency_ms, 2),
|
|
26
|
+
"status": status,
|
|
27
|
+
}
|
|
28
|
+
if extra:
|
|
29
|
+
payload.update(extra)
|
|
30
|
+
line = json.dumps(payload, ensure_ascii=False) + "\n"
|
|
31
|
+
sys.stderr.write(line)
|
|
32
|
+
sys.stderr.flush()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Точка входа: парсинг аргументов, проверка ENV, запуск MCP по stdio."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from zai_coding_gateway.server import get_mcp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(description="ZAI Coding Gateway — MCP-сервер для Z.AI Coding Plan")
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--stdio",
|
|
14
|
+
action="store_true",
|
|
15
|
+
default=True,
|
|
16
|
+
help="Запуск с транспортом stdio (по умолчанию)",
|
|
17
|
+
)
|
|
18
|
+
args = parser.parse_args()
|
|
19
|
+
|
|
20
|
+
if not os.environ.get("ZAI_API_KEY"):
|
|
21
|
+
sys.stderr.write("Ошибка: ZAI_API_KEY не задан. Укажите ключ в env (например в mcp.json).\n")
|
|
22
|
+
sys.stderr.flush()
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
from zai_coding_gateway.client import get_effective_base_url
|
|
26
|
+
effective = get_effective_base_url()
|
|
27
|
+
plan = "Coding Plan" if "/coding/" in effective else "общий paas (не Coding Plan)"
|
|
28
|
+
sys.stderr.write(f"zai-coding-gateway: endpoint = {effective.rstrip('/')} ({plan})\n")
|
|
29
|
+
sys.stderr.flush()
|
|
30
|
+
|
|
31
|
+
mcp = get_mcp()
|
|
32
|
+
# Добавление StreamableHTTP: второй branch в main или отдельный entry point,
|
|
33
|
+
# запуск сервера на указанном host/port.
|
|
34
|
+
mcp.run(transport="stdio")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""MCP Server: регистрация инструментов, без привязки к транспорту."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
6
|
+
|
|
7
|
+
from zai_coding_gateway.tools.file_io import (
|
|
8
|
+
_file_uri_to_path,
|
|
9
|
+
get_cached_project_root,
|
|
10
|
+
set_cached_project_root,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _ensure_project_root_from_mcp(context: Context | None) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Если ZAI_PROJECT_ROOT не задан и кэш пуст — запрашивает roots/list у клиента (Cursor)
|
|
17
|
+
и кэширует первый корень как проект. При ошибке или отсутствии context — без изменений.
|
|
18
|
+
"""
|
|
19
|
+
if context is None:
|
|
20
|
+
return
|
|
21
|
+
if os.environ.get("ZAI_PROJECT_ROOT") or get_cached_project_root() is not None:
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
result = await context.session.list_roots()
|
|
25
|
+
if result.roots:
|
|
26
|
+
path = _file_uri_to_path(str(result.roots[0].uri))
|
|
27
|
+
if path and os.path.isdir(path):
|
|
28
|
+
set_cached_project_root(path)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
from zai_coding_gateway.tools.solve_in_workspace import (
|
|
32
|
+
answer_session_question as answer_session_question_impl,
|
|
33
|
+
apply_changes as apply_changes_impl,
|
|
34
|
+
confirm_session_done as confirm_session_done_impl,
|
|
35
|
+
solve_in_workspace as solve_in_workspace_impl,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP(
|
|
39
|
+
"ZAI Coding Gateway",
|
|
40
|
+
json_response=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool(
|
|
45
|
+
name="solve_in_workspace",
|
|
46
|
+
description=(
|
|
47
|
+
"Точка входа: запускает сессию рабочего пространства. "
|
|
48
|
+
"Вход: instruction (строка — задача), todos (список строк), file_paths (список путей относительно корня проекта, обязательный начальный контекст), max_steps (число, по умолчанию 10). "
|
|
49
|
+
"Внутри сессии модель получает полный контекст, может запрашивать файлы (need_more), использовать list_dir/grep/glob/read, затем вернуть отчёт и suggested_changes. "
|
|
50
|
+
"Модель может предлагать и новые файлы в suggested_changes — создание в проекте только через apply_changes после вашего утверждения. "
|
|
51
|
+
"Выход: session_id, report, suggested_changes, files_used либо при отсутствии корня проекта или вопросе модели — status «question», session_id, prompt (ответ передайте через answer_session_question)."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
async def solve_in_workspace_tool(
|
|
55
|
+
instruction: str,
|
|
56
|
+
todos: list[str],
|
|
57
|
+
file_paths: list[str],
|
|
58
|
+
max_steps: int = 10,
|
|
59
|
+
mcp_context: Context | None = None,
|
|
60
|
+
) -> dict:
|
|
61
|
+
"""Run workspace session: need_more/done loop with Z.AI (fixed model flow)."""
|
|
62
|
+
await _ensure_project_root_from_mcp(mcp_context)
|
|
63
|
+
return solve_in_workspace_impl(
|
|
64
|
+
instruction=instruction,
|
|
65
|
+
todos=todos,
|
|
66
|
+
file_paths=file_paths,
|
|
67
|
+
max_steps=max_steps,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool(
|
|
72
|
+
name="answer_session_question",
|
|
73
|
+
description=(
|
|
74
|
+
"Передаёт ответ на вопрос сессии и продолжает выполнение. Вызывайте, когда solve_in_workspace вернул status «question». "
|
|
75
|
+
"Вход: session_id (из ответа solve_in_workspace), answer (строка — ответ на вопрос: путь к корню проекта или ответ модели, например «да» для дозагрузки файла). "
|
|
76
|
+
"Выход: тот же формат, что и solve_in_workspace (session_id, report, suggested_changes, files_used либо снова status «question», session_id, prompt)."
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
async def answer_session_question_tool(session_id: str, answer: str) -> dict:
|
|
80
|
+
"""Submit architect answer to session question and continue."""
|
|
81
|
+
return answer_session_question_impl(session_id, answer)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@mcp.tool(
|
|
85
|
+
name="apply_changes",
|
|
86
|
+
description=(
|
|
87
|
+
"Применяет утверждённые изменения к проекту. Вызывайте после solve_in_workspace, когда приняли решение по suggested_changes. "
|
|
88
|
+
"Вход: session_id (из ответа solve_in_workspace), changes (список объектов: каждый объект — {path: путь к файлу, content: новое содержимое целиком} или {path, diff: unified diff или полный текст}), confirm_after (bool, по умолчанию True — после применения очистить сессию). "
|
|
89
|
+
"Пути проверяются только на нахождение внутри корня проекта; новые файлы разрешены. Каждое изменение обрабатывается отдельно. "
|
|
90
|
+
"Выход: session_id, applied (список применённых путей), errors (список {path, error} для неудачных), cleaned (bool)."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
async def apply_changes_tool(
|
|
94
|
+
session_id: str,
|
|
95
|
+
changes: list[dict],
|
|
96
|
+
confirm_after: bool = True,
|
|
97
|
+
) -> dict:
|
|
98
|
+
"""Apply approved changes to project files."""
|
|
99
|
+
return apply_changes_impl(
|
|
100
|
+
session_id=session_id,
|
|
101
|
+
changes=[dict(c) for c in changes],
|
|
102
|
+
confirm_after=confirm_after,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(
|
|
107
|
+
name="confirm_session_done",
|
|
108
|
+
description=(
|
|
109
|
+
"Очищает темп-каталог сессии после принятия решения (после apply_changes или при отказе от изменений). "
|
|
110
|
+
"Вход: session_id (строка из ответа solve_in_workspace). Выход: session_id, cleaned (true)."
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
async def confirm_session_done_tool(session_id: str) -> dict:
|
|
114
|
+
"""Clean session temp dir."""
|
|
115
|
+
return confirm_session_done_impl(session_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_mcp() -> FastMCP:
|
|
119
|
+
"""Возвращает экземпляр MCP-сервера (для main или тестов)."""
|
|
120
|
+
return mcp
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Инструменты: solve_in_workspace, answer_session_question, apply_changes, confirm_session_done, file_io (внутреннее)."""
|
|
2
|
+
|
|
3
|
+
from zai_coding_gateway.tools.solve_in_workspace import (
|
|
4
|
+
answer_session_question,
|
|
5
|
+
apply_changes,
|
|
6
|
+
confirm_session_done,
|
|
7
|
+
solve_in_workspace,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["solve_in_workspace", "answer_session_question", "apply_changes", "confirm_session_done"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Чтение и запись файлов проекта в пределах ZAI_PROJECT_ROOT."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from zai_coding_gateway.errors import FileOutsideProjectError, ProjectRootNotSetError
|
|
9
|
+
|
|
10
|
+
# Каталоги, которые не обходим при grep/glob
|
|
11
|
+
_SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv", "venv"})
|
|
12
|
+
_MAX_GLOB_PATHS = 500
|
|
13
|
+
_MAX_GREP_FILES = 100
|
|
14
|
+
_MAX_GREP_OUTPUT_CHARS = 5000
|
|
15
|
+
|
|
16
|
+
# Кэш корня из MCP roots/list (клиент передаёт workspace). Cursor пока не поддерживает roots/list.
|
|
17
|
+
_cached_mcp_project_root: str | None = None
|
|
18
|
+
|
|
19
|
+
# Маркеры корня проекта при поиске вверх по дереву от cwd
|
|
20
|
+
_PROJECT_ROOT_MARKERS = ("package.json", "pyproject.toml", ".git")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _infer_project_root_from_cwd() -> str | None:
|
|
24
|
+
"""
|
|
25
|
+
Поднимается от os.getcwd() вверх и возвращает каталог, содержащий package.json, .git или pyproject.toml.
|
|
26
|
+
Если cwd — уже корень или не найден маркер — возвращает None.
|
|
27
|
+
"""
|
|
28
|
+
cwd = os.path.abspath(os.getcwd())
|
|
29
|
+
if not os.path.isdir(cwd):
|
|
30
|
+
return None
|
|
31
|
+
current = cwd
|
|
32
|
+
while current and current != os.path.dirname(current):
|
|
33
|
+
for marker in _PROJECT_ROOT_MARKERS:
|
|
34
|
+
if os.path.exists(os.path.join(current, marker)):
|
|
35
|
+
return current
|
|
36
|
+
current = os.path.dirname(current)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_project_root() -> str:
|
|
41
|
+
"""
|
|
42
|
+
Корень проекта: ZAI_PROJECT_ROOT (env) > кэш из MCP roots/list > вывод по маркерам от cwd > cwd.
|
|
43
|
+
Если итоговый корень — "/" или не подходит, выбрасывает ProjectRootNotSetError (Cursor не поддерживает roots/list).
|
|
44
|
+
"""
|
|
45
|
+
root = os.environ.get("ZAI_PROJECT_ROOT")
|
|
46
|
+
if root:
|
|
47
|
+
return os.path.abspath(root)
|
|
48
|
+
if _cached_mcp_project_root is not None:
|
|
49
|
+
return _cached_mcp_project_root
|
|
50
|
+
inferred = _infer_project_root_from_cwd()
|
|
51
|
+
if inferred:
|
|
52
|
+
return inferred
|
|
53
|
+
cwd = os.path.abspath(os.getcwd())
|
|
54
|
+
if not cwd or cwd == os.path.sep or cwd == "/":
|
|
55
|
+
raise ProjectRootNotSetError(
|
|
56
|
+
"Корень проекта не задан. Cursor пока не поддерживает MCP roots/list. "
|
|
57
|
+
"Задайте ZAI_PROJECT_ROOT в настройках MCP (env) — абсолютный путь к папке проекта (например к папке с package.json)."
|
|
58
|
+
)
|
|
59
|
+
if not os.path.isdir(cwd):
|
|
60
|
+
raise ProjectRootNotSetError(
|
|
61
|
+
"Текущая рабочая директория не является каталогом. Задайте ZAI_PROJECT_ROOT в env MCP."
|
|
62
|
+
)
|
|
63
|
+
return cwd
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_cached_project_root(path: str) -> None:
|
|
67
|
+
"""Установить кэшированный корень (из MCP roots/list). Для тестов — сброс через None не делаем, только через env."""
|
|
68
|
+
global _cached_mcp_project_root
|
|
69
|
+
_cached_mcp_project_root = os.path.abspath(path) if path else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_cached_project_root() -> str | None:
|
|
73
|
+
"""Текущее значение кэша (для тестов)."""
|
|
74
|
+
return _cached_mcp_project_root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _file_uri_to_path(uri: str) -> str:
|
|
78
|
+
"""Преобразует file:// URI в абсолютный путь к файлу."""
|
|
79
|
+
parsed = urlparse(uri)
|
|
80
|
+
if parsed.scheme != "file":
|
|
81
|
+
return ""
|
|
82
|
+
path = parsed.path
|
|
83
|
+
if not path:
|
|
84
|
+
return ""
|
|
85
|
+
if os.name == "nt" and path.startswith("/") and len(path) > 2 and path[2] == ":":
|
|
86
|
+
path = path[1:]
|
|
87
|
+
return os.path.normpath(path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_path(path: str, project_root: str | None = None) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Преобразует путь (относительный или абсолютный) в абсолютный в пределах project_root.
|
|
93
|
+
Выбрасывает FileOutsideProjectError, если итоговый путь вне корня.
|
|
94
|
+
"""
|
|
95
|
+
root = project_root or get_project_root()
|
|
96
|
+
path_abs = os.path.normpath(os.path.join(root, path)) if not os.path.isabs(path) else os.path.normpath(path)
|
|
97
|
+
root_real = os.path.realpath(root)
|
|
98
|
+
path_real = os.path.realpath(path_abs)
|
|
99
|
+
if not path_real.startswith(root_real):
|
|
100
|
+
raise FileOutsideProjectError(f"Путь вне корня проекта: {path!r}")
|
|
101
|
+
return path_real
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_file_content(path: str, project_root: str | None = None) -> str:
|
|
105
|
+
"""Читает содержимое файла по пути относительно корня проекта. UTF-8."""
|
|
106
|
+
resolved = resolve_path(path, project_root)
|
|
107
|
+
with open(resolved, encoding="utf-8") as f:
|
|
108
|
+
return f.read()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_file(path: str, project_root: str | None = None) -> dict:
|
|
112
|
+
"""
|
|
113
|
+
MCP-инструмент: прочитать файл по пути относительно корня проекта.
|
|
114
|
+
Возвращает content и path (подтверждение).
|
|
115
|
+
"""
|
|
116
|
+
root = project_root or get_project_root()
|
|
117
|
+
content = read_file_content(path, root)
|
|
118
|
+
return {"content": content, "path": path}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_file(path: str, content: str, project_root: str | None = None) -> dict:
|
|
122
|
+
"""
|
|
123
|
+
MCP-инструмент: записать содержимое в файл по пути относительно корня проекта.
|
|
124
|
+
Создаёт родительские директории при необходимости.
|
|
125
|
+
"""
|
|
126
|
+
root = project_root or get_project_root()
|
|
127
|
+
resolved = resolve_path(path, root)
|
|
128
|
+
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
|
|
129
|
+
with open(resolved, "w", encoding="utf-8") as f:
|
|
130
|
+
f.write(content)
|
|
131
|
+
return {"path": path, "written": True, "summary": f"written {len(content)} bytes"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_dir_in_project(path: str = ".", project_root: str | None = None) -> list[str]:
|
|
135
|
+
"""
|
|
136
|
+
Список имён прямых потомков каталога (один уровень). Путь относительно корня проекта.
|
|
137
|
+
Для файла или несуществующего пути выбрасывает OSError/FileOutsideProjectError.
|
|
138
|
+
"""
|
|
139
|
+
root = project_root or get_project_root()
|
|
140
|
+
path = path.strip() or "."
|
|
141
|
+
resolved = resolve_path(path, root)
|
|
142
|
+
if not os.path.isdir(resolved):
|
|
143
|
+
raise NotADirectoryError(f"Not a directory: {path!r}")
|
|
144
|
+
return sorted(os.listdir(resolved))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def grep_in_project(
|
|
148
|
+
pattern: str,
|
|
149
|
+
path_or_none: str | None,
|
|
150
|
+
project_root: str | None = None,
|
|
151
|
+
max_files: int = _MAX_GREP_FILES,
|
|
152
|
+
max_output_chars: int = _MAX_GREP_OUTPUT_CHARS,
|
|
153
|
+
) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Поиск подстроки (или regex) по содержимому текстовых файлов в пределах path или всего проекта.
|
|
156
|
+
Возвращает сводку: path:line_no:line_content (обрезано по max_output_chars).
|
|
157
|
+
"""
|
|
158
|
+
root = project_root or get_project_root()
|
|
159
|
+
root_path = Path(root).resolve()
|
|
160
|
+
start_path = root_path
|
|
161
|
+
if path_or_none and path_or_none.strip():
|
|
162
|
+
start_path = Path(resolve_path(path_or_none.strip(), root)).resolve()
|
|
163
|
+
if not start_path.is_dir() and start_path.is_file():
|
|
164
|
+
files_to_scan = [start_path]
|
|
165
|
+
else:
|
|
166
|
+
files_to_scan = []
|
|
167
|
+
for p in start_path.rglob("*"):
|
|
168
|
+
if p.is_file() and not any(part in _SKIP_DIRS for part in p.relative_to(root_path).parts):
|
|
169
|
+
files_to_scan.append(p)
|
|
170
|
+
if len(files_to_scan) >= max_files:
|
|
171
|
+
break
|
|
172
|
+
else:
|
|
173
|
+
files_to_scan = []
|
|
174
|
+
for p in root_path.rglob("*"):
|
|
175
|
+
if p.is_file() and not any(part in _SKIP_DIRS for part in p.relative_to(root_path).parts):
|
|
176
|
+
files_to_scan.append(p)
|
|
177
|
+
if len(files_to_scan) >= max_files:
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
regex = re.compile(pattern)
|
|
182
|
+
except re.error:
|
|
183
|
+
regex = re.compile(re.escape(pattern))
|
|
184
|
+
|
|
185
|
+
lines_out: list[str] = []
|
|
186
|
+
total_chars = 0
|
|
187
|
+
for fp in files_to_scan[:max_files]:
|
|
188
|
+
try:
|
|
189
|
+
with open(fp, encoding="utf-8", errors="replace") as f:
|
|
190
|
+
rel = str(fp.relative_to(root_path)).replace("\\", "/")
|
|
191
|
+
for line_no, line in enumerate(f, 1):
|
|
192
|
+
if regex.search(line):
|
|
193
|
+
frag = line.strip()[:200]
|
|
194
|
+
s = f"{rel}:{line_no}:{frag}\n"
|
|
195
|
+
lines_out.append(s)
|
|
196
|
+
total_chars += len(s)
|
|
197
|
+
if total_chars >= max_output_chars:
|
|
198
|
+
lines_out.append("(output truncated)\n")
|
|
199
|
+
return "".join(lines_out)
|
|
200
|
+
except (OSError, UnicodeDecodeError):
|
|
201
|
+
continue
|
|
202
|
+
return "".join(lines_out) if lines_out else "(no matches)"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def glob_in_project(pattern: str, project_root: str | None = None) -> list[str]:
|
|
206
|
+
"""
|
|
207
|
+
Список относительных путей файлов, совпадающих с маской (например *.py, **/test_*.py).
|
|
208
|
+
Исключаются .git, node_modules, __pycache__. Не более _MAX_GLOB_PATHS путей.
|
|
209
|
+
"""
|
|
210
|
+
root = project_root or get_project_root()
|
|
211
|
+
root_path = Path(root).resolve()
|
|
212
|
+
pattern = pattern.strip() or "*"
|
|
213
|
+
if "**" not in pattern and "/" not in pattern and "\\" not in pattern:
|
|
214
|
+
pattern = f"**/{pattern}"
|
|
215
|
+
paths: list[str] = []
|
|
216
|
+
try:
|
|
217
|
+
for p in root_path.glob(pattern):
|
|
218
|
+
if not p.is_file():
|
|
219
|
+
continue
|
|
220
|
+
if any(part in _SKIP_DIRS for part in p.relative_to(root_path).parts):
|
|
221
|
+
continue
|
|
222
|
+
rel = str(p.relative_to(root_path)).replace("\\", "/")
|
|
223
|
+
paths.append(rel)
|
|
224
|
+
if len(paths) >= _MAX_GLOB_PATHS:
|
|
225
|
+
break
|
|
226
|
+
except (ValueError, OSError):
|
|
227
|
+
pass
|
|
228
|
+
return sorted(paths)
|