modforge-cli 0.1.5__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.
modforge_cli/cli.py ADDED
@@ -0,0 +1,559 @@
1
+ import asyncio
2
+ import json
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import aiohttp
10
+ import typer
11
+ from pyfiglet import figlet_format
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.prompt import Confirm
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from modforge_cli.api import ModrinthAPIConfig
19
+ from modforge_cli.core import Manifest, ModDownloader, SearchResult
20
+
21
+ # Import version info
22
+ try:
23
+ from modforge_cli.__version__ import __author__, __version__
24
+ except ImportError:
25
+ __version__ = "unknown"
26
+ __author__ = "Frank1o3"
27
+
28
+ app = typer.Typer(
29
+ add_completion=False,
30
+ no_args_is_help=False, # We handle this manually in the callback for the banner
31
+ )
32
+ console = Console()
33
+ FABRIC_LOADER_VERSION = "0.18.3"
34
+ CONFIG_PATH = Path().home() / ".config" / "ModForge-CLI"
35
+ REGISTRY_PATH = CONFIG_PATH / "registry.json"
36
+ MODRINTH_API = CONFIG_PATH / "modrinth_api.json"
37
+ POLICY_PATH = CONFIG_PATH / "policy.json"
38
+
39
+ FABRIC_INSTALLER_URL = (
40
+ "https://maven.fabricmc.net/net/fabricmc/"
41
+ "fabric-installer/1.1.1/fabric-installer-1.1.1.jar"
42
+ )
43
+ DEFAULT_MODRINTH_API_URL = "https://raw.githubusercontent.com/Frank1o3/ModForge-CLI/refs/heads/main/configs/modrinth_api.json"
44
+
45
+ DEFAULT_POLICY_URL = "https://raw.githubusercontent.com/Frank1o3/ModForge-CLI/refs/heads/main/configs/policy.json"
46
+
47
+
48
+ def ensure_config_file(path: Path, url: str, label: str):
49
+ if path.exists():
50
+ return
51
+
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ console.print(f"[yellow]Missing {label} config.[/yellow] Downloading default…")
55
+
56
+ try:
57
+ import urllib.request
58
+
59
+ urllib.request.urlretrieve(url, path)
60
+ console.print(f"[green]✓ {label} config installed at {path}[/green]")
61
+ except Exception as e:
62
+ console.print(f"[red]Failed to download {label} config:[/red] {e}")
63
+ raise typer.Exit(1)
64
+
65
+
66
+ ensure_config_file(
67
+ MODRINTH_API,
68
+ DEFAULT_MODRINTH_API_URL,
69
+ "Modrinth API",
70
+ )
71
+
72
+ ensure_config_file(
73
+ POLICY_PATH,
74
+ DEFAULT_POLICY_URL,
75
+ "Policy",
76
+ )
77
+
78
+
79
+ api = ModrinthAPIConfig(MODRINTH_API)
80
+
81
+
82
+ # --- Async Helper ---
83
+ async def get_api_session():
84
+ """Returns a session with the correct ModForge-CLI headers."""
85
+ return aiohttp.ClientSession(
86
+ headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
87
+ raise_for_status=True,
88
+ )
89
+
90
+
91
+ def get_manifest(path: Path = Path.cwd()) -> Optional[Manifest]:
92
+ p = path / "ModForge-CLI.json"
93
+ if not p.exists():
94
+ return None
95
+ try:
96
+ return Manifest.model_validate_json(p.read_text())
97
+ except Exception as e:
98
+ console.print(e)
99
+ return None
100
+
101
+
102
+ def install_fabric(
103
+ installer: Path,
104
+ mc_version: str,
105
+ loader_version: str,
106
+ game_dir: Path,
107
+ ):
108
+ subprocess.run(
109
+ [
110
+ "java",
111
+ "-jar",
112
+ installer,
113
+ "client",
114
+ "-mcversion",
115
+ mc_version,
116
+ "-loader",
117
+ loader_version,
118
+ "-dir",
119
+ str(game_dir),
120
+ "-noprofile",
121
+ ],
122
+ check=True,
123
+ )
124
+
125
+
126
+ def detect_install_method() -> str:
127
+ prefix = Path(sys.prefix)
128
+
129
+ if "pipx" in prefix.parts:
130
+ return "pipx"
131
+ return "pip"
132
+
133
+ def self_update():
134
+ method = detect_install_method()
135
+
136
+ if method == "pipx":
137
+ console.print("[cyan]Updating ModForge-CLI using pipx...[/cyan]")
138
+ subprocess.run(["pipx", "upgrade", "ModForge-CLI"], check=True)
139
+
140
+ else:
141
+ console.print("[cyan]Updating ModForge-CLI using pip...[/cyan]")
142
+ subprocess.run(
143
+ [sys.executable, "-m", "pip", "install", "--upgrade", "ModForge-CLI"],
144
+ check=True,
145
+ )
146
+
147
+ console.print("[green]ModForge-CLI updated successfully.[/green]")
148
+
149
+
150
+ def render_banner():
151
+ """Renders a high-quality stylized banner"""
152
+ ascii_art = figlet_format("ModForge-CLI", font="slant")
153
+
154
+ # Create a colorful gradient-like effect for the text
155
+ banner_text = Text(ascii_art, style="bold cyan")
156
+
157
+ # Add extra info line
158
+ info_line = Text.assemble(
159
+ (" ⛏ ", "yellow"),
160
+ (f"v{__version__}", "bold white"),
161
+ (" | ", "dim"),
162
+ ("Created by ", "italic white"),
163
+ (f"{__author__}", "bold magenta"),
164
+ )
165
+
166
+ # Wrap in a nice panel
167
+ console.print(
168
+ Panel(
169
+ Text.assemble(banner_text, "\n", info_line),
170
+ border_style="blue",
171
+ padding=(1, 2),
172
+ expand=False,
173
+ )
174
+ )
175
+
176
+
177
+ @app.callback(invoke_without_command=True)
178
+ def main_callback(
179
+ ctx: typer.Context,
180
+ version: Optional[bool] = typer.Option(
181
+ None, "--version", "-v", help="Show version and exit"
182
+ ),
183
+ ):
184
+ """
185
+ ModForge-CLI: A powerful Minecraft modpack manager for Modrinth.
186
+ """
187
+ if version:
188
+ console.print(f"ModForge-CLI Version: [bold cyan]{__version__}[/bold cyan]")
189
+ raise typer.Exit()
190
+
191
+ # If no command is provided (e.g., just 'ModForge-CLI')
192
+ if ctx.invoked_subcommand is None:
193
+ render_banner()
194
+ console.print("\n[bold yellow]Usage:[/bold yellow] ModForge-CLI [COMMAND] [ARGS]...")
195
+ console.print("\n[bold cyan]Core Commands:[/bold cyan]")
196
+ console.print(" [green]setup[/green] Initialize a new modpack project")
197
+ console.print(" [green]ls[/green] List all registered projects")
198
+ console.print(" [green]add[/green] Add a mod/resource/shader to manifest")
199
+ console.print(
200
+ " [green]build[/green] Download files and setup loader version"
201
+ )
202
+ console.print(" [green]export[/green] Create the final .mrpack zip")
203
+
204
+ console.print("\nRun [white]ModForge-CLI --help[/white] for full command details.\n")
205
+
206
+
207
+ @app.command()
208
+ def setup(name: str, mc: str = "1.21.1", loader: str = "fabric", loader_version: str = FABRIC_LOADER_VERSION):
209
+ """Initialize the working directory for a new pack"""
210
+ pack_dir = Path.cwd() / name
211
+ pack_dir.mkdir(parents=True, exist_ok=True)
212
+
213
+
214
+ # Standard ModForge-CLI structure (The Watermark)
215
+ for folder in [
216
+ "mods",
217
+ "overrides/resourcepacks",
218
+ "overrides/shaderpacks",
219
+ "overrides/config",
220
+ "versions",
221
+ ]:
222
+ (pack_dir / folder).mkdir(parents=True, exist_ok=True)
223
+
224
+ manifest: Manifest = Manifest(name=name, minecraft=mc, loader=loader, loader_version=loader_version)
225
+ (pack_dir / "ModForge-CLI.json").write_text(manifest.model_dump_json(indent=4))
226
+
227
+ # Register globally
228
+ registry: dict[str, str] = (
229
+ json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
230
+ )
231
+ registry[name] = str(pack_dir.absolute())
232
+ REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
233
+ REGISTRY_PATH.write_text(json.dumps(registry, indent=4))
234
+
235
+ index_data: dict[str, dict[str, str] | list[str] | str | int] = {
236
+ "formatVersion": 1,
237
+ "game": "minecraft",
238
+ "versionId": "1.0.0",
239
+ "name": name,
240
+ "dependencies": {"minecraft": mc, loader: "*"},
241
+ "files": [],
242
+
243
+ }
244
+ (pack_dir / "modrinth.index.json").write_text(json.dumps(index_data, indent=2))
245
+
246
+ console.print(
247
+ f"Project [bold cyan]{name}[/bold cyan] ready at {pack_dir}", style="green"
248
+ )
249
+
250
+
251
+ @app.command()
252
+ def add(name: str, project_type: str = "mod", pack_name: str = "testpack"):
253
+ """Search and add a project to the manifest without overwriting existing data"""
254
+
255
+ # --- API LIMITATION CHECK ---
256
+ if project_type in ["resourcepack", "shaderpack"]:
257
+ console.print(
258
+ f"[bold yellow]Notice:[/bold yellow] Adding {project_type}s is currently [red]Not Implemented[/red]. "
259
+ "The search API is currently limited to mods."
260
+ )
261
+ return
262
+
263
+ registry = json.loads(REGISTRY_PATH.read_text())
264
+ if pack_name not in registry:
265
+ console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
266
+ return
267
+
268
+ pack_path = Path(registry[pack_name])
269
+ manifest_file = pack_path / "ModForge-CLI.json"
270
+
271
+ manifest = get_manifest(pack_path)
272
+ if not manifest:
273
+ console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
274
+ return
275
+
276
+ async def perform_add():
277
+ async with await get_api_session() as session:
278
+ url = api.search(
279
+ name,
280
+ game_versions=[manifest.minecraft],
281
+ loaders=[manifest.loader],
282
+ project_type=project_type,
283
+ )
284
+
285
+ async with session.get(url) as response:
286
+ results = SearchResult.model_validate_json(await response.text())
287
+
288
+ if not results or not results.hits:
289
+ console.print(f"[red]No {project_type} found for '{name}'")
290
+ return
291
+
292
+ # Match slug
293
+ target_hit = next(
294
+ (h for h in results.hits if h.slug == name), results.hits[0]
295
+ )
296
+ slug = target_hit.slug
297
+
298
+ # 3. Modify the existing manifest object
299
+ # Only 'mod' will reach here currently due to the check above
300
+ target_list = {
301
+ "mod": manifest.mods,
302
+ "resourcepack": manifest.resourcepacks,
303
+ "shaderpack": manifest.shaderpacks,
304
+ }.get(project_type, manifest.mods)
305
+
306
+ if slug not in target_list:
307
+ target_list.append(slug)
308
+ manifest_file.write_text(manifest.model_dump_json(indent=4))
309
+ console.print(f"Added [green]{slug}[/green] to {project_type}s")
310
+ else:
311
+ console.print(f"{slug} is already in the manifest.")
312
+
313
+ asyncio.run(perform_add())
314
+
315
+
316
+ @app.command()
317
+ def resolve(pack_name: str = "testpack"):
318
+ from ModForge-CLI.core import ModPolicy, ModResolver
319
+
320
+ # 1. Load Registry and Manifest
321
+ registry = json.loads(REGISTRY_PATH.read_text())
322
+ if pack_name not in registry:
323
+ console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
324
+ return
325
+
326
+ pack_path = Path(registry[pack_name])
327
+ manifest_file = pack_path / "ModForge-CLI.json"
328
+
329
+ manifest = get_manifest(pack_path)
330
+ if not manifest:
331
+ console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
332
+ return
333
+
334
+ # 2. Run Resolution Logic
335
+ console.print(
336
+ f"Resolving dependencies for [bold cyan]{pack_name}[/bold cyan]...",
337
+ style="yellow",
338
+ )
339
+ policy = ModPolicy(POLICY_PATH)
340
+ resolver = ModResolver(
341
+ policy=policy, api=api, mc_version=manifest.minecraft, loader=manifest.loader
342
+ )
343
+
344
+ # This returns a Set[str] of unique Modrinth Project IDs
345
+ resolved_mods = resolver.resolve(manifest.mods)
346
+
347
+ # 3. Update Manifest with Resolved IDs
348
+ # We convert the set to a sorted list for a clean JSON file
349
+ manifest.mods = sorted(list(resolved_mods))
350
+
351
+ # 4. Save back to ModForge-CLI.json
352
+ try:
353
+ manifest_file.write_text(manifest.model_dump_json(indent=4))
354
+ console.print(f"Successfully updated [bold]{manifest_file.name}[/bold]")
355
+ console.print(
356
+ f"Total mods resolved: [bold green]{len(manifest.mods)}[/bold green]"
357
+ )
358
+ except Exception as e:
359
+ console.print(f"[red]Error saving manifest:[/red] {e}")
360
+
361
+ # Optional: Print a summary table of the IDs
362
+ if manifest.mods:
363
+ table = Table(title=f"Resolved IDs for {pack_name}")
364
+ table.add_column("Project ID", style="green")
365
+ for mod_id in manifest.mods:
366
+ table.add_row(mod_id)
367
+ console.print(table)
368
+
369
+
370
+ @app.command()
371
+ def build(pack_name: str = "testpack"):
372
+ """Download dependencies and set up the loader version"""
373
+
374
+ # 1. Load Registry and Manifest
375
+ registry = json.loads(REGISTRY_PATH.read_text())
376
+ if pack_name not in registry:
377
+ console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
378
+ return
379
+
380
+ pack_path = Path(registry[pack_name])
381
+ manifest_file = pack_path / "ModForge-CLI.json"
382
+
383
+ manifest = get_manifest(pack_path)
384
+ if not manifest:
385
+ console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
386
+ return
387
+
388
+ pack_root = Path.cwd() / manifest.name
389
+ mods_dir = pack_root / "mods"
390
+ index_file = pack_root / "modrinth.index.json"
391
+
392
+ mods_dir.mkdir(exist_ok=True)
393
+
394
+ async def run():
395
+ async with aiohttp.ClientSession(
396
+ headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
397
+ raise_for_status=True,
398
+ ) as session:
399
+ downloader = ModDownloader(
400
+ api=api,
401
+ mc_version=manifest.minecraft,
402
+ loader=manifest.loader,
403
+ output_dir=mods_dir,
404
+ index_file=index_file,
405
+ session=session,
406
+ )
407
+ await downloader.download_all(manifest.mods)
408
+
409
+ console.print(f"🛠 Building [bold cyan]{manifest.name}[/bold cyan]...")
410
+ asyncio.run(run())
411
+ console.print("✨ Build complete. Mods downloaded and indexed.", style="green")
412
+
413
+
414
+ @app.command()
415
+ def export(pack_name: str = "testpack"):
416
+ """Finalize and export the pack as a runnable .zip"""
417
+
418
+ # 1. Load Registry and Manifest
419
+ registry = json.loads(REGISTRY_PATH.read_text())
420
+ if pack_name not in registry:
421
+ console.print(f"[red]Error:[/red] Pack '{pack_name}' not found in registry.")
422
+ return
423
+
424
+ pack_path = Path(registry[pack_name])
425
+ manifest_file = pack_path / "ModForge-CLI.json"
426
+
427
+ manifest = get_manifest(pack_path)
428
+ if not manifest:
429
+ console.print(f"[red]Error:[/red] Could not load manifest at {manifest_file}")
430
+ return
431
+ loader_version = manifest.loader_version or FABRIC_LOADER_VERSION
432
+
433
+ console.print("📦 Finalizing pack...", style="cyan")
434
+
435
+ mods_dir = Path.cwd() / manifest.name / "mods"
436
+ if not mods_dir.exists() or not any(mods_dir.iterdir()):
437
+ console.print("[red]No mods found. Run `ModForge-CLI build` first.[/red]")
438
+ raise typer.Exit(1)
439
+
440
+ if manifest.loader == "fabric":
441
+ installer = Path.cwd() / manifest.name / ".fabric-installer.jar"
442
+
443
+ if not installer.exists():
444
+ console.print("Downloading Fabric installer...")
445
+ import urllib.request
446
+
447
+ urllib.request.urlretrieve(FABRIC_INSTALLER_URL, installer)
448
+
449
+ console.print("Installing Fabric...")
450
+ install_fabric(
451
+ installer=installer,
452
+ mc_version=manifest.minecraft,
453
+ loader_version=loader_version,
454
+ game_dir=Path.cwd() / manifest.name,
455
+ )
456
+
457
+ index_file = Path.cwd() / manifest.name / "modrinth.index.json"
458
+ index = json.loads(index_file.read_text())
459
+ index["dependencies"]["fabric-loader"] = FABRIC_LOADER_VERSION
460
+ index_file.write_text(json.dumps(index, indent=2))
461
+
462
+ installer.unlink(missing_ok=True)
463
+
464
+ pack_name = manifest.name
465
+ zip_path = Path.cwd().parent / f"{pack_name}.zip"
466
+
467
+ shutil.make_archive(
468
+ base_name=str(zip_path.with_suffix("")),
469
+ format="zip",
470
+ root_dir=Path.cwd(),
471
+ )
472
+
473
+ console.print(f"✅ Exported {zip_path.name}", style="green bold")
474
+
475
+
476
+ @app.command()
477
+ def remove(pack_name: str):
478
+ """Completely remove a modpack and unregister it"""
479
+ if not REGISTRY_PATH.exists():
480
+ console.print("[red]No registry found.[/red]")
481
+ raise typer.Exit(1)
482
+
483
+ registry = json.loads(REGISTRY_PATH.read_text())
484
+
485
+ if pack_name not in registry:
486
+ console.print(f"[red]Pack '{pack_name}' not found in registry.[/red]")
487
+ raise typer.Exit(1)
488
+
489
+ pack_path = Path(registry[pack_name])
490
+
491
+ console.print(
492
+ Panel.fit(
493
+ f"[bold red]This will permanently delete:[/bold red]\n\n"
494
+ f"[white]{pack_name}[/white]\n"
495
+ f"[dim]{pack_path}[/dim]",
496
+ title="⚠️ Destructive Action",
497
+ border_style="red",
498
+ )
499
+ )
500
+
501
+ if not Confirm.ask("Are you sure you want to continue?", default=False):
502
+ console.print("Aborted.", style="dim")
503
+ raise typer.Exit()
504
+
505
+ # Remove directory
506
+ try:
507
+ if pack_path.exists():
508
+ shutil.rmtree(pack_path)
509
+ else:
510
+ console.print(
511
+ f"[yellow]Warning:[/yellow] Pack directory does not exist: {pack_path}"
512
+ )
513
+ except Exception as e:
514
+ console.print(f"[red]Failed to delete pack directory:[/red] {e}")
515
+ raise typer.Exit(1)
516
+
517
+ # Update registry
518
+ del registry[pack_name]
519
+ REGISTRY_PATH.write_text(json.dumps(registry, indent=4))
520
+
521
+ console.print(
522
+ f"🗑️ Removed pack [bold cyan]{pack_name}[/bold cyan] successfully.",
523
+ style="green",
524
+ )
525
+
526
+
527
+ @app.command(name="ls")
528
+ def list_projects():
529
+ """Show all ModForge-CLI projects"""
530
+ if not REGISTRY_PATH.exists():
531
+ console.print("No projects registered.")
532
+ return
533
+
534
+ registry = json.loads(REGISTRY_PATH.read_text())
535
+ table = Table(title="ModForge-CLI Managed Packs", header_style="bold magenta")
536
+ table.add_column("Pack Name", style="cyan")
537
+ table.add_column("Location", style="dim")
538
+
539
+ for name, path in registry.items():
540
+ table.add_row(name, path)
541
+ console.print(table)
542
+
543
+ @app.command()
544
+ def self_update_cmd():
545
+ """
546
+ Update ModForge-CLI to the latest version.
547
+ """
548
+ try:
549
+ self_update()
550
+ except subprocess.CalledProcessError:
551
+ raise typer.Exit(code=1)
552
+
553
+
554
+ def main():
555
+ app()
556
+
557
+
558
+ if __name__ == "__main__":
559
+ main()
@@ -0,0 +1,6 @@
1
+ from .policy import ModPolicy
2
+ from .resolver import ModResolver
3
+ from .downloader import ModDownloader
4
+ from .models import Manifest, Hit, SearchResult, ProjectVersion, ProjectVersionList
5
+
6
+ __all__ = ["ModPolicy", "ModResolver", "Manifest", "Hit", "SearchResult", "ProjectVersion", "ProjectVersionList", "ModDownloader"]
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ import aiohttp
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, BarColumn, 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]):
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):
55
+ # 1. Fetch compatible version
56
+ url = self.api.project_versions(
57
+ project_id
58
+ )
59
+
60
+ async with self.session.get(url) as r:
61
+ versions = await r.json()
62
+
63
+ if not versions:
64
+ console.print(f"[yellow]No compatible version for {project_id}[/yellow]")
65
+ return
66
+
67
+ version = versions[0]
68
+ file = next(f for f in version["files"] if f["primary"])
69
+
70
+ # 2. Download file
71
+ dest = self.output_dir / file["filename"]
72
+ async with self.session.get(file["url"]) as r:
73
+ data = await r.read()
74
+ dest.write_bytes(data)
75
+
76
+ # 3. Verify hash
77
+ sha1 = hashlib.sha1(data).hexdigest()
78
+ if sha1 != file["hashes"]["sha1"]:
79
+ raise RuntimeError(f"Hash mismatch for {file['filename']}")
80
+
81
+ # 4. Register in index
82
+ self.index["files"].append(
83
+ {
84
+ "path": f"mods/{file['filename']}",
85
+ "hashes": {"sha1": sha1},
86
+ "downloads": [file["url"]],
87
+ "fileSize": file["size"],
88
+ }
89
+ )