indesign-cli 0.2.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.
Files changed (72) hide show
  1. cli_anything/indesign/README.md +32 -0
  2. cli_anything/indesign/__init__.py +1 -0
  3. cli_anything/indesign/__main__.py +5 -0
  4. cli_anything/indesign/core/artifacts.py +57 -0
  5. cli_anything/indesign/core/catalog.py +405 -0
  6. cli_anything/indesign/core/domains.py +178 -0
  7. cli_anything/indesign/core/envelope.py +65 -0
  8. cli_anything/indesign/core/errors.py +30 -0
  9. cli_anything/indesign/core/health.py +46 -0
  10. cli_anything/indesign/core/hidden_backend.py +116 -0
  11. cli_anything/indesign/core/hidden_handler_schemas.py +223 -0
  12. cli_anything/indesign/core/mcp_backend.py +152 -0
  13. cli_anything/indesign/core/node_setup.py +35 -0
  14. cli_anything/indesign/core/paths.py +41 -0
  15. cli_anything/indesign/core/plugins/__init__.py +2 -0
  16. cli_anything/indesign/core/plugins/backend.py +90 -0
  17. cli_anything/indesign/core/plugins/discovery.py +69 -0
  18. cli_anything/indesign/core/plugins/host_actions.py +76 -0
  19. cli_anything/indesign/core/plugins/install.py +38 -0
  20. cli_anything/indesign/core/plugins/manifest.py +279 -0
  21. cli_anything/indesign/core/plugins/validate.py +181 -0
  22. cli_anything/indesign/core/router.py +217 -0
  23. cli_anything/indesign/core/runtime.py +59 -0
  24. cli_anything/indesign/core/scripts.py +44 -0
  25. cli_anything/indesign/core/session.py +68 -0
  26. cli_anything/indesign/indesign_cli.py +320 -0
  27. cli_anything/indesign/node/hidden_handler_bridge.mjs +111 -0
  28. cli_anything/indesign/server/package-lock.json +168 -0
  29. cli_anything/indesign/server/package.json +45 -0
  30. cli_anything/indesign/server/src/advanced/index.js +76 -0
  31. cli_anything/indesign/server/src/core/InDesignMCPServer.js +273 -0
  32. cli_anything/indesign/server/src/core/scriptExecutor.js +271 -0
  33. cli_anything/indesign/server/src/core/sessionManager.js +545 -0
  34. cli_anything/indesign/server/src/handlers/advancedTemplateHandlers.js +1072 -0
  35. cli_anything/indesign/server/src/handlers/bookHandlers.js +490 -0
  36. cli_anything/indesign/server/src/handlers/documentHandlers.js +1472 -0
  37. cli_anything/indesign/server/src/handlers/exportHandlers.js +208 -0
  38. cli_anything/indesign/server/src/handlers/graphicsHandlers.js +605 -0
  39. cli_anything/indesign/server/src/handlers/groupHandlers.js +358 -0
  40. cli_anything/indesign/server/src/handlers/helpHandlers.js +347 -0
  41. cli_anything/indesign/server/src/handlers/index.js +77 -0
  42. cli_anything/indesign/server/src/handlers/layerHandlers.js +75 -0
  43. cli_anything/indesign/server/src/handlers/masterSpreadHandlers.js +451 -0
  44. cli_anything/indesign/server/src/handlers/pageHandlers.js +698 -0
  45. cli_anything/indesign/server/src/handlers/pageItemHandlers.js +704 -0
  46. cli_anything/indesign/server/src/handlers/presentationHandlers.js +220 -0
  47. cli_anything/indesign/server/src/handlers/spreadHandlers.js +348 -0
  48. cli_anything/indesign/server/src/handlers/styleHandlers.js +458 -0
  49. cli_anything/indesign/server/src/handlers/textHandlers.js +431 -0
  50. cli_anything/indesign/server/src/handlers/utilityHandlers.js +83 -0
  51. cli_anything/indesign/server/src/index.js +17 -0
  52. cli_anything/indesign/server/src/types/index.js +106 -0
  53. cli_anything/indesign/server/src/types/toolDefinitionsAdvancedTemplates.js +144 -0
  54. cli_anything/indesign/server/src/types/toolDefinitionsBook.js +224 -0
  55. cli_anything/indesign/server/src/types/toolDefinitionsContent.js +353 -0
  56. cli_anything/indesign/server/src/types/toolDefinitionsDocument.js +409 -0
  57. cli_anything/indesign/server/src/types/toolDefinitionsExport.js +65 -0
  58. cli_anything/indesign/server/src/types/toolDefinitionsLayer.js +40 -0
  59. cli_anything/indesign/server/src/types/toolDefinitionsMasterSpread.js +160 -0
  60. cli_anything/indesign/server/src/types/toolDefinitionsPage.js +271 -0
  61. cli_anything/indesign/server/src/types/toolDefinitionsPageItemGroup.js +437 -0
  62. cli_anything/indesign/server/src/types/toolDefinitionsPresentation.js +83 -0
  63. cli_anything/indesign/server/src/types/toolDefinitionsSpread.js +158 -0
  64. cli_anything/indesign/server/src/types/toolDefinitionsUtility.js +40 -0
  65. cli_anything/indesign/server/src/utils/stringUtils.js +107 -0
  66. cli_anything/indesign/skills/SKILL.md +198 -0
  67. indesign_cli-0.2.0.dist-info/METADATA +267 -0
  68. indesign_cli-0.2.0.dist-info/RECORD +72 -0
  69. indesign_cli-0.2.0.dist-info/WHEEL +5 -0
  70. indesign_cli-0.2.0.dist-info/entry_points.txt +3 -0
  71. indesign_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  72. indesign_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from .errors import CliError
8
+
9
+
10
+ def request_id() -> str:
11
+ return uuid.uuid4().hex[:16]
12
+
13
+
14
+ def success(
15
+ *,
16
+ command: str,
17
+ data: Any,
18
+ duration_ms: int,
19
+ tool_id: str | None = None,
20
+ domain: str | None = None,
21
+ source: str | None = None,
22
+ backend: str | None = None,
23
+ warnings: list[str] | None = None,
24
+ ) -> dict[str, Any]:
25
+ return {
26
+ "schema_version": 1,
27
+ "ok": True,
28
+ "exit_code": 0,
29
+ "request_id": request_id(),
30
+ "command": command,
31
+ "tool_id": tool_id,
32
+ "domain": domain,
33
+ "source": source,
34
+ "backend": backend,
35
+ "mcp_ok": True,
36
+ "tool_success": True,
37
+ "raw_result_type": "json",
38
+ "duration_ms": duration_ms,
39
+ "data": data,
40
+ "warnings": warnings or [],
41
+ }
42
+
43
+
44
+ def failure(*, command: str, error: CliError, duration_ms: int) -> dict[str, Any]:
45
+ return {
46
+ "schema_version": 1,
47
+ "ok": False,
48
+ "exit_code": 1,
49
+ "request_id": request_id(),
50
+ "command": command,
51
+ "duration_ms": duration_ms,
52
+ "error": {
53
+ "type": error.__class__.__name__,
54
+ "code": error.code,
55
+ "message": error.message,
56
+ "details": error.details,
57
+ "retryable": error.retryable,
58
+ "hint": error.hint,
59
+ },
60
+ "warnings": [],
61
+ }
62
+
63
+
64
+ def now_ms() -> int:
65
+ return int(time.perf_counter() * 1000)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CliError(Exception):
5
+ def __init__(
6
+ self,
7
+ message: str,
8
+ *,
9
+ code: str = "CLI_ERROR",
10
+ retryable: bool = False,
11
+ details: dict | None = None,
12
+ hint: str | None = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.code = code
17
+ self.retryable = retryable
18
+ self.details = details or {}
19
+ self.hint = hint
20
+
21
+
22
+ class TimeoutError(CliError):
23
+ def __init__(self, message: str, *, details: dict | None = None) -> None:
24
+ super().__init__(
25
+ message,
26
+ code="TIMEOUT",
27
+ retryable=True,
28
+ details=details,
29
+ hint="缩短脚本或增加 --timeout;若 InDesign 卡住,先检查应用窗口状态。",
30
+ )
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def health(repo_root: Path, deep: bool = False) -> dict[str, Any]:
10
+ payload: dict[str, Any] = {
11
+ "deep": deep,
12
+ "node": {"available": shutil.which("node") is not None},
13
+ "python": {"available": shutil.which("python") is not None},
14
+ "node_entry_advanced": {
15
+ "path": "src/advanced/index.js",
16
+ "exists": (repo_root / "src/advanced/index.js").exists(),
17
+ },
18
+ "node_entry_classic": {
19
+ "path": "src/index.js",
20
+ "exists": (repo_root / "src/index.js").exists(),
21
+ },
22
+ }
23
+ if deep:
24
+ payload["winax"] = _check_winax(repo_root)
25
+ payload["indesign_com"] = {
26
+ "checked": False,
27
+ "available": None,
28
+ "reason": "health --deep 不主动连接 COM,避免隐式启动或干扰 InDesign;真实验证请运行 INDESIGN_E2E=1 的 E2E 测试。",
29
+ }
30
+ return payload
31
+
32
+
33
+ def _check_winax(repo_root: Path) -> dict[str, Any]:
34
+ try:
35
+ result = subprocess.run(
36
+ ["node", "-e", "require('winax'); process.stdout.write('ok')"],
37
+ cwd=repo_root,
38
+ text=True,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.PIPE,
41
+ timeout=10,
42
+ check=False,
43
+ )
44
+ except (OSError, subprocess.TimeoutExpired):
45
+ return {"checked": True, "available": False}
46
+ return {"checked": True, "available": result.returncode == 0}
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import CliError, TimeoutError
10
+ from .hidden_handler_schemas import HIDDEN_HANDLER_SCHEMAS
11
+ from .paths import scrub_text_paths
12
+ from .runtime import hidden_handler_bridge_path
13
+
14
+
15
+ class HiddenHandlerBackend:
16
+ def __init__(self, repo_root: Path, timeout_seconds: int = 60) -> None:
17
+ self.repo_root = repo_root
18
+ self.timeout_seconds = timeout_seconds
19
+
20
+ def schema(self, tool_id: str) -> dict[str, Any]:
21
+ try:
22
+ return HIDDEN_HANDLER_SCHEMAS[tool_id]
23
+ except KeyError as exc:
24
+ raise CliError(f"Hidden handler schema missing: {tool_id}", code="SCHEMA_NOT_FOUND") from exc
25
+
26
+ def call_tool(self, tool: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]:
27
+ schema = self.schema(tool["id"])
28
+ self._validate_required(schema, arguments)
29
+
30
+ bridge = hidden_handler_bridge_path()
31
+
32
+ request = {
33
+ "domain": tool["domain"],
34
+ "name": tool["name"],
35
+ "args": arguments,
36
+ }
37
+ try:
38
+ proc = subprocess.run(
39
+ ["node", str(bridge)],
40
+ cwd=self.repo_root,
41
+ env={**os.environ, "INDESIGN_CLI_SERVER_ROOT": str(self.repo_root)},
42
+ input=json.dumps(request, ensure_ascii=False),
43
+ text=True,
44
+ encoding="utf-8",
45
+ stdout=subprocess.PIPE,
46
+ stderr=subprocess.PIPE,
47
+ check=False,
48
+ timeout=self.timeout_seconds,
49
+ )
50
+ except subprocess.TimeoutExpired as exc:
51
+ raise TimeoutError(
52
+ "Hidden handler bridge timed out",
53
+ details={"tool": tool["id"], "stderr_tail": scrub_text_paths((exc.stderr or "")[-2000:])},
54
+ ) from exc
55
+ except OSError as exc:
56
+ raise CliError("Failed to start hidden handler bridge", code="HIDDEN_HANDLER_START_FAILED") from exc
57
+
58
+ payload = self._parse_bridge_payload(proc, tool["id"])
59
+ if not payload.get("ok"):
60
+ error = payload.get("error", {})
61
+ raise CliError(
62
+ "Hidden handler bridge failed",
63
+ code=error.get("code", "HIDDEN_HANDLER_FAILED"),
64
+ details={
65
+ "tool": tool["id"],
66
+ "message": scrub_text_paths(str(error.get("message", ""))),
67
+ },
68
+ )
69
+
70
+ result = payload.get("result", {})
71
+ if isinstance(result, dict) and result.get("success") is False:
72
+ raise CliError(
73
+ "Hidden handler failed",
74
+ code="HIDDEN_HANDLER_FAILED",
75
+ details={"tool": tool["id"], "operation": result.get("operation")},
76
+ )
77
+ if isinstance(result, dict) and isinstance(result.get("result"), str) and result["result"].startswith("Error:"):
78
+ raise CliError(
79
+ "Hidden handler failed",
80
+ code="HIDDEN_HANDLER_FAILED",
81
+ details={
82
+ "tool": tool["id"],
83
+ "operation": result.get("operation"),
84
+ "result": scrub_text_paths(result["result"]),
85
+ },
86
+ )
87
+ return result
88
+
89
+ def _parse_bridge_payload(self, proc: subprocess.CompletedProcess[str], tool_id: str) -> dict[str, Any]:
90
+ stderr_tail = scrub_text_paths((proc.stderr or "")[-2000:])
91
+ if proc.returncode != 0:
92
+ raise CliError(
93
+ "Hidden handler bridge exited with an error",
94
+ code="HIDDEN_HANDLER_BRIDGE_FAILED",
95
+ details={"tool": tool_id, "returncode": proc.returncode, "stderr_tail": stderr_tail},
96
+ )
97
+
98
+ stdout = (proc.stdout or "").strip()
99
+ try:
100
+ payload = json.loads(stdout)
101
+ except json.JSONDecodeError as exc:
102
+ raise CliError(
103
+ "Hidden handler bridge response is not JSON",
104
+ code="HIDDEN_HANDLER_BAD_JSON",
105
+ details={"tool": tool_id, "stdout": scrub_text_paths(stdout[-1000:]), "stderr_tail": stderr_tail},
106
+ ) from exc
107
+ if not isinstance(payload, dict):
108
+ raise CliError("Hidden handler bridge response must be an object", code="HIDDEN_HANDLER_BAD_JSON")
109
+ return payload
110
+
111
+ @staticmethod
112
+ def _validate_required(schema: dict[str, Any], arguments: dict[str, Any]) -> None:
113
+ for key in schema.get("required", []):
114
+ value = arguments.get(key)
115
+ if value in (None, ""):
116
+ raise CliError(f"Missing required argument: {key}", code="MISSING_ARGUMENT", details={"argument": key})
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def _object_schema(properties: dict[str, dict[str, Any]], required: list[str] | None = None) -> dict[str, Any]:
7
+ schema: dict[str, Any] = {
8
+ "type": "object",
9
+ "additionalProperties": False,
10
+ "properties": properties,
11
+ }
12
+ if required:
13
+ schema["required"] = required
14
+ return schema
15
+
16
+
17
+ def _string(description: str) -> dict[str, Any]:
18
+ return {"type": "string", "description": description}
19
+
20
+
21
+ def _number(description: str, *, minimum: int | float | None = None) -> dict[str, Any]:
22
+ schema: dict[str, Any] = {"type": "number", "description": description}
23
+ if minimum is not None:
24
+ schema["minimum"] = minimum
25
+ return schema
26
+
27
+
28
+ def _boolean(description: str, default: bool | None = None) -> dict[str, Any]:
29
+ schema: dict[str, Any] = {"type": "boolean", "description": description}
30
+ if default is not None:
31
+ schema["default"] = default
32
+ return schema
33
+
34
+
35
+ BOOK_PATH = _string("InDesign Book 文件路径,通常为 .indb")
36
+ DOCUMENT_PATH = _string("要加入 Book 的 InDesign 文档路径,通常为 .indd")
37
+ OUTPUT_PATH = _string("输出文件或输出目录路径")
38
+
39
+ BOOK_SYNC_PROPERTIES = {
40
+ "automaticPagination": _boolean("是否启用自动页码"),
41
+ "automaticDocumentConversion": _boolean("是否启用自动文档转换"),
42
+ "insertBlankPage": _boolean("是否插入空白页"),
43
+ "mergeIdenticalLayers": _boolean("是否合并同名图层"),
44
+ "synchronizeBulletNumberingList": _boolean("是否同步项目符号和编号列表"),
45
+ "synchronizeCellStyle": _boolean("是否同步单元格样式"),
46
+ "synchronizeCharacterStyle": _boolean("是否同步字符样式"),
47
+ "synchronizeConditionalText": _boolean("是否同步条件文本"),
48
+ "synchronizeCrossReferenceFormat": _boolean("是否同步交叉引用格式"),
49
+ "synchronizeMasterPage": _boolean("是否同步主页"),
50
+ "synchronizeObjectStyle": _boolean("是否同步对象样式"),
51
+ "synchronizeParagraphStyle": _boolean("是否同步段落样式"),
52
+ "synchronizeSwatch": _boolean("是否同步色板"),
53
+ "synchronizeTableOfContentStyle": _boolean("是否同步目录样式"),
54
+ "synchronizeTableStyle": _boolean("是否同步表格样式"),
55
+ "synchronizeTextVariable": _boolean("是否同步文本变量"),
56
+ "synchronizeTrapStyle": _boolean("是否同步陷印样式"),
57
+ }
58
+
59
+ HIDDEN_HANDLER_SCHEMAS: dict[str, dict[str, Any]] = {
60
+ "book.create_book": _object_schema(
61
+ {"filePath": BOOK_PATH},
62
+ ["filePath"],
63
+ ),
64
+ "book.open_book": _object_schema(
65
+ {"filePath": BOOK_PATH},
66
+ ["filePath"],
67
+ ),
68
+ "book.add_document_to_book": _object_schema(
69
+ {
70
+ "bookPath": BOOK_PATH,
71
+ "documentPath": DOCUMENT_PATH,
72
+ },
73
+ ["bookPath", "documentPath"],
74
+ ),
75
+ "book.synchronize_book": _object_schema(
76
+ {"bookPath": BOOK_PATH},
77
+ ["bookPath"],
78
+ ),
79
+ "book.export_book": _object_schema(
80
+ {
81
+ "bookPath": BOOK_PATH,
82
+ "outputPath": OUTPUT_PATH,
83
+ "format": {
84
+ "type": "string",
85
+ "enum": ["PDF", "EPUB", "HTML"],
86
+ "default": "PDF",
87
+ "description": "导出格式",
88
+ },
89
+ },
90
+ ["bookPath", "outputPath"],
91
+ ),
92
+ "book.package_book": _object_schema(
93
+ {
94
+ "bookPath": BOOK_PATH,
95
+ "outputPath": OUTPUT_PATH,
96
+ "copyingFonts": _boolean("是否复制字体", True),
97
+ "copyingLinkedGraphics": _boolean("是否复制链接图像", True),
98
+ "copyingProfiles": _boolean("是否复制色彩配置文件", True),
99
+ "updatingGraphics": _boolean("是否更新图像链接", True),
100
+ "includingHiddenLayers": _boolean("是否包含隐藏图层", False),
101
+ "ignorePreflightErrors": _boolean("是否忽略预检错误", False),
102
+ "creatingReport": _boolean("是否创建打包报告", True),
103
+ "includeIdml": _boolean("是否包含 IDML", False),
104
+ "includePdf": _boolean("是否包含 PDF", False),
105
+ },
106
+ ["bookPath", "outputPath"],
107
+ ),
108
+ "book.get_book_info": _object_schema(
109
+ {"bookPath": BOOK_PATH},
110
+ ["bookPath"],
111
+ ),
112
+ "book.list_books": _object_schema({}),
113
+ "book.repaginate_book": _object_schema(
114
+ {"bookPath": BOOK_PATH},
115
+ ["bookPath"],
116
+ ),
117
+ "book.update_all_cross_references": _object_schema(
118
+ {"bookPath": BOOK_PATH},
119
+ ["bookPath"],
120
+ ),
121
+ "book.update_all_numbers": _object_schema(
122
+ {"bookPath": BOOK_PATH},
123
+ ["bookPath"],
124
+ ),
125
+ "book.update_chapter_and_paragraph_numbers": _object_schema(
126
+ {"bookPath": BOOK_PATH},
127
+ ["bookPath"],
128
+ ),
129
+ "book.preflight_book": _object_schema(
130
+ {
131
+ "bookPath": BOOK_PATH,
132
+ "outputPath": OUTPUT_PATH,
133
+ "autoOpen": _boolean("生成预检报告后是否自动打开", False),
134
+ },
135
+ ["bookPath"],
136
+ ),
137
+ "book.print_book": _object_schema(
138
+ {
139
+ "bookPath": BOOK_PATH,
140
+ "printDialog": _boolean("是否显示打印对话框", True),
141
+ "printerPreset": {
142
+ "type": "string",
143
+ "default": "DEFAULT_VALUE",
144
+ "description": "InDesign PrinterPresetTypes 枚举名",
145
+ },
146
+ },
147
+ ["bookPath"],
148
+ ),
149
+ "book.set_book_properties": _object_schema(
150
+ {"bookPath": BOOK_PATH, **BOOK_SYNC_PROPERTIES},
151
+ ["bookPath"],
152
+ ),
153
+ "presentation.create_presentation_document": _object_schema(
154
+ {
155
+ "preset": {
156
+ "type": "string",
157
+ "enum": ["A3_LANDSCAPE", "A4_LANDSCAPE", "RATIO_16x9"],
158
+ "default": "A3_LANDSCAPE",
159
+ "description": "演示文稿页面尺寸预设",
160
+ },
161
+ "width": _number("自定义页面宽度,单位 mm", minimum=1),
162
+ "height": _number("自定义页面高度,单位 mm", minimum=1),
163
+ "pages": {
164
+ "type": "integer",
165
+ "minimum": 1,
166
+ "default": 1,
167
+ "description": "初始页数",
168
+ },
169
+ "facingPages": _boolean("是否启用对页", False),
170
+ },
171
+ ),
172
+ "presentation.add_cover_page": _object_schema(
173
+ {
174
+ "title": _string("封面标题"),
175
+ "subtitle": _string("封面副标题"),
176
+ "bgImagePath": _string("封面背景图片路径"),
177
+ },
178
+ ),
179
+ "presentation.add_section_page": _object_schema(
180
+ {"title": _string("章节页标题")},
181
+ ),
182
+ "presentation.add_full_bleed_image": _object_schema(
183
+ {
184
+ "filePath": _string("要铺满当前页的图片路径"),
185
+ "caption": _string("可选图片说明"),
186
+ },
187
+ ["filePath"],
188
+ ),
189
+ "presentation.add_image_grid": _object_schema(
190
+ {
191
+ "files": {
192
+ "type": "array",
193
+ "items": {"type": "string"},
194
+ "description": "要置入网格的图片路径列表",
195
+ },
196
+ "rows": {
197
+ "type": "integer",
198
+ "minimum": 1,
199
+ "default": 2,
200
+ "description": "网格行数",
201
+ },
202
+ "columns": {
203
+ "type": "integer",
204
+ "minimum": 1,
205
+ "default": 3,
206
+ "description": "网格列数",
207
+ },
208
+ "gap": _number("网格间距,单位与文档标尺一致", minimum=0),
209
+ },
210
+ ["files"],
211
+ ),
212
+ "presentation.export_presentation_pdf": _object_schema(
213
+ {
214
+ "filePath": _string("PDF 输出路径"),
215
+ "preset": {
216
+ "type": "string",
217
+ "default": "High Quality Print",
218
+ "description": "InDesign PDF 导出预设名称",
219
+ },
220
+ },
221
+ ["filePath"],
222
+ ),
223
+ }
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from .errors import CliError, TimeoutError
10
+ from .paths import scrub_text_paths
11
+
12
+
13
+ class McpBackend:
14
+ def __init__(self, repo_root: Path, entry: str, timeout_seconds: int = 30) -> None:
15
+ self.repo_root = repo_root
16
+ self.entry = entry
17
+ self.timeout_seconds = timeout_seconds
18
+ self._next_id = 1
19
+
20
+ def list_tools(self) -> list[dict[str, Any]]:
21
+ response = self._with_process(lambda proc: self._request(proc, "tools/list", {}))
22
+ return response.get("tools", [])
23
+
24
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
25
+ response = self._with_process(
26
+ lambda proc: self._request(proc, "tools/call", {"name": name, "arguments": arguments})
27
+ )
28
+ return self._parse_tool_response(name, response)
29
+
30
+ def _with_process(self, action: Callable[[subprocess.Popen[str]], dict[str, Any]]) -> dict[str, Any]:
31
+ entry_path = self.repo_root / self.entry
32
+ if not entry_path.exists():
33
+ raise CliError(f"MCP entry not found: {self.entry}", code="MCP_ENTRY_NOT_FOUND")
34
+
35
+ try:
36
+ proc = subprocess.Popen(
37
+ ["node", self.entry],
38
+ cwd=self.repo_root,
39
+ stdin=subprocess.PIPE,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ text=True,
43
+ encoding="utf-8",
44
+ )
45
+ except OSError as exc:
46
+ raise CliError("Failed to start MCP process", code="MCP_START_FAILED", details={"entry": self.entry}) from exc
47
+
48
+ timed_out = {"value": False}
49
+ stderr_tail: list[str] = []
50
+
51
+ def drain_stderr() -> None:
52
+ if proc.stderr is None:
53
+ return
54
+ for line in proc.stderr:
55
+ stderr_tail.append(scrub_text_paths(line.strip()))
56
+ del stderr_tail[:-20]
57
+
58
+ def kill_process() -> None:
59
+ timed_out["value"] = True
60
+ proc.kill()
61
+
62
+ timer = threading.Timer(self.timeout_seconds, kill_process)
63
+ stderr_thread = threading.Thread(target=drain_stderr, daemon=True)
64
+ try:
65
+ timer.start()
66
+ stderr_thread.start()
67
+ self._request(
68
+ proc,
69
+ "initialize",
70
+ {
71
+ "protocolVersion": "2024-11-05",
72
+ "capabilities": {},
73
+ "clientInfo": {"name": "indesign-cli", "version": "0.2.0"},
74
+ },
75
+ )
76
+ self._notify(proc, "notifications/initialized", {})
77
+ return action(proc)
78
+ finally:
79
+ timer.cancel()
80
+ if proc.poll() is None:
81
+ proc.terminate()
82
+ try:
83
+ proc.wait(timeout=3)
84
+ except subprocess.TimeoutExpired:
85
+ proc.kill()
86
+ if timed_out["value"]:
87
+ raise TimeoutError("MCP process timed out", details={"entry": self.entry, "stderr_tail": stderr_tail})
88
+
89
+ def _request(self, proc: subprocess.Popen[str], method: str, params: dict[str, Any]) -> dict[str, Any]:
90
+ if proc.stdin is None or proc.stdout is None:
91
+ raise CliError("MCP process stdio is unavailable", code="MCP_STDIO_UNAVAILABLE")
92
+ request = {"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}
93
+ self._next_id += 1
94
+ proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
95
+ proc.stdin.flush()
96
+ line = proc.stdout.readline()
97
+ if line == "":
98
+ raise TimeoutError("MCP process ended before response", details={"method": method, "entry": self.entry})
99
+ try:
100
+ response = json.loads(line)
101
+ except json.JSONDecodeError as exc:
102
+ raise CliError("MCP response is not JSON", code="MCP_BAD_JSON", details={"line": line[:500]}) from exc
103
+ if "error" in response:
104
+ raise CliError(
105
+ response["error"].get("message", "MCP request failed"),
106
+ code="MCP_PROTOCOL_ERROR",
107
+ retryable=False,
108
+ details=response["error"],
109
+ )
110
+ return response.get("result", {})
111
+
112
+ def _notify(self, proc: subprocess.Popen[str], method: str, params: dict[str, Any]) -> None:
113
+ if proc.stdin is None:
114
+ raise CliError("MCP process stdin is unavailable", code="MCP_STDIN_UNAVAILABLE")
115
+ proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
116
+ proc.stdin.flush()
117
+
118
+ def _parse_tool_response(self, name: str, response: dict[str, Any]) -> dict[str, Any]:
119
+ content = response.get("content", [])
120
+ if not content:
121
+ return response
122
+
123
+ first = content[0]
124
+ if first.get("type") != "text":
125
+ return response
126
+
127
+ text = first.get("text", "")
128
+ try:
129
+ parsed = json.loads(text)
130
+ except json.JSONDecodeError:
131
+ if response.get("isError"):
132
+ raise CliError(
133
+ "MCP tool failed",
134
+ code="MCP_TOOL_FAILED",
135
+ details={"tool": name, "result": scrub_text_paths(text)},
136
+ )
137
+ return response
138
+
139
+ if response.get("isError") or (isinstance(parsed, dict) and parsed.get("success") is False):
140
+ details = {"tool": name}
141
+ if isinstance(parsed, dict):
142
+ details["operation"] = parsed.get("operation")
143
+ details["result"] = scrub_text_paths(str(parsed.get("result", "")))
144
+ raise CliError("MCP tool failed", code="MCP_TOOL_FAILED", details=details)
145
+
146
+ payload = {"content": content, "parsed": parsed}
147
+ if isinstance(parsed, dict) and isinstance(parsed.get("result"), str):
148
+ try:
149
+ payload["result_json"] = json.loads(parsed["result"])
150
+ except json.JSONDecodeError:
151
+ pass
152
+ return payload
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .errors import CliError
8
+ from .paths import scrub_text_paths
9
+
10
+
11
+ def setup_node_dependencies(server_root: Path) -> dict[str, Any]:
12
+ try:
13
+ result = subprocess.run(
14
+ ["npm", "install"],
15
+ cwd=server_root,
16
+ text=True,
17
+ encoding="utf-8",
18
+ stdout=subprocess.PIPE,
19
+ stderr=subprocess.PIPE,
20
+ check=False,
21
+ timeout=180,
22
+ )
23
+ except (OSError, subprocess.TimeoutExpired) as exc:
24
+ raise CliError("Failed to install Node dependencies", code="NPM_INSTALL_FAILED") from exc
25
+
26
+ payload = {
27
+ "ok": result.returncode == 0,
28
+ "server_root": str(server_root),
29
+ "returncode": result.returncode,
30
+ "stdout_tail": scrub_text_paths((result.stdout or "")[-2000:]),
31
+ "stderr_tail": scrub_text_paths((result.stderr or "")[-2000:]),
32
+ }
33
+ if result.returncode != 0:
34
+ raise CliError("npm install failed", code="NPM_INSTALL_FAILED", details=payload)
35
+ return payload