modforge-cli 0.2.7__tar.gz → 0.2.9__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.
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/PKG-INFO +2 -6
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/pyproject.toml +16 -11
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/__init__.py +1 -1
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/__main__.py +3 -2
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/__version__.py +1 -1
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/api/modrinth.py +14 -14
- modforge_cli-0.2.9/src/modforge_cli/cli/__init__.py +7 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/modpack.py +20 -5
- modforge_cli-0.2.9/src/modforge_cli/cli/mods.py +161 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/downloader.py +20 -24
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/models.py +2 -1
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/resolver.py +97 -32
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/utils.py +23 -23
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/test.py +1 -1
- modforge_cli-0.2.7/src/modforge_cli/cli/__init__.py +0 -7
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/LICENSE +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/README.md +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/api/__init__.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/export.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/project.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/setup.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/shared.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/sklauncher.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/cli/utils.py +0 -0
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/__init__.py +9 -9
- {modforge_cli-0.2.7 → modforge_cli-0.2.9}/src/modforge_cli/core/policy.py +0 -0
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modforge-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: ModForge-CLI — a Modrinth-based Minecraft modpack builder
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Author: Frank1o3
|
|
8
8
|
Author-email: jahdy1o3@gmail.com
|
|
9
|
-
Requires-Python: >=3.
|
|
9
|
+
Requires-Python: >=3.13,<3.15
|
|
10
10
|
Classifier: Development Status :: 4 - Beta
|
|
11
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
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
14
|
Classifier: Operating System :: OS Independent
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "modforge-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.9"
|
|
4
4
|
description = "ModForge-CLI — a Modrinth-based Minecraft modpack builder"
|
|
5
|
-
authors = [{ name = "Frank1o3", email = "jahdy1o3@gmail.com" }]
|
|
6
|
-
license = { text = "MIT" }
|
|
7
5
|
readme = "README.md"
|
|
8
|
-
requires-python = ">=3.
|
|
6
|
+
requires-python = ">=3.13,<3.15"
|
|
7
|
+
|
|
9
8
|
dependencies = [
|
|
10
9
|
"requests (>=2.32.5,<3.0.0)",
|
|
11
10
|
"jsonschema (>=4.25.1,<5.0.0)",
|
|
@@ -17,31 +16,37 @@ dependencies = [
|
|
|
17
16
|
"pyfiglet (>=1.0.4,<2.0.0)",
|
|
18
17
|
"typer (>=0.21.1,<0.22.0)",
|
|
19
18
|
]
|
|
19
|
+
|
|
20
20
|
classifiers = [
|
|
21
21
|
"Development Status :: 4 - Beta",
|
|
22
22
|
"Environment :: Console",
|
|
23
|
-
"Programming Language :: Python :: 3",
|
|
24
|
-
"Programming Language :: Python :: 3.10",
|
|
25
|
-
"Programming Language :: Python :: 3.11",
|
|
26
|
-
"Programming Language :: Python :: 3.12",
|
|
27
23
|
"Programming Language :: Python :: 3.13",
|
|
28
24
|
"License :: OSI Approved :: MIT License",
|
|
29
25
|
"Operating System :: OS Independent",
|
|
30
26
|
"Topic :: Software Development :: Build Tools",
|
|
31
27
|
]
|
|
32
28
|
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Frank1o3"
|
|
31
|
+
email = "jahdy1o3@gmail.com"
|
|
32
|
+
|
|
33
|
+
[project.license]
|
|
34
|
+
text = "MIT"
|
|
35
|
+
|
|
33
36
|
[build-system]
|
|
34
37
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
35
38
|
build-backend = "poetry.core.masonry.api"
|
|
36
39
|
|
|
37
40
|
[dependency-groups]
|
|
38
41
|
dev = [
|
|
42
|
+
"ruff (>=0.14.8,<0.15.0)",
|
|
43
|
+
"mypy (>=1.19.0,<2.0.0)",
|
|
44
|
+
]
|
|
45
|
+
types = [
|
|
39
46
|
"types-requests (>=2.32.4.20250913,<3.0.0.0)",
|
|
40
47
|
"types-jsonschema (>=4.25.1.20251009,<5.0.0.0)",
|
|
41
48
|
"types-tqdm (>=4.67.0.20250809,<5.0.0.0)",
|
|
42
|
-
"
|
|
43
|
-
"mypy (>=1.19.0,<2.0.0)",
|
|
44
|
-
"types-colorama (>=0.4.15.20250801,<0.5.0.0)",
|
|
49
|
+
"types-colorama (>=0.4.15.20250801,<0.5.0.0)"
|
|
45
50
|
]
|
|
46
51
|
|
|
47
52
|
[tool.poetry]
|
|
@@ -103,9 +103,10 @@ def register_commands() -> None:
|
|
|
103
103
|
Delayed import prevents import-time crashes.
|
|
104
104
|
"""
|
|
105
105
|
|
|
106
|
-
from modforge_cli.cli import export, modpack, project, setup, sklauncher
|
|
106
|
+
from modforge_cli.cli import export, modpack, mods, project, setup, sklauncher
|
|
107
107
|
|
|
108
108
|
app.command()(setup.setup)
|
|
109
|
+
app.add_typer(mods.app, name="mods", help="Mod management commands")
|
|
109
110
|
app.add_typer(project.app, name="project", help="Project management commands")
|
|
110
111
|
app.command("ls")(project.list_projects)
|
|
111
112
|
app.command()(project.remove)
|
|
@@ -132,4 +133,4 @@ def main() -> None:
|
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
if __name__ == "__main__":
|
|
135
|
-
main()
|
|
136
|
+
main()
|
|
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
9
|
+
from typing import Any
|
|
10
10
|
from urllib.parse import quote_plus
|
|
11
11
|
|
|
12
12
|
|
|
@@ -16,14 +16,14 @@ class ModrinthAPIConfig:
|
|
|
16
16
|
def __init__(self, config_path: str | Path = "configs/modrinth_api.json"):
|
|
17
17
|
self.config_path = config_path if isinstance(config_path, Path) else Path(config_path)
|
|
18
18
|
self.base_url: str = ""
|
|
19
|
-
self.endpoints:
|
|
19
|
+
self.endpoints: dict[str, Any] = {}
|
|
20
20
|
self._load_config()
|
|
21
21
|
|
|
22
22
|
def _load_config(self) -> None:
|
|
23
23
|
if not self.config_path.exists():
|
|
24
24
|
raise FileNotFoundError(f"Modrinth API config not found: {self.config_path}")
|
|
25
25
|
|
|
26
|
-
with open(self.config_path,
|
|
26
|
+
with open(self.config_path, encoding="utf-8") as f:
|
|
27
27
|
data = json.load(f)
|
|
28
28
|
|
|
29
29
|
self.base_url = data.get("BASE_URL", "").rstrip("/")
|
|
@@ -31,7 +31,7 @@ class ModrinthAPIConfig:
|
|
|
31
31
|
raise ValueError("BASE_URL missing in modrinth_api.json")
|
|
32
32
|
|
|
33
33
|
self.endpoints = data.get("ENDPOINTS", {})
|
|
34
|
-
if not isinstance(self.endpoints,
|
|
34
|
+
if not isinstance(self.endpoints, dict):
|
|
35
35
|
raise ValueError("ENDPOINTS section is invalid")
|
|
36
36
|
|
|
37
37
|
def build_url(self, template: str, **kwargs: str) -> str:
|
|
@@ -46,16 +46,16 @@ class ModrinthAPIConfig:
|
|
|
46
46
|
|
|
47
47
|
def search(
|
|
48
48
|
self,
|
|
49
|
-
query:
|
|
50
|
-
facets:
|
|
51
|
-
categories:
|
|
52
|
-
loaders:
|
|
53
|
-
game_versions:
|
|
54
|
-
license_:
|
|
55
|
-
project_type:
|
|
56
|
-
offset:
|
|
57
|
-
limit:
|
|
58
|
-
index:
|
|
49
|
+
query: str | None = None,
|
|
50
|
+
facets: list[list[str]] | str | None = None,
|
|
51
|
+
categories: list[str] | None = None,
|
|
52
|
+
loaders: list[str] | None = None,
|
|
53
|
+
game_versions: list[str] | None = None,
|
|
54
|
+
license_: str | None = None,
|
|
55
|
+
project_type: str | None = None,
|
|
56
|
+
offset: int | None = None,
|
|
57
|
+
limit: int | None = 10,
|
|
58
|
+
index: str | None = "relevance",
|
|
59
59
|
) -> str:
|
|
60
60
|
"""
|
|
61
61
|
Build the Modrinth search URL with query parameters.
|
|
@@ -61,7 +61,14 @@ def add(name: str, project_type: str = "mod", pack_name: str | None = None) -> N
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
@app.command()
|
|
64
|
-
def resolve(
|
|
64
|
+
def resolve(
|
|
65
|
+
pack_name: str | None = None,
|
|
66
|
+
include_optional: bool = typer.Option(
|
|
67
|
+
False,
|
|
68
|
+
"--include-optional",
|
|
69
|
+
help="Include optional dependencies (e.g., ViaFabric multi-version sub-mods)",
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
65
72
|
"""Resolve all mod dependencies"""
|
|
66
73
|
api = ModrinthAPIConfig(MODRINTH_API)
|
|
67
74
|
|
|
@@ -87,14 +94,22 @@ def resolve(pack_name: str | None = None) -> None:
|
|
|
87
94
|
console.print("[red]Could not load manifest[/red]")
|
|
88
95
|
raise typer.Exit(1)
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
if include_optional:
|
|
98
|
+
console.print(f"[cyan]Resolving dependencies for {pack_name} (including optional)...[/cyan]")
|
|
99
|
+
else:
|
|
100
|
+
console.print(f"[cyan]Resolving dependencies for {pack_name} (required only)...[/cyan]")
|
|
101
|
+
console.print("[dim]Tip: Use --include-optional to add optional dependencies[/dim]")
|
|
91
102
|
|
|
92
103
|
policy = ModPolicy(POLICY_PATH)
|
|
93
104
|
resolver = ModResolver(
|
|
94
|
-
policy=policy,
|
|
105
|
+
policy=policy,
|
|
106
|
+
api=api,
|
|
107
|
+
mc_version=manifest.minecraft,
|
|
108
|
+
loader=manifest.loader,
|
|
109
|
+
include_optional_deps=include_optional,
|
|
95
110
|
)
|
|
96
111
|
|
|
97
|
-
async def do_resolve():
|
|
112
|
+
async def do_resolve() -> set[str]:
|
|
98
113
|
async with await get_api_session() as session:
|
|
99
114
|
return await resolver.resolve(manifest.mods, session)
|
|
100
115
|
|
|
@@ -104,7 +119,7 @@ def resolve(pack_name: str | None = None) -> None:
|
|
|
104
119
|
console.print(f"[red]Resolution failed:[/red] {e}")
|
|
105
120
|
raise typer.Exit(1) from e
|
|
106
121
|
|
|
107
|
-
manifest.mods = sorted(
|
|
122
|
+
manifest.mods = sorted(resolved_mods)
|
|
108
123
|
manifest_file.write_text(manifest.model_dump_json(indent=4))
|
|
109
124
|
|
|
110
125
|
console.print(f"[green]✓ Resolved {len(manifest.mods)} mods[/green]")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mod management command - List, inspect, and verify mods in a modpack
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from modforge_cli.api import ModrinthAPIConfig
|
|
13
|
+
from modforge_cli.cli.shared import MODRINTH_API, REGISTRY_PATH, console
|
|
14
|
+
from modforge_cli.core import get_api_session, get_manifest, load_registry
|
|
15
|
+
|
|
16
|
+
app = typer.Typer()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list_mods(pack_name: str | None = None, resolved: bool = False) -> None:
|
|
21
|
+
"""
|
|
22
|
+
List all mods in the current modpack.
|
|
23
|
+
|
|
24
|
+
Shows mod names, slugs, and project IDs from the manifest.
|
|
25
|
+
Use --resolved to also fetch and display actual mod names from Modrinth.
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
ModForge-CLI mods list # Show manifest entries
|
|
29
|
+
ModForge-CLI mods list --resolved # Fetch and show actual mod names
|
|
30
|
+
ModForge-CLI mods list --pack-name MyPack
|
|
31
|
+
"""
|
|
32
|
+
# Auto-detect pack if not specified
|
|
33
|
+
if not pack_name:
|
|
34
|
+
manifest = get_manifest(console, path=Path.cwd())
|
|
35
|
+
if manifest:
|
|
36
|
+
pack_name = manifest.name
|
|
37
|
+
else:
|
|
38
|
+
console.print("[red]No manifest found in current directory[/red]")
|
|
39
|
+
console.print("[yellow]Specify --pack-name or run from project directory[/yellow]")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
registry = load_registry(REGISTRY_PATH)
|
|
43
|
+
if pack_name not in registry:
|
|
44
|
+
console.print(f"[red]Pack '{pack_name}' not found in registry[/red]")
|
|
45
|
+
console.print("[yellow]Available packs:[/yellow]")
|
|
46
|
+
for p in registry:
|
|
47
|
+
console.print(f" - {p}")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
pack_path = Path(registry[pack_name])
|
|
51
|
+
manifest = get_manifest(console, pack_path)
|
|
52
|
+
if not manifest:
|
|
53
|
+
console.print(f"[red]Could not load manifest for '{pack_name}'[/red]")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
if not manifest.mods:
|
|
57
|
+
console.print("[yellow]No mods in this modpack[/yellow]")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if resolved:
|
|
61
|
+
# Fetch actual mod names from Modrinth
|
|
62
|
+
asyncio.run(_fetch_and_display_mod_info(manifest.mods))
|
|
63
|
+
else:
|
|
64
|
+
# Just show what's in the manifest
|
|
65
|
+
_show_manifest_mods(manifest.mods, manifest.name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _show_manifest_mods(mods: list[str], pack_name: str) -> None:
|
|
69
|
+
"""Display mods from manifest without API calls"""
|
|
70
|
+
console.print(f"\n[cyan bold]Mods in '{pack_name}' (from manifest):[/cyan bold]\n")
|
|
71
|
+
|
|
72
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
73
|
+
table.add_column("#", style="dim", width=4)
|
|
74
|
+
table.add_column("Slug / ID", style="green")
|
|
75
|
+
table.add_column("Type", style="dim")
|
|
76
|
+
|
|
77
|
+
for idx, mod in enumerate(sorted(mods), 1):
|
|
78
|
+
# Heuristic: project IDs are typically numeric-like strings (e.g., "AANobbMI")
|
|
79
|
+
# Slugs are typically lowercase with dashes
|
|
80
|
+
if any(c.isupper() for c in mod) or len(mod) < 5:
|
|
81
|
+
mod_type = "Project ID"
|
|
82
|
+
else:
|
|
83
|
+
mod_type = "Slug"
|
|
84
|
+
table.add_row(str(idx), mod, mod_type)
|
|
85
|
+
|
|
86
|
+
console.print(table)
|
|
87
|
+
console.print(f"\n[dim]Total: {len(mods)} mod(s)[/dim]")
|
|
88
|
+
console.print("[yellow]Tip: Use 'ModForge-CLI mods list --resolved' to fetch actual names[/yellow]\n")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _fetch_and_display_mod_info(mod_entries: list[str]) -> None:
|
|
92
|
+
"""Fetch mod info from Modrinth API and display"""
|
|
93
|
+
api = ModrinthAPIConfig(MODRINTH_API)
|
|
94
|
+
|
|
95
|
+
console.print("\n[cyan bold]Fetching mod information from Modrinth...[/cyan bold]\n")
|
|
96
|
+
|
|
97
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
98
|
+
table.add_column("#", style="dim", width=4)
|
|
99
|
+
table.add_column("Name", style="green")
|
|
100
|
+
table.add_column("Slug", style="cyan")
|
|
101
|
+
table.add_column("Project ID", style="dim")
|
|
102
|
+
table.add_column("Summary", style="yellow")
|
|
103
|
+
|
|
104
|
+
async with await get_api_session() as session:
|
|
105
|
+
tasks = [_fetch_mod_info(api, entry, session) for entry in sorted(mod_entries)]
|
|
106
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
107
|
+
|
|
108
|
+
success_count = 0
|
|
109
|
+
for idx, result in enumerate(results, 1):
|
|
110
|
+
if isinstance(result, BaseException):
|
|
111
|
+
table.add_row(str(idx), "[red]Error[/red]", "[red]Error[/red]", "[red]Error[/red]", "")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if result is None:
|
|
115
|
+
table.add_row(str(idx), "[yellow]Not found[/yellow]", "[yellow]N/A[/yellow]", "[yellow]N/A[/yellow]", "")
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
success_count += 1
|
|
119
|
+
name, slug, project_id, summary = result
|
|
120
|
+
# Truncate summary if too long
|
|
121
|
+
if summary and len(summary) > 60:
|
|
122
|
+
summary = summary[:57] + "..."
|
|
123
|
+
table.add_row(str(idx), name, slug, project_id, summary or "")
|
|
124
|
+
|
|
125
|
+
console.print(table)
|
|
126
|
+
console.print(f"\n[dim]Resolved: {success_count}/{len(mod_entries)} mod(s)[/dim]\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _fetch_mod_info(
|
|
130
|
+
api: ModrinthAPIConfig, entry: str, session: aiohttp.ClientSession
|
|
131
|
+
) -> tuple[str, str, str, str] | None:
|
|
132
|
+
"""
|
|
133
|
+
Fetch mod information from Modrinth API.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
entry: Can be either a slug or project ID
|
|
137
|
+
session: Active aiohttp session
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple of (name, slug, project_id, summary) or None on failure
|
|
141
|
+
"""
|
|
142
|
+
url = api.project(entry)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
async with session.get(url) as response:
|
|
146
|
+
if response.status != 200:
|
|
147
|
+
console.print(f"[dim] Warning: Failed to fetch '{entry}' (HTTP {response.status})[/dim]")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
data = await response.json()
|
|
151
|
+
|
|
152
|
+
name = data.get("title", entry)
|
|
153
|
+
slug = data.get("slug", entry)
|
|
154
|
+
project_id = data.get("id", entry)
|
|
155
|
+
summary = data.get("description", "")
|
|
156
|
+
|
|
157
|
+
return (name, slug, project_id, summary)
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
console.print(f"[dim] Error fetching '{entry}': {e}[/dim]")
|
|
161
|
+
return None
|
|
@@ -5,7 +5,7 @@ from collections.abc import Iterable
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
import aiohttp
|
|
11
11
|
from rich.console import Console
|
|
@@ -54,43 +54,40 @@ class ModDownloader:
|
|
|
54
54
|
if "files" not in self.index:
|
|
55
55
|
self.index["files"] = []
|
|
56
56
|
|
|
57
|
-
def _select_compatible_version(self, versions: list[
|
|
57
|
+
def _select_compatible_version(self, versions: list[Any]) -> Any | None:
|
|
58
58
|
"""
|
|
59
|
-
Select the
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
Select the best version for the target MC version and loader.
|
|
60
|
+
|
|
61
|
+
Priority:
|
|
62
|
+
1. Release versions matching MC + loader (newest first)
|
|
63
|
+
2. Any version matching MC + loader (newest first)
|
|
64
|
+
|
|
65
|
+
This matches the resolver's _select_version logic for consistency.
|
|
63
66
|
"""
|
|
64
|
-
# Normalize loader name for comparison
|
|
65
67
|
loader_lower = self.loader.lower()
|
|
68
|
+
version_priority = {"release": 3, "beta": 2, "alpha": 1}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
def version_score(v: Any) -> tuple[int, str]:
|
|
71
|
+
vtype = version_priority.get(v.get("version_type", "alpha"), 0)
|
|
72
|
+
# Use date_published for chronological ordering (ISO 8601, sortable)
|
|
73
|
+
vdate = v.get("date_published", "") or ""
|
|
74
|
+
return (vtype, str(vdate))
|
|
75
|
+
|
|
76
|
+
# Filter compatible versions
|
|
68
77
|
compatible = []
|
|
69
78
|
for v in versions:
|
|
70
|
-
# Check if MC version matches
|
|
71
79
|
if self.mc_version not in v.get("game_versions", []):
|
|
72
80
|
continue
|
|
73
|
-
|
|
74
|
-
# Check if loader matches (case-insensitive)
|
|
75
|
-
loaders = [l.lower() for l in v.get("loaders", [])]
|
|
81
|
+
loaders = [loader.lower() for loader in v.get("loaders", [])]
|
|
76
82
|
if loader_lower not in loaders:
|
|
77
83
|
continue
|
|
78
|
-
|
|
79
84
|
compatible.append(v)
|
|
80
85
|
|
|
81
86
|
if not compatible:
|
|
82
87
|
return None
|
|
83
88
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def version_score(v) -> int:
|
|
88
|
-
vtype = v.get("version_type", "alpha")
|
|
89
|
-
return version_priority.get(vtype, 0)
|
|
90
|
-
|
|
91
|
-
# Sort by version type, then by date (newest first)
|
|
92
|
-
compatible.sort(key=lambda v: (version_score(v), v.get("date_published", "")), reverse=True)
|
|
93
|
-
|
|
89
|
+
# Sort by version type (release > beta > alpha), then by ID (newest first)
|
|
90
|
+
compatible.sort(key=version_score, reverse=True)
|
|
94
91
|
return compatible[0]
|
|
95
92
|
|
|
96
93
|
async def download_all(self, project_ids: Iterable[str]) -> None:
|
|
@@ -122,7 +119,6 @@ class ModDownloader:
|
|
|
122
119
|
# 1. Fetch all versions for this project
|
|
123
120
|
project_url = self.api.project(project_id)
|
|
124
121
|
url = self.api.project_versions(project_id)
|
|
125
|
-
self.api.environments()
|
|
126
122
|
|
|
127
123
|
try:
|
|
128
124
|
async with self.session.get(url) as r, self.session.get(project_url) as rs:
|
|
@@ -55,6 +55,7 @@ class ProjectVersion(BaseAPIModel):
|
|
|
55
55
|
files: list[File] = Field(default_factory=list)
|
|
56
56
|
game_versions: list[str] = Field(default_factory=list)
|
|
57
57
|
loaders: list[str] = Field(default_factory=list)
|
|
58
|
+
date_published: str | None = None
|
|
58
59
|
|
|
59
60
|
@property
|
|
60
61
|
def is_release(self) -> bool:
|
|
@@ -63,4 +64,4 @@ class ProjectVersion(BaseAPIModel):
|
|
|
63
64
|
|
|
64
65
|
ProjectVersionList = TypeAdapter(list[ProjectVersion])
|
|
65
66
|
|
|
66
|
-
__all__ = ["Manifest", "
|
|
67
|
+
__all__ = ["Manifest", "ProjectVersion", "ProjectVersionList", "SearchResult"]
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from collections import deque
|
|
3
3
|
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
import aiohttp
|
|
7
|
+
from rich.console import Console
|
|
6
8
|
|
|
7
9
|
from modforge_cli.api import ModrinthAPIConfig
|
|
8
|
-
from modforge_cli.core.models import ProjectVersion, ProjectVersionList, SearchResult
|
|
10
|
+
from modforge_cli.core.models import Hit, ProjectVersion, ProjectVersionList, SearchResult
|
|
9
11
|
from modforge_cli.core.policy import ModPolicy
|
|
12
|
+
from modforge_cli.core.utils import calculate_match_score
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
10
15
|
|
|
11
16
|
try:
|
|
12
17
|
from modforge_cli.__version__ import __author__, __version__
|
|
@@ -23,52 +28,97 @@ class ModResolver:
|
|
|
23
28
|
api: ModrinthAPIConfig,
|
|
24
29
|
mc_version: str,
|
|
25
30
|
loader: str,
|
|
31
|
+
include_optional_deps: bool = False,
|
|
26
32
|
) -> None:
|
|
27
33
|
self.policy = policy
|
|
28
34
|
self.api = api
|
|
29
35
|
self.mc_version = mc_version
|
|
30
36
|
self.loader = loader
|
|
37
|
+
self.include_optional_deps = include_optional_deps
|
|
31
38
|
|
|
32
39
|
self._headers = {"User-Agent": f"{__author__}/ModForge-CLI/{__version__}"}
|
|
33
40
|
|
|
34
41
|
def _select_version(self, versions: list[ProjectVersion]) -> ProjectVersion | None:
|
|
35
42
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
Select the best version for the target MC version and loader.
|
|
44
|
+
|
|
45
|
+
Priority:
|
|
46
|
+
1. Release versions matching MC + loader (newest first)
|
|
47
|
+
2. Any version matching MC + loader (newest first)
|
|
39
48
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
version_priority = {"release": 3, "beta": 2, "alpha": 1}
|
|
50
|
+
|
|
51
|
+
def version_score(v: ProjectVersion) -> tuple[int, str]:
|
|
52
|
+
vtype = version_priority.get(v.version_type, 0)
|
|
53
|
+
# Use date_published for chronological ordering (ISO 8601, sortable)
|
|
54
|
+
vdate = v.date_published or ""
|
|
55
|
+
return (vtype, vdate)
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
# Filter compatible versions
|
|
58
|
+
compatible = [
|
|
59
|
+
v for v in versions
|
|
60
|
+
if self.mc_version in v.game_versions and self.loader in v.loaders
|
|
61
|
+
]
|
|
47
62
|
|
|
48
|
-
|
|
63
|
+
if not compatible:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Sort by version type (release > beta > alpha), then by date (newest first)
|
|
67
|
+
compatible.sort(key=version_score, reverse=True)
|
|
68
|
+
return compatible[0]
|
|
49
69
|
|
|
50
70
|
async def _search_project(self, slug: str, session: aiohttp.ClientSession) -> str | None:
|
|
51
|
-
"""
|
|
71
|
+
"""
|
|
72
|
+
Search for a project by slug and return its project_id.
|
|
73
|
+
|
|
74
|
+
Uses fuzzy matching to find the best hit (same scoring as 'add' command).
|
|
75
|
+
Returns None if no suitable match is found.
|
|
76
|
+
"""
|
|
52
77
|
url = self.api.search(
|
|
53
78
|
slug,
|
|
54
79
|
game_versions=[self.mc_version],
|
|
55
80
|
loaders=[self.loader],
|
|
81
|
+
project_type="mod",
|
|
56
82
|
)
|
|
57
83
|
|
|
58
84
|
try:
|
|
59
85
|
async with session.get(url) as response:
|
|
60
86
|
data = SearchResult.model_validate_json(await response.text())
|
|
61
87
|
|
|
88
|
+
if not data.hits:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Find best matching hit using the same scoring as 'add' command
|
|
92
|
+
best_hit: Hit | None = None
|
|
93
|
+
best_score = 0
|
|
94
|
+
|
|
62
95
|
for hit in data.hits:
|
|
63
96
|
if hit.project_type != "mod":
|
|
64
97
|
continue
|
|
65
98
|
if self.mc_version not in hit.versions:
|
|
66
99
|
continue
|
|
67
|
-
return hit.project_id
|
|
68
|
-
except Exception as e:
|
|
69
|
-
print(f"Warning: Failed to search for '{slug}': {e}")
|
|
70
100
|
|
|
71
|
-
|
|
101
|
+
score = calculate_match_score(slug, hit.slug, getattr(hit, "title", ""))
|
|
102
|
+
if score > best_score:
|
|
103
|
+
best_score = score
|
|
104
|
+
best_hit = hit
|
|
105
|
+
|
|
106
|
+
# Only accept high-confidence matches (score >= 80)
|
|
107
|
+
if best_hit and best_score >= 80:
|
|
108
|
+
return best_hit.project_id
|
|
109
|
+
|
|
110
|
+
# Fallback: if exact slug match (score 100), accept regardless
|
|
111
|
+
for hit in data.hits:
|
|
112
|
+
if hit.project_type != "mod":
|
|
113
|
+
continue
|
|
114
|
+
if hit.slug == slug:
|
|
115
|
+
return hit.project_id
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[yellow]Warning: Failed to search for '{slug}': {e}[/yellow]")
|
|
121
|
+
return None
|
|
72
122
|
|
|
73
123
|
async def _fetch_versions(
|
|
74
124
|
self, project_id: str, session: aiohttp.ClientSession
|
|
@@ -80,7 +130,7 @@ class ModResolver:
|
|
|
80
130
|
async with session.get(url) as response:
|
|
81
131
|
return ProjectVersionList.validate_json(await response.text())
|
|
82
132
|
except Exception as e:
|
|
83
|
-
print(f"Warning: Failed to fetch versions for '{project_id}': {e}")
|
|
133
|
+
console.print(f"[yellow]Warning: Failed to fetch versions for '{project_id}': {e}[/yellow]")
|
|
84
134
|
return []
|
|
85
135
|
|
|
86
136
|
async def resolve(self, mods: Iterable[str], session: aiohttp.ClientSession) -> set[str]:
|
|
@@ -99,8 +149,9 @@ class ModResolver:
|
|
|
99
149
|
resolved: set[str] = set()
|
|
100
150
|
queue: deque[str] = deque()
|
|
101
151
|
|
|
102
|
-
search_cache: dict[str,
|
|
152
|
+
search_cache: dict[str, Any] = {}
|
|
103
153
|
version_cache: dict[str, list[ProjectVersion]] = {}
|
|
154
|
+
failed_slugs: list[str] = []
|
|
104
155
|
|
|
105
156
|
# ---- Phase 1: slug → project_id (parallel) ----
|
|
106
157
|
search_tasks = []
|
|
@@ -114,12 +165,15 @@ class ModResolver:
|
|
|
114
165
|
if search_tasks:
|
|
115
166
|
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
|
116
167
|
|
|
117
|
-
for slug, result in zip(slugs_to_search, search_results):
|
|
168
|
+
for slug, result in zip(slugs_to_search, search_results, strict=False):
|
|
118
169
|
if isinstance(result, Exception):
|
|
119
|
-
print(f"Error searching for '{slug}': {result}")
|
|
170
|
+
console.print(f"[red]Error searching for '{slug}': {result}[/red]")
|
|
171
|
+
failed_slugs.append(slug)
|
|
172
|
+
elif result is None:
|
|
173
|
+
failed_slugs.append(slug)
|
|
120
174
|
search_cache[slug] = None
|
|
121
175
|
else:
|
|
122
|
-
search_cache[slug] = result
|
|
176
|
+
search_cache[slug] = str(result)
|
|
123
177
|
|
|
124
178
|
# Add found projects to queue
|
|
125
179
|
for slug in expanded:
|
|
@@ -150,12 +204,12 @@ class ModResolver:
|
|
|
150
204
|
if version_tasks:
|
|
151
205
|
version_results = await asyncio.gather(*version_tasks, return_exceptions=True)
|
|
152
206
|
|
|
153
|
-
for pid,
|
|
154
|
-
if isinstance(
|
|
155
|
-
print(f"Error fetching versions for '{pid}': {
|
|
207
|
+
for pid, ver_result in zip(projects_to_fetch, version_results, strict=False):
|
|
208
|
+
if isinstance(ver_result, Exception):
|
|
209
|
+
console.print(f"[red]Error fetching versions for '{pid}': {ver_result}[/red]")
|
|
156
210
|
version_cache[pid] = []
|
|
157
|
-
|
|
158
|
-
version_cache[pid] =
|
|
211
|
+
elif isinstance(ver_result, list):
|
|
212
|
+
version_cache[pid] = ver_result
|
|
159
213
|
|
|
160
214
|
# Process dependencies
|
|
161
215
|
for pid in batch:
|
|
@@ -163,7 +217,7 @@ class ModResolver:
|
|
|
163
217
|
version = self._select_version(versions)
|
|
164
218
|
|
|
165
219
|
if not version:
|
|
166
|
-
print(f"Warning: No compatible version found for '{pid}'")
|
|
220
|
+
console.print(f"[yellow]Warning: No compatible version found for '{pid}'[/yellow]")
|
|
167
221
|
continue
|
|
168
222
|
|
|
169
223
|
for dep in version.dependencies:
|
|
@@ -175,14 +229,25 @@ class ModResolver:
|
|
|
175
229
|
|
|
176
230
|
if dtype == "incompatible" and dep_id in resolved:
|
|
177
231
|
resolved.remove(dep_id)
|
|
178
|
-
print(
|
|
179
|
-
f"Warning: Removed incompatible dependency '{dep_id}' "
|
|
180
|
-
f"(conflicts with '{pid}')
|
|
232
|
+
console.print(
|
|
233
|
+
f"[yellow]Warning: Removed incompatible dependency '{dep_id}' "
|
|
234
|
+
f"(conflicts with '{pid}')[/yellow]"
|
|
181
235
|
)
|
|
182
236
|
|
|
183
|
-
if dtype
|
|
237
|
+
if dtype == "required" and dep_id not in resolved:
|
|
184
238
|
resolved.add(dep_id)
|
|
185
239
|
queue.append(dep_id)
|
|
240
|
+
elif dtype == "optional" and self.include_optional_deps and dep_id not in resolved:
|
|
241
|
+
resolved.add(dep_id)
|
|
242
|
+
queue.append(dep_id)
|
|
243
|
+
console.print(f"[dim] + Optional dep added: {dep_id}[/dim]")
|
|
244
|
+
|
|
245
|
+
# Report failed slugs
|
|
246
|
+
if failed_slugs:
|
|
247
|
+
console.print(f"\n[yellow]Warning: {len(failed_slugs)} mod(s) could not be resolved:[/yellow]")
|
|
248
|
+
for slug in failed_slugs:
|
|
249
|
+
console.print(f" - {slug}")
|
|
250
|
+
console.print("[dim]Check the slugs or try searching on https://modrinth.com[/dim]\n")
|
|
186
251
|
|
|
187
|
-
del queue, expanded, search_cache, version_cache
|
|
252
|
+
del queue, expanded, search_cache, version_cache, failed_slugs
|
|
188
253
|
return resolved
|
|
@@ -65,43 +65,43 @@ def calculate_match_score(search_term: str, hit_slug: str, hit_title: str = "")
|
|
|
65
65
|
"""
|
|
66
66
|
search_lower = search_term.lower()
|
|
67
67
|
search_normalized = normalize_search_term(search_term)
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
slug_lower = hit_slug.lower()
|
|
70
70
|
slug_normalized = normalize_search_term(hit_slug)
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
title_lower = hit_title.lower() if hit_title else ""
|
|
73
73
|
title_normalized = normalize_search_term(hit_title) if hit_title else ""
|
|
74
|
-
|
|
74
|
+
|
|
75
75
|
# Exact matches (highest priority)
|
|
76
76
|
if search_term == hit_slug:
|
|
77
77
|
return 100
|
|
78
78
|
if search_lower == title_lower:
|
|
79
79
|
return 90
|
|
80
|
-
|
|
80
|
+
|
|
81
81
|
# Normalized matches
|
|
82
82
|
if search_normalized == slug_normalized:
|
|
83
83
|
return 80
|
|
84
84
|
if title_normalized and search_normalized == title_normalized:
|
|
85
85
|
return 70
|
|
86
|
-
|
|
86
|
+
|
|
87
87
|
# Starts with matches
|
|
88
88
|
if slug_lower.startswith(search_lower):
|
|
89
89
|
return 60
|
|
90
90
|
if title_lower and title_lower.startswith(search_lower):
|
|
91
91
|
return 50
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
# Contains matches
|
|
94
94
|
if search_lower in slug_lower:
|
|
95
95
|
return 40
|
|
96
96
|
if title_lower and search_lower in title_lower:
|
|
97
97
|
return 30
|
|
98
|
-
|
|
98
|
+
|
|
99
99
|
# Normalized contains (fallback)
|
|
100
100
|
if search_normalized in slug_normalized:
|
|
101
101
|
return 20
|
|
102
102
|
if title_normalized and search_normalized in title_normalized:
|
|
103
103
|
return 10
|
|
104
|
-
|
|
104
|
+
|
|
105
105
|
return 0
|
|
106
106
|
|
|
107
107
|
|
|
@@ -114,16 +114,16 @@ def find_best_match(search_term: str, hits: list) -> tuple[object, int]:
|
|
|
114
114
|
"""
|
|
115
115
|
best_hit = None
|
|
116
116
|
best_score = 0
|
|
117
|
-
|
|
117
|
+
|
|
118
118
|
for hit in hits:
|
|
119
119
|
# Get title from hit if available
|
|
120
120
|
title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
|
|
121
121
|
score = calculate_match_score(search_term, hit.slug, title)
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
if score > best_score:
|
|
124
124
|
best_score = score
|
|
125
125
|
best_hit = hit
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
return best_hit, best_score
|
|
128
128
|
|
|
129
129
|
|
|
@@ -198,7 +198,7 @@ def load_registry(path: Path) -> dict[str, str]:
|
|
|
198
198
|
|
|
199
199
|
try:
|
|
200
200
|
return json.loads(path.read_text())
|
|
201
|
-
except json.JSONDecodeError
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
202
|
# Registry is corrupted - back it up and start fresh
|
|
203
203
|
backup = path.with_suffix(f".corrupt-{datetime.now():%Y%m%d-%H%M%S}.json")
|
|
204
204
|
shutil.copy(path, backup)
|
|
@@ -223,7 +223,7 @@ def setup_crash_logging() -> Path:
|
|
|
223
223
|
traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
|
|
224
224
|
|
|
225
225
|
console = Console()
|
|
226
|
-
console.print(
|
|
226
|
+
console.print("\n[red bold]ModForge-CLI crashed![/red bold]")
|
|
227
227
|
console.print(f"[yellow]Crash log saved to:[/yellow] {log_file}")
|
|
228
228
|
console.print("[dim]Please include this file when reporting the issue at:")
|
|
229
229
|
console.print("[dim]https://github.com/Frank1o3/ModForge-CLI/issues\n")
|
|
@@ -354,13 +354,13 @@ async def perform_add(
|
|
|
354
354
|
|
|
355
355
|
# Find best match using scoring system
|
|
356
356
|
best_hit, best_score = find_best_match(name, results.hits)
|
|
357
|
-
|
|
357
|
+
|
|
358
358
|
if not best_hit:
|
|
359
359
|
console.print(f"[red]No suitable match found for '{name}'[/red]")
|
|
360
360
|
return
|
|
361
|
-
|
|
361
|
+
|
|
362
362
|
slug = best_hit.slug
|
|
363
|
-
|
|
363
|
+
|
|
364
364
|
# Show what we found with confidence level
|
|
365
365
|
confidence_msg = ""
|
|
366
366
|
if best_score >= 80:
|
|
@@ -371,9 +371,9 @@ async def perform_add(
|
|
|
371
371
|
confidence_msg = "[yellow](low confidence match)[/yellow]"
|
|
372
372
|
else:
|
|
373
373
|
confidence_msg = "[red](uncertain match - please verify)[/red]"
|
|
374
|
-
|
|
374
|
+
|
|
375
375
|
console.print(f"[cyan]Found:[/cyan] {slug} {confidence_msg}")
|
|
376
|
-
|
|
376
|
+
|
|
377
377
|
# If confidence is low and there are multiple results, show alternatives
|
|
378
378
|
if best_score < 60 and len(results.hits) > 1:
|
|
379
379
|
console.print("\n[yellow]Other possible matches:[/yellow]")
|
|
@@ -381,19 +381,19 @@ async def perform_add(
|
|
|
381
381
|
table.add_column("#", style="dim", width=3)
|
|
382
382
|
table.add_column("Slug", style="cyan")
|
|
383
383
|
table.add_column("Score", justify="right", style="dim")
|
|
384
|
-
|
|
384
|
+
|
|
385
385
|
# Show top 5 alternatives
|
|
386
386
|
scored_hits = []
|
|
387
387
|
for hit in results.hits[:10]:
|
|
388
388
|
title = getattr(hit, 'title', '') or getattr(hit, 'name', '')
|
|
389
389
|
score = calculate_match_score(name, hit.slug, title)
|
|
390
390
|
scored_hits.append((hit, score))
|
|
391
|
-
|
|
391
|
+
|
|
392
392
|
scored_hits.sort(key=lambda x: x[1], reverse=True)
|
|
393
|
-
|
|
393
|
+
|
|
394
394
|
for idx, (hit, score) in enumerate(scored_hits[:5], 1):
|
|
395
395
|
table.add_row(str(idx), hit.slug, str(score))
|
|
396
|
-
|
|
396
|
+
|
|
397
397
|
console.print(table)
|
|
398
398
|
console.print("\n[dim]Tip: Use the exact slug if the match is wrong[/dim]")
|
|
399
399
|
console.print(f"[dim]Example: ModForge-CLI add {scored_hits[1][0].slug if len(scored_hits) > 1 else 'exact-slug'}[/dim]\n")
|
|
@@ -413,4 +413,4 @@ async def perform_add(
|
|
|
413
413
|
except Exception as e:
|
|
414
414
|
console.print(f"[red]Failed to save manifest:[/red] {e}")
|
|
415
415
|
else:
|
|
416
|
-
console.print(f"[yellow]{slug} is already in the manifest[/yellow]")
|
|
416
|
+
console.print(f"[yellow]{slug} is already in the manifest[/yellow]")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -17,23 +17,23 @@ from .utils import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
+
"Hit",
|
|
21
|
+
"Manifest",
|
|
22
|
+
"ModDownloader",
|
|
20
23
|
"ModPolicy",
|
|
21
24
|
"ModResolver",
|
|
22
|
-
"Manifest",
|
|
23
|
-
"Hit",
|
|
24
|
-
"SearchResult",
|
|
25
25
|
"ProjectVersion",
|
|
26
26
|
"ProjectVersionList",
|
|
27
|
-
"
|
|
27
|
+
"SearchResult",
|
|
28
|
+
"detect_install_method",
|
|
28
29
|
"ensure_config_file",
|
|
29
|
-
"install_fabric",
|
|
30
|
-
"run",
|
|
31
30
|
"get_api_session",
|
|
32
31
|
"get_manifest",
|
|
33
|
-
"
|
|
34
|
-
"perform_add",
|
|
35
|
-
"detect_install_method",
|
|
32
|
+
"install_fabric",
|
|
36
33
|
"load_registry",
|
|
34
|
+
"perform_add",
|
|
35
|
+
"run",
|
|
37
36
|
"save_registry_atomic",
|
|
37
|
+
"self_update",
|
|
38
38
|
"setup_crash_logging",
|
|
39
39
|
]
|
|
File without changes
|