com.elestrago.unity.package-tools 2.2.3 → 2.4.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.
Files changed (21) hide show
  1. package/CAHNGELOG.md +22 -0
  2. package/Documentation~/api.md +230 -198
  3. package/Documentation~/manual.md +19 -19
  4. package/Documentation~/samples.md +44 -10
  5. package/Editor/Tools/FileTools.cs +11 -5
  6. package/README.md +39 -172
  7. package/Samples~/ClaudeSkills/unity-package-docs/SKILL.md +50 -8
  8. package/Samples~/ClaudeSkills/unity-package-docs/assets/api-chunk.md.template +10 -4
  9. package/Samples~/ClaudeSkills/unity-package-docs/assets/api.md.template +13 -4
  10. package/Samples~/ClaudeSkills/unity-package-docs/scripts/scan_package.py +50 -2
  11. package/Samples~/ClaudeSkills/unity-package-release/SKILL.md +373 -0
  12. package/Samples~/ClaudeSkills/unity-package-release/scripts/discover_package.py +366 -0
  13. package/package.json +4 -4
  14. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/SKILL.md +0 -309
  15. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/README.md.template +0 -42
  16. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-chunk.md.template +0 -41
  17. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-index.md.template +0 -26
  18. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api.md.template +0 -43
  19. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/manual.md.template +0 -57
  20. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/samples.md.template +0 -56
  21. package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/scripts/scan_package.py +0 -504
@@ -0,0 +1,366 @@
1
+ """Discover the PackageManifestConfig.asset in a Unity-package repo and report
2
+ the fields and files the release skill needs to drive its pipeline.
3
+
4
+ The skill must not hardcode paths or line numbers because it is shipped to
5
+ multiple repos that all use the same `com.elestrago.unity.package-tools` editor
6
+ tool but differ in their layout, package name, changelog filename, README
7
+ location, and publish flow. Everything repo-specific is read at runtime.
8
+
9
+ Run from the repo root (default) or pass `--repo <path>`:
10
+
11
+ python3 .claude/skills/unity-package-release/scripts/discover_package.py
12
+
13
+ Output is a single JSON object on stdout. Errors go to stderr; non-zero exit
14
+ codes mean the caller cannot proceed safely.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ # A PackageManifestConfig asset is a Unity ScriptableObject with a recognisable
30
+ # set of fields. We detect by signature rather than by path, since the canonical
31
+ # location varies between repos (the package-tool repo keeps it under
32
+ # Assets/PackageManifest/, but consumers are free to put it anywhere under
33
+ # Assets/).
34
+ CONFIG_SIGNATURE_FIELDS = (
35
+ "packageName:",
36
+ "packageVersion:",
37
+ "packageDestinationPath:",
38
+ "changelogPath:",
39
+ )
40
+
41
+ # Top-level SO fields in Unity YAML are indented two spaces under `MonoBehaviour:`.
42
+ # Dependency entries are inside a `dependencies:` list and indent four spaces, so
43
+ # the rule "first line matching exactly `^ <field>: `" reliably picks the SO's
44
+ # own value and skips nested `packageVersion:` entries inside dependencies.
45
+ TOP_LEVEL_FIELD_RE = re.compile(r"^ (?P<field>[A-Za-z_][A-Za-z0-9_]*): ?(?P<value>.*)$")
46
+
47
+ # Inline-list `[a, b, c]` and `[]` show up for string-array fields. We don't
48
+ # parse them here — none of the fields the release skill cares about are arrays
49
+ # of primitives — but the regex above tolerates them by capturing the whole
50
+ # right-hand side as a string.
51
+
52
+
53
+ def find_config_asset(repo: Path) -> Path | None:
54
+ """Walk `<repo>/Assets/` for the first `.asset` file whose contents contain
55
+ all of the PackageManifestConfig signature fields. Returns None when no
56
+ candidate is found — the caller should refuse to proceed in that case."""
57
+ assets_dir = repo / "Assets"
58
+ if not assets_dir.is_dir():
59
+ return None
60
+ candidates: list[Path] = []
61
+ for path in assets_dir.rglob("*.asset"):
62
+ try:
63
+ head = path.read_text(encoding="utf-8", errors="replace")[:4096]
64
+ except OSError:
65
+ continue
66
+ if all(sig in head for sig in CONFIG_SIGNATURE_FIELDS):
67
+ candidates.append(path)
68
+ if not candidates:
69
+ return None
70
+ # If multiple configs exist (some repos package more than one), prefer the
71
+ # one nearest the repo root by path-segment count, then by name for
72
+ # determinism. Multi-package repos are out of scope for this skill — the
73
+ # user should pass --config-asset to disambiguate.
74
+ candidates.sort(key=lambda p: (len(p.relative_to(repo).parts), str(p)))
75
+ return candidates[0]
76
+
77
+
78
+ def parse_config_fields(asset_path: Path) -> dict[str, Any]:
79
+ """Extract the top-level scalar fields of a PackageManifestConfig asset.
80
+ Returns a dict mapping field-name → {"value": str, "line": int} for every
81
+ top-level field encountered, and a flat dict of values under "_values" for
82
+ convenience. Skips list-element fields under `dependencies:`, `samples:`,
83
+ `copyEntries:`, etc., because they sit at four-space indent."""
84
+ lines = asset_path.read_text(encoding="utf-8").splitlines()
85
+ fields: dict[str, dict[str, Any]] = {}
86
+ values: dict[str, str] = {}
87
+ for idx, raw in enumerate(lines, start=1):
88
+ m = TOP_LEVEL_FIELD_RE.match(raw)
89
+ if not m:
90
+ continue
91
+ name = m.group("field")
92
+ val = m.group("value").strip()
93
+ # Re-occurrences at top level shadow earlier ones; the SO YAML doesn't
94
+ # normally do that for scalars, but be defensive.
95
+ fields[name] = {"value": val, "line": idx}
96
+ values[name] = val
97
+ return {"_fields": fields, "_values": values}
98
+
99
+
100
+ def resolve_repo_relative(repo: Path, value: str) -> Path:
101
+ """Mirror the runtime path resolution that
102
+ `FileTools.CopyOrReplaceFileToDirectory` uses: `Path.GetFullPath(value)` is
103
+ resolved against the current working directory, which Unity sets to the
104
+ project root. So a relative path in the asset means "relative to repo
105
+ root"."""
106
+ p = Path(value)
107
+ if p.is_absolute():
108
+ return p
109
+ return (repo / p).resolve()
110
+
111
+
112
+ def read_last_changelog_heading(changelog_path: Path) -> dict[str, Any] | None:
113
+ """Read the first `## ` heading from the changelog and try to extract the
114
+ version and the URL it links to, so the skill can mirror the same format
115
+ when prepending a new entry. The skill should not assume any particular URL
116
+ host — it's free-form text per repo."""
117
+ if not changelog_path.is_file():
118
+ return None
119
+ heading_re = re.compile(r"^##\s+(.*)$")
120
+ version_in_heading_re = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
121
+ plain_version_re = re.compile(r"\[?([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.+-]+)?)\]?")
122
+ try:
123
+ text = changelog_path.read_text(encoding="utf-8")
124
+ except OSError:
125
+ return None
126
+ for line in text.splitlines():
127
+ m = heading_re.match(line)
128
+ if not m:
129
+ continue
130
+ body = m.group(1).strip()
131
+ linked = version_in_heading_re.search(body)
132
+ if linked:
133
+ version = linked.group(1).strip()
134
+ url = linked.group(2).strip()
135
+ tag_url_format = url.replace(version, "{version}", 1) if version and version in url else None
136
+ return {
137
+ "heading": line.rstrip(),
138
+ "version": version,
139
+ "url": url,
140
+ "tag_url_format": tag_url_format,
141
+ }
142
+ # Heading without a markdown link — still useful for showing the user.
143
+ plain = plain_version_re.search(body)
144
+ return {
145
+ "heading": line.rstrip(),
146
+ "version": plain.group(1) if plain else None,
147
+ "url": None,
148
+ "tag_url_format": None,
149
+ }
150
+ return None
151
+
152
+
153
+ def find_readme_install_snippet(readme_path: Path, package_name: str) -> dict[str, Any] | None:
154
+ """Find the consumer-install snippet line in the project-root README — the
155
+ one that looks like `"com.example.foo": "X.Y.Z"`. Returns the line number,
156
+ the version string, and the raw line so the skill can do a targeted rewrite
157
+ without touching anything else in the README. Returns None when the README
158
+ has no such snippet (some repos document installation differently)."""
159
+ if not readme_path.is_file():
160
+ return None
161
+ pattern = re.compile(r'"' + re.escape(package_name) + r'"\s*:\s*"([^"]+)"')
162
+ try:
163
+ text = readme_path.read_text(encoding="utf-8")
164
+ except OSError:
165
+ return None
166
+ for idx, line in enumerate(text.splitlines(), start=1):
167
+ m = pattern.search(line)
168
+ if m:
169
+ return {"line": idx, "version": m.group(1), "raw": line}
170
+ return None
171
+
172
+
173
+ def detect_publish_flow(repo: Path) -> dict[str, Any]:
174
+ """Sniff out what publish flow this repo uses. The release skill prints a
175
+ different handoff block per flow, and the maintainer can override via the
176
+ `publish-flow=` skill arg. Detection is best-effort — when ambiguous, the
177
+ skill should ask the user rather than guessing."""
178
+ gitlab_ci = repo / ".gitlab-ci.yml"
179
+ github_workflows_dir = repo / ".github" / "workflows"
180
+ flow: dict[str, Any] = {
181
+ "gitlab_ci_present": gitlab_ci.is_file(),
182
+ "gitlab_ci_includes_elestrago_unity_npm": False,
183
+ "github_workflows_present": github_workflows_dir.is_dir() and any(
184
+ p.is_file() and p.suffix in {".yml", ".yaml"}
185
+ for p in github_workflows_dir.iterdir()
186
+ ),
187
+ "github_workflow_files": [],
188
+ "detected_flow": "manual",
189
+ }
190
+ if gitlab_ci.is_file():
191
+ try:
192
+ ci_text = gitlab_ci.read_text(encoding="utf-8")
193
+ except OSError:
194
+ ci_text = ""
195
+ # The shared pipeline include is the signal the skill's original CI
196
+ # handoff block was written for. Any other GitLab CI setup we treat as
197
+ # "gitlab-generic" so the skill can degrade gracefully.
198
+ if "unity-package-npm-publish" in ci_text or "elestrago/ci-pipelines" in ci_text:
199
+ flow["gitlab_ci_includes_elestrago_unity_npm"] = True
200
+ if flow["github_workflows_present"]:
201
+ flow["github_workflow_files"] = sorted(
202
+ str(p.relative_to(repo)) for p in github_workflows_dir.iterdir()
203
+ if p.is_file() and p.suffix in {".yml", ".yaml"}
204
+ )
205
+ # Prioritise: the elestrago-CI signal is the most specific, then any other
206
+ # GitLab CI, then GitHub Actions, then manual.
207
+ if flow["gitlab_ci_includes_elestrago_unity_npm"]:
208
+ flow["detected_flow"] = "gitlab-elestrago-ci"
209
+ elif flow["gitlab_ci_present"]:
210
+ flow["detected_flow"] = "gitlab-generic"
211
+ elif flow["github_workflows_present"]:
212
+ flow["detected_flow"] = "github-actions"
213
+ else:
214
+ flow["detected_flow"] = "manual"
215
+ return flow
216
+
217
+
218
+ def read_release_package_json(repo: Path, destination_path: str) -> dict[str, Any] | None:
219
+ """Read the `package.json` inside the exported destination if it exists.
220
+ Some CI flows (notably gitlab-elestrago-ci) trigger publishing on a version
221
+ delta of this file, so the skill needs to know whether it exists and what
222
+ version it currently advertises — so it can warn when the user is about to
223
+ skip the Export Package Source step and silently break the publish."""
224
+ if not destination_path:
225
+ return None
226
+ dest = resolve_repo_relative(repo, destination_path)
227
+ package_json = dest / "package.json"
228
+ if not package_json.is_file():
229
+ return {
230
+ "path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
231
+ "present": False,
232
+ "version": None,
233
+ }
234
+ try:
235
+ import json as _json
236
+ data = _json.loads(package_json.read_text(encoding="utf-8"))
237
+ version = data.get("version") if isinstance(data, dict) else None
238
+ except (OSError, ValueError):
239
+ version = None
240
+ return {
241
+ "path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
242
+ "present": True,
243
+ "version": version,
244
+ }
245
+
246
+
247
+ def read_git_state(repo: Path) -> dict[str, Any]:
248
+ """Best-effort git state. Failures (missing git, not a repo) reduce to
249
+ empty/null fields rather than aborting — the skill can still do its job
250
+ without git, just without the convenience signals."""
251
+ def _run(args: list[str]) -> str | None:
252
+ try:
253
+ out = subprocess.check_output(
254
+ args, cwd=str(repo), stderr=subprocess.DEVNULL, text=True
255
+ )
256
+ return out.strip()
257
+ except (subprocess.CalledProcessError, FileNotFoundError):
258
+ return None
259
+ return {
260
+ "branch": _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
261
+ "remote_url": _run(["git", "remote", "get-url", "origin"]),
262
+ "recent_commits": _run(["git", "log", "--oneline", "-5"]),
263
+ "status_porcelain": _run(["git", "status", "--porcelain"]),
264
+ "diff_stat_head": _run(["git", "diff", "--stat", "HEAD"]),
265
+ }
266
+
267
+
268
+ def discover(repo: Path, override_config: Path | None = None) -> dict[str, Any]:
269
+ asset = override_config if override_config else find_config_asset(repo)
270
+ if asset is None or not asset.is_file():
271
+ return {
272
+ "ok": False,
273
+ "error": (
274
+ "No PackageManifestConfig.asset found under Assets/. This skill "
275
+ "requires the com.elestrago.unity.package-tools editor tool to be "
276
+ "installed in the project (it provides the ScriptableObject this "
277
+ "skill drives)."
278
+ ),
279
+ }
280
+ parsed = parse_config_fields(asset)
281
+ values = parsed["_values"]
282
+ fields = parsed["_fields"]
283
+
284
+ package_name = values.get("packageName", "")
285
+ package_version = values.get("packageVersion")
286
+ changelog_path_raw = values.get("changelogPath", "")
287
+ readme_path_raw = values.get("readmePath", "")
288
+ license_path_raw = values.get("licensePath", "")
289
+ documentation_path_raw = values.get("documentationPath", "")
290
+ destination_path_raw = values.get("packageDestinationPath", "")
291
+
292
+ changelog_resolved = resolve_repo_relative(repo, changelog_path_raw) if changelog_path_raw else None
293
+ readme_resolved = resolve_repo_relative(repo, readme_path_raw) if readme_path_raw else None
294
+
295
+ changelog_info = read_last_changelog_heading(changelog_resolved) if changelog_resolved else None
296
+ readme_snippet = (
297
+ find_readme_install_snippet(readme_resolved, package_name)
298
+ if readme_resolved and package_name else None
299
+ )
300
+
301
+ release_json_info = read_release_package_json(repo, destination_path_raw)
302
+ publish_flow = detect_publish_flow(repo)
303
+ git_state = read_git_state(repo)
304
+
305
+ return {
306
+ "ok": True,
307
+ "repo": str(repo),
308
+ "config_asset": {
309
+ "path": str(asset.relative_to(repo)) if asset.is_relative_to(repo) else str(asset),
310
+ "package_version_line": fields.get("packageVersion", {}).get("line"),
311
+ },
312
+ "package": {
313
+ "name": package_name,
314
+ "display_name": values.get("displayName"),
315
+ "version": package_version,
316
+ "unity_version": values.get("unityVersion"),
317
+ "documentation_path": documentation_path_raw or None,
318
+ "destination_path": destination_path_raw or None,
319
+ },
320
+ "changelog": {
321
+ "path_in_config": changelog_path_raw or None,
322
+ "resolved_path": str(changelog_resolved.relative_to(repo)) if changelog_resolved and changelog_resolved.is_relative_to(repo) else (str(changelog_resolved) if changelog_resolved else None),
323
+ "exists": bool(changelog_resolved and changelog_resolved.is_file()),
324
+ "latest_heading": changelog_info,
325
+ },
326
+ "readme": {
327
+ "path_in_config": readme_path_raw or None,
328
+ "resolved_path": str(readme_resolved.relative_to(repo)) if readme_resolved and readme_resolved.is_relative_to(repo) else (str(readme_resolved) if readme_resolved else None),
329
+ "exists": bool(readme_resolved and readme_resolved.is_file()),
330
+ "install_snippet": readme_snippet,
331
+ },
332
+ "license": {
333
+ "path_in_config": license_path_raw or None,
334
+ },
335
+ "release_package_json": release_json_info,
336
+ "publish_flow": publish_flow,
337
+ "git": git_state,
338
+ }
339
+
340
+
341
+ def main() -> int:
342
+ parser = argparse.ArgumentParser(description=__doc__)
343
+ parser.add_argument(
344
+ "--repo",
345
+ default=os.getcwd(),
346
+ help="Repository root (defaults to current working directory).",
347
+ )
348
+ parser.add_argument(
349
+ "--config-asset",
350
+ default=None,
351
+ help=(
352
+ "Explicit path to PackageManifestConfig.asset. Use to disambiguate "
353
+ "in repos that ship multiple configs."
354
+ ),
355
+ )
356
+ args = parser.parse_args()
357
+ repo = Path(args.repo).resolve()
358
+ override = Path(args.config_asset).resolve() if args.config_asset else None
359
+ result = discover(repo, override)
360
+ json.dump(result, sys.stdout, indent=2)
361
+ sys.stdout.write("\n")
362
+ return 0 if result.get("ok") else 2
363
+
364
+
365
+ if __name__ == "__main__":
366
+ sys.exit(main())
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "com.elestrago.unity.package-tools",
3
- "version": "2.2.3",
3
+ "version": "2.4.0",
4
4
  "displayName": "Package Tool",
5
5
  "description": "Tool for create unity packages",
6
6
  "category": "unity",
7
7
  "unity": "2021.3",
8
8
  "homepage": "https://gitlab.com/elestrago-pkg/package-tool",
9
- "documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.2.3/README.md",
10
- "changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.2.3/CHANGELOG.md",
11
- "licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.2.3/LICENSE",
9
+ "documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/README.md",
10
+ "changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/CHANGELOG.md",
11
+ "licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/LICENSE",
12
12
  "license": "MIT",
13
13
  "keywords": [
14
14
  "unity",