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.
@@ -0,0 +1,3 @@
1
+ """ZAI Coding Gateway — MCP-сервер для решения задач в рабочем пространстве через Z.AI Coding Plan."""
2
+
3
+ __version__ = "0.1.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)