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.
- cli_anything/indesign/README.md +32 -0
- cli_anything/indesign/__init__.py +1 -0
- cli_anything/indesign/__main__.py +5 -0
- cli_anything/indesign/core/artifacts.py +57 -0
- cli_anything/indesign/core/catalog.py +405 -0
- cli_anything/indesign/core/domains.py +178 -0
- cli_anything/indesign/core/envelope.py +65 -0
- cli_anything/indesign/core/errors.py +30 -0
- cli_anything/indesign/core/health.py +46 -0
- cli_anything/indesign/core/hidden_backend.py +116 -0
- cli_anything/indesign/core/hidden_handler_schemas.py +223 -0
- cli_anything/indesign/core/mcp_backend.py +152 -0
- cli_anything/indesign/core/node_setup.py +35 -0
- cli_anything/indesign/core/paths.py +41 -0
- cli_anything/indesign/core/plugins/__init__.py +2 -0
- cli_anything/indesign/core/plugins/backend.py +90 -0
- cli_anything/indesign/core/plugins/discovery.py +69 -0
- cli_anything/indesign/core/plugins/host_actions.py +76 -0
- cli_anything/indesign/core/plugins/install.py +38 -0
- cli_anything/indesign/core/plugins/manifest.py +279 -0
- cli_anything/indesign/core/plugins/validate.py +181 -0
- cli_anything/indesign/core/router.py +217 -0
- cli_anything/indesign/core/runtime.py +59 -0
- cli_anything/indesign/core/scripts.py +44 -0
- cli_anything/indesign/core/session.py +68 -0
- cli_anything/indesign/indesign_cli.py +320 -0
- cli_anything/indesign/node/hidden_handler_bridge.mjs +111 -0
- cli_anything/indesign/server/package-lock.json +168 -0
- cli_anything/indesign/server/package.json +45 -0
- cli_anything/indesign/server/src/advanced/index.js +76 -0
- cli_anything/indesign/server/src/core/InDesignMCPServer.js +273 -0
- cli_anything/indesign/server/src/core/scriptExecutor.js +271 -0
- cli_anything/indesign/server/src/core/sessionManager.js +545 -0
- cli_anything/indesign/server/src/handlers/advancedTemplateHandlers.js +1072 -0
- cli_anything/indesign/server/src/handlers/bookHandlers.js +490 -0
- cli_anything/indesign/server/src/handlers/documentHandlers.js +1472 -0
- cli_anything/indesign/server/src/handlers/exportHandlers.js +208 -0
- cli_anything/indesign/server/src/handlers/graphicsHandlers.js +605 -0
- cli_anything/indesign/server/src/handlers/groupHandlers.js +358 -0
- cli_anything/indesign/server/src/handlers/helpHandlers.js +347 -0
- cli_anything/indesign/server/src/handlers/index.js +77 -0
- cli_anything/indesign/server/src/handlers/layerHandlers.js +75 -0
- cli_anything/indesign/server/src/handlers/masterSpreadHandlers.js +451 -0
- cli_anything/indesign/server/src/handlers/pageHandlers.js +698 -0
- cli_anything/indesign/server/src/handlers/pageItemHandlers.js +704 -0
- cli_anything/indesign/server/src/handlers/presentationHandlers.js +220 -0
- cli_anything/indesign/server/src/handlers/spreadHandlers.js +348 -0
- cli_anything/indesign/server/src/handlers/styleHandlers.js +458 -0
- cli_anything/indesign/server/src/handlers/textHandlers.js +431 -0
- cli_anything/indesign/server/src/handlers/utilityHandlers.js +83 -0
- cli_anything/indesign/server/src/index.js +17 -0
- cli_anything/indesign/server/src/types/index.js +106 -0
- cli_anything/indesign/server/src/types/toolDefinitionsAdvancedTemplates.js +144 -0
- cli_anything/indesign/server/src/types/toolDefinitionsBook.js +224 -0
- cli_anything/indesign/server/src/types/toolDefinitionsContent.js +353 -0
- cli_anything/indesign/server/src/types/toolDefinitionsDocument.js +409 -0
- cli_anything/indesign/server/src/types/toolDefinitionsExport.js +65 -0
- cli_anything/indesign/server/src/types/toolDefinitionsLayer.js +40 -0
- cli_anything/indesign/server/src/types/toolDefinitionsMasterSpread.js +160 -0
- cli_anything/indesign/server/src/types/toolDefinitionsPage.js +271 -0
- cli_anything/indesign/server/src/types/toolDefinitionsPageItemGroup.js +437 -0
- cli_anything/indesign/server/src/types/toolDefinitionsPresentation.js +83 -0
- cli_anything/indesign/server/src/types/toolDefinitionsSpread.js +158 -0
- cli_anything/indesign/server/src/types/toolDefinitionsUtility.js +40 -0
- cli_anything/indesign/server/src/utils/stringUtils.js +107 -0
- cli_anything/indesign/skills/SKILL.md +198 -0
- indesign_cli-0.2.0.dist-info/METADATA +267 -0
- indesign_cli-0.2.0.dist-info/RECORD +72 -0
- indesign_cli-0.2.0.dist-info/WHEEL +5 -0
- indesign_cli-0.2.0.dist-info/entry_points.txt +3 -0
- indesign_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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,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
|
+
}
|