sylo-ignition 0.1.0

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,278 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch Ignition documentation locally for the sylo-ignition package.
3
+
4
+ Downloads the Ignition 8.3 User Manual (docs.inductiveautomation.com) and the
5
+ Ignition SDK Programmer's Guide (sdk-docs.inductiveautomation.com) — both
6
+ Docusaurus static sites — and converts every page to offline Markdown.
7
+
8
+ Also downloads the official 8.1 PDF manual bundle (legacy-docs exports) so the
9
+ 8.1 fallback path has offline coverage.
10
+
11
+ Usage:
12
+ python fetch_docs.py # full run (download + convert + pdfs)
13
+ python fetch_docs.py --no-download # convert from existing html cache
14
+ python fetch_docs.py --no-pdfs
15
+ python fetch_docs.py --refresh # re-download even if cache exists
16
+
17
+ Requires: pandoc 3.x on PATH. Python 3.10+ (stdlib only).
18
+
19
+ Output layout (relative to packages/sylo-ignition/):
20
+ references/user-manual-8.3/<page-path>.md
21
+ references/sdk-docs/<page-path>.md
22
+ references/user-manual-8.1-pdfs/*.pdf
23
+ references/README.md is maintained by hand (provenance/index).
24
+
25
+ Scratch cache: ~/igscrape/html/{user,sdk}/...
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import concurrent.futures as cf
32
+ import posixpath
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ import time
37
+ import urllib.request
38
+ from pathlib import Path
39
+
40
+ USER_SITE = "https://www.docs.inductiveautomation.com"
41
+ SDK_SITE = "https://www.sdk-docs.inductiveautomation.com"
42
+ USER_SITEMAP = USER_SITE + "/sitemap.xml"
43
+ SDK_SITEMAP = SDK_SITE + "/sitemap.xml"
44
+
45
+ # Official 8.1 PDF exports (legacy-docs page, taken 2024-02-21).
46
+ PDF_81 = [
47
+ "DOC-81-1-welcome-intro-other-editions",
48
+ "DOC-81-2-0-vision",
49
+ "DOC-81-2-1-perspective",
50
+ "DOC-81-2-2-opc-ua-and-drivers",
51
+ "DOC-81-2-3-tag-historian-and-sql-bridge",
52
+ "DOC-81-2-4-reporting",
53
+ "DOC-81-2-5-alarm-notification",
54
+ "DOC-81-2-6-enterprise-administration",
55
+ "DOC-81-2-7-sequential-function-charts",
56
+ "DOC-81-2-8-secsgem",
57
+ "DOC-81-2-9-symbol-factory-webdev-mongodb-connector",
58
+ "DOC-81-3-platform",
59
+ "DOC-81-4-scripting-functions",
60
+ "DOC-81-5-expression-functions",
61
+ "DOC-81-6-1-vision-components",
62
+ "DOC-81-6-2-perspective-components",
63
+ "DOC-81-6-3-reporting-components",
64
+ ]
65
+ PDF_81_BASE = "https://d1v6u62vatllb2.cloudfront.net/81/"
66
+
67
+ PKG_ROOT = Path(__file__).resolve().parent.parent
68
+ REFS = PKG_ROOT / "references"
69
+ SCRATCH = Path.home() / "igscrape"
70
+ HTML_CACHE = SCRATCH / "html"
71
+
72
+ UA = {"User-Agent": "Mozilla/5.0 (sylo-ignition doc fetcher)"}
73
+
74
+
75
+ def http_get(url: str, retries: int = 3) -> bytes:
76
+ last = None
77
+ for attempt in range(retries):
78
+ try:
79
+ req = urllib.request.Request(url, headers=UA)
80
+ with urllib.request.urlopen(req, timeout=45) as r:
81
+ return r.read()
82
+ except Exception as exc: # noqa: BLE001
83
+ last = exc
84
+ time.sleep(1.0 + attempt)
85
+ raise RuntimeError(f"failed {url}: {last}")
86
+
87
+
88
+ def sitemap_urls(sitemap_url: str, keep_prefixes: tuple[str, ...]) -> list[str]:
89
+ xml = http_get(sitemap_url).decode("utf-8", errors="replace")
90
+ urls = re.findall(r"<loc>([^<]+)</loc>", xml)
91
+ return [u for u in urls if any(u.startswith(p) for p in keep_prefixes)]
92
+
93
+
94
+ def local_rel(site_url: str, url: str, version_prefix: str) -> str:
95
+ """Map a docs URL to a local path under the manual root (no version dir)."""
96
+ rel = url[len(site_url):]
97
+ rel = rel[len(version_prefix):] if rel.startswith(version_prefix) else rel
98
+ rel = rel.strip("/")
99
+ return (rel + "/index.md") if rel == "" or url.endswith("/") else rel + ".md"
100
+
101
+
102
+ def download_all(jobs: list[tuple[str, Path]], workers: int = 8) -> tuple[int, int]:
103
+ ok, fail = 0, 0
104
+
105
+ def one(job):
106
+ url, dest = job
107
+ dest.parent.mkdir(parents=True, exist_ok=True)
108
+ if dest.exists() and dest.stat().st_size > 0:
109
+ return True
110
+ try:
111
+ data = http_get(url)
112
+ except Exception:
113
+ return False
114
+ dest.write_bytes(data)
115
+ return len(data) > 200
116
+
117
+ with cf.ThreadPoolExecutor(max_workers=workers) as ex:
118
+ for r in ex.map(one, jobs):
119
+ ok += 1 if r else 0
120
+ fail += 0 if r else 1
121
+ return ok, fail
122
+
123
+
124
+ # ---------------------------------------------------------------- conversion
125
+
126
+ PANDOC = ["pandoc", "-f", "html", "-t", "gfm", "--wrap=none"]
127
+
128
+
129
+ def pandoc(fragment: str, fmt: str = "gfm") -> str:
130
+ args = ["pandoc", "-f", "html", "-t", fmt, "--wrap=none"]
131
+ p = subprocess.run(args, input=fragment.encode(), capture_output=True)
132
+ return p.stdout.decode("utf-8", errors="replace")
133
+
134
+
135
+ def html_to_md(html: str, source_url: str) -> str | None:
136
+ """Extract the Docusaurus markdown container and convert to Markdown."""
137
+ k = html.find("theme-doc-markdown")
138
+ if k < 0:
139
+ return None
140
+ i = html.find(">", k) + 1 # start AFTER the container's opening tag
141
+ j = html.rfind("</article>")
142
+ if i <= 0 or j < 0:
143
+ return None
144
+ frag = html[i:j] # container closes naturally inside the article
145
+
146
+ # Destructive simplifications so pandoc emits clean Markdown:
147
+ frag = re.sub(r"<colgroup>.*?</colgroup>", "", frag, flags=re.S) # widths -> pipe tables
148
+ frag = re.sub(r'<nav class="pagination-nav.*?</nav>', "", frag, flags=re.S)
149
+ frag = re.sub(r"<footer.*?</footer>", "", frag, flags=re.S)
150
+ # <details>/<summary> -> bold heading (avoids raw-HTML blocks)
151
+ frag = re.sub(r"</?(details|summary)( [^>]*)?>", "", frag)
152
+ # Trim the trailing "Edit this page" metadata row.
153
+ frag = re.sub(r'<div class="row margin-top--sm theme-doc-footer-edit-meta-row">.*', "", frag, flags=re.S)
154
+
155
+ # Flatten div/span/header/section wrappers — gfm cannot represent them
156
+ # anyway; stripping the tags keeps their content as clean flow.
157
+ frag = re.sub(r"</?div[^>]*>", "", frag)
158
+ frag = re.sub(r"</?span[^>]*>", "", frag)
159
+ frag = re.sub(r"</?header[^>]*>", "", frag)
160
+ frag = re.sub(r"</?section[^>]*>", "", frag)
161
+
162
+ m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
163
+ title = m.group(1).split("|")[0].strip() if m else ""
164
+
165
+ md = pandoc(frag)
166
+
167
+ # De-duplicate the in-content H1 (we already emit `# {title}`).
168
+ first = md.split("\n", 1)[0].strip()
169
+ if first == f"# {title}":
170
+ md = md.split("\n", 1)[1].lstrip("\n") if "\n" in md else ""
171
+ # Hybrid table pass: tables that GFM could not express stay as raw <table>
172
+ # HTML blocks; re-run those through pandoc's markdown (grid tables).
173
+ def gridify(match: re.Match) -> str:
174
+ return pandoc(match.group(0), "markdown").strip() or match.group(0)
175
+
176
+ md = re.sub(r"<table>.*?</table>", gridify, md, flags=re.S)
177
+
178
+ # Post-conversion cleanup: drop heading hash-links and empty card anchors.
179
+ md = re.sub(r'<a href="#[^"]*" class="hash-link"[^>]*>.*?</a>', "", md)
180
+ md = re.sub(r'<a href="/docs/8\.3/[^"]*" class="card[^"]*"[^>]*>\s*</a>', "", md)
181
+
182
+ # Absolute-ize image URLs (images are not mirrored; keep them resolvable).
183
+ site = USER_SITE if "docs.inductiveautomation.com" in source_url else SDK_SITE
184
+ md = re.sub(r'\((/img/[^)]+)\)', lambda m2: f"({site}{m2.group(1)})", md)
185
+
186
+ header = f"# {title}\n\n> Source: {source_url}\n\n"
187
+ return header + md.strip() + "\n"
188
+
189
+
190
+ def rewrite_links(md: str, cur_rel: str, rels: set[str], version_prefix: str, site: str) -> str:
191
+ """Rewrite internal docs links to relative .md paths when the target is mirrored."""
192
+ link_re = re.compile(r"\]\((/docs/8\.3/[^)#\s]+?)(/?(#[^)\s]*)?)\)")
193
+
194
+ def repl(m: re.Match) -> str:
195
+ path, _slash, anchor = m.group(1), m.group(2), m.group(3) or ""
196
+ rel = local_rel(site, site + path, version_prefix)
197
+ if rel not in rels:
198
+ return m.group(0) # not mirrored — leave the absolute link
199
+ here = posixpath.dirname(cur_rel)
200
+ target = posixpath.relpath(rel, here) if here else rel
201
+ return f"]({target}{anchor})"
202
+
203
+ return link_re.sub(repl, md)
204
+
205
+
206
+ def convert_site(site: str, sitemap_url: str, version_prefix: str, cache: Path, out_root: Path, download: bool = True) -> dict:
207
+ urls = sitemap_urls(sitemap_url, (site + version_prefix,))
208
+ # Drop auto-generated category index pages and version pickers.
209
+ urls = [u for u in urls if "/category/" not in u and u.rstrip("/") != site + "/versions"]
210
+ jobs = [(u, cache / (local_rel(site, u, version_prefix).removesuffix(".md") + ".html")) for u in urls]
211
+ if download:
212
+ print(f"[{site}] {len(jobs)} pages to download")
213
+ ok, fail = download_all(jobs)
214
+ print(f"[{site}] downloaded ok={ok} fail={fail}")
215
+ else:
216
+ jobs = [(u, p) for u, p in jobs if p.exists()]
217
+ print(f"[{site}] converting {len(jobs)} cached pages")
218
+
219
+ stats = {"pages": 0, "skipped": 0, "bytes": 0}
220
+ rels = {local_rel(site, u, version_prefix) for u in urls}
221
+ for url, html_path in jobs:
222
+ if not html_path.exists():
223
+ stats["skipped"] += 1
224
+ continue
225
+ html = html_path.read_text(encoding="utf-8", errors="replace")
226
+ md = html_to_md(html, url)
227
+ if md is None:
228
+ stats["skipped"] += 1
229
+ continue
230
+ rel = local_rel(site, url, version_prefix)
231
+ md = rewrite_links(md, rel, rels, version_prefix, site)
232
+ dest = out_root / rel
233
+ dest.parent.mkdir(parents=True, exist_ok=True)
234
+ dest.write_text(md, encoding="utf-8")
235
+ stats["pages"] += 1
236
+ stats["bytes"] += len(md)
237
+ print(f"[{site}] converted {stats['pages']} pages, {stats['bytes']/1e6:.1f} MB md, skipped {stats['skipped']}")
238
+ return stats
239
+
240
+
241
+ def fetch_pdfs() -> None:
242
+ out = REFS / "user-manual-8.1-pdfs"
243
+ out.mkdir(parents=True, exist_ok=True)
244
+ for name in PDF_81:
245
+ dest = out / f"{name}.pdf"
246
+ if dest.exists() and dest.stat().st_size > 0:
247
+ continue
248
+ url = PDF_81_BASE + name + ".pdf"
249
+ try:
250
+ dest.write_bytes(http_get(url))
251
+ print(f"[pdf] {name}.pdf ({dest.stat().st_size/1e6:.1f} MB)")
252
+ except Exception as exc: # noqa: BLE001
253
+ print(f"[pdf] FAILED {name}: {exc}", file=sys.stderr)
254
+
255
+
256
+ def main() -> None:
257
+ ap = argparse.ArgumentParser()
258
+ ap.add_argument("--no-download", action="store_true")
259
+ ap.add_argument("--no-pdfs", action="store_true")
260
+ ap.add_argument("--refresh", action="store_true")
261
+ args = ap.parse_args()
262
+
263
+ if args.refresh and HTML_CACHE.exists():
264
+ for p in HTML_CACHE.rglob("*.html"):
265
+ p.unlink()
266
+
267
+ if args.no_download:
268
+ convert_site(USER_SITE, USER_SITEMAP, "/docs/8.3/", HTML_CACHE / "user", REFS / "user-manual-8.3", download=False)
269
+ convert_site(SDK_SITE, SDK_SITEMAP, "/docs/8.3/", HTML_CACHE / "sdk", REFS / "sdk-docs", download=False)
270
+ else:
271
+ convert_site(USER_SITE, USER_SITEMAP, "/docs/8.3/", HTML_CACHE / "user", REFS / "user-manual-8.3")
272
+ convert_site(SDK_SITE, SDK_SITEMAP, "/docs/8.3/", HTML_CACHE / "sdk", REFS / "sdk-docs")
273
+ if not args.no_pdfs:
274
+ fetch_pdfs()
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Read gateway logs via REST — for debugging scan failures / runtime errors.
3
+
4
+ For ignition_gateway_logs tool. GET /data/api/v1/logs with query filters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ from typing import Any
12
+
13
+ from _ignition import api, load_config
14
+ from _json_out import emit, emit_error
15
+
16
+ MAX_CHARS = 30_000
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--limit", type=int, default=100)
22
+ parser.add_argument("--min-level", default="", help="e.g. WARN, ERROR")
23
+ parser.add_argument("--search", default="", help="substring search across log messages")
24
+ parser.add_argument("--logger", default="", help="logger name filter")
25
+ args = parser.parse_args()
26
+
27
+ cfg = load_config()
28
+ params = [f"limit={args.limit}"]
29
+ if args.min_level:
30
+ params.append(f"minLevel={args.min_level}")
31
+ if args.search:
32
+ params.append(f"search={args.search}")
33
+ if args.logger:
34
+ params.append(f"logger={args.logger}")
35
+ route = "/data/api/v1/logs?" + "&".join(params)
36
+
37
+ status, data, _ = api(cfg, route, timeout=20)
38
+ if status >= 400:
39
+ emit_error(f"GET {route} -> HTTP {status}: {str(data)[:200]}")
40
+
41
+ text = json.dumps(data, indent=2) if not isinstance(data, str) else data
42
+ truncated = False
43
+ if len(text) > MAX_CHARS:
44
+ text = text[:MAX_CHARS]
45
+ truncated = True
46
+
47
+ out: dict[str, Any] = {
48
+ "ok": True,
49
+ "route": route,
50
+ "truncated": truncated,
51
+ "logs": data if not truncated else text + "\n...[truncated]",
52
+ }
53
+ emit(out)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Create a NEW Ignition project via REST, for ignition_project_create tool.
3
+
4
+ GATED by the write-allowlist (allow_project_create). Creating a project is
5
+ isolated (new empty folder) — the lowest-risk gateway write there is. After
6
+ creation the project appears on disk under data/projects/<name>/ after a scan.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from typing import Any
13
+
14
+ from _allowlist import gate_project_create, load_allowlist
15
+ from _ignition import api, load_config, project_dir
16
+ from _json_out import emit
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--name", required=True)
22
+ parser.add_argument("--title", default="")
23
+ parser.add_argument("--description", default="")
24
+ args = parser.parse_args()
25
+
26
+ allow = load_allowlist()
27
+ gate_project_create(allow, args.name)
28
+ cfg = load_config()
29
+
30
+ body = {
31
+ "name": args.name,
32
+ "title": args.title or args.name,
33
+ "description": args.description,
34
+ "enabled": True,
35
+ "inheritable": False,
36
+ "parent": "",
37
+ }
38
+ status, resp, _ = api(cfg, "/data/api/v1/projects", method="POST", body=body, timeout=30)
39
+ if status >= 400:
40
+ hint = resp.get("message") if isinstance(resp, dict) else str(resp)[:200]
41
+ emit({"ok": False, "error": f"Create project -> HTTP {status}: {hint}"})
42
+
43
+ # Ask the gateway to scan so the folder lands on disk
44
+ scan_status, _, _ = api(cfg, "/data/api/v1/scan/projects", method="POST", body={}, timeout=30)
45
+
46
+ pdir = project_dir(cfg, args.name)
47
+ out: dict[str, Any] = {
48
+ "ok": True,
49
+ "project": args.name,
50
+ "created": True,
51
+ "scan_triggered": scan_status == 200,
52
+ "expected_dir": str(pdir),
53
+ "dir_on_disk": pdir.is_dir(),
54
+ "operator_chat": (
55
+ f"Project '{args.name}' created{' and scanned' if scan_status == 200 else ''}. "
56
+ "To let the agent write its resources, make sure it is enabled in the "
57
+ "write-allowlist (assets/write-allowlist.json) — the agent must not edit that file."
58
+ ),
59
+ }
60
+ emit(out)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Resource tree listing for a project, for ignition_project_resources tool.
3
+
4
+ Walks data/projects/<project>/ and returns resource folders (dirs with
5
+ resource.json) plus notable files. Module scope is the first path segment
6
+ (e.g. com.inductiveautomation.perspective).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from _ignition import load_config, project_dir, resolve_project
16
+ from _json_out import emit, emit_error
17
+
18
+ MAX_ENTRIES = 600
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--project", default="")
24
+ parser.add_argument("--filter", default="", help="case-insensitive substring filter on path")
25
+ parser.add_argument("--files", action="store_true", help="also list individual files")
26
+ args = parser.parse_args()
27
+
28
+ cfg = load_config(require_token=False)
29
+ name = resolve_project(cfg, args.project)
30
+ root = project_dir(cfg, name)
31
+ if not root.is_dir():
32
+ emit_error(
33
+ f"Project folder not found on disk: {root}. If the project exists in the gateway "
34
+ "but not on disk, it may need a scan (POST /data/api/v1/scan/projects) — or the "
35
+ "data_dir in config is wrong."
36
+ )
37
+
38
+ flt = args.filter.strip().lower()
39
+ resources: list[dict[str, Any]] = []
40
+ files: list[dict[str, Any]] = []
41
+ for dirpath, dirnames, filenames in sorted_walk(root):
42
+ rel = Path(dirpath).relative_to(root).as_posix()
43
+ if rel.count("/") == 0 and not rel:
44
+ continue # project root: project.json handled separately
45
+ has_resource = "resource.json" in filenames
46
+ is_resource_folder = has_resource
47
+ if not is_resource_folder and not args.files:
48
+ continue
49
+ entry: dict[str, Any] = {"path": rel, "module": rel.split("/")[0]}
50
+ if is_resource_folder:
51
+ entry["kind"] = "resource"
52
+ payload = detect_payload(filenames)
53
+ entry["payload"] = payload
54
+ entry["folder"] = dirpath
55
+ resources.append(entry)
56
+ else:
57
+ entry["kind"] = "file"
58
+ entry["files"] = sorted(filenames)
59
+ files.append(entry)
60
+ if len(resources) + len(files) >= MAX_ENTRIES:
61
+ break
62
+
63
+ def keep(e: dict[str, Any]) -> bool:
64
+ return not flt or flt in e["path"].lower()
65
+
66
+ resources = [e for e in resources if keep(e)]
67
+ files = [e for e in files if keep(e)]
68
+ by_module: dict[str, int] = {}
69
+ for e in resources:
70
+ by_module[e["module"]] = by_module.get(e["module"], 0) + 1
71
+
72
+ emit(
73
+ {
74
+ "ok": True,
75
+ "project": name,
76
+ "root": str(root),
77
+ "project_json": (root / "project.json").is_file(),
78
+ "resource_count": len(resources),
79
+ "by_module": by_module,
80
+ "resources": [
81
+ {"path": e["path"], "module": e["module"], "payload": e["payload"]} for e in resources
82
+ ],
83
+ "file_groups": [{"path": e["path"], "files": e["files"]} for e in files],
84
+ "hint": (
85
+ "Read any resource with ignition_resource_read using path=<resource folder path>. "
86
+ "Perspective views live under com.inductiveautomation.perspective/views/."
87
+ )
88
+ if not args.files
89
+ else "",
90
+ }
91
+ )
92
+
93
+
94
+ def sorted_walk(root: Path):
95
+ """Deterministic walk: dirs sorted by name."""
96
+ stack = [root]
97
+ while stack:
98
+ d = stack.pop()
99
+ try:
100
+ entries = sorted(d.iterdir(), key=lambda p: p.name)
101
+ except OSError:
102
+ continue
103
+ dirnames, filenames = [], []
104
+ for p in entries:
105
+ if p.name in ("thumbnail.png",):
106
+ continue
107
+ if p.is_dir():
108
+ dirnames.append(p.name)
109
+ stack.append(p)
110
+ else:
111
+ filenames.append(p.name)
112
+ yield str(d), dirnames, filenames
113
+
114
+
115
+ def detect_payload(filenames: list[str]) -> str:
116
+ for f in ("view.json", "config.json", "props.json", "style_classes.json"):
117
+ if f in filenames:
118
+ return f
119
+ if any(f.endswith(".py") for f in filenames):
120
+ return ".py"
121
+ if filenames:
122
+ return filenames[0]
123
+ return ""
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,3 @@
1
+ # Only ignition_screenshot needs Playwright (uses installed Chrome/Edge via
2
+ # channel fallbacks — no browser download). All other tools are stdlib-only.
3
+ playwright>=1.40
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """Read a resource/file from a project, for ignition_resource_read tool."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from _ignition import load_config, project_dir, resolve_project
12
+ from _json_out import emit, emit_error
13
+
14
+ BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bin", ".idb", ".zip", ".ttf", ".woff", ".woff2"}
15
+ DEFAULT_MAX_CHARS = 40_000
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--project", default="")
21
+ parser.add_argument("--path", required=True, help="path relative to project root")
22
+ parser.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS)
23
+ args = parser.parse_args()
24
+
25
+ cfg = load_config(require_token=False)
26
+ name = resolve_project(cfg, args.project)
27
+ root = project_dir(cfg, name)
28
+ target = safe_join(root, args.path)
29
+
30
+ if not target.exists():
31
+ emit_error(
32
+ f"Not found: {args.path} (in {root}). List resources with ignition_project_resources. "
33
+ "If you just created the folder, note the gateway may not have scanned it yet."
34
+ )
35
+ if target.is_dir():
36
+ # Directory: show the folder's files (resource.json + payload)
37
+ listing = sorted(p.name for p in target.iterdir())
38
+ emit(
39
+ {
40
+ "ok": True,
41
+ "project": name,
42
+ "path": args.path,
43
+ "type": "directory",
44
+ "files": listing,
45
+ "hint": "Read a specific file by appending it to the path.",
46
+ }
47
+ )
48
+ if target.suffix.lower() in BINARY_EXTS:
49
+ emit_error(
50
+ f"{args.path} is binary ({target.suffix}) — not readable as text. "
51
+ "For images use the gateway web UI; for .bin resources use the Designer."
52
+ )
53
+
54
+ raw = target.read_text(encoding="utf-8", errors="replace")
55
+ data: Any = raw
56
+ json_parsed = True
57
+ if target.suffix == ".json":
58
+ try:
59
+ data = json.loads(raw)
60
+ except (json.JSONDecodeError, ValueError):
61
+ json_parsed = False
62
+ data = raw
63
+
64
+ truncated = False
65
+ if isinstance(data, str) and len(data) > args.max_chars:
66
+ data = data[: args.max_chars]
67
+ truncated = True
68
+
69
+ out: dict[str, Any] = {
70
+ "ok": True,
71
+ "project": name,
72
+ "path": args.path,
73
+ "abs_path": str(target),
74
+ "size_bytes": target.stat().st_size,
75
+ "truncated": truncated,
76
+ "content": data,
77
+ }
78
+ if target.name == "resource.json" and json_parsed:
79
+ out["hint"] = (
80
+ "resource.json attributes are gateway-managed (signatures/timestamps). "
81
+ "Never hand-edit an existing one — the scan regenerates them."
82
+ )
83
+ emit(out)
84
+
85
+
86
+ def safe_join(root: Path, rel: str) -> Path:
87
+ """Join and refuse escapes (.., absolute, drive letters)."""
88
+ rel_clean = rel.replace("\\", "/").strip("/")
89
+ if not rel_clean:
90
+ emit_error("Empty path.")
91
+ if any(part in ("..",) for part in rel_clean.split("/")) or ":" in rel_clean:
92
+ emit_error(f"Illegal path: {rel!r}")
93
+ target = (root / rel_clean).resolve()
94
+ try:
95
+ target.relative_to(root.resolve())
96
+ except ValueError:
97
+ emit_error(f"Path escapes project root: {rel!r}")
98
+ return target
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()