modforge-cli 0.2.7__tar.gz → 0.2.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/PKG-INFO +2 -6
  2. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/pyproject.toml +16 -11
  3. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/__init__.py +1 -1
  4. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/__main__.py +3 -2
  5. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/__version__.py +1 -1
  6. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/api/modrinth.py +14 -14
  7. modforge_cli-0.2.8/src/modforge_cli/cli/__init__.py +7 -0
  8. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/modpack.py +1 -1
  9. modforge_cli-0.2.8/src/modforge_cli/cli/mods.py +161 -0
  10. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/core/downloader.py +20 -24
  11. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/core/models.py +1 -1
  12. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/core/resolver.py +89 -31
  13. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/core/utils.py +23 -23
  14. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/test.py +1 -1
  15. modforge_cli-0.2.7/src/modforge_cli/cli/__init__.py +0 -7
  16. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/LICENSE +0 -0
  17. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/README.md +0 -0
  18. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/api/__init__.py +0 -0
  19. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/export.py +0 -0
  20. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/project.py +0 -0
  21. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/setup.py +0 -0
  22. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/shared.py +0 -0
  23. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/sklauncher.py +0 -0
  24. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/cli/utils.py +0 -0
  25. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/src/modforge_cli/core/__init__.py +9 -9
  26. {modforge_cli-0.2.7 → modforge_cli-0.2.8}/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.7
3
+ Version: 0.2.8
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.10,<4.0
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.7"
3
+ version = "0.2.8"
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.10,<4.0"
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
- "ruff (>=0.14.8,<0.15.0)",
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]
@@ -1,3 +1,3 @@
1
1
  """
2
2
  CLI package for ModForge-CLI
3
- """
3
+ """
@@ -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()
@@ -1,5 +1,5 @@
1
1
  """
2
2
  Auto-generated file. DO NOT EDIT.
3
3
  """
4
- __version__ = "0.2.7"
4
+ __version__ = "0.2.8"
5
5
  __author__ = "Frank1o3"
@@ -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, Dict, List, Optional
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: Dict[str, Any] = {}
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, "r", encoding="utf-8") as f:
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, Dict):
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: Optional[str] = None,
50
- facets: Optional[List[List[str]] | str] = None,
51
- categories: Optional[List[str]] = None,
52
- loaders: Optional[List[str]] = None,
53
- game_versions: Optional[List[str]] = None,
54
- license_: Optional[str] = None,
55
- project_type: Optional[str] = None,
56
- offset: Optional[int] = None,
57
- limit: Optional[int] = 10,
58
- index: Optional[str] = "relevance",
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.
@@ -0,0 +1,7 @@
1
+ """
2
+ Commands package - Exports all command groups
3
+ """
4
+
5
+ from . import export, modpack, mods, project, setup, sklauncher, utils
6
+
7
+ __all__ = ["export", "modpack", "mods", "project", "setup", "sklauncher", "utils"]
@@ -104,7 +104,7 @@ def resolve(pack_name: str | None = None) -> None:
104
104
  console.print(f"[red]Resolution failed:[/red] {e}")
105
105
  raise typer.Exit(1) from e
106
106
 
107
- manifest.mods = sorted(list(resolved_mods))
107
+ manifest.mods = sorted(resolved_mods)
108
108
  manifest_file.write_text(manifest.model_dump_json(indent=4))
109
109
 
110
110
  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 pprint import pprint
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[dict]) -> dict | None:
57
+ def _select_compatible_version(self, versions: list[Any]) -> Any | None:
58
58
  """
59
- Select the most appropriate version based on:
60
- 1. Loader compatibility (fabric/forge/quilt/neoforge)
61
- 2. Minecraft version
62
- 3. Version type (prefer release > beta > alpha)
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
- # Filter versions that match both MC version and loader
70
+ def version_score(v: Any) -> tuple[int, str]:
71
+ vtype = version_priority.get(v.get("version_type", "alpha"), 0)
72
+ # Use version id as proxy for date (IDs are timestamp-based)
73
+ vid = v.get("id", "")
74
+ return (vtype, vid)
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
- # Prioritize by version type: release > beta > alpha
85
- version_priority = {"release": 3, "beta": 2, "alpha": 1}
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:
@@ -63,4 +63,4 @@ class ProjectVersion(BaseAPIModel):
63
63
 
64
64
  ProjectVersionList = TypeAdapter(list[ProjectVersion])
65
65
 
66
- __all__ = ["Manifest", "SearchResult", "ProjectVersion", "ProjectVersionList"]
66
+ __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__
@@ -33,42 +38,84 @@ class ModResolver:
33
38
 
34
39
  def _select_version(self, versions: list[ProjectVersion]) -> ProjectVersion | None:
35
40
  """
36
- Prefer:
37
- 1. Release versions
38
- 2. Matching MC + loader
41
+ Select the best version for the target MC version and loader.
42
+
43
+ Priority:
44
+ 1. Release versions matching MC + loader (newest first)
45
+ 2. Any version matching MC + loader (newest first)
39
46
  """
40
- for v in versions:
41
- if v.is_release and self.mc_version in v.game_versions and self.loader in v.loaders:
42
- return v
47
+ version_priority = {"release": 3, "beta": 2, "alpha": 1}
48
+
49
+ def version_score(v: ProjectVersion) -> tuple[int, str]:
50
+ vtype = version_priority.get(v.version_type, 0)
51
+ # Use version id as proxy for date (IDs are timestamp-based)
52
+ return (vtype, v.id)
43
53
 
44
- for v in versions:
45
- if self.mc_version in v.game_versions and self.loader in v.loaders:
46
- return v
54
+ # Filter compatible versions
55
+ compatible = [
56
+ v for v in versions
57
+ if self.mc_version in v.game_versions and self.loader in v.loaders
58
+ ]
47
59
 
48
- return None
60
+ if not compatible:
61
+ return None
62
+
63
+ # Sort by version type (release > beta > alpha), then by ID (newest first)
64
+ compatible.sort(key=version_score, reverse=True)
65
+ return compatible[0]
49
66
 
50
67
  async def _search_project(self, slug: str, session: aiohttp.ClientSession) -> str | None:
51
- """Search for a project by slug and return its project_id"""
68
+ """
69
+ Search for a project by slug and return its project_id.
70
+
71
+ Uses fuzzy matching to find the best hit (same scoring as 'add' command).
72
+ Returns None if no suitable match is found.
73
+ """
52
74
  url = self.api.search(
53
75
  slug,
54
76
  game_versions=[self.mc_version],
55
77
  loaders=[self.loader],
78
+ project_type="mod",
56
79
  )
57
80
 
58
81
  try:
59
82
  async with session.get(url) as response:
60
83
  data = SearchResult.model_validate_json(await response.text())
61
84
 
85
+ if not data.hits:
86
+ return None
87
+
88
+ # Find best matching hit using the same scoring as 'add' command
89
+ best_hit: Hit | None = None
90
+ best_score = 0
91
+
62
92
  for hit in data.hits:
63
93
  if hit.project_type != "mod":
64
94
  continue
65
95
  if self.mc_version not in hit.versions:
66
96
  continue
67
- return hit.project_id
68
- except Exception as e:
69
- print(f"Warning: Failed to search for '{slug}': {e}")
70
97
 
71
- return None
98
+ score = calculate_match_score(slug, hit.slug, getattr(hit, "title", ""))
99
+ if score > best_score:
100
+ best_score = score
101
+ best_hit = hit
102
+
103
+ # Only accept high-confidence matches (score >= 80)
104
+ if best_hit and best_score >= 80:
105
+ return best_hit.project_id
106
+
107
+ # Fallback: if exact slug match (score 100), accept regardless
108
+ for hit in data.hits:
109
+ if hit.project_type != "mod":
110
+ continue
111
+ if hit.slug == slug:
112
+ return hit.project_id
113
+
114
+ return None
115
+
116
+ except Exception as e:
117
+ console.print(f"[yellow]Warning: Failed to search for '{slug}': {e}[/yellow]")
118
+ return None
72
119
 
73
120
  async def _fetch_versions(
74
121
  self, project_id: str, session: aiohttp.ClientSession
@@ -80,7 +127,7 @@ class ModResolver:
80
127
  async with session.get(url) as response:
81
128
  return ProjectVersionList.validate_json(await response.text())
82
129
  except Exception as e:
83
- print(f"Warning: Failed to fetch versions for '{project_id}': {e}")
130
+ console.print(f"[yellow]Warning: Failed to fetch versions for '{project_id}': {e}[/yellow]")
84
131
  return []
85
132
 
86
133
  async def resolve(self, mods: Iterable[str], session: aiohttp.ClientSession) -> set[str]:
@@ -99,8 +146,9 @@ class ModResolver:
99
146
  resolved: set[str] = set()
100
147
  queue: deque[str] = deque()
101
148
 
102
- search_cache: dict[str, str | None] = {}
149
+ search_cache: dict[str, Any] = {}
103
150
  version_cache: dict[str, list[ProjectVersion]] = {}
151
+ failed_slugs: list[str] = []
104
152
 
105
153
  # ---- Phase 1: slug → project_id (parallel) ----
106
154
  search_tasks = []
@@ -114,12 +162,15 @@ class ModResolver:
114
162
  if search_tasks:
115
163
  search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
116
164
 
117
- for slug, result in zip(slugs_to_search, search_results):
165
+ for slug, result in zip(slugs_to_search, search_results, strict=False):
118
166
  if isinstance(result, Exception):
119
- print(f"Error searching for '{slug}': {result}")
167
+ console.print(f"[red]Error searching for '{slug}': {result}[/red]")
168
+ failed_slugs.append(slug)
169
+ elif result is None:
170
+ failed_slugs.append(slug)
120
171
  search_cache[slug] = None
121
172
  else:
122
- search_cache[slug] = result
173
+ search_cache[slug] = str(result)
123
174
 
124
175
  # Add found projects to queue
125
176
  for slug in expanded:
@@ -150,12 +201,12 @@ class ModResolver:
150
201
  if version_tasks:
151
202
  version_results = await asyncio.gather(*version_tasks, return_exceptions=True)
152
203
 
153
- for pid, result in zip(projects_to_fetch, version_results, strict=False):
154
- if isinstance(result, Exception):
155
- print(f"Error fetching versions for '{pid}': {result}")
204
+ for pid, ver_result in zip(projects_to_fetch, version_results, strict=False):
205
+ if isinstance(ver_result, Exception):
206
+ console.print(f"[red]Error fetching versions for '{pid}': {ver_result}[/red]")
156
207
  version_cache[pid] = []
157
- else:
158
- version_cache[pid] = result
208
+ elif isinstance(ver_result, list):
209
+ version_cache[pid] = ver_result
159
210
 
160
211
  # Process dependencies
161
212
  for pid in batch:
@@ -163,7 +214,7 @@ class ModResolver:
163
214
  version = self._select_version(versions)
164
215
 
165
216
  if not version:
166
- print(f"Warning: No compatible version found for '{pid}'")
217
+ console.print(f"[yellow]Warning: No compatible version found for '{pid}'[/yellow]")
167
218
  continue
168
219
 
169
220
  for dep in version.dependencies:
@@ -175,14 +226,21 @@ class ModResolver:
175
226
 
176
227
  if dtype == "incompatible" and dep_id in resolved:
177
228
  resolved.remove(dep_id)
178
- print(
179
- f"Warning: Removed incompatible dependency '{dep_id}' "
180
- f"(conflicts with '{pid}') — it may have been added earlier."
229
+ console.print(
230
+ f"[yellow]Warning: Removed incompatible dependency '{dep_id}' "
231
+ f"(conflicts with '{pid}')[/yellow]"
181
232
  )
182
233
 
183
234
  if dtype in ("required", "optional") and dep_id not in resolved:
184
235
  resolved.add(dep_id)
185
236
  queue.append(dep_id)
186
237
 
187
- del queue, expanded, search_cache, version_cache
238
+ # Report failed slugs
239
+ if failed_slugs:
240
+ console.print(f"\n[yellow]Warning: {len(failed_slugs)} mod(s) could not be resolved:[/yellow]")
241
+ for slug in failed_slugs:
242
+ console.print(f" - {slug}")
243
+ console.print("[dim]Check the slugs or try searching on https://modrinth.com[/dim]\n")
244
+
245
+ del queue, expanded, search_cache, version_cache, failed_slugs
188
246
  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 as e:
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(f"\n[red bold]ModForge-CLI crashed![/red bold]")
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]")
@@ -15,4 +15,4 @@ async def run() -> None:
15
15
  data = await res.json()
16
16
  print(data["hits"][0]["server_side"], data["hits"][0]["client_side"])
17
17
 
18
- asyncio.run(run())
18
+ asyncio.run(run())
@@ -1,7 +0,0 @@
1
- """
2
- Commands package - Exports all command groups
3
- """
4
-
5
- from . import export, modpack, project, setup, sklauncher, utils
6
-
7
- __all__ = ["setup", "project", "modpack", "export", "sklauncher", "utils"]
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
- "ModDownloader",
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
- "self_update",
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
  ]