modforge-cli 0.2.3__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/__init__.py +7 -0
- modforge_cli/__main__.py +132 -0
- modforge_cli/__version__.py +5 -0
- modforge_cli/api/__init__.py +7 -0
- modforge_cli/api/modrinth.py +210 -0
- modforge_cli/cli/__init__.py +7 -0
- modforge_cli/cli/export.py +246 -0
- modforge_cli/cli/modpack.py +150 -0
- modforge_cli/cli/project.py +72 -0
- modforge_cli/cli/setup.py +72 -0
- modforge_cli/cli/shared.py +41 -0
- modforge_cli/cli/sklauncher.py +125 -0
- modforge_cli/cli/utils.py +64 -0
- modforge_cli/core/__init__.py +39 -0
- modforge_cli/core/downloader.py +208 -0
- modforge_cli/core/models.py +66 -0
- modforge_cli/core/policy.py +161 -0
- modforge_cli/core/resolver.py +184 -0
- modforge_cli/core/utils.py +416 -0
- modforge_cli-0.2.3.dist-info/METADATA +70 -0
- modforge_cli-0.2.3.dist-info/RECORD +24 -0
- modforge_cli-0.2.3.dist-info/WHEEL +4 -0
- modforge_cli-0.2.3.dist-info/entry_points.txt +3 -0
- modforge_cli-0.2.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import traceback
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from modforge_cli.api import ModrinthAPIConfig
|
|
18
|
+
from modforge_cli.core import ModDownloader
|
|
19
|
+
from modforge_cli.core.models import Manifest, SearchResult
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from modforge_cli.__version__ import __author__, __version__
|
|
23
|
+
except ImportError:
|
|
24
|
+
__version__ = "unknown"
|
|
25
|
+
__author__ = "Frank1o3"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_search_term(term: str) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Normalize a search term for fuzzy matching.
|
|
31
|
+
|
|
32
|
+
- Converts to lowercase
|
|
33
|
+
- Removes spaces, dashes, underscores
|
|
34
|
+
- Removes special characters
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
"Dynamic FPS" -> "dynamicfps"
|
|
38
|
+
"sodium-extra" -> "sodiumextra"
|
|
39
|
+
"3D Skin Layers" -> "3dskinlayers"
|
|
40
|
+
"""
|
|
41
|
+
# Convert to lowercase
|
|
42
|
+
normalized = term.lower()
|
|
43
|
+
# Remove spaces, dashes, underscores
|
|
44
|
+
normalized = re.sub(r'[\s\-_]', '', normalized)
|
|
45
|
+
# Remove special characters except alphanumeric
|
|
46
|
+
normalized = re.sub(r'[^a-z0-9]', '', normalized)
|
|
47
|
+
return normalized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def calculate_match_score(search_term: str, hit_slug: str, hit_title: str = "") -> int:
|
|
51
|
+
"""
|
|
52
|
+
Calculate a match score between search term and a mod.
|
|
53
|
+
Higher score = better match.
|
|
54
|
+
|
|
55
|
+
Scoring:
|
|
56
|
+
100: Exact slug match
|
|
57
|
+
90: Exact title match (case-insensitive)
|
|
58
|
+
80: Normalized slug match
|
|
59
|
+
70: Normalized title match
|
|
60
|
+
60: Slug starts with search term
|
|
61
|
+
50: Title starts with search term
|
|
62
|
+
40: Slug contains search term
|
|
63
|
+
30: Title contains search term
|
|
64
|
+
0: No match
|
|
65
|
+
"""
|
|
66
|
+
search_lower = search_term.lower()
|
|
67
|
+
search_normalized = normalize_search_term(search_term)
|
|
68
|
+
|
|
69
|
+
slug_lower = hit_slug.lower()
|
|
70
|
+
slug_normalized = normalize_search_term(hit_slug)
|
|
71
|
+
|
|
72
|
+
title_lower = hit_title.lower() if hit_title else ""
|
|
73
|
+
title_normalized = normalize_search_term(hit_title) if hit_title else ""
|
|
74
|
+
|
|
75
|
+
# Exact matches (highest priority)
|
|
76
|
+
if search_term == hit_slug:
|
|
77
|
+
return 100
|
|
78
|
+
if search_lower == title_lower:
|
|
79
|
+
return 90
|
|
80
|
+
|
|
81
|
+
# Normalized matches
|
|
82
|
+
if search_normalized == slug_normalized:
|
|
83
|
+
return 80
|
|
84
|
+
if title_normalized and search_normalized == title_normalized:
|
|
85
|
+
return 70
|
|
86
|
+
|
|
87
|
+
# Starts with matches
|
|
88
|
+
if slug_lower.startswith(search_lower):
|
|
89
|
+
return 60
|
|
90
|
+
if title_lower and title_lower.startswith(search_lower):
|
|
91
|
+
return 50
|
|
92
|
+
|
|
93
|
+
# Contains matches
|
|
94
|
+
if search_lower in slug_lower:
|
|
95
|
+
return 40
|
|
96
|
+
if title_lower and search_lower in title_lower:
|
|
97
|
+
return 30
|
|
98
|
+
|
|
99
|
+
# Normalized contains (fallback)
|
|
100
|
+
if search_normalized in slug_normalized:
|
|
101
|
+
return 20
|
|
102
|
+
if title_normalized and search_normalized in title_normalized:
|
|
103
|
+
return 10
|
|
104
|
+
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_best_match(search_term: str, hits: list) -> tuple[object, int]:
|
|
109
|
+
"""
|
|
110
|
+
Find the best matching mod from search results.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
(best_hit, score) tuple
|
|
114
|
+
"""
|
|
115
|
+
best_hit = None
|
|
116
|
+
best_score = 0
|
|
117
|
+
|
|
118
|
+
for hit in hits:
|
|
119
|
+
# Get title from hit if available
|
|
120
|
+
title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
|
|
121
|
+
score = calculate_match_score(search_term, hit.slug, title)
|
|
122
|
+
|
|
123
|
+
if score > best_score:
|
|
124
|
+
best_score = score
|
|
125
|
+
best_hit = hit
|
|
126
|
+
|
|
127
|
+
return best_hit, best_score
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_config_file(path: Path, url: str, label: str, console: Console) -> None:
|
|
131
|
+
"""Download config file if missing"""
|
|
132
|
+
if path.exists():
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
console.print(f"[yellow]Missing {label} config.[/yellow] Downloading default…")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
urllib.request.urlretrieve(url, path)
|
|
141
|
+
console.print(f"[green]✓ {label} config installed at {path}[/green]")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
console.print(f"[red]Failed to download {label} config:[/red] {e}")
|
|
144
|
+
raise typer.Exit(1) from e
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- Async Helper ---
|
|
148
|
+
async def get_api_session() -> aiohttp.ClientSession:
|
|
149
|
+
"""Returns a session with the correct ModForge-CLI headers."""
|
|
150
|
+
timeout = aiohttp.ClientTimeout(total=60, connect=10)
|
|
151
|
+
return aiohttp.ClientSession(
|
|
152
|
+
headers={"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"},
|
|
153
|
+
timeout=timeout,
|
|
154
|
+
raise_for_status=False, # Handle errors manually
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_manifest(console: Console, path: Path = Path.cwd()) -> Manifest | None:
|
|
159
|
+
"""Load and validate manifest file"""
|
|
160
|
+
p = path / "ModForge-CLI.json"
|
|
161
|
+
if not p.exists():
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
return Manifest.model_validate_json(p.read_text())
|
|
165
|
+
except Exception as e:
|
|
166
|
+
console.print(f"[red]Error parsing manifest:[/red] {e}")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def save_registry_atomic(registry: dict, path: Path) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Atomically save registry to prevent corruption from concurrent access.
|
|
173
|
+
|
|
174
|
+
Uses a temp file + atomic rename to ensure the registry is never
|
|
175
|
+
left in a partially-written state.
|
|
176
|
+
"""
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
# Write to temp file in same directory (required for atomic rename)
|
|
180
|
+
with tempfile.NamedTemporaryFile(
|
|
181
|
+
mode="w", delete=False, dir=path.parent, prefix=".registry-", suffix=".tmp"
|
|
182
|
+
) as f:
|
|
183
|
+
json.dump(registry, f, indent=4)
|
|
184
|
+
temp_path = Path(f.name)
|
|
185
|
+
|
|
186
|
+
# Atomic rename (POSIX guarantees atomicity)
|
|
187
|
+
try:
|
|
188
|
+
temp_path.replace(path)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
temp_path.unlink(missing_ok=True)
|
|
191
|
+
raise RuntimeError(f"Failed to save registry: {e}") from e
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_registry(path: Path) -> dict[str, str]:
|
|
195
|
+
"""Load registry with error handling"""
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return {}
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(path.read_text())
|
|
201
|
+
except json.JSONDecodeError as e:
|
|
202
|
+
# Registry is corrupted - back it up and start fresh
|
|
203
|
+
backup = path.with_suffix(f".corrupt-{datetime.now():%Y%m%d-%H%M%S}.json")
|
|
204
|
+
shutil.copy(path, backup)
|
|
205
|
+
print(f"Warning: Corrupted registry backed up to {backup}")
|
|
206
|
+
return {}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def setup_crash_logging() -> Path:
|
|
210
|
+
"""Configure crash logging for bug reports"""
|
|
211
|
+
log_dir = Path.home() / ".config" / "ModForge-CLI" / "logs"
|
|
212
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
def excepthook(exc_type, exc_value, exc_traceback) -> None:
|
|
215
|
+
"""Log crashes for bug reports"""
|
|
216
|
+
|
|
217
|
+
log_file = log_dir / f"crash-{datetime.now():%Y%m%d-%H%M%S}.log"
|
|
218
|
+
|
|
219
|
+
with open(log_file, "w") as f:
|
|
220
|
+
f.write(f"ModForge-CLI v{__version__}\n")
|
|
221
|
+
f.write(f"Python {sys.version}\n")
|
|
222
|
+
f.write(f"Platform: {sys.platform}\n\n")
|
|
223
|
+
traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
|
|
224
|
+
|
|
225
|
+
console = Console()
|
|
226
|
+
console.print(f"\n[red bold]ModForge-CLI crashed![/red bold]")
|
|
227
|
+
console.print(f"[yellow]Crash log saved to:[/yellow] {log_file}")
|
|
228
|
+
console.print("[dim]Please include this file when reporting the issue at:")
|
|
229
|
+
console.print("[dim]https://github.com/Frank1o3/ModForge-CLI/issues\n")
|
|
230
|
+
|
|
231
|
+
sys.excepthook = excepthook
|
|
232
|
+
return log_dir
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def install_fabric(
|
|
236
|
+
installer: Path,
|
|
237
|
+
mc_version: str,
|
|
238
|
+
loader_version: str,
|
|
239
|
+
game_dir: Path,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Install Fabric loader with better error handling"""
|
|
242
|
+
try:
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
[
|
|
245
|
+
"java",
|
|
246
|
+
"-jar",
|
|
247
|
+
str(installer),
|
|
248
|
+
"client",
|
|
249
|
+
"-mcversion",
|
|
250
|
+
mc_version,
|
|
251
|
+
"-loader",
|
|
252
|
+
loader_version,
|
|
253
|
+
"-dir",
|
|
254
|
+
str(game_dir),
|
|
255
|
+
"-noprofile",
|
|
256
|
+
],
|
|
257
|
+
check=True,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
)
|
|
261
|
+
except subprocess.CalledProcessError as e:
|
|
262
|
+
raise RuntimeError(
|
|
263
|
+
f"Fabric installation failed:\n{e.stderr}\n\n"
|
|
264
|
+
f"Make sure Java is installed and accessible."
|
|
265
|
+
) from e
|
|
266
|
+
except FileNotFoundError as e:
|
|
267
|
+
raise RuntimeError("Java not found. Please install Java 17 or higher and try again.") from e
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def detect_install_method() -> str:
|
|
271
|
+
"""Detect how ModForge-CLI was installed"""
|
|
272
|
+
prefix = Path(sys.prefix)
|
|
273
|
+
|
|
274
|
+
if "pipx" in prefix.parts or "pipx" in str(prefix):
|
|
275
|
+
return "pipx"
|
|
276
|
+
return "pip"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def self_update(console: Console) -> None:
|
|
280
|
+
"""Update ModForge-CLI to latest version"""
|
|
281
|
+
method = detect_install_method()
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
if method == "pipx":
|
|
285
|
+
console.print("[cyan]Updating ModForge-CLI using pipx...[/cyan]")
|
|
286
|
+
subprocess.run(["pipx", "upgrade", "ModForge-CLI"], check=True)
|
|
287
|
+
else:
|
|
288
|
+
console.print("[cyan]Updating ModForge-CLI using pip...[/cyan]")
|
|
289
|
+
subprocess.run(
|
|
290
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "ModForge-CLI"],
|
|
291
|
+
check=True,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
console.print("[green]✓ ModForge-CLI updated successfully.[/green]")
|
|
295
|
+
except subprocess.CalledProcessError as e:
|
|
296
|
+
console.print(f"[red]Update failed:[/red] {e}")
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def run(api: ModrinthAPIConfig, manifest: Manifest, mods_dir: Path, index_file: Path) -> None:
|
|
301
|
+
"""Download all mods with progress tracking"""
|
|
302
|
+
async with await get_api_session() as session:
|
|
303
|
+
downloader = ModDownloader(
|
|
304
|
+
api=api,
|
|
305
|
+
mc_version=manifest.minecraft,
|
|
306
|
+
loader=manifest.loader,
|
|
307
|
+
output_dir=mods_dir,
|
|
308
|
+
index_file=index_file,
|
|
309
|
+
session=session,
|
|
310
|
+
)
|
|
311
|
+
await downloader.download_all(manifest.mods)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def perform_add(
|
|
315
|
+
api: ModrinthAPIConfig,
|
|
316
|
+
name: str,
|
|
317
|
+
manifest: Manifest,
|
|
318
|
+
project_type: str,
|
|
319
|
+
console: Console,
|
|
320
|
+
manifest_file: Path,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""
|
|
323
|
+
Search and add a project to the manifest with improved fuzzy matching.
|
|
324
|
+
|
|
325
|
+
This function now:
|
|
326
|
+
- Normalizes search terms to handle spaces, dashes, case variations
|
|
327
|
+
- Scores matches to find the best result
|
|
328
|
+
- Shows multiple options if the match is uncertain
|
|
329
|
+
- Provides helpful feedback about what was found
|
|
330
|
+
"""
|
|
331
|
+
async with await get_api_session() as session:
|
|
332
|
+
url = api.search(
|
|
333
|
+
name,
|
|
334
|
+
game_versions=[manifest.minecraft],
|
|
335
|
+
loaders=[manifest.loader],
|
|
336
|
+
project_type=project_type,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
async with session.get(url) as response:
|
|
341
|
+
if response.status != 200:
|
|
342
|
+
console.print(f"[red]API request failed with status {response.status}[/red]")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
results = SearchResult.model_validate_json(await response.text())
|
|
346
|
+
except Exception as e:
|
|
347
|
+
console.print(f"[red]Failed to search Modrinth:[/red] {e}")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
if not results or not results.hits:
|
|
351
|
+
console.print(f"[red]No {project_type} found for '{name}'[/red]")
|
|
352
|
+
console.print(f"[dim]Try searching on https://modrinth.com/mods?q={name}[/dim]")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
# Find best match using scoring system
|
|
356
|
+
best_hit, best_score = find_best_match(name, results.hits)
|
|
357
|
+
|
|
358
|
+
if not best_hit:
|
|
359
|
+
console.print(f"[red]No suitable match found for '{name}'[/red]")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
slug = best_hit.slug
|
|
363
|
+
|
|
364
|
+
# Show what we found with confidence level
|
|
365
|
+
confidence_msg = ""
|
|
366
|
+
if best_score >= 80:
|
|
367
|
+
confidence_msg = "[green](high confidence match)[/green]"
|
|
368
|
+
elif best_score >= 60:
|
|
369
|
+
confidence_msg = "[yellow](medium confidence match)[/yellow]"
|
|
370
|
+
elif best_score >= 40:
|
|
371
|
+
confidence_msg = "[yellow](low confidence match)[/yellow]"
|
|
372
|
+
else:
|
|
373
|
+
confidence_msg = "[red](uncertain match - please verify)[/red]"
|
|
374
|
+
|
|
375
|
+
console.print(f"[cyan]Found:[/cyan] {slug} {confidence_msg}")
|
|
376
|
+
|
|
377
|
+
# If confidence is low and there are multiple results, show alternatives
|
|
378
|
+
if best_score < 60 and len(results.hits) > 1:
|
|
379
|
+
console.print("\n[yellow]Other possible matches:[/yellow]")
|
|
380
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
381
|
+
table.add_column("#", style="dim", width=3)
|
|
382
|
+
table.add_column("Slug", style="cyan")
|
|
383
|
+
table.add_column("Score", justify="right", style="dim")
|
|
384
|
+
|
|
385
|
+
# Show top 5 alternatives
|
|
386
|
+
scored_hits = []
|
|
387
|
+
for hit in results.hits[:10]:
|
|
388
|
+
title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
|
|
389
|
+
score = calculate_match_score(name, hit.slug, title)
|
|
390
|
+
scored_hits.append((hit, score))
|
|
391
|
+
|
|
392
|
+
scored_hits.sort(key=lambda x: x[1], reverse=True)
|
|
393
|
+
|
|
394
|
+
for idx, (hit, score) in enumerate(scored_hits[:5], 1):
|
|
395
|
+
table.add_row(str(idx), hit.slug, str(score))
|
|
396
|
+
|
|
397
|
+
console.print(table)
|
|
398
|
+
console.print("\n[dim]Tip: Use the exact slug if the match is wrong[/dim]")
|
|
399
|
+
console.print(f"[dim]Example: ModForge-CLI add {scored_hits[1][0].slug if len(scored_hits) > 1 else 'exact-slug'}[/dim]\n")
|
|
400
|
+
|
|
401
|
+
# Add to appropriate list
|
|
402
|
+
target_list = {
|
|
403
|
+
"mod": manifest.mods,
|
|
404
|
+
"resourcepack": manifest.resourcepacks,
|
|
405
|
+
"shaderpack": manifest.shaderpacks,
|
|
406
|
+
}.get(project_type, manifest.mods)
|
|
407
|
+
|
|
408
|
+
if slug not in target_list:
|
|
409
|
+
target_list.append(slug)
|
|
410
|
+
try:
|
|
411
|
+
manifest_file.write_text(manifest.model_dump_json(indent=4))
|
|
412
|
+
console.print(f"[green]✓ Added {slug} to {project_type}s[/green]")
|
|
413
|
+
except Exception as e:
|
|
414
|
+
console.print(f"[red]Failed to save manifest:[/red] {e}")
|
|
415
|
+
else:
|
|
416
|
+
console.print(f"[yellow]{slug} is already in the manifest[/yellow]")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modforge-cli
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: ModForge-CLI — a Modrinth-based Minecraft modpack builder
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Frank1o3
|
|
8
|
+
Author-email: jahdy1o3@gmail.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Dist: aiofiles (>=25.1.0,<26.0.0)
|
|
21
|
+
Requires-Dist: aiohttp (>=3.13.3,<4.0.0)
|
|
22
|
+
Requires-Dist: jsonschema (>=4.25.1,<5.0.0)
|
|
23
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
24
|
+
Requires-Dist: pyfiglet (>=1.0.4,<2.0.0)
|
|
25
|
+
Requires-Dist: pyzipper (>=0.3.6,<0.4.0)
|
|
26
|
+
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
27
|
+
Requires-Dist: rich (>=14.2.0,<15.0.0)
|
|
28
|
+
Requires-Dist: typer (>=0.21.1,<0.22.0)
|
|
29
|
+
Project-URL: Homepage, https://frank1o3.github.io/ModForge-CLI/
|
|
30
|
+
Project-URL: Repository, https://github.com/Frank1o3/ModForge-CLI
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ModForge-CLI ⛏
|
|
34
|
+
|
|
35
|
+
[](https://www.python.org/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://docs.modrinth.com/api-spec)
|
|
38
|
+
|
|
39
|
+
**ModForge-CLI** is a powerful CLI tool for building and managing custom Minecraft modpacks using the Modrinth API v2.
|
|
40
|
+
|
|
41
|
+
Search for projects, fetch versions, validate manifests, download mods with hash checks, and generate complete files — all from the terminal.
|
|
42
|
+
|
|
43
|
+
Ideal for modpack developers, server admins, and automation scripts.
|
|
44
|
+
|
|
45
|
+
## Terminal Banner
|
|
46
|
+
|
|
47
|
+
When you run ModForge-CLI, you'll be greeted with this colorful Minecraft-themed banner
|
|
48
|
+
|
|
49
|
+
## Key Features
|
|
50
|
+
|
|
51
|
+
- **Modrinth API v2 Integration**: Search projects, list versions, fetch metadata in bulk.
|
|
52
|
+
- **Modpack Management**: Read/validate `modrinth.index.json`, build packs from metadata.
|
|
53
|
+
- **Validation**: Full JSON Schema checks + optional Pydantic models for strict typing.
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
Requires **Python 3.13+**.
|
|
58
|
+
|
|
59
|
+
**Recommended (Poetry)**:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
poetry install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Alternative (pip)**:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -r requirements.txt
|
|
69
|
+
```
|
|
70
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
modforge_cli/__init__.py,sha256=UFmSxUpR5CtjtHIrKKfgWhpZeM559ufyWg3Q03SbJ_g,77
|
|
2
|
+
modforge_cli/__main__.py,sha256=KxLK89lHZn5zaMp8l42d4AAzdqh2I9LSyE9XSWlL18k,4257
|
|
3
|
+
modforge_cli/__version__.py,sha256=339BKq5iPtupYyyjt36PE8YybSEHI19D6hVQAqJvMEU,88
|
|
4
|
+
modforge_cli/api/__init__.py,sha256=5BlfsH345QbdgkwpRCOLKhNLGlgRRWAy7oaceAXgbmU,138
|
|
5
|
+
modforge_cli/api/modrinth.py,sha256=ptxAzaQ309i2uWZd1hLjjQDyZNcbAJr7RxT_13bqCXM,7787
|
|
6
|
+
modforge_cli/cli/__init__.py,sha256=XzI-mK4_ycE-GFkwE4vtGklzzDEAjKDApiiCeq7d9BA,196
|
|
7
|
+
modforge_cli/cli/export.py,sha256=pk6HZmnF-DN15TIu16-SXtq_B2AlhJW2BbbxCMgZO30,9734
|
|
8
|
+
modforge_cli/cli/modpack.py,sha256=MpOjVT9UAnwJ-hliDi2Xtz0PzWRTwpCPMic922oRMp4,4595
|
|
9
|
+
modforge_cli/cli/project.py,sha256=19-rLCAb3xNdEr7z0eq8dW3EfbQNjTufrCBLvVz54HE,1916
|
|
10
|
+
modforge_cli/cli/setup.py,sha256=ZhnfWsH9OnMxf2eSR3ZWFdyK0jIv8zfqeffcLoD7-Yo,2081
|
|
11
|
+
modforge_cli/cli/shared.py,sha256=-YAylKVUIHlPrPXv6dm6KiF7R5DkUcBnPkjqmrhK8zU,1161
|
|
12
|
+
modforge_cli/cli/sklauncher.py,sha256=OaNc3W_5bCHEIN192UWv5T_Yc6kujzU4S5cMjtNI0n8,4408
|
|
13
|
+
modforge_cli/cli/utils.py,sha256=Wn-bXsrVTdsKGZd-P7APycNQWu3mnZZaP9ud4SaThMk,1960
|
|
14
|
+
modforge_cli/core/__init__.py,sha256=WHmCpDLbhIh7I0HL63mNAk3w6opFgJhCwybnFCqIrJk,844
|
|
15
|
+
modforge_cli/core/downloader.py,sha256=LWOwpAZjXXANTGnDJz5IqiLK88oLNzsob6YfHUYmpAU,7134
|
|
16
|
+
modforge_cli/core/models.py,sha256=gGn9f8P89dggMrk-Mu4NYYlylyJ5uF6WVU4PnDNErJk,1782
|
|
17
|
+
modforge_cli/core/policy.py,sha256=mAnIyTd2aRTJTzQBDQWjuas9sFQVxy4fTNTwpCOBt6M,4766
|
|
18
|
+
modforge_cli/core/resolver.py,sha256=qxRH115rQUNSy_7rg3P5knuKx15zLccc04vUyOorPYA,6210
|
|
19
|
+
modforge_cli/core/utils.py,sha256=rTKApM5PNKXjZz3uOIIVReXYu62_v9t0k19h6RPoj30,13938
|
|
20
|
+
modforge_cli-0.2.3.dist-info/METADATA,sha256=PM3rStkpJAJ5JcgbhVbwxMANnsT5mcZsmJGGP8APYKA,2468
|
|
21
|
+
modforge_cli-0.2.3.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
|
|
22
|
+
modforge_cli-0.2.3.dist-info/entry_points.txt,sha256=_6WKjhPS5-J1nNSwGiljk6l9xchIpXmeH7gxrTfse7E,59
|
|
23
|
+
modforge_cli-0.2.3.dist-info/licenses/LICENSE,sha256=SAuHlb0YymKIXKAXl0lwgwwu31REY-3oBdLARIObBbs,1065
|
|
24
|
+
modforge_cli-0.2.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Frank1o3
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|