modforge-cli 0.1.9__tar.gz → 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modforge-cli
3
- Version: 0.1.9
3
+ Version: 0.2.0
4
4
  Summary: ModForge-CLI — a Modrinth-based Minecraft modpack builder
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modforge-cli"
3
- version = "0.1.9"
3
+ version = "0.2.0"
4
4
  description = "ModForge-CLI — a Modrinth-based Minecraft modpack builder"
5
5
  authors = [{ name = "Frank1o3", email = "jahdy1o3@gmail.com" }]
6
6
  license = { text = "MIT" }
@@ -1,5 +1,5 @@
1
1
  """
2
2
  Auto-generated file. DO NOT EDIT.
3
3
  """
4
- __version__ = "0.1.9"
4
+ __version__ = "0.2.0"
5
5
  __author__ = "Frank1o3"
@@ -5,7 +5,6 @@ from pathlib import Path
5
5
  import shutil
6
6
  import subprocess
7
7
  import sys
8
- from typing import Optional
9
8
  import urllib.request
10
9
 
11
10
  from pyfiglet import figlet_format
@@ -47,7 +46,7 @@ app = typer.Typer(
47
46
  console = Console()
48
47
 
49
48
  # Configuration
50
- FABRIC_LOADER_VERSION = "0.16.9"
49
+ FABRIC_LOADER_VERSION = "0.18.4"
51
50
  CONFIG_PATH = Path.home() / ".config" / "ModForge-CLI"
52
51
  REGISTRY_PATH = CONFIG_PATH / "registry.json"
53
52
  MODRINTH_API = CONFIG_PATH / "modrinth_api.json"
@@ -78,7 +77,7 @@ ensure_config_file(POLICY_PATH, DEFAULT_POLICY_URL, "Policy", console)
78
77
  api = ModrinthAPIConfig(MODRINTH_API)
79
78
 
80
79
 
81
- def render_banner():
80
+ def render_banner() -> None:
82
81
  """Renders a stylized banner"""
83
82
  width = console.width
84
83
  font = "slant" if width > 60 else "small"
@@ -108,13 +107,14 @@ def render_banner():
108
107
  @app.callback(invoke_without_command=True)
109
108
  def main_callback(
110
109
  ctx: typer.Context,
111
- version: Optional[bool] = typer.Option(None, "--version", "-v", help="Show version and exit"),
112
- verbose: Optional[bool] = typer.Option(None, "--verbose", help="Enable verbose logging"),
113
- ):
110
+ version: bool | None = typer.Option(None, "--version", "-v", help="Show version and exit"),
111
+ verbose: bool | None = typer.Option(None, "--verbose", help="Enable verbose logging"),
112
+ ) -> None:
114
113
  """ModForge-CLI: A powerful Minecraft modpack manager for Modrinth."""
115
114
 
116
115
  if verbose:
117
116
  # Enable verbose logging
117
+
118
118
  logging.basicConfig(
119
119
  level=logging.DEBUG,
120
120
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -151,7 +151,7 @@ def setup(
151
151
  mc: str = "1.21.1",
152
152
  loader: str = "fabric",
153
153
  loader_version: str = FABRIC_LOADER_VERSION,
154
- ):
154
+ ) -> None:
155
155
  """Initialize a new modpack project"""
156
156
  pack_dir = Path.cwd() / name
157
157
 
@@ -177,13 +177,22 @@ def setup(
177
177
  (pack_dir / "ModForge-CLI.json").write_text(manifest.model_dump_json(indent=4))
178
178
 
179
179
  # Create Modrinth index
180
+ # Map loader names to their dependency keys
181
+ loader_key_map = {
182
+ "fabric": "fabric-loader",
183
+ "quilt": "quilt-loader",
184
+ "forge": "forge",
185
+ "neoforge": "neoforge",
186
+ }
187
+ loader_key = loader_key_map.get(loader.lower(), loader.lower())
188
+
180
189
  index_data = {
181
190
  "formatVersion": 1,
182
191
  "game": "minecraft",
183
192
  "versionId": "1.0.0",
184
193
  "name": name,
185
- "dependencies": {"minecraft": mc, loader: "*"},
186
- "files": [],
194
+ "dependencies": {"minecraft": mc, loader_key: loader_version},
195
+ "files": [], # Only for overrides, not mods
187
196
  }
188
197
  (pack_dir / "modrinth.index.json").write_text(json.dumps(index_data, indent=2))
189
198
 
@@ -197,7 +206,7 @@ def setup(
197
206
 
198
207
 
199
208
  @app.command()
200
- def add(name: str, project_type: str = "mod", pack_name: Optional[str] = None):
209
+ def add(name: str, project_type: str = "mod", pack_name: str | None = None) -> None:
201
210
  """Add a project to the manifest"""
202
211
 
203
212
  if project_type not in ["mod", "resourcepack", "shaderpack"]:
@@ -219,7 +228,7 @@ def add(name: str, project_type: str = "mod", pack_name: Optional[str] = None):
219
228
  if pack_name not in registry:
220
229
  console.print(f"[red]Pack '{pack_name}' not found in registry[/red]")
221
230
  console.print("[yellow]Available packs:[/yellow]")
222
- for p in registry.keys():
231
+ for p in registry:
223
232
  console.print(f" - {p}")
224
233
  raise typer.Exit(1)
225
234
 
@@ -235,7 +244,7 @@ def add(name: str, project_type: str = "mod", pack_name: Optional[str] = None):
235
244
 
236
245
 
237
246
  @app.command()
238
- def resolve(pack_name: Optional[str] = None):
247
+ def resolve(pack_name: str | None = None) -> None:
239
248
  """Resolve all mod dependencies"""
240
249
 
241
250
  # Auto-detect pack
@@ -257,7 +266,7 @@ def resolve(pack_name: Optional[str] = None):
257
266
 
258
267
  manifest = get_manifest(console, pack_path)
259
268
  if not manifest:
260
- console.print(f"[red]Could not load manifest[/red]")
269
+ console.print("[red]Could not load manifest[/red]")
261
270
  raise typer.Exit(1)
262
271
 
263
272
  console.print(f"[cyan]Resolving dependencies for {pack_name}...[/cyan]")
@@ -275,7 +284,7 @@ def resolve(pack_name: Optional[str] = None):
275
284
  resolved_mods = asyncio.run(do_resolve())
276
285
  except Exception as e:
277
286
  console.print(f"[red]Resolution failed:[/red] {e}")
278
- raise typer.Exit(1)
287
+ raise typer.Exit(1) from e
279
288
 
280
289
  manifest.mods = sorted(list(resolved_mods))
281
290
  manifest_file.write_text(manifest.model_dump_json(indent=4))
@@ -284,7 +293,7 @@ def resolve(pack_name: Optional[str] = None):
284
293
 
285
294
 
286
295
  @app.command()
287
- def build(pack_name: Optional[str] = None):
296
+ def build(pack_name: str | None = None) -> None:
288
297
  """Download all mods and dependencies"""
289
298
 
290
299
  if not pack_name:
@@ -318,11 +327,11 @@ def build(pack_name: Optional[str] = None):
318
327
  console.print("[green]✓ Build complete[/green]")
319
328
  except Exception as e:
320
329
  console.print(f"[red]Build failed:[/red] {e}")
321
- raise typer.Exit(1)
330
+ raise typer.Exit(1) from e
322
331
 
323
332
 
324
333
  @app.command()
325
- def export(pack_name: Optional[str] = None):
334
+ def export(pack_name: str | None = None) -> None:
326
335
  """Create final .mrpack file"""
327
336
 
328
337
  if not pack_name:
@@ -358,6 +367,7 @@ def export(pack_name: Optional[str] = None):
358
367
 
359
368
  if not installer.exists():
360
369
  console.print("[yellow]Downloading Fabric installer...[/yellow]")
370
+
361
371
 
362
372
  urllib.request.urlretrieve(FABRIC_INSTALLER_URL, installer)
363
373
 
@@ -377,15 +387,10 @@ def export(pack_name: Optional[str] = None):
377
387
  loader_version=loader_version,
378
388
  game_dir=pack_path,
379
389
  )
390
+ console.print(f"[green]✓ Fabric {loader_version} installed[/green]")
380
391
  except RuntimeError as e:
381
392
  console.print(f"[red]{e}[/red]")
382
- raise typer.Exit(1)
383
-
384
- # Update index
385
- index_file = pack_path / "modrinth.index.json"
386
- index = json.loads(index_file.read_text())
387
- index["dependencies"]["fabric-loader"] = loader_version
388
- index_file.write_text(json.dumps(index, indent=2))
393
+ raise typer.Exit(1) from e
389
394
 
390
395
  installer.unlink(missing_ok=True)
391
396
 
@@ -406,7 +411,7 @@ def export(pack_name: Optional[str] = None):
406
411
 
407
412
 
408
413
  @app.command()
409
- def remove(pack_name: str):
414
+ def remove(pack_name: str) -> None:
410
415
  """Remove a modpack and unregister it"""
411
416
  registry = load_registry(REGISTRY_PATH)
412
417
 
@@ -442,7 +447,7 @@ def remove(pack_name: str):
442
447
 
443
448
 
444
449
  @app.command(name="ls")
445
- def list_projects():
450
+ def list_projects() -> None:
446
451
  """List all registered modpacks"""
447
452
  registry = load_registry(REGISTRY_PATH)
448
453
 
@@ -462,7 +467,7 @@ def list_projects():
462
467
 
463
468
 
464
469
  @app.command()
465
- def doctor():
470
+ def doctor() -> None:
466
471
  """Validate ModForge-CLI installation"""
467
472
  console.print("[bold cyan]Running diagnostics...[/bold cyan]\n")
468
473
 
@@ -471,11 +476,7 @@ def doctor():
471
476
  # Check Python version
472
477
 
473
478
  py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
474
- if sys.version_info >= (3, 10):
475
- console.print(f"[green]✓[/green] Python {py_version}")
476
- else:
477
- console.print(f"[red]✗[/red] Python {py_version} (requires 3.10+)")
478
- issues.append("Upgrade Python")
479
+ console.print(f"[green]✓[/green] Python {py_version}")
479
480
 
480
481
  # Check config files
481
482
  for name, path in [("API Config", MODRINTH_API), ("Policy", POLICY_PATH)]:
@@ -491,6 +492,7 @@ def doctor():
491
492
 
492
493
  # Check Java
493
494
  try:
495
+
494
496
  result = subprocess.run(["java", "-version"], capture_output=True, text=True, check=True)
495
497
  console.print("[green]✓[/green] Java installed")
496
498
  except (FileNotFoundError, subprocess.CalledProcessError):
@@ -508,16 +510,16 @@ def doctor():
508
510
 
509
511
 
510
512
  @app.command(name="self-update")
511
- def self_update_cmd():
513
+ def self_update_cmd() -> None:
512
514
  """Update ModForge-CLI to latest version"""
513
515
  try:
514
516
  self_update(console)
515
517
  except Exception as e:
516
518
  console.print(f"[red]Update failed:[/red] {e}")
517
- raise typer.Exit(1)
519
+ raise typer.Exit(1) from e
518
520
 
519
521
 
520
- def main():
522
+ def main() -> None:
521
523
  app()
522
524
 
523
525
 
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Iterable
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import aiohttp
10
+ from rich.console import Console
11
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
12
+
13
+ from modforge_cli.api import ModrinthAPIConfig
14
+
15
+ console = Console()
16
+
17
+
18
+ class ModDownloader:
19
+ def __init__(
20
+ self,
21
+ api: ModrinthAPIConfig,
22
+ mc_version: str,
23
+ loader: str,
24
+ output_dir: Path,
25
+ index_file: Path,
26
+ session: aiohttp.ClientSession,
27
+ ):
28
+ self.api = api
29
+ self.mc_version = mc_version
30
+ self.loader = loader
31
+ self.output_dir = output_dir
32
+ self.index_file = index_file
33
+ self.session = session
34
+
35
+ self.index = json.loads(index_file.read_text())
36
+
37
+ def _select_compatible_version(self, versions: list[dict]) -> dict | None:
38
+ """
39
+ Select the most appropriate version based on:
40
+ 1. Loader compatibility (fabric/forge/quilt/neoforge)
41
+ 2. Minecraft version
42
+ 3. Version type (prefer release > beta > alpha)
43
+ """
44
+ # Normalize loader name for comparison
45
+ loader_lower = self.loader.lower()
46
+
47
+ # Filter versions that match both MC version and loader
48
+ compatible = []
49
+ for v in versions:
50
+ # Check if MC version matches
51
+ if self.mc_version not in v.get("game_versions", []):
52
+ continue
53
+
54
+ # Check if loader matches (case-insensitive)
55
+ loaders = [l.lower() for l in v.get("loaders", [])]
56
+ if loader_lower not in loaders:
57
+ continue
58
+
59
+ compatible.append(v)
60
+
61
+ if not compatible:
62
+ return None
63
+
64
+ # Prioritize by version type: release > beta > alpha
65
+ version_priority = {"release": 3, "beta": 2, "alpha": 1}
66
+
67
+ def version_score(v):
68
+ vtype = v.get("version_type", "alpha")
69
+ return version_priority.get(vtype, 0)
70
+
71
+ # Sort by version type, then by date (newest first)
72
+ compatible.sort(key=lambda v: (version_score(v), v.get("date_published", "")), reverse=True)
73
+
74
+ return compatible[0]
75
+
76
+ async def download_all(self, project_ids: Iterable[str]) -> None:
77
+ """
78
+ Download all mods and update modrinth.index.json dependencies.
79
+
80
+ Note: Modrinth launchers auto-download mods based on dependencies.
81
+ We download to mods/ folder for local use, but the index only needs
82
+ the version IDs in dependencies, not file paths.
83
+ """
84
+ tasks = [self._download_project(pid) for pid in project_ids]
85
+
86
+ with Progress(
87
+ SpinnerColumn(),
88
+ TextColumn("[bold cyan]{task.description}"),
89
+ BarColumn(),
90
+ TextColumn("{task.completed}/{task.total}"),
91
+ console=console,
92
+ ) as progress:
93
+ task_id = progress.add_task("Downloading mods", total=len(tasks))
94
+ for coro in asyncio.as_completed(tasks):
95
+ await coro
96
+ progress.advance(task_id)
97
+
98
+ # Update dependencies section with correct loader version
99
+ self._update_dependencies()
100
+ self.index_file.write_text(json.dumps(self.index, indent=2))
101
+
102
+ def _update_dependencies(self) -> None:
103
+ """
104
+ Ensure dependencies section has correct MC version and loader.
105
+ This is what launchers use to setup the game.
106
+ """
107
+ if "dependencies" not in self.index:
108
+ self.index["dependencies"] = {}
109
+
110
+ # Set Minecraft version
111
+ self.index["dependencies"]["minecraft"] = self.mc_version
112
+
113
+ # Set loader (fabric-loader, forge, quilt-loader, neoforge)
114
+ loader_key_map = {
115
+ "fabric": "fabric-loader",
116
+ "quilt": "quilt-loader",
117
+ "forge": "forge",
118
+ "neoforge": "neoforge",
119
+ }
120
+
121
+ loader_key = loader_key_map.get(self.loader.lower(), self.loader.lower())
122
+
123
+ # Use "*" to let launcher pick latest compatible version
124
+ # Or you can specify exact version if available
125
+ self.index["dependencies"][loader_key] = "*"
126
+
127
+ async def _download_project(self, project_id: str) -> None:
128
+ # 1. Fetch all versions for this project
129
+ url = self.api.project_versions(project_id)
130
+
131
+ try:
132
+ async with self.session.get(url) as r:
133
+ if r.status != 200:
134
+ console.print(
135
+ f"[red]Failed to fetch versions for {project_id}: HTTP {r.status}[/red]"
136
+ )
137
+ return
138
+ versions = await r.json()
139
+ except Exception as e:
140
+ console.print(f"[red]Error fetching {project_id}: {e}[/red]")
141
+ return
142
+
143
+ if not versions:
144
+ console.print(f"[yellow]No versions found for {project_id}[/yellow]")
145
+ return
146
+
147
+ # 2. Select compatible version
148
+ version = self._select_compatible_version(versions)
149
+
150
+ if not version:
151
+ console.print(
152
+ f"[yellow]No compatible version for {project_id}[/yellow]\n"
153
+ f"[dim] Required: MC {self.mc_version}, Loader: {self.loader}[/dim]"
154
+ )
155
+ return
156
+
157
+ # 3. Find primary file
158
+ files = version.get("files", [])
159
+ primary_file = next((f for f in files if f.get("primary")), None)
160
+
161
+ if not primary_file and files:
162
+ # Fallback to first file if no primary is marked
163
+ primary_file = files[0]
164
+
165
+ if not primary_file:
166
+ console.print(
167
+ f"[yellow]No files found for {project_id} version {version.get('version_number')}[/yellow]"
168
+ )
169
+ return
170
+
171
+ # 4. Download file to mods/ directory
172
+ dest = self.output_dir / primary_file["filename"]
173
+
174
+ # Skip if already downloaded and hash matches
175
+ if dest.exists():
176
+ existing_hash = hashlib.sha1(dest.read_bytes()).hexdigest()
177
+ if existing_hash == primary_file["hashes"]["sha1"]:
178
+ console.print(f"[dim]✓ {primary_file['filename']} (cached)[/dim]")
179
+ return
180
+ else:
181
+ console.print(
182
+ f"[yellow]Re-downloading {primary_file['filename']} (hash mismatch)[/yellow]"
183
+ )
184
+
185
+ try:
186
+ async with self.session.get(primary_file["url"]) as r:
187
+ if r.status != 200:
188
+ console.print(
189
+ f"[red]Failed to download {primary_file['filename']}: HTTP {r.status}[/red]"
190
+ )
191
+ return
192
+ data = await r.read()
193
+ dest.write_bytes(data)
194
+ except Exception as e:
195
+ console.print(f"[red]Download error for {primary_file['filename']}: {e}[/red]")
196
+ return
197
+
198
+ # 5. Verify hash
199
+ sha1 = hashlib.sha1(data).hexdigest()
200
+ if sha1 != primary_file["hashes"]["sha1"]:
201
+ dest.unlink(missing_ok=True) # Delete corrupted file
202
+ raise RuntimeError(
203
+ f"Hash mismatch for {primary_file['filename']}\n"
204
+ f" Expected: {primary_file['hashes']['sha1']}\n"
205
+ f" Got: {sha1}"
206
+ )
207
+
208
+ console.print(
209
+ f"[green]✓[/green] {primary_file['filename']} "
210
+ f"[dim](v{version.get('version_number')}, {self.loader})[/dim]"
211
+ )
@@ -1,87 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- from collections.abc import Iterable
5
- import hashlib
6
- import json
7
- from pathlib import Path
8
-
9
- import aiohttp
10
- from rich.console import Console
11
- from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
12
-
13
- from modforge_cli.api import ModrinthAPIConfig
14
-
15
- console = Console()
16
-
17
-
18
- class ModDownloader:
19
- def __init__(
20
- self,
21
- api: ModrinthAPIConfig,
22
- mc_version: str,
23
- loader: str,
24
- output_dir: Path,
25
- index_file: Path,
26
- session: aiohttp.ClientSession,
27
- ):
28
- self.api = api
29
- self.mc_version = mc_version
30
- self.loader = loader
31
- self.output_dir = output_dir
32
- self.index_file = index_file
33
- self.session = session
34
-
35
- self.index = json.loads(index_file.read_text())
36
-
37
- async def download_all(self, project_ids: Iterable[str]) -> None:
38
- tasks = [self._download_project(pid) for pid in project_ids]
39
-
40
- with Progress(
41
- SpinnerColumn(),
42
- TextColumn("[bold cyan]{task.description}"),
43
- BarColumn(),
44
- TextColumn("{task.completed}/{task.total}"),
45
- console=console,
46
- ) as progress:
47
- task_id = progress.add_task("Downloading mods", total=len(tasks))
48
- for coro in asyncio.as_completed(tasks):
49
- await coro
50
- progress.advance(task_id)
51
-
52
- self.index_file.write_text(json.dumps(self.index, indent=2))
53
-
54
- async def _download_project(self, project_id: str) -> None:
55
- # 1. Fetch compatible version
56
- url = self.api.project_versions(project_id)
57
-
58
- async with self.session.get(url) as r:
59
- versions = await r.json()
60
-
61
- if not versions:
62
- console.print(f"[yellow]No compatible version for {project_id}[/yellow]")
63
- return
64
-
65
- version = versions[0]
66
- file = next(f for f in version["files"] if f["primary"])
67
-
68
- # 2. Download file
69
- dest = self.output_dir / file["filename"]
70
- async with self.session.get(file["url"]) as r:
71
- data = await r.read()
72
- dest.write_bytes(data)
73
-
74
- # 3. Verify hash
75
- sha1 = hashlib.sha1(data).hexdigest()
76
- if sha1 != file["hashes"]["sha1"]:
77
- raise RuntimeError(f"Hash mismatch for {file['filename']}")
78
-
79
- # 4. Register in index
80
- self.index["files"].append(
81
- {
82
- "path": f"mods/{file['filename']}",
83
- "hashes": {"sha1": sha1},
84
- "downloads": [file["url"]],
85
- "fileSize": file["size"],
86
- }
87
- )
File without changes
File without changes