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,41 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ def _hash_path(path: Path) -> str:
9
+ digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()
10
+ return digest[:16]
11
+
12
+
13
+ def scrub_path(path_value: str, cwd: Path) -> dict[str, object]:
14
+ path = Path(path_value)
15
+ try:
16
+ resolved = path.resolve()
17
+ except OSError:
18
+ resolved = path
19
+ try:
20
+ relative = resolved.relative_to(cwd.resolve())
21
+ return {
22
+ "path": str(relative).replace("\\", "/"),
23
+ "external": False,
24
+ "extension": resolved.suffix.lower(),
25
+ }
26
+ except ValueError:
27
+ return {
28
+ "external": True,
29
+ "kind": "external_path",
30
+ "extension": resolved.suffix.lower(),
31
+ "hash": _hash_path(resolved),
32
+ }
33
+
34
+
35
+ def scrub_text_paths(value: str) -> str:
36
+ def replace(match: re.Match[str]) -> str:
37
+ raw_path = match.group(0)
38
+ suffix = Path(raw_path).suffix.lower()
39
+ return f"<external_path extension={suffix or 'unknown'} hash={_hash_path(Path(raw_path))}>"
40
+
41
+ return re.sub(r"[A-Za-z]:[\\/][^'\"\r\n]+", replace, value)
@@ -0,0 +1,2 @@
1
+ from __future__ import annotations
2
+
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..errors import CliError
10
+ from .manifest import PluginRecord
11
+
12
+
13
+ class PluginBackend:
14
+ def __init__(self, record: PluginRecord, *, timeout: int = 30) -> None:
15
+ self.record = record
16
+ self.timeout = timeout
17
+
18
+ def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
19
+ payload = {
20
+ "jsonrpc": "2.0",
21
+ "id": uuid.uuid4().hex[:12],
22
+ "method": method,
23
+ "params": params or {},
24
+ }
25
+ try:
26
+ completed = subprocess.run(
27
+ ["node", str(self.record.entry_path)],
28
+ cwd=self.record.root,
29
+ input=json.dumps(payload, ensure_ascii=False),
30
+ text=True,
31
+ encoding="utf-8",
32
+ stdout=subprocess.PIPE,
33
+ stderr=subprocess.PIPE,
34
+ timeout=self.timeout,
35
+ check=False,
36
+ )
37
+ except subprocess.TimeoutExpired as exc:
38
+ raise CliError("Plugin timed out", code="PLUGIN_TIMEOUT", retryable=True, details={"plugin": self.record.id, "method": method}) from exc
39
+
40
+ if completed.returncode != 0:
41
+ raise CliError(
42
+ "Plugin process failed",
43
+ code="PLUGIN_CALL_FAILED",
44
+ details={"plugin": self.record.id, "method": method, "returncode": completed.returncode, "stderr": completed.stderr[-1000:]},
45
+ )
46
+ try:
47
+ response = json.loads(completed.stdout)
48
+ except json.JSONDecodeError as exc:
49
+ raise CliError(
50
+ "Plugin stdout must be a single JSON response",
51
+ code="PLUGIN_STDOUT_INVALID",
52
+ details={"plugin": self.record.id, "method": method, "stdout_prefix": completed.stdout[:200]},
53
+ ) from exc
54
+ if not isinstance(response, dict):
55
+ raise CliError("Plugin response must be an object", code="PLUGIN_RESPONSE_INVALID", details={"plugin": self.record.id, "method": method})
56
+ if response.get("error"):
57
+ error = response["error"]
58
+ if not isinstance(error, dict):
59
+ raise CliError("Plugin error must be an object", code="PLUGIN_RESPONSE_INVALID", details={"plugin": self.record.id, "method": method})
60
+ raise CliError(
61
+ str(error.get("message") or "Plugin error"),
62
+ code=str(error.get("code") or "PLUGIN_ERROR"),
63
+ details=error.get("details") if isinstance(error.get("details"), dict) else {},
64
+ )
65
+ result = response.get("result")
66
+ if not isinstance(result, dict):
67
+ raise CliError("Plugin result must be an object", code="PLUGIN_RESPONSE_INVALID", details={"plugin": self.record.id, "method": method})
68
+ return result
69
+
70
+ def handshake(self, host: dict[str, Any]) -> dict[str, Any]:
71
+ return self.request("plugin/handshake", {"host": host})
72
+
73
+ def list_tools(self, context: dict[str, Any] | None = None) -> list[dict[str, Any]]:
74
+ result = self.request("tools/list", {"context": context or {}})
75
+ tools = result.get("tools")
76
+ if not isinstance(tools, list):
77
+ raise CliError("Plugin tools/list must return tools array", code="PLUGIN_RESPONSE_INVALID", details={"plugin": self.record.id})
78
+ return [tool for tool in tools if isinstance(tool, dict)]
79
+
80
+ def schema(self, tool_id: str) -> dict[str, Any]:
81
+ return self.request("tools/schema", {"tool_id": tool_id})
82
+
83
+ def call_tool(self, tool_id: str, args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
84
+ return self.request("tools/call", {"tool_id": tool_id, "args": args, "context": context})
85
+
86
+ def resume_tool(self, tool_id: str, state: dict[str, Any], host_results: list[dict[str, Any]]) -> dict[str, Any]:
87
+ return self.request("tools/resume", {"tool_id": tool_id, "state": state, "host_results": host_results})
88
+
89
+ def doctor(self) -> dict[str, Any]:
90
+ return self.request("plugin/doctor", {})
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ..errors import CliError
8
+ from .manifest import PluginRecord, load_installed_plugin
9
+
10
+
11
+ SOURCE_PRIORITY = {
12
+ "project": 1,
13
+ "user": 2,
14
+ "entry_point": 3,
15
+ "builtin": 4,
16
+ }
17
+
18
+
19
+ def project_plugin_dir(cwd: Path) -> Path:
20
+ return cwd / ".indesign-cli" / "plugins"
21
+
22
+
23
+ def user_plugin_dir(home: Path | None = None) -> Path:
24
+ base = home or Path.home()
25
+ return base / ".indesign-cli" / "plugins"
26
+
27
+
28
+ def discover_project_plugins(cwd: Path, *, host_version: str) -> tuple[list[PluginRecord], list[str]]:
29
+ directory = project_plugin_dir(cwd)
30
+ if not directory.exists():
31
+ return [], []
32
+ records: list[PluginRecord] = []
33
+ warnings: list[str] = []
34
+ for path in sorted(directory.glob("*.json")):
35
+ try:
36
+ record = load_installed_plugin(path, source="project", host_version=host_version)
37
+ except CliError as exc:
38
+ warnings.append(f"plugin record unavailable: {path.name}: {exc.code}")
39
+ continue
40
+ if record.enabled:
41
+ records.append(record)
42
+ return records, warnings
43
+
44
+
45
+ def discover_plugins(cwd: Path, *, host_version: str) -> tuple[list[PluginRecord], list[str]]:
46
+ candidates, warnings = discover_project_plugins(cwd, host_version=host_version)
47
+ by_id: dict[str, list[PluginRecord]] = defaultdict(list)
48
+ for record in candidates:
49
+ by_id[record.id].append(record)
50
+
51
+ selected: list[PluginRecord] = []
52
+ for plugin_id, records in by_id.items():
53
+ grouped: dict[int, list[PluginRecord]] = defaultdict(list)
54
+ for record in records:
55
+ grouped[SOURCE_PRIORITY.get(record.source, 99)].append(record)
56
+ best_priority = min(grouped)
57
+ if len(grouped[best_priority]) > 1:
58
+ raise CliError("Duplicate plugins at the same priority", code="PLUGIN_DUPLICATE_ID", details={"id": plugin_id})
59
+ selected_record = grouped[best_priority][0]
60
+ selected.append(selected_record)
61
+ covered = [record for record in records if record is not selected_record]
62
+ for record in covered:
63
+ warnings.append(f"plugin {plugin_id} from {record.source} was covered by {selected_record.source}")
64
+
65
+ return sorted(selected, key=lambda item: item.id), warnings
66
+
67
+
68
+ def plugin_summaries(records: list[PluginRecord]) -> list[dict[str, Any]]:
69
+ return [record.summary() for record in sorted(records, key=lambda item: item.id)]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ..errors import CliError
7
+
8
+
9
+ ALLOWED_HOST_ACTIONS = {"script.run", "export.verify", "session.show"}
10
+
11
+
12
+ def _validate_script_path(path_value: str, cwd: Path) -> None:
13
+ script_path = Path(path_value).resolve()
14
+ cwd_root = cwd.resolve()
15
+ try:
16
+ script_path.relative_to(cwd_root)
17
+ except ValueError as exc:
18
+ raise CliError(
19
+ "Plugin script.run host action must stay inside the current project",
20
+ code="PLUGIN_HOST_ACTION_DENIED",
21
+ details={"path": str(script_path)},
22
+ ) from exc
23
+
24
+
25
+ class HostActionExecutor:
26
+ def __init__(self, router: Any, cwd: Path, *, max_resume_rounds: int = 3) -> None:
27
+ self.router = router
28
+ self.cwd = cwd
29
+ self.max_resume_rounds = max_resume_rounds
30
+
31
+ def complete(self, backend: Any, tool_id: str, initial: dict[str, Any]) -> dict[str, Any]:
32
+ current = initial
33
+ failed_results: list[dict[str, Any]] = []
34
+ for _round in range(self.max_resume_rounds + 1):
35
+ if current.get("status") != "requires_host_actions":
36
+ if failed_results and current.get("data", {}).get("ok") is not False:
37
+ raise CliError(
38
+ "Plugin host action failed",
39
+ code="PLUGIN_HOST_ACTION_FAILED",
40
+ details={"tool_id": tool_id, "host_results": failed_results, "plugin_result": current},
41
+ )
42
+ return current
43
+ actions = current.get("actions")
44
+ if not isinstance(actions, list):
45
+ raise CliError("Plugin host actions must be an array", code="PLUGIN_HOST_ACTION_INVALID")
46
+ host_results = [self._execute_action(action) for action in actions if isinstance(action, dict)]
47
+ failed_results.extend(result for result in host_results if result.get("ok") is False)
48
+ current = backend.resume_tool(tool_id, current.get("state") if isinstance(current.get("state"), dict) else {}, host_results)
49
+ raise CliError("Plugin exceeded host action resume limit", code="PLUGIN_HOST_ACTION_LIMIT_EXCEEDED", details={"tool_id": tool_id})
50
+
51
+ def _execute_action(self, action: dict[str, Any]) -> dict[str, Any]:
52
+ action_id = str(action.get("id") or "")
53
+ host_tool_id = str(action.get("tool_id") or "")
54
+ args = action.get("args") if isinstance(action.get("args"), dict) else {}
55
+ if host_tool_id not in ALLOWED_HOST_ACTIONS:
56
+ return {
57
+ "id": action_id,
58
+ "tool_id": host_tool_id,
59
+ "ok": False,
60
+ "error": {
61
+ "code": "PLUGIN_HOST_ACTION_DENIED",
62
+ "message": f"Host action is not allowed: {host_tool_id}",
63
+ },
64
+ }
65
+ try:
66
+ if host_tool_id == "script.run" and args.get("file"):
67
+ _validate_script_path(str(args["file"]), self.cwd)
68
+ data = self.router.call(host_tool_id, args)
69
+ except CliError as exc:
70
+ return {
71
+ "id": action_id,
72
+ "tool_id": host_tool_id,
73
+ "ok": False,
74
+ "error": {"code": exc.code, "message": exc.message, "details": exc.details},
75
+ }
76
+ return {"id": action_id, "tool_id": host_tool_id, "ok": True, "data": data}
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ..errors import CliError
8
+ from .discovery import discover_plugins, plugin_summaries, project_plugin_dir
9
+ from .manifest import install_record_for, load_plugin_record
10
+
11
+
12
+ def install_plugin(path_value: str, *, cwd: Path, host_version: str) -> dict[str, Any]:
13
+ record = load_plugin_record(Path(path_value), source="project", host_version=host_version)
14
+ directory = project_plugin_dir(cwd)
15
+ directory.mkdir(parents=True, exist_ok=True)
16
+ target = directory / f"{record.id}.json"
17
+ target.write_text(json.dumps(install_record_for(record), ensure_ascii=False, indent=2), encoding="utf-8")
18
+ return {
19
+ "installed": True,
20
+ "id": record.id,
21
+ "domain": record.domain,
22
+ "version": record.version,
23
+ "record_path": str(target),
24
+ "root": str(record.root),
25
+ }
26
+
27
+
28
+ def remove_plugin(plugin_id: str, *, cwd: Path) -> dict[str, Any]:
29
+ target = project_plugin_dir(cwd) / f"{plugin_id}.json"
30
+ if not target.exists():
31
+ raise CliError("Plugin is not installed in this project", code="PLUGIN_NOT_INSTALLED", details={"id": plugin_id})
32
+ target.unlink()
33
+ return {"removed": True, "id": plugin_id, "record_path": str(target)}
34
+
35
+
36
+ def list_plugins(*, cwd: Path, host_version: str) -> dict[str, Any]:
37
+ records, warnings = discover_plugins(cwd, host_version=host_version)
38
+ return {"plugins": plugin_summaries(records), "warnings": warnings}
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..errors import CliError
10
+
11
+
12
+ PLUGIN_PROTOCOL = "indesign-cli-plugin.v1"
13
+ PLUGIN_SCHEMA_VERSION = 1
14
+ SUPPORTED_KINDS = {"node-plugin"}
15
+ ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
16
+ DOMAIN_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
17
+ TOOL_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}\.[a-z][a-z0-9_]*$")
18
+ CORE_DOMAINS = {
19
+ "template",
20
+ "document",
21
+ "page",
22
+ "spread",
23
+ "master",
24
+ "layer",
25
+ "object",
26
+ "text",
27
+ "graphics",
28
+ "style",
29
+ "export",
30
+ "book",
31
+ "presentation",
32
+ "script",
33
+ "session",
34
+ "server",
35
+ "skill",
36
+ "utility",
37
+ }
38
+
39
+ REQUIRED_MANIFEST_FIELDS = {
40
+ "schema_version",
41
+ "protocol",
42
+ "id",
43
+ "name",
44
+ "version",
45
+ "kind",
46
+ "domain",
47
+ "entry",
48
+ "description",
49
+ "requires",
50
+ "capabilities",
51
+ "permissions",
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PluginRecord:
57
+ id: str
58
+ source: str
59
+ root: Path
60
+ manifest_path: Path
61
+ manifest: dict[str, Any]
62
+ install_record_path: Path | None = None
63
+ enabled: bool = True
64
+
65
+ @property
66
+ def domain(self) -> str:
67
+ return str(self.manifest["domain"])
68
+
69
+ @property
70
+ def version(self) -> str:
71
+ return str(self.manifest["version"])
72
+
73
+ @property
74
+ def entry_path(self) -> Path:
75
+ return (self.root / str(self.manifest["entry"])).resolve()
76
+
77
+ def summary(self) -> dict[str, Any]:
78
+ return {
79
+ "id": self.id,
80
+ "domain": self.domain,
81
+ "version": self.version,
82
+ "source": self.source,
83
+ "enabled": self.enabled,
84
+ "root": str(self.root),
85
+ "manifest": str(self.manifest_path),
86
+ }
87
+
88
+
89
+ def read_json(path: Path) -> dict[str, Any]:
90
+ try:
91
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
92
+ except FileNotFoundError as exc:
93
+ raise CliError("Plugin manifest not found", code="PLUGIN_MANIFEST_NOT_FOUND", details={"path": str(path)}) from exc
94
+ except json.JSONDecodeError as exc:
95
+ raise CliError("Plugin manifest must be valid JSON", code="PLUGIN_MANIFEST_JSON_INVALID", details={"path": str(path)}) from exc
96
+ if not isinstance(payload, dict):
97
+ raise CliError("Plugin manifest must be a JSON object", code="PLUGIN_MANIFEST_INVALID", details={"path": str(path)})
98
+ return payload
99
+
100
+
101
+ def default_manifest_path(root: Path) -> Path:
102
+ preferred = root / "src" / "indesign-cli-plugin" / "manifest.json"
103
+ if preferred.exists():
104
+ return preferred
105
+ return root / "manifest.json"
106
+
107
+
108
+ def resolve_manifest_input(path_or_manifest: Path) -> tuple[Path, Path, dict[str, Any]]:
109
+ path = path_or_manifest.resolve()
110
+ if path.is_dir():
111
+ root = path
112
+ manifest_path = default_manifest_path(root).resolve()
113
+ else:
114
+ manifest_path = path
115
+ if manifest_path.parent.name == "indesign-cli-plugin" and manifest_path.parent.parent.name == "src":
116
+ root = manifest_path.parent.parent.parent
117
+ else:
118
+ root = manifest_path.parent
119
+ manifest = read_json(manifest_path)
120
+ return root, manifest_path, manifest
121
+
122
+
123
+ def resolve_install_record(record_path: Path) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
124
+ record = read_json(record_path)
125
+ root_value = record.get("root")
126
+ if not isinstance(root_value, str) or not root_value:
127
+ raise CliError("Plugin install record requires root", code="PLUGIN_INSTALL_RECORD_INVALID", details={"path": str(record_path)})
128
+ root = Path(root_value).resolve()
129
+ manifest_relative = record.get("manifest") or "src/indesign-cli-plugin/manifest.json"
130
+ manifest_path = (root / str(manifest_relative)).resolve()
131
+ if not manifest_path.exists() and (root / "manifest.json").exists():
132
+ manifest_path = (root / "manifest.json").resolve()
133
+ manifest = read_json(manifest_path)
134
+ return root, manifest_path, manifest, record
135
+
136
+
137
+ def _version_tuple(value: str) -> tuple[int, int, int]:
138
+ parts = value.split(".")
139
+ if len(parts) < 3:
140
+ raise ValueError(value)
141
+ return tuple(int(part) for part in parts[:3])
142
+
143
+
144
+ def version_satisfies(version: str, requirement: str | None) -> bool:
145
+ if not requirement:
146
+ return True
147
+ requirement = requirement.strip()
148
+ if requirement.startswith(">="):
149
+ try:
150
+ return _version_tuple(version) >= _version_tuple(requirement[2:].strip())
151
+ except ValueError:
152
+ return False
153
+ return version == requirement
154
+
155
+
156
+ def validate_manifest(
157
+ *,
158
+ manifest: dict[str, Any],
159
+ root: Path,
160
+ manifest_path: Path,
161
+ host_version: str,
162
+ allow_core_domain: bool = False,
163
+ ) -> list[dict[str, Any]]:
164
+ errors: list[dict[str, Any]] = []
165
+
166
+ missing = sorted(field for field in REQUIRED_MANIFEST_FIELDS if field not in manifest)
167
+ for field in missing:
168
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": f"Missing manifest field: {field}", "details": {"field": field}})
169
+
170
+ if errors:
171
+ return errors
172
+
173
+ if manifest.get("schema_version") != PLUGIN_SCHEMA_VERSION:
174
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Unsupported plugin schema_version", "details": {"schema_version": manifest.get("schema_version")}})
175
+ if manifest.get("protocol") != PLUGIN_PROTOCOL:
176
+ errors.append({"code": "PLUGIN_PROTOCOL_UNSUPPORTED", "message": "Unsupported plugin protocol", "details": {"protocol": manifest.get("protocol")}})
177
+ if manifest.get("kind") not in SUPPORTED_KINDS:
178
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Unsupported plugin kind", "details": {"kind": manifest.get("kind")}})
179
+
180
+ plugin_id = manifest.get("id")
181
+ if not isinstance(plugin_id, str) or not ID_PATTERN.match(plugin_id):
182
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Invalid plugin id", "details": {"id": plugin_id}})
183
+
184
+ domain = manifest.get("domain")
185
+ if not isinstance(domain, str) or not DOMAIN_PATTERN.match(domain):
186
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Invalid plugin domain", "details": {"domain": domain}})
187
+ elif domain in CORE_DOMAINS and not allow_core_domain:
188
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Plugin domain conflicts with core domain", "details": {"domain": domain}})
189
+
190
+ version = manifest.get("version")
191
+ if not isinstance(version, str):
192
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Plugin version must be a string", "details": {"version": version}})
193
+ else:
194
+ try:
195
+ _version_tuple(version)
196
+ except ValueError:
197
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "Plugin version must be SemVer-like", "details": {"version": version}})
198
+
199
+ entry = manifest.get("entry")
200
+ if not isinstance(entry, str) or not entry:
201
+ errors.append({"code": "PLUGIN_ENTRY_NOT_FOUND", "message": "Plugin entry is required", "details": {"entry": entry}})
202
+ else:
203
+ entry_path = (root / entry).resolve()
204
+ try:
205
+ entry_path.relative_to(root.resolve())
206
+ except ValueError:
207
+ errors.append({"code": "PLUGIN_ENTRY_NOT_FOUND", "message": "Plugin entry must stay inside plugin root", "details": {"entry": entry}})
208
+ if not entry_path.exists():
209
+ errors.append({"code": "PLUGIN_ENTRY_NOT_FOUND", "message": "Plugin entry file not found", "details": {"entry": str(entry_path)}})
210
+
211
+ permissions = manifest.get("permissions")
212
+ if not isinstance(permissions, dict) or permissions.get("indesign") != "host_only":
213
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "permissions.indesign must be host_only", "details": {"permissions": permissions}})
214
+
215
+ requires = manifest.get("requires")
216
+ if not isinstance(requires, dict):
217
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "requires must be an object", "details": {"requires": requires}})
218
+ elif not version_satisfies(host_version, requires.get("indesign_cli")):
219
+ errors.append(
220
+ {
221
+ "code": "PLUGIN_PROTOCOL_UNSUPPORTED",
222
+ "message": "Plugin requires a different indesign-cli version",
223
+ "details": {"requires": requires.get("indesign_cli"), "host_version": host_version},
224
+ }
225
+ )
226
+
227
+ if not isinstance(manifest.get("capabilities"), dict):
228
+ errors.append({"code": "PLUGIN_MANIFEST_INVALID", "message": "capabilities must be an object", "details": {"capabilities": manifest.get("capabilities")}})
229
+
230
+ return errors
231
+
232
+
233
+ def load_plugin_record(path_or_manifest: Path, *, source: str, host_version: str) -> PluginRecord:
234
+ root, manifest_path, manifest = resolve_manifest_input(path_or_manifest)
235
+ errors = validate_manifest(manifest=manifest, root=root, manifest_path=manifest_path, host_version=host_version)
236
+ if errors:
237
+ first = errors[0]
238
+ raise CliError(first["message"], code=first["code"], details={"errors": errors, "manifest": str(manifest_path)})
239
+ return PluginRecord(
240
+ id=str(manifest["id"]),
241
+ source=source,
242
+ root=root,
243
+ manifest_path=manifest_path,
244
+ manifest=manifest,
245
+ enabled=True,
246
+ )
247
+
248
+
249
+ def load_installed_plugin(record_path: Path, *, source: str, host_version: str) -> PluginRecord:
250
+ root, manifest_path, manifest, record = resolve_install_record(record_path)
251
+ enabled = bool(record.get("enabled", True))
252
+ errors = validate_manifest(manifest=manifest, root=root, manifest_path=manifest_path, host_version=host_version)
253
+ if errors:
254
+ first = errors[0]
255
+ raise CliError(first["message"], code=first["code"], details={"errors": errors, "record": str(record_path)})
256
+ return PluginRecord(
257
+ id=str(manifest["id"]),
258
+ source=source,
259
+ root=root,
260
+ manifest_path=manifest_path,
261
+ manifest=manifest,
262
+ install_record_path=record_path,
263
+ enabled=enabled,
264
+ )
265
+
266
+
267
+ def install_record_for(record: PluginRecord) -> dict[str, Any]:
268
+ try:
269
+ manifest_relative = record.manifest_path.relative_to(record.root)
270
+ except ValueError:
271
+ manifest_relative = record.manifest_path
272
+ return {
273
+ "schema_version": 1,
274
+ "id": record.id,
275
+ "kind": record.manifest["kind"],
276
+ "root": str(record.root),
277
+ "manifest": str(manifest_relative).replace("\\", "/"),
278
+ "enabled": True,
279
+ }