modsmith-cli 0.1.1__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.
@@ -0,0 +1,68 @@
1
+ """Find the files an instance holds by the names players use for them."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from modsmith import metadata
6
+ from modsmith.instances import ContentFile, Instance
7
+
8
+ from .errors import AppError
9
+
10
+ DISABLED = ".disabled"
11
+
12
+
13
+ def _file_name(path: str) -> str:
14
+ return path.rsplit("/", 1)[-1].lower()
15
+
16
+
17
+ def _declared(instance: Instance, item: ContentFile) -> set[str]:
18
+ """Return the id and name a file declares about itself, in lower case."""
19
+ try:
20
+ info = metadata.read(instance.path / item.location)
21
+ except OSError:
22
+ return set()
23
+ if info is None:
24
+ return set()
25
+ return {text.lower() for text in (info.id, info.name) if text}
26
+
27
+
28
+ def _matches(
29
+ instance: Instance, contents: Iterable[ContentFile], name: str
30
+ ) -> list[ContentFile]:
31
+ """Return the files a name means, trying cheap matches before reading files."""
32
+ wanted = name.lower().removesuffix(DISABLED)
33
+ hits = [
34
+ item
35
+ for item in contents
36
+ if (item.addition is not None and item.addition.matches(name))
37
+ or item.path.lower() == wanted
38
+ or _file_name(item.path) == wanted
39
+ ]
40
+ if hits:
41
+ return hits
42
+ return [item for item in contents if wanted in _declared(instance, item)]
43
+
44
+
45
+ def find_contents(instance: Instance, names: Iterable[str]) -> list[ContentFile]:
46
+ """Match each name to one mod, resource pack or shader pack in the instance.
47
+
48
+ A name can be an added project's id or slug, a path such as
49
+ ``mods/sodium.jar``, a file name with or without ``.disabled``, or the id
50
+ or name a mod declares about itself, such as ``iris``.
51
+
52
+ Raises:
53
+ AppError: A name matches nothing, or more than one file.
54
+ """
55
+ contents = instance.contents()
56
+ found: list[ContentFile] = []
57
+ for name in names:
58
+ hits = _matches(instance, contents, name)
59
+ if not hits:
60
+ raise AppError(
61
+ f"Nothing in {instance.path.name} is called {name!r}.",
62
+ hint=f"See what it holds with: modsmith show {instance.path.name}",
63
+ )
64
+ if len(hits) > 1:
65
+ listed = ", ".join(item.path for item in hits)
66
+ raise AppError(f"{name!r} could mean {listed}.", hint="Use the full path.")
67
+ found.append(hits[0])
68
+ return list(dict.fromkeys(found))
@@ -0,0 +1,19 @@
1
+ """Errors the app reports to the player."""
2
+
3
+
4
+ class AppError(Exception):
5
+ """Something the player can fix, with a hint on how.
6
+
7
+ Args:
8
+ message: What went wrong.
9
+ hint: What to do about it, or None.
10
+
11
+ Attributes:
12
+ message: What went wrong.
13
+ hint: What to do about it, or None.
14
+ """
15
+
16
+ def __init__(self, message: str, hint: str | None = None) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.hint = hint
@@ -0,0 +1,244 @@
1
+ """Getting a pack ready to install, and adding it to the official launcher."""
2
+
3
+ import dataclasses
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from modsmith.errors import LauncherError, ModsmithError
9
+ from modsmith.launcher import Launcher, icon_data_uri
10
+ from modsmith.minecraft import GameDir
11
+ from modsmith.net import Session
12
+ from modsmith.packs import Modpack, PackEntry, load
13
+ from modsmith.platforms import Platform, Project, Release
14
+ from modsmith.progress import CancelToken, ProgressCallback
15
+
16
+ from .errors import AppError
17
+ from .names import name_from
18
+ from .records import PackSource
19
+ from .refs import LocalPack, ProjectRef
20
+
21
+ PROFILE_PREFIX = "modsmith-"
22
+ # The launcher shows PNG and JPEG icons; other formats are left out.
23
+ ICON_TYPES = ("image/png", "image/jpeg")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FetchedPack:
28
+ """A pack read and ready to install.
29
+
30
+ Attributes:
31
+ pack: The pack, with downloads looked up where its format needs it.
32
+ source: Where it came from.
33
+ project: Its project on a platform, or None for a local file.
34
+ """
35
+
36
+ pack: Modpack
37
+ source: PackSource
38
+ project: Project | None = None
39
+
40
+
41
+ def _resolved(pack: Modpack, open_platform: Callable[[str], Platform]) -> Modpack:
42
+ """Look up downloads for a CurseForge pack, whose files are named by id."""
43
+ if pack.format != "curseforge":
44
+ return pack
45
+ with open_platform("curseforge") as platform:
46
+ return platform.resolve(pack)
47
+
48
+
49
+ def latest_pack(
50
+ platform: Platform, name: str, *, allow_prereleases: bool
51
+ ) -> tuple[Project, Release]:
52
+ """Find a modpack project and its newest release.
53
+
54
+ Raises:
55
+ AppError: The project is not a modpack, or has no release.
56
+ NetworkError: The platform could not be reached.
57
+ """
58
+ project = platform.get_project(name, project_type="modpack")
59
+ if project.project_type != "modpack":
60
+ raise AppError(
61
+ f"{project.name} is a {project.project_type}, not a modpack.",
62
+ hint=f"Add it to an instance with: modsmith add <instance> "
63
+ f"{platform.name}:{name}",
64
+ )
65
+ release = platform.latest_release(project, release_only=not allow_prereleases)
66
+ if release is None:
67
+ raise AppError(
68
+ f"{project.name} has no release to install.",
69
+ hint="Alpha and beta releases count if you run: "
70
+ "modsmith config set allow_prereleases true",
71
+ )
72
+ return project, release
73
+
74
+
75
+ def download_pack(
76
+ platform: Platform,
77
+ project: Project,
78
+ release: Release,
79
+ open_platform: Callable[[str], Platform],
80
+ *,
81
+ cache_dir: Path,
82
+ progress: ProgressCallback | None = None,
83
+ cancel: CancelToken | None = None,
84
+ ) -> FetchedPack:
85
+ """Download a pack release into the cache and read it.
86
+
87
+ Raises:
88
+ PackError: The file is not a valid pack.
89
+ NetworkError: A platform could not be reached.
90
+ """
91
+ folder = cache_dir / "packs" / platform.name
92
+ path = platform.fetch(release, folder, progress=progress, cancel=cancel)
93
+ pack = _resolved(load(path), open_platform)
94
+ return FetchedPack(pack, PackSource(platform.name, project.id, release.id), project)
95
+
96
+
97
+ def fetch_pack(
98
+ ref: ProjectRef | LocalPack,
99
+ open_platform: Callable[[str], Platform],
100
+ *,
101
+ cache_dir: Path,
102
+ allow_prereleases: bool,
103
+ progress: ProgressCallback | None = None,
104
+ cancel: CancelToken | None = None,
105
+ ) -> FetchedPack:
106
+ """Read a local pack, or download a platform pack's newest release.
107
+
108
+ Args:
109
+ ref: The pack.
110
+ open_platform: Opens a platform by name.
111
+ cache_dir: Where downloaded packs are kept.
112
+ allow_prereleases: Whether an alpha or beta release may be chosen.
113
+ progress: Receives download updates.
114
+ cancel: Stops the download.
115
+
116
+ Returns:
117
+ The pack.
118
+
119
+ Raises:
120
+ AppError: The project is not a modpack, or has no release.
121
+ PackError: The file is not a valid pack.
122
+ NetworkError: A platform could not be reached.
123
+ """
124
+ if isinstance(ref, LocalPack):
125
+ pack = load(ref.path)
126
+ source = PackSource(file=str(ref.path.resolve()))
127
+ return FetchedPack(_resolved(pack, open_platform), source)
128
+ with open_platform(ref.platform) as platform:
129
+ project, release = latest_pack(
130
+ platform, ref.name, allow_prereleases=allow_prereleases
131
+ )
132
+ return download_pack(
133
+ platform,
134
+ project,
135
+ release,
136
+ open_platform,
137
+ cache_dir=cache_dir,
138
+ progress=progress,
139
+ cancel=cancel,
140
+ )
141
+
142
+
143
+ def optional_entries(pack: Modpack) -> list[PackEntry]:
144
+ """Return the files a pack lets the player choose to install."""
145
+ return [
146
+ entry
147
+ for entry in pack.entries
148
+ if entry.path and entry.support("client") == "optional"
149
+ ]
150
+
151
+
152
+ def match_optional(pack: Modpack, patterns: Iterable[str]) -> list[str]:
153
+ """Find the optional files whose paths contain each pattern.
154
+
155
+ Raises:
156
+ AppError: A pattern matches no optional file.
157
+ """
158
+ options = [entry.path for entry in optional_entries(pack) if entry.path]
159
+ chosen: list[str] = []
160
+ for pattern in patterns:
161
+ hits = [path for path in options if pattern.lower() in path.lower()]
162
+ if not hits:
163
+ listed = ", ".join(options) or "none"
164
+ raise AppError(
165
+ f"No optional file matches {pattern!r}.",
166
+ hint=f"The optional files are: {listed}.",
167
+ )
168
+ chosen.extend(hits)
169
+ return list(dict.fromkeys(chosen))
170
+
171
+
172
+ def with_optional(pack: Modpack, chosen: Iterable[str]) -> Modpack:
173
+ """Return the pack with the chosen optional files made part of the install."""
174
+ wanted = set(chosen)
175
+ entries = tuple(
176
+ dataclasses.replace(entry, client="required")
177
+ if entry.path in wanted and entry.support("client") == "optional"
178
+ else entry
179
+ for entry in pack.entries
180
+ )
181
+ return pack.with_entries(entries)
182
+
183
+
184
+ def download_sizes(pack: Modpack) -> dict[str, int | None]:
185
+ """Return each file's download size, for a plan's summary."""
186
+ return {entry.path: entry.size for entry in pack.entries if entry.path}
187
+
188
+
189
+ def check_launcher(game: GameDir, running: bool) -> Launcher:
190
+ """Make sure the official launcher's profiles can be changed now.
191
+
192
+ Raises:
193
+ AppError: The launcher is open, or has never run with this game
194
+ directory.
195
+ """
196
+ if running:
197
+ raise AppError(
198
+ "Close the official launcher first.",
199
+ hint="It saves its own list of installations when it closes, "
200
+ "which would undo this change.",
201
+ )
202
+ launcher = Launcher(game)
203
+ try:
204
+ launcher.profiles()
205
+ except LauncherError as e:
206
+ raise AppError(
207
+ f"The official launcher has not been set up in {game.path}.",
208
+ hint="Start the official launcher once and close it, then try again. "
209
+ "If you use another game directory, set it with: "
210
+ "modsmith config set game_dir <folder>",
211
+ ) from e
212
+ return launcher
213
+
214
+
215
+ def profile_name(launcher: Launcher, wanted: str, key: str, instance: str) -> str:
216
+ """Return a launcher name for an installation that no other one has.
217
+
218
+ A second instance of the same pack is told apart by its instance name, as
219
+ in ``Fabulously Optimized (fo-2)``.
220
+ """
221
+ taken = {
222
+ profile.name for other, profile in launcher.profiles().items() if other != key
223
+ }
224
+ return wanted if wanted not in taken else f"{wanted} ({instance})"
225
+
226
+
227
+ def profile_key(name: str) -> str:
228
+ """Return the launcher profile key for an instance."""
229
+ return PROFILE_PREFIX + name_from(name)
230
+
231
+
232
+ def project_icon(project: Project | None) -> str | None:
233
+ """Download a project's icon as a data URI, or return None if there is none."""
234
+ if project is None or not project.icon_url:
235
+ return None
236
+ try:
237
+ with Session() as session:
238
+ response = session.request("GET", project.icon_url)
239
+ except ModsmithError:
240
+ return None
241
+ kind = response.headers.get("Content-Type", "").split(";")[0].strip()
242
+ if kind not in ICON_TYPES:
243
+ return None
244
+ return icon_data_uri(response.content, kind)
@@ -0,0 +1,30 @@
1
+ """Instance names: what is allowed, and a default made from a pack's name."""
2
+
3
+ import re
4
+
5
+ from .errors import AppError
6
+
7
+ NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
8
+
9
+
10
+ def check_name(name: str) -> str:
11
+ """Return a valid instance name unchanged.
12
+
13
+ Raises:
14
+ AppError: The name could not be a folder name on every system.
15
+ """
16
+ if not NAME.match(name) or name.endswith((" ", ".")):
17
+ raise AppError(
18
+ f"{name!r} cannot be an instance name.",
19
+ hint=(
20
+ "Use up to 64 letters, digits, spaces, dots, dashes and "
21
+ "underscores, starting with a letter or digit."
22
+ ),
23
+ )
24
+ return name
25
+
26
+
27
+ def name_from(text: str) -> str:
28
+ """Make an instance name from a pack's name, such as ``fabulously-optimized``."""
29
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:64].strip("-")
30
+ return slug or "instance"
@@ -0,0 +1,63 @@
1
+ """Where the app keeps its configuration, data and caches."""
2
+
3
+ import os
4
+ import sys
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from platformdirs import user_cache_path, user_config_path, user_data_path
10
+
11
+ HOME_VARIABLE = "MODSMITH_HOME"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AppPaths:
16
+ """The app's own folders.
17
+
18
+ Attributes:
19
+ config_dir: Holds ``config.toml``.
20
+ data_dir: Holds instances and the list of instances kept elsewhere.
21
+ cache_dir: Holds downloaded Java runtimes and loader installers.
22
+ """
23
+
24
+ config_dir: Path
25
+ data_dir: Path
26
+ cache_dir: Path
27
+
28
+ @property
29
+ def config_file(self) -> Path:
30
+ """The configuration file."""
31
+ return self.config_dir / "config.toml"
32
+
33
+ @property
34
+ def default_instances_dir(self) -> Path:
35
+ """Where instances go unless the configuration says otherwise."""
36
+ return self.data_dir / "instances"
37
+
38
+ @property
39
+ def external_file(self) -> Path:
40
+ """The list of instances outside the instances folder."""
41
+ return self.data_dir / "external.json"
42
+
43
+ @classmethod
44
+ def from_environment(cls, environ: Mapping[str, str] = os.environ) -> "AppPaths":
45
+ """Use ``MODSMITH_HOME`` if it is set, and the system's usual folders if not.
46
+
47
+ Args:
48
+ environ: The environment variables.
49
+
50
+ Returns:
51
+ The folders.
52
+ """
53
+ home = environ.get(HOME_VARIABLE)
54
+ if home:
55
+ root = Path(home).expanduser()
56
+ return cls(root, root, root / "cache")
57
+ # Linux folders are lowercase by convention.
58
+ name = "modsmith" if sys.platform.startswith("linux") else "Modsmith"
59
+ return cls(
60
+ user_config_path(name, appauthor=False, roaming=True),
61
+ user_data_path(name, appauthor=False),
62
+ user_cache_path(name, appauthor=False),
63
+ )
@@ -0,0 +1,98 @@
1
+ """Open the platforms that projects come from."""
2
+
3
+ from collections.abc import Callable, Collection, Generator
4
+ from contextlib import contextmanager
5
+
6
+ from modsmith.errors import NetworkError, PlatformError
7
+ from modsmith.platforms import CurseForge, Modrinth, Platform
8
+
9
+ from .. import __version__
10
+ from .config import Config
11
+ from .errors import AppError
12
+
13
+ USER_AGENT = f"modsmith-cli/{__version__} (+https://github.com/ModsmithMC/modsmith-cli)"
14
+ CURSEFORGE_CONSOLE = "https://console.curseforge.com"
15
+ # CurseForge answers these when the key is wrong.
16
+ REJECTED = (401, 403)
17
+ NOT_FOUND = 404
18
+ TITLES = {"modrinth": "Modrinth", "curseforge": "CurseForge"}
19
+
20
+ # Opens a platform by name. Tests pass one that serves a catalogue from memory.
21
+ PlatformOpener = Callable[[str, Config], Platform]
22
+
23
+
24
+ def open_platform(name: str, config: Config) -> Platform:
25
+ """Open a platform. Close it when done, or use it as a context manager.
26
+
27
+ Args:
28
+ name: ``modrinth`` or ``curseforge``.
29
+ config: The settings, which hold the CurseForge key.
30
+
31
+ Returns:
32
+ The platform.
33
+
34
+ Raises:
35
+ AppError: CurseForge has no key, or the name is unknown.
36
+ """
37
+ if name == "modrinth":
38
+ return Modrinth(USER_AGENT)
39
+ if name == "curseforge":
40
+ if not config.curseforge_api_key:
41
+ raise AppError(
42
+ "CurseForge needs an API key.",
43
+ hint=(
44
+ f"Get one at {CURSEFORGE_CONSOLE}, then run: "
45
+ "modsmith config set curseforge_api_key <key>"
46
+ ),
47
+ )
48
+ return CurseForge(config.curseforge_api_key)
49
+ raise AppError(f"{name!r} is not a platform.", hint="Use modrinth or curseforge.")
50
+
51
+
52
+ def platform_title(name: str) -> str:
53
+ """Return a platform's name as people write it, such as ``CurseForge``."""
54
+ return TITLES.get(name, name)
55
+
56
+
57
+ @contextmanager
58
+ def explained(name: str, wanted: Collection[str] = ()) -> Generator[None]:
59
+ """Turn a platform's refusal into an `AppError` that says what to fix.
60
+
61
+ Args:
62
+ name: The platform in use.
63
+ wanted: The projects asked for, to name the one the platform lacks.
64
+
65
+ Raises:
66
+ AppError: CurseForge rejected the API key, or the platform has no
67
+ project by one of the wanted names.
68
+ """
69
+ try:
70
+ yield
71
+ except NetworkError as e:
72
+ if name == "curseforge" and e.status in REJECTED:
73
+ raise AppError(
74
+ "CurseForge rejected the API key.",
75
+ hint=(
76
+ "Set it again: modsmith config set curseforge_api_key '<key>'. "
77
+ "In PowerShell, use single quotes, because $ starts a "
78
+ "variable inside double quotes."
79
+ ),
80
+ ) from e
81
+ if e.status == NOT_FOUND and wanted:
82
+ raise _unknown(name, wanted, str(e)) from e
83
+ raise
84
+ except PlatformError as e:
85
+ # CurseForge finds slugs by searching, and says so when none matches.
86
+ if any(item in str(e) for item in wanted):
87
+ raise _unknown(name, wanted, str(e)) from e
88
+ raise
89
+
90
+
91
+ def _unknown(name: str, wanted: Collection[str], message: str) -> AppError:
92
+ """Say which of the wanted projects a platform does not have."""
93
+ unknown = [item for item in wanted if item in message] or list(wanted)
94
+ names = " or ".join(repr(item) for item in unknown)
95
+ return AppError(
96
+ f"{platform_title(name)} has no project called {names}.",
97
+ hint=f"Look for it with: modsmith search {unknown[0]} --platform {name}",
98
+ )
@@ -0,0 +1,73 @@
1
+ """What the app remembers about each instance, next to the library's own state."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .errors import AppError
9
+
10
+ RECORD_FILE = ".modsmith/app.json"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PackSource:
15
+ """Where an instance's pack came from, so it can be updated.
16
+
17
+ Attributes:
18
+ platform: The platform's name, or None for a local file.
19
+ project_id: The pack's project id on that platform.
20
+ release_id: The installed release's id on that platform.
21
+ file: The local pack file, or None for a platform pack.
22
+ """
23
+
24
+ platform: str | None = None
25
+ project_id: str | None = None
26
+ release_id: str | None = None
27
+ file: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Record:
32
+ """What the app remembers about one instance.
33
+
34
+ Attributes:
35
+ source: Where its pack came from, or None if it has no pack.
36
+ profile: The key of its official launcher profile, or None.
37
+ optional: The optional pack files the player chose.
38
+ """
39
+
40
+ source: PackSource | None = None
41
+ profile: str | None = None
42
+ optional: tuple[str, ...] = field(default_factory=tuple[str, ...])
43
+
44
+
45
+ def read_record(instance: Path) -> Record:
46
+ """Read an instance's record, or an empty one if it has none.
47
+
48
+ Raises:
49
+ AppError: The record is corrupt.
50
+ """
51
+ path = instance / RECORD_FILE
52
+ try:
53
+ data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
54
+ except FileNotFoundError:
55
+ return Record()
56
+ except ValueError as e:
57
+ raise AppError(f"{path} is corrupt: {e}") from e
58
+ try:
59
+ source = data.get("source")
60
+ return Record(
61
+ source=PackSource(**source) if source else None,
62
+ profile=data.get("profile"),
63
+ optional=tuple(data.get("optional", ())),
64
+ )
65
+ except TypeError as e:
66
+ raise AppError(f"{path} is corrupt: {e}") from e
67
+
68
+
69
+ def write_record(instance: Path, record: Record) -> None:
70
+ """Save an instance's record."""
71
+ path = instance / RECORD_FILE
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(json.dumps(asdict(record), indent=2), encoding="utf-8")
@@ -0,0 +1,96 @@
1
+ """Read how a player names a project or a pack.
2
+
3
+ ``sodium`` uses the default platform, ``curseforge:238222`` or ``modrinth:iris``
4
+ names one, a project page's address works too, and a path to a ``.mrpack`` or
5
+ ``.zip`` file means a local pack.
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .config import PLATFORMS
13
+ from .errors import AppError
14
+
15
+ PACK_SUFFIXES = (".mrpack", ".zip")
16
+ ADDRESSES = [
17
+ ("modrinth", re.compile(r"^https?://(?:www\.)?modrinth\.com/[a-z]+/([^/?#]+)")),
18
+ (
19
+ "curseforge",
20
+ re.compile(r"^https?://(?:www\.)?curseforge\.com/minecraft/[a-z-]+/([^/?#]+)"),
21
+ ),
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProjectRef:
27
+ """A project on a platform.
28
+
29
+ Attributes:
30
+ platform: The platform's name.
31
+ name: The project's id or slug.
32
+ """
33
+
34
+ platform: str
35
+ name: str
36
+
37
+ def __str__(self) -> str:
38
+ return f"{self.platform}:{self.name}"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LocalPack:
43
+ """A pack file on this computer.
44
+
45
+ Attributes:
46
+ path: The file.
47
+ """
48
+
49
+ path: Path
50
+
51
+
52
+ def parse_project(text: str, default_platform: str) -> ProjectRef:
53
+ """Read a project name, a ``platform:name`` pair or a project's address.
54
+
55
+ Args:
56
+ text: What the player typed.
57
+ default_platform: The platform for a bare name.
58
+
59
+ Returns:
60
+ The project.
61
+
62
+ Raises:
63
+ AppError: The text is empty or names an unknown platform.
64
+ """
65
+ text = text.strip()
66
+ for platform, pattern in ADDRESSES:
67
+ match = pattern.match(text)
68
+ if match:
69
+ return ProjectRef(platform, match[1])
70
+ platform, separator, name = text.partition(":")
71
+ if not separator:
72
+ platform, name = default_platform, text
73
+ platform = platform.lower()
74
+ if platform not in PLATFORMS:
75
+ raise AppError(
76
+ f"{platform!r} is not a platform.",
77
+ hint="Write modrinth:<name> or curseforge:<name>.",
78
+ )
79
+ if not name:
80
+ raise AppError("A project needs a name.")
81
+ return ProjectRef(platform, name)
82
+
83
+
84
+ def parse_pack(text: str, default_platform: str) -> ProjectRef | LocalPack:
85
+ """Read a pack: a local file, or a project as `parse_project` reads it.
86
+
87
+ Raises:
88
+ AppError: The text names a pack file that does not exist, or is not a
89
+ project.
90
+ """
91
+ path = Path(text).expanduser()
92
+ if path.suffix.lower() in PACK_SUFFIXES:
93
+ if not path.is_file():
94
+ raise AppError(f"{path} does not exist.")
95
+ return LocalPack(path)
96
+ return parse_project(text, default_platform)