bg3-authoring 1.0.0b1__tar.gz

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 (51) hide show
  1. bg3_authoring-1.0.0b1/.gitignore +58 -0
  2. bg3_authoring-1.0.0b1/PKG-INFO +70 -0
  3. bg3_authoring-1.0.0b1/README.md +62 -0
  4. bg3_authoring-1.0.0b1/bg3_authoring/__init__.py +0 -0
  5. bg3_authoring-1.0.0b1/bg3_authoring/add_mod_dependency.py +367 -0
  6. bg3_authoring-1.0.0b1/bg3_authoring/cli.py +32 -0
  7. bg3_authoring-1.0.0b1/bg3_authoring/create_mod_scaffold.py +469 -0
  8. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/__init__.py +0 -0
  9. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/check_mod_conflicts.py +637 -0
  10. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/diagnose_toolkit_logs.py +765 -0
  11. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/inspect_save_game.py +29 -0
  12. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/sync_localization.py +280 -0
  13. bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/validate_mod.py +576 -0
  14. bg3_authoring-1.0.0b1/bg3_authoring/generate_compat_patch.py +400 -0
  15. bg3_authoring-1.0.0b1/bg3_authoring/inject_treasure_table.py +267 -0
  16. bg3_authoring-1.0.0b1/bg3_authoring/merge_staging_to_mod.py +297 -0
  17. bg3_authoring-1.0.0b1/bg3_authoring/packaging/__init__.py +0 -0
  18. bg3_authoring-1.0.0b1/bg3_authoring/packaging/pack_mod_pak.py +385 -0
  19. bg3_authoring-1.0.0b1/bg3_authoring/packaging/sync_game_data.py +773 -0
  20. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_action_resource.py +222 -0
  21. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_appearance_visual.py +238 -0
  22. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_class_description.py +455 -0
  23. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_default_values.py +250 -0
  24. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_feat.py +269 -0
  25. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_icon_uvs.py +235 -0
  26. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_level_map.py +210 -0
  27. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_origin.py +223 -0
  28. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_progression_node.py +360 -0
  29. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_race.py +298 -0
  30. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_root_template.py +403 -0
  31. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_spell_list.py +196 -0
  32. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_stat_entry.py +455 -0
  33. bg3_authoring-1.0.0b1/bg3_authoring/scaffold_tag.py +179 -0
  34. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/SKILL.md +45 -0
  35. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/references/conflict_methodology.md +33 -0
  36. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/resources/remediation_patch_template.md +43 -0
  37. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/SKILL.md +55 -0
  38. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/references/grill_me_interview_protocol.md +15 -0
  39. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/resources/subagent_templates.md +40 -0
  40. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-diagnose-mod/SKILL.md +52 -0
  41. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-diagnose-mod/references/runtime_log_triage.md +30 -0
  42. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-post-release/SKILL.md +52 -0
  43. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-post-release/references/storefront_metadata_guide.md +37 -0
  44. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-release-prep/SKILL.md +52 -0
  45. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-release-prep/references/release_pipeline_specs.md +17 -0
  46. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/SKILL.md +58 -0
  47. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/references/save_audit_runbook.md +21 -0
  48. bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/references/smoke_testing_runbook.md +23 -0
  49. bg3_authoring-1.0.0b1/bg3_authoring/templates/custom_mod_readme.md +30 -0
  50. bg3_authoring-1.0.0b1/pyproject.toml +19 -0
  51. bg3_authoring-1.0.0b1/templates/custom_mod_readme.md +30 -0
@@ -0,0 +1,58 @@
1
+ # ==============================================================================
2
+ # Baldur's Gate 3 Mod Studio & Mod.io Toolkit - Git Ignore Rules
3
+ # ==============================================================================
4
+
5
+ # Secrets & Environment Configurations
6
+ .env
7
+ *.env
8
+ .env.*.local
9
+
10
+ # Python runtime & caches
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.so
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+ dist/
19
+ build/
20
+ *.egg-info/
21
+ .eggs/
22
+ .cache/
23
+
24
+ # Virtual Environments
25
+ .venv/
26
+ venv/
27
+ env/
28
+ ENV/
29
+
30
+ # IDE & Editor Files
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Operating System Files
38
+ .DS_Store
39
+ Thumbs.db
40
+ desktop.ini
41
+
42
+ # Build artifacts, logs & archives
43
+ *.log
44
+ *.tmp
45
+ *.bak
46
+ *.pak
47
+ *.loca
48
+ *.zip
49
+
50
+ # Scratch / temporary experimental folders
51
+ scratch/
52
+
53
+ # Externalized User Content (Ignore any local studio folders or accidental spills)
54
+ build_guides/
55
+ mods/
56
+ my_mods/
57
+ BG3_Mod_Studio/
58
+ .agents/ledgers/artifacts-ledger.md
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: bg3-authoring
3
+ Version: 1.0.0b1
4
+ Summary: Baldur's Gate 3 Mod Studio Authoring, Diagnostics, and Packaging Toolkit
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: bg3-core
7
+ Description-Content-Type: text/markdown
8
+
9
+ # bg3-authoring
10
+
11
+ Mod authoring, asset scaffolding, static diagnostics, and packaging toolchain for the **Baldur's Gate 3 Mod Studio** workspace.
12
+
13
+ ---
14
+
15
+ ## Domain Architecture & Scope
16
+
17
+ `bg3-authoring` provides end-to-end tooling for creating, linting, packaging, and debugging custom *Baldur's Gate 3* mods. It generates Patch 8 compliant Larian XML structures, deterministic UUIDv4 identifiers, and localized string handles (`h...g...`), enforcing clean separation between authored source mods and the game's Virtual File System (VFS).
18
+
19
+ ```mermaid
20
+ flowchart TD
21
+ Scaffold["14 Asset Generators\n(bg3_authoring.scaffold_*)"] --> ModDir["Mods/authored/<ModSlug>/"]
22
+ ModDir --> Diagnostics["Static Diagnostics & Linter\n(validate_mod, check_mod_conflicts)"]
23
+ Diagnostics --> Packaging["LSPK v18 Packager\n(packaging.pack_mod_pak)"]
24
+ Packaging --> VFS["Game VFS Sync\n(packaging.sync_game_data)"]
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Subpackages & Core Capabilities
30
+
31
+ ### 1. Asset Scaffolding Generators
32
+ 14 specialized generator modules creating standard Larian engine structures:
33
+ - `create_mod_scaffold.py`: Generates the canonical 4-root mod directory layout (`Mods/`, `Projects/`, `Public/`, `Localization/`).
34
+ - `scaffold_class_description.py`: Scaffolds classes, subclasses, ProgressionDescriptions, and parent progression wiring.
35
+ - `scaffold_feat.py`: Generates feats, FeatDescriptions, and localization string tables.
36
+ - `scaffold_spell_list.py`: Scaffolds standalone SpellLists and PassiveLists.
37
+ - `scaffold_level_map.py`: Configures LevelMapValues scaling curves across character levels 1–20.
38
+ - `scaffold_action_resource.py`: Creates custom action points, spell slots, and resource replenish conditions.
39
+ - `scaffold_progression_node.py`: Appends level-up progressions into `Progressions.lsx`.
40
+ - `scaffold_root_template.py`: Generates 3D GameObject root templates for items and equipment.
41
+ - `scaffold_stat_entry.py`: Scaffolds Weapons, Armor, Spells, Passives, and StatusData text entries.
42
+ - `scaffold_icon_uvs.py`: Computes texture atlas UV coordinates for custom spell and item icons.
43
+
44
+ ### 2. Diagnostics & Compatibility (`bg3_authoring.diagnostics`)
45
+ - `validate_mod.py`: Deep static validation checking XML/LSX syntax, UUID collisions, missing string handles, and required folder layouts.
46
+ - `check_mod_conflicts.py`: Cross-mod progression collision detector auditing vanilla and community mod overrides.
47
+ - `diagnose_toolkit_logs.py`: Automated triage of Larian Toolkit and engine runtime crash logs with actionable fix recommendations.
48
+
49
+ ### 3. Packaging & Synchronization (`bg3_authoring.packaging`)
50
+ - `pack_mod_pak.py`: Pure-Python LSPK v18 binary compiler that writes 40-byte headers, compresses files using LZ4 block compression (LSPK v18), compiles XML localization into binary `.loca`, and outputs deployable `.pak` archives.
51
+ - `sync_game_data.py`: Idempotent differential file synchronizer that mirrors mod assets into the game's `Data/` directory for live in-engine playtesting.
52
+
53
+ ---
54
+
55
+ ## CLI Console Entrypoint
56
+
57
+ This package registers the `bg3-mod` console script (also routed via the main `bg3` router):
58
+
59
+ ```toml
60
+ [project.scripts]
61
+ bg3-mod = "bg3_authoring.cli:main"
62
+ ```
63
+
64
+ ### Common Commands:
65
+ - `bg3 mod scaffold <asset-type> --mod <ModSlug> ...` — Scaffold new game assets.
66
+ - `bg3 mod validate <ModSlug>` — Run strict pre-commit validation.
67
+ - `bg3 mod conflict <path> --against-library` — Check progression collisions.
68
+ - `bg3 mod pack <ModSlug> --publish-local` — Compile LSPK `.pak` and deploy to local mods folder.
69
+ - `bg3 mod sync-game --mods <ModSlug> [--watch]` — Hot-reload source files into the game runtime.
70
+ - `bg3 save inspect latest` — Audit save-game mod dependencies before launching the game.
@@ -0,0 +1,62 @@
1
+ # bg3-authoring
2
+
3
+ Mod authoring, asset scaffolding, static diagnostics, and packaging toolchain for the **Baldur's Gate 3 Mod Studio** workspace.
4
+
5
+ ---
6
+
7
+ ## Domain Architecture & Scope
8
+
9
+ `bg3-authoring` provides end-to-end tooling for creating, linting, packaging, and debugging custom *Baldur's Gate 3* mods. It generates Patch 8 compliant Larian XML structures, deterministic UUIDv4 identifiers, and localized string handles (`h...g...`), enforcing clean separation between authored source mods and the game's Virtual File System (VFS).
10
+
11
+ ```mermaid
12
+ flowchart TD
13
+ Scaffold["14 Asset Generators\n(bg3_authoring.scaffold_*)"] --> ModDir["Mods/authored/<ModSlug>/"]
14
+ ModDir --> Diagnostics["Static Diagnostics & Linter\n(validate_mod, check_mod_conflicts)"]
15
+ Diagnostics --> Packaging["LSPK v18 Packager\n(packaging.pack_mod_pak)"]
16
+ Packaging --> VFS["Game VFS Sync\n(packaging.sync_game_data)"]
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Subpackages & Core Capabilities
22
+
23
+ ### 1. Asset Scaffolding Generators
24
+ 14 specialized generator modules creating standard Larian engine structures:
25
+ - `create_mod_scaffold.py`: Generates the canonical 4-root mod directory layout (`Mods/`, `Projects/`, `Public/`, `Localization/`).
26
+ - `scaffold_class_description.py`: Scaffolds classes, subclasses, ProgressionDescriptions, and parent progression wiring.
27
+ - `scaffold_feat.py`: Generates feats, FeatDescriptions, and localization string tables.
28
+ - `scaffold_spell_list.py`: Scaffolds standalone SpellLists and PassiveLists.
29
+ - `scaffold_level_map.py`: Configures LevelMapValues scaling curves across character levels 1–20.
30
+ - `scaffold_action_resource.py`: Creates custom action points, spell slots, and resource replenish conditions.
31
+ - `scaffold_progression_node.py`: Appends level-up progressions into `Progressions.lsx`.
32
+ - `scaffold_root_template.py`: Generates 3D GameObject root templates for items and equipment.
33
+ - `scaffold_stat_entry.py`: Scaffolds Weapons, Armor, Spells, Passives, and StatusData text entries.
34
+ - `scaffold_icon_uvs.py`: Computes texture atlas UV coordinates for custom spell and item icons.
35
+
36
+ ### 2. Diagnostics & Compatibility (`bg3_authoring.diagnostics`)
37
+ - `validate_mod.py`: Deep static validation checking XML/LSX syntax, UUID collisions, missing string handles, and required folder layouts.
38
+ - `check_mod_conflicts.py`: Cross-mod progression collision detector auditing vanilla and community mod overrides.
39
+ - `diagnose_toolkit_logs.py`: Automated triage of Larian Toolkit and engine runtime crash logs with actionable fix recommendations.
40
+
41
+ ### 3. Packaging & Synchronization (`bg3_authoring.packaging`)
42
+ - `pack_mod_pak.py`: Pure-Python LSPK v18 binary compiler that writes 40-byte headers, compresses files using LZ4 block compression (LSPK v18), compiles XML localization into binary `.loca`, and outputs deployable `.pak` archives.
43
+ - `sync_game_data.py`: Idempotent differential file synchronizer that mirrors mod assets into the game's `Data/` directory for live in-engine playtesting.
44
+
45
+ ---
46
+
47
+ ## CLI Console Entrypoint
48
+
49
+ This package registers the `bg3-mod` console script (also routed via the main `bg3` router):
50
+
51
+ ```toml
52
+ [project.scripts]
53
+ bg3-mod = "bg3_authoring.cli:main"
54
+ ```
55
+
56
+ ### Common Commands:
57
+ - `bg3 mod scaffold <asset-type> --mod <ModSlug> ...` — Scaffold new game assets.
58
+ - `bg3 mod validate <ModSlug>` — Run strict pre-commit validation.
59
+ - `bg3 mod conflict <path> --against-library` — Check progression collisions.
60
+ - `bg3 mod pack <ModSlug> --publish-local` — Compile LSPK `.pak` and deploy to local mods folder.
61
+ - `bg3 mod sync-game --mods <ModSlug> [--watch]` — Hot-reload source files into the game runtime.
62
+ - `bg3 save inspect latest` — Audit save-game mod dependencies before launching the game.
File without changes
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ packages/bg3-authoring/bg3_authoring/add_mod_dependency.py
4
+ -----------------------------------------------------------
5
+ Appends community or core mod dependencies and test dependencies to an existing custom mod in my_mods/.
6
+
7
+ Features:
8
+ - Resolves dependency metadata (UUID, Version64, MD5, Folder, Name, PublishHandle)
9
+ via local mods/library.md before falling back to mod.io.
10
+ - Injects standard Larian v4.8 ModuleShortDesc nodes into Mods/*/meta.lsx.
11
+ - Updates mod README.md under ## Dependencies.
12
+ - Supports adding both permanent dependencies and test dependencies in a single invocation.
13
+ - Supports multiple comma-separated mod slugs or repeated CLI flags.
14
+ - Supports --test-only (with values or as a flag) and --test-dep flags to manage test dependencies in .test_deps.json.
15
+ - Supports target mod detection via --mod, positional argument, or current working directory.
16
+ - Supports --dry-run and structured --json output.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+ import xml.etree.ElementTree as ET
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
28
+ from bg3_core.env_paths import MY_MODS_DIR, resolve_mod_dir
29
+ from bg3_core.mod_resolver import resolve_dependency_metadata
30
+
31
+
32
+ def detect_mod_from_cwd() -> Optional[str]:
33
+ """Detects mod name if the current working directory is inside a mod in MY_MODS_DIR."""
34
+ try:
35
+ cwd = Path.cwd().resolve()
36
+ my_mods = MY_MODS_DIR.resolve()
37
+ rel = cwd.relative_to(my_mods)
38
+ if rel.parts:
39
+ return rel.parts[0]
40
+ except Exception:
41
+ pass
42
+ return None
43
+
44
+
45
+ def parse_slug_list(values: Any) -> List[str]:
46
+ """Parses a list of CLI values which may contain comma-separated mod slugs."""
47
+ if not values:
48
+ return []
49
+ if isinstance(values, str):
50
+ values = [values]
51
+ slugs: List[str] = []
52
+ for val in values:
53
+ if isinstance(val, str):
54
+ for part in val.split(","):
55
+ cleaned = part.strip()
56
+ if cleaned and cleaned not in slugs:
57
+ slugs.append(cleaned)
58
+ return slugs
59
+
60
+
61
+ def add_test_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
62
+ """Adds temporary test dependencies to .test_deps.json in the mod workspace."""
63
+ config_file = mod_dir / ".test_deps.json"
64
+ data: Dict[str, Any] = {"test_dependencies": []}
65
+ if config_file.is_file():
66
+ try:
67
+ data = json.loads(config_file.read_text(encoding="utf-8"))
68
+ if "test_dependencies" not in data or not isinstance(data["test_dependencies"], list):
69
+ data["test_dependencies"] = []
70
+ except Exception:
71
+ data = {"test_dependencies": []}
72
+
73
+ existing = [str(d).lower() for d in data["test_dependencies"]]
74
+ results: List[Dict[str, Any]] = []
75
+ modified = False
76
+
77
+ for dep_meta in dep_metas:
78
+ dep_name = dep_meta.get("Name") or dep_meta.get("Folder") or dep_meta.get("UUID") or "Unknown"
79
+ dep_identifier = dep_meta.get("Folder") or dep_name
80
+
81
+ if dep_name.lower() in existing or dep_identifier.lower() in existing:
82
+ results.append({
83
+ "status": "already_present",
84
+ "mod": mod_dir.name,
85
+ "type": "test_dependency",
86
+ "dependency": dep_name,
87
+ "config_file": str(config_file),
88
+ })
89
+ continue
90
+
91
+ data["test_dependencies"].append(dep_name)
92
+ existing.append(dep_name.lower())
93
+ existing.append(dep_identifier.lower())
94
+ modified = True
95
+
96
+ results.append({
97
+ "status": "added_test_dependency",
98
+ "mod": mod_dir.name,
99
+ "type": "test_dependency",
100
+ "dependency": dep_name,
101
+ "uuid": dep_meta.get("UUID"),
102
+ "config_file": str(config_file),
103
+ "dry_run": dry_run,
104
+ })
105
+
106
+ if modified and not dry_run:
107
+ config_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
108
+
109
+ return results
110
+
111
+
112
+ def add_test_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
113
+ """Single-dependency wrapper for add_test_dependencies."""
114
+ res = add_test_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
115
+ return res[0] if res else {}
116
+
117
+
118
+ def add_permanent_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
119
+ """Injects dependency ModuleShortDesc nodes into meta.lsx and updates README.md."""
120
+ meta_files = list(mod_dir.glob("Mods/*/meta.lsx"))
121
+ if not meta_files:
122
+ raise FileNotFoundError(f"Could not locate Mods/*/meta.lsx in '{mod_dir}'.")
123
+
124
+ target_meta = meta_files[0]
125
+ tree = ET.parse(target_meta)
126
+ root = tree.getroot()
127
+
128
+ # Find or create Dependencies node
129
+ deps_node = root.find(".//node[@id='Dependencies']")
130
+ if deps_node is None:
131
+ root_node = root.find(".//node[@id='root']/children")
132
+ if root_node is None:
133
+ raise ValueError(f"Malformed meta.lsx in '{target_meta}': missing root children.")
134
+ deps_node = ET.SubElement(root_node, "node", id="Dependencies")
135
+
136
+ children = deps_node.find("children")
137
+ if children is None:
138
+ children = ET.SubElement(deps_node, "children")
139
+
140
+ existing_uuids = set()
141
+ for short_desc in children.findall("node[@id='ModuleShortDesc']"):
142
+ u_attr = short_desc.find("attribute[@id='UUID']")
143
+ if u_attr is not None and u_attr.get("value"):
144
+ existing_uuids.add(u_attr.get("value", "").lower())
145
+
146
+ results: List[Dict[str, Any]] = []
147
+ meta_modified = False
148
+ new_readme_entries: List[Tuple[Dict[str, Any], str]] = []
149
+
150
+ for dep_meta in dep_metas:
151
+ dep_uuid = (dep_meta.get("UUID") or "").lower()
152
+ if dep_uuid and dep_uuid in existing_uuids:
153
+ results.append({
154
+ "status": "already_present",
155
+ "mod": mod_dir.name,
156
+ "type": "permanent_dependency",
157
+ "dependency": dep_meta.get("Name"),
158
+ "uuid": dep_meta.get("UUID"),
159
+ "file": str(target_meta),
160
+ })
161
+ continue
162
+
163
+ # Construct new ModuleShortDesc node
164
+ new_desc = ET.Element("node", id="ModuleShortDesc")
165
+ ET.SubElement(new_desc, "attribute", id="Folder", type="LSString", value=dep_meta.get("Folder", dep_meta.get("Name", "")))
166
+ ET.SubElement(new_desc, "attribute", id="MD5", type="LSString", value=dep_meta.get("MD5", ""))
167
+ ET.SubElement(new_desc, "attribute", id="Name", type="LSString", value=dep_meta.get("Name", ""))
168
+ ET.SubElement(new_desc, "attribute", id="PublishHandle", type="uint64", value=str(dep_meta.get("PublishHandle", 0)))
169
+ ET.SubElement(new_desc, "attribute", id="UUID", type="guid", value=dep_meta.get("UUID", ""))
170
+ ET.SubElement(new_desc, "attribute", id="Version64", type="int64", value=str(dep_meta.get("Version64", 36028797018963968)))
171
+
172
+ children.append(new_desc)
173
+ if dep_uuid:
174
+ existing_uuids.add(dep_uuid)
175
+ meta_modified = True
176
+
177
+ dep_entry = f"- `{dep_meta.get('Name')}` (`{dep_meta.get('UUID')}`)"
178
+ new_readme_entries.append((dep_meta, dep_entry))
179
+
180
+ results.append({
181
+ "status": "added_dependency",
182
+ "mod": mod_dir.name,
183
+ "type": "permanent_dependency",
184
+ "dependency": dep_meta.get("Name"),
185
+ "uuid": dep_meta.get("UUID"),
186
+ "folder": dep_meta.get("Folder"),
187
+ "publish_handle": dep_meta.get("PublishHandle"),
188
+ "meta_file": str(target_meta),
189
+ "readme_updated": False,
190
+ "dry_run": dry_run,
191
+ })
192
+
193
+ if meta_modified and not dry_run:
194
+ ET.indent(tree, space=" ")
195
+ tree.write(target_meta, encoding="utf-8", xml_declaration=True)
196
+
197
+ # Update README.md if present
198
+ readme_path = mod_dir / "README.md"
199
+ readme_updated = False
200
+ if readme_path.is_file() and new_readme_entries:
201
+ readme_txt = readme_path.read_text(encoding="utf-8")
202
+ unlisted = [e for e in new_readme_entries if e[0].get("UUID") and e[0]["UUID"] not in readme_txt]
203
+ if unlisted and "## Dependencies" in readme_txt:
204
+ entries_block = "\n".join(e[1] for e in unlisted)
205
+ if re.search(r"## Dependencies\s*\n\s*- None\.", readme_txt):
206
+ readme_txt = re.sub(r"(## Dependencies\s*\n)\s*- None\.", r"\1" + entries_block, readme_txt)
207
+ else:
208
+ readme_txt = re.sub(r"(## Dependencies\s*\n)", r"\1" + entries_block + "\n", readme_txt)
209
+ readme_updated = True
210
+ if not dry_run:
211
+ readme_path.write_text(readme_txt, encoding="utf-8")
212
+
213
+ for r in results:
214
+ if r.get("status") == "added_dependency":
215
+ r["readme_updated"] = readme_updated
216
+
217
+ return results
218
+
219
+
220
+ def add_permanent_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
221
+ """Single-dependency wrapper for add_permanent_dependencies."""
222
+ res = add_permanent_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
223
+ return res[0] if res else {}
224
+
225
+
226
+ def resolve_cli_targets(args: argparse.Namespace) -> Tuple[List[str], List[str]]:
227
+ """
228
+ Categorizes CLI input into permanent dependencies and test dependencies.
229
+ Supports comma-separated slugs, repeated flags, --test-dep, and --test-only as flag or value.
230
+ """
231
+ perm_slugs: List[str] = []
232
+ test_slugs: List[str] = []
233
+
234
+ # 1. Parse explicit --test-dep / --test-deps values
235
+ if getattr(args, "test_dep", None):
236
+ test_slugs.extend(parse_slug_list(args.test_dep))
237
+
238
+ # 2. Parse --test-only
239
+ raw_test_only = getattr(args, "test_only", [])
240
+ has_test_only_flag = any(v is True for v in raw_test_only)
241
+ test_only_values = parse_slug_list([v for v in raw_test_only if isinstance(v, str)])
242
+ if test_only_values:
243
+ test_slugs.extend(test_only_values)
244
+
245
+ # 3. Parse --dep / --dependency
246
+ raw_dep = getattr(args, "dep", [])
247
+ dep_slugs = parse_slug_list(raw_dep)
248
+
249
+ # If --test-only was passed as a bare flag without slug values:
250
+ # route --dep values to test dependencies (preserves backward compatibility)
251
+ if has_test_only_flag and not test_only_values:
252
+ test_slugs.extend(dep_slugs)
253
+ else:
254
+ perm_slugs.extend(dep_slugs)
255
+
256
+ # Remove duplicates preserving order
257
+ dedup_perm: List[str] = []
258
+ for s in perm_slugs:
259
+ if s not in dedup_perm:
260
+ dedup_perm.append(s)
261
+
262
+ dedup_test: List[str] = []
263
+ for s in test_slugs:
264
+ if s not in dedup_test:
265
+ dedup_test.append(s)
266
+
267
+ return dedup_perm, dedup_test
268
+
269
+
270
+ def main() -> None:
271
+ parser = argparse.ArgumentParser(
272
+ description="Add community or core mod dependencies and test dependencies to an existing custom mod in my_mods/."
273
+ )
274
+ parser.add_argument("pos_mod", nargs="?", default=None, help="Target mod name or slug (optional if --mod is specified)")
275
+ parser.add_argument("--mod", default=None, help="Custom mod name, slug, or folder in my_mods/")
276
+ parser.add_argument("--dep", "--dependency", dest="dep", action="append", default=[], help="Dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
277
+ parser.add_argument("--test-dep", "--test-deps", dest="test_dep", action="append", default=[], help="Test dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
278
+ parser.add_argument(
279
+ "--test-only",
280
+ dest="test_only",
281
+ nargs="?",
282
+ const=True,
283
+ action="append",
284
+ default=[],
285
+ help="Add as test dependency in .test_deps.json. Can be a bare flag or take comma-separated mod slug(s)",
286
+ )
287
+ parser.add_argument("--dry-run", action="store_true", help="Preview changes without modifying files")
288
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON result")
289
+
290
+ args = parser.parse_args()
291
+
292
+ try:
293
+ target_mod = args.mod or args.pos_mod or detect_mod_from_cwd()
294
+ if not target_mod:
295
+ raise ValueError("Target mod must be specified via --mod <name>, positional argument, or by running inside a mod folder in my_mods/.")
296
+
297
+ mod_dir = resolve_mod_dir(target_mod, prefer_my_mods=True)
298
+ if not mod_dir or not mod_dir.is_dir():
299
+ raise FileNotFoundError(f"Could not locate mod directory for '{target_mod}'.")
300
+
301
+ perm_slugs, test_slugs = resolve_cli_targets(args)
302
+ if not perm_slugs and not test_slugs:
303
+ raise ValueError("No dependencies specified. Provide dependency mod slugs via --dep, --test-dep, or --test-only.")
304
+
305
+ perm_results: List[Dict[str, Any]] = []
306
+ if perm_slugs:
307
+ perm_metas = [
308
+ resolve_dependency_metadata(slug, auto_document=True, strict=True)
309
+ for slug in perm_slugs
310
+ ]
311
+ perm_results = add_permanent_dependencies(mod_dir, perm_metas, dry_run=args.dry_run)
312
+
313
+ test_results: List[Dict[str, Any]] = []
314
+ if test_slugs:
315
+ test_metas = [
316
+ resolve_dependency_metadata(slug, auto_document=True, strict=False)
317
+ for slug in test_slugs
318
+ ]
319
+ test_results = add_test_dependencies(mod_dir, test_metas, dry_run=args.dry_run)
320
+
321
+ all_results = perm_results + test_results
322
+ success = True
323
+
324
+ if args.json:
325
+ response: Dict[str, Any] = {
326
+ "mod": mod_dir.name,
327
+ "status": "success",
328
+ "dependencies": perm_results,
329
+ "test_dependencies": test_results,
330
+ "results": all_results,
331
+ "success": success,
332
+ "dry_run": args.dry_run,
333
+ }
334
+ if len(all_results) == 1:
335
+ single = all_results[0]
336
+ for k, v in single.items():
337
+ if k not in response:
338
+ response[k] = v
339
+ response["status"] = single.get("status", "success")
340
+ print(json.dumps(response, indent=2))
341
+ else:
342
+ verb = "[DRY-RUN] Would add" if args.dry_run else "Successfully added"
343
+ for r in all_results:
344
+ dep_name = r.get("dependency") or r.get("uuid") or "Unknown"
345
+ status = r.get("status")
346
+ dep_kind = "test dependency" if r.get("type") == "test_dependency" else "dependency"
347
+ if status == "already_present":
348
+ print(f"• {dep_kind.capitalize()} '{dep_name}' is already present in '{r.get('mod')}'.")
349
+ else:
350
+ print(f"✓ {verb} {dep_kind} '{dep_name}' to '{r.get('mod')}'.")
351
+ if r.get("uuid"):
352
+ print(f" UUID: {r.get('uuid')}")
353
+ if r.get("meta_file"):
354
+ print(f" Updated: {r.get('meta_file')}")
355
+ elif r.get("config_file") and status != "already_present":
356
+ print(f" Updated: {r.get('config_file')}")
357
+
358
+ except Exception as e:
359
+ if args.json:
360
+ print(json.dumps({"error": str(e), "success": False}, indent=2))
361
+ else:
362
+ print(f"Error: {e}", file=sys.stderr)
363
+ sys.exit(1)
364
+
365
+
366
+ if __name__ == "__main__":
367
+ main()
@@ -0,0 +1,32 @@
1
+ """bg3-authoring CLI entrypoint."""
2
+
3
+ import sys
4
+
5
+ from bg3_core.cli import build_parser, dispatch_command
6
+ from bg3_core.cli_common import ConsoleColor, ExitCode, OutputEmitter
7
+
8
+
9
+ def main() -> None:
10
+ parser = build_parser()
11
+ if len(sys.argv) == 1:
12
+ parser.print_help(sys.stderr)
13
+ sys.exit(ExitCode.USAGE)
14
+
15
+ args = parser.parse_args()
16
+ color = ConsoleColor(stream=sys.stderr, force_color=False)
17
+ emitter = OutputEmitter(json_mode=args.json, color=color)
18
+
19
+ try:
20
+ ret = dispatch_command(args, emitter)
21
+ sys.exit(ret)
22
+ except KeyboardInterrupt:
23
+ emitter.error(
24
+ message="Operation aborted by user.",
25
+ code="SIGINT",
26
+ exit_code=130,
27
+ exit_process=True,
28
+ )
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()