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.
- modsmith_cli/__init__.py +8 -0
- modsmith_cli/__main__.py +6 -0
- modsmith_cli/app.py +224 -0
- modsmith_cli/commands/__init__.py +1 -0
- modsmith_cli/commands/config.py +81 -0
- modsmith_cli/commands/contents.py +332 -0
- modsmith_cli/commands/discovery.py +152 -0
- modsmith_cli/commands/install.py +351 -0
- modsmith_cli/commands/instance.py +234 -0
- modsmith_cli/commands/list.py +48 -0
- modsmith_cli/commands/search.py +205 -0
- modsmith_cli/commands/update.py +324 -0
- modsmith_cli/context.py +117 -0
- modsmith_cli/core/__init__.py +5 -0
- modsmith_cli/core/config.py +243 -0
- modsmith_cli/core/contents.py +68 -0
- modsmith_cli/core/errors.py +19 -0
- modsmith_cli/core/install.py +244 -0
- modsmith_cli/core/names.py +30 -0
- modsmith_cli/core/paths.py +63 -0
- modsmith_cli/core/platforms.py +98 -0
- modsmith_cli/core/records.py +73 -0
- modsmith_cli/core/refs.py +96 -0
- modsmith_cli/core/registry.py +183 -0
- modsmith_cli/errors.py +180 -0
- modsmith_cli/target.py +43 -0
- modsmith_cli/ui.py +219 -0
- modsmith_cli-0.1.1.dist-info/METADATA +255 -0
- modsmith_cli-0.1.1.dist-info/RECORD +32 -0
- modsmith_cli-0.1.1.dist-info/WHEEL +4 -0
- modsmith_cli-0.1.1.dist-info/entry_points.txt +2 -0
- modsmith_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
modsmith_cli/__init__.py
ADDED
modsmith_cli/__main__.py
ADDED
modsmith_cli/app.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The ``modsmith`` command and its global options."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, TextIO
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.logging import RichHandler
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .commands.config import config_app
|
|
14
|
+
from .commands.contents import (
|
|
15
|
+
add_command,
|
|
16
|
+
adopt_command,
|
|
17
|
+
disable_command,
|
|
18
|
+
enable_command,
|
|
19
|
+
pin_command,
|
|
20
|
+
remove_command,
|
|
21
|
+
show_command,
|
|
22
|
+
unpin_command,
|
|
23
|
+
)
|
|
24
|
+
from .commands.discovery import info_command, loaders_command
|
|
25
|
+
from .commands.install import create_command, install_command
|
|
26
|
+
from .commands.instance import delete_command, import_command, move_command
|
|
27
|
+
from .commands.list import list_command
|
|
28
|
+
from .commands.search import search_command
|
|
29
|
+
from .commands.update import update_command
|
|
30
|
+
from .context import AppContext, ColorMode, Services
|
|
31
|
+
from .core.paths import AppPaths
|
|
32
|
+
from .errors import reported
|
|
33
|
+
from .target import examples
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
help=(
|
|
37
|
+
"Install Minecraft modpacks, mods, resource packs and shaders "
|
|
38
|
+
"for the official launcher."
|
|
39
|
+
),
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
pretty_exceptions_enable=False,
|
|
42
|
+
rich_markup_mode=None,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _show_version(value: bool) -> None:
|
|
47
|
+
if value:
|
|
48
|
+
typer.echo(f"modsmith {__version__}")
|
|
49
|
+
raise typer.Exit
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback()
|
|
53
|
+
def root(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
instances_dir: Annotated[
|
|
56
|
+
Path | None,
|
|
57
|
+
typer.Option("--instances-dir", help="Use this instances folder for this run."),
|
|
58
|
+
] = None,
|
|
59
|
+
game_dir: Annotated[
|
|
60
|
+
Path | None,
|
|
61
|
+
typer.Option("--game-dir", help="Use this game directory for this run."),
|
|
62
|
+
] = None,
|
|
63
|
+
verbose: Annotated[
|
|
64
|
+
int,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--verbose", "-v", count=True, help="Show more detail. Repeat for more."
|
|
67
|
+
),
|
|
68
|
+
] = 0,
|
|
69
|
+
color: Annotated[
|
|
70
|
+
ColorMode,
|
|
71
|
+
typer.Option("--color", help="When to use colour. NO_COLOR also turns it off."),
|
|
72
|
+
] = ColorMode.AUTO,
|
|
73
|
+
version: Annotated[
|
|
74
|
+
bool,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--version",
|
|
77
|
+
callback=_show_version,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
help="Show the version and exit.",
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Set up the run before any command."""
|
|
84
|
+
services = ctx.obj if isinstance(ctx.obj, Services) else Services()
|
|
85
|
+
environ = services.environ
|
|
86
|
+
paths = (
|
|
87
|
+
AppPaths.from_environment()
|
|
88
|
+
if environ is None
|
|
89
|
+
else AppPaths.from_environment(environ)
|
|
90
|
+
)
|
|
91
|
+
overrides: dict[str, object] = {}
|
|
92
|
+
if instances_dir is not None:
|
|
93
|
+
overrides["instances_dir"] = instances_dir.expanduser()
|
|
94
|
+
if game_dir is not None:
|
|
95
|
+
overrides["game_dir"] = game_dir.expanduser()
|
|
96
|
+
context = AppContext(paths, services, overrides, verbose=verbose, color=color)
|
|
97
|
+
if verbose:
|
|
98
|
+
level = logging.INFO if verbose == 1 else logging.DEBUG
|
|
99
|
+
handler = RichHandler(console=context.err, show_path=False)
|
|
100
|
+
logging.basicConfig(level=level, handlers=[handler], force=True)
|
|
101
|
+
ctx.obj = context
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
app.command(
|
|
105
|
+
"install",
|
|
106
|
+
epilog=examples(
|
|
107
|
+
"modsmith install fabulously-optimized",
|
|
108
|
+
"modsmith install curseforge:all-the-mods-9 --name atm9",
|
|
109
|
+
"modsmith install Downloads/pack.mrpack --all-optional",
|
|
110
|
+
"modsmith install curseforge:some-pack --from Downloads/manual",
|
|
111
|
+
),
|
|
112
|
+
)(reported(install_command))
|
|
113
|
+
app.command(
|
|
114
|
+
"create",
|
|
115
|
+
epilog=examples(
|
|
116
|
+
"modsmith create vanilla --minecraft 1.21.1",
|
|
117
|
+
"modsmith create modded --minecraft 1.21.1 --loader fabric",
|
|
118
|
+
"modsmith create old --minecraft 1.20.1 --loader forge --loader-version 47.3.0",
|
|
119
|
+
),
|
|
120
|
+
)(reported(create_command))
|
|
121
|
+
app.command("list")(reported(list_command))
|
|
122
|
+
app.command(
|
|
123
|
+
"search",
|
|
124
|
+
epilog=examples(
|
|
125
|
+
"modsmith search sodium",
|
|
126
|
+
'modsmith search "fabulously optimized" --type modpack',
|
|
127
|
+
"modsmith search shaders --type shader --sort downloads",
|
|
128
|
+
"modsmith search jei --platform all --minecraft 1.20.1 --loader forge",
|
|
129
|
+
),
|
|
130
|
+
)(reported(search_command))
|
|
131
|
+
app.command("show", epilog=examples("modsmith show", "modsmith show survival"))(
|
|
132
|
+
reported(show_command)
|
|
133
|
+
)
|
|
134
|
+
app.command(
|
|
135
|
+
"add",
|
|
136
|
+
epilog=examples(
|
|
137
|
+
"modsmith add survival sodium lithium",
|
|
138
|
+
"modsmith add survival curseforge:jei",
|
|
139
|
+
"modsmith add survival complementary-reimagined --type shader",
|
|
140
|
+
),
|
|
141
|
+
)(reported(add_command))
|
|
142
|
+
app.command(
|
|
143
|
+
"remove",
|
|
144
|
+
epilog=examples(
|
|
145
|
+
"modsmith remove survival sodium", "modsmith remove iris --keep-deps"
|
|
146
|
+
),
|
|
147
|
+
)(reported(remove_command))
|
|
148
|
+
app.command("enable", epilog=examples("modsmith enable survival sodium"))(
|
|
149
|
+
reported(enable_command)
|
|
150
|
+
)
|
|
151
|
+
app.command(
|
|
152
|
+
"disable",
|
|
153
|
+
epilog=examples(
|
|
154
|
+
"modsmith disable survival sodium",
|
|
155
|
+
"modsmith disable survival mods/OptiFine.jar",
|
|
156
|
+
),
|
|
157
|
+
)(reported(disable_command))
|
|
158
|
+
app.command("pin", epilog=examples("modsmith pin survival sodium"))(
|
|
159
|
+
reported(pin_command)
|
|
160
|
+
)
|
|
161
|
+
app.command("unpin", epilog=examples("modsmith unpin survival sodium"))(
|
|
162
|
+
reported(unpin_command)
|
|
163
|
+
)
|
|
164
|
+
app.command(
|
|
165
|
+
"adopt",
|
|
166
|
+
epilog=examples("modsmith adopt survival", "modsmith adopt survival -p modrinth"),
|
|
167
|
+
)(reported(adopt_command))
|
|
168
|
+
app.command(
|
|
169
|
+
"info",
|
|
170
|
+
epilog=examples(
|
|
171
|
+
"modsmith info sodium",
|
|
172
|
+
"modsmith info curseforge:jei --minecraft 1.20.1 --loader forge",
|
|
173
|
+
"modsmith info fabulously-optimized --type modpack",
|
|
174
|
+
),
|
|
175
|
+
)(reported(info_command))
|
|
176
|
+
app.command(
|
|
177
|
+
"loaders",
|
|
178
|
+
epilog=examples(
|
|
179
|
+
"modsmith loaders fabric 1.21.1", "modsmith loaders forge 1.20.1 --all"
|
|
180
|
+
),
|
|
181
|
+
)(reported(loaders_command))
|
|
182
|
+
app.command(
|
|
183
|
+
"update",
|
|
184
|
+
epilog=examples(
|
|
185
|
+
"modsmith update survival",
|
|
186
|
+
"modsmith update survival --dry-run",
|
|
187
|
+
"modsmith update survival --pack-only --optional shaders",
|
|
188
|
+
"modsmith update atm9 --file Downloads/atm9-1.2.zip",
|
|
189
|
+
),
|
|
190
|
+
)(reported(update_command))
|
|
191
|
+
app.command(
|
|
192
|
+
"import",
|
|
193
|
+
epilog=examples(
|
|
194
|
+
'modsmith import "My Fabric world"', "modsmith import old-pack --dry-run"
|
|
195
|
+
),
|
|
196
|
+
)(reported(import_command))
|
|
197
|
+
app.command(
|
|
198
|
+
"move",
|
|
199
|
+
epilog=examples(
|
|
200
|
+
"modsmith move survival creative", "modsmith move survival D:/Games/survival"
|
|
201
|
+
),
|
|
202
|
+
)(reported(move_command))
|
|
203
|
+
app.command("delete", epilog=examples("modsmith delete survival"))(
|
|
204
|
+
reported(delete_command)
|
|
205
|
+
)
|
|
206
|
+
app.add_typer(config_app, name="config")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def tolerate_unencodable(*streams: TextIO) -> None:
|
|
210
|
+
"""Write characters an output cannot encode as ``?`` instead of failing.
|
|
211
|
+
|
|
212
|
+
Piped or redirected output uses the system code page, so that the shell
|
|
213
|
+
reading it decodes it correctly. On Windows that code page often cannot
|
|
214
|
+
hold the emoji found in project names and summaries.
|
|
215
|
+
"""
|
|
216
|
+
for stream in streams:
|
|
217
|
+
if isinstance(stream, io.TextIOWrapper):
|
|
218
|
+
stream.reconfigure(errors="replace")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main() -> None:
|
|
222
|
+
"""Run the command line."""
|
|
223
|
+
tolerate_unencodable(sys.stdout, sys.stderr)
|
|
224
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The commands, one module per group; `modsmith_cli.app` puts them together."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""``modsmith config``: show and change settings."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ..context import get
|
|
9
|
+
from ..core import config as settings
|
|
10
|
+
from ..errors import reported
|
|
11
|
+
|
|
12
|
+
config_app = typer.Typer(
|
|
13
|
+
help="Show and change settings. With no command, show them all.",
|
|
14
|
+
invoke_without_command=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
Key = Annotated[str, typer.Argument(help="The setting's name.", show_default=False)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@config_app.callback()
|
|
21
|
+
@reported
|
|
22
|
+
def show_all(ctx: typer.Context) -> None:
|
|
23
|
+
"""Show every setting, its value and where the value comes from."""
|
|
24
|
+
if ctx.invoked_subcommand is not None:
|
|
25
|
+
return
|
|
26
|
+
app = get(ctx)
|
|
27
|
+
values = app.config.__dict__
|
|
28
|
+
origins = settings.sources(app.paths, app.environ)
|
|
29
|
+
table = Table(box=None, pad_edge=False)
|
|
30
|
+
table.add_column("Setting", no_wrap=True)
|
|
31
|
+
# Paths fold rather than shorten, so they can always be copied whole.
|
|
32
|
+
table.add_column("Value", overflow="fold")
|
|
33
|
+
table.add_column("From", overflow="fold")
|
|
34
|
+
for key in settings.SETTINGS:
|
|
35
|
+
origin = "command line" if key in app.overrides else origins[key]
|
|
36
|
+
table.add_row(key, settings.shown(key, values[key]), origin)
|
|
37
|
+
app.out.print(table)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@config_app.command("get")
|
|
41
|
+
@reported
|
|
42
|
+
def get_value(ctx: typer.Context, key: Key) -> None:
|
|
43
|
+
"""Print one setting's value."""
|
|
44
|
+
app = get(ctx)
|
|
45
|
+
settings.setting(key)
|
|
46
|
+
app.out.print(settings.shown(key, app.config.__dict__[key]), highlight=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@config_app.command("set")
|
|
50
|
+
@reported
|
|
51
|
+
def set_value(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
key: Key,
|
|
54
|
+
value: Annotated[str, typer.Argument(help="The new value.", show_default=False)],
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Save a setting to the configuration file."""
|
|
57
|
+
app = get(ctx)
|
|
58
|
+
settings.save_setting(app.paths, key, value)
|
|
59
|
+
app.out.print(f"Saved {key}.")
|
|
60
|
+
variable = settings.SETTINGS[key].variable
|
|
61
|
+
if variable and app.environ.get(variable):
|
|
62
|
+
app.err.print(
|
|
63
|
+
f"${variable} is set, so it still takes precedence.", style="yellow"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@config_app.command("unset")
|
|
68
|
+
@reported
|
|
69
|
+
def unset_value(ctx: typer.Context, key: Key) -> None:
|
|
70
|
+
"""Remove a setting from the configuration file, so its default applies."""
|
|
71
|
+
app = get(ctx)
|
|
72
|
+
settings.save_setting(app.paths, key, None)
|
|
73
|
+
app.out.print(f"Removed {key}.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@config_app.command("path")
|
|
77
|
+
@reported
|
|
78
|
+
def show_path(ctx: typer.Context) -> None:
|
|
79
|
+
"""Print where the configuration file is."""
|
|
80
|
+
app = get(ctx)
|
|
81
|
+
app.out.print(str(app.paths.config_file), highlight=False, soft_wrap=True)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Commands that look at and change what an instance holds."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from modsmith import metadata
|
|
9
|
+
from modsmith.errors import DependencyError, InstanceError, ModsmithError
|
|
10
|
+
from modsmith.instances import Changes, Instance, Plan
|
|
11
|
+
from modsmith.platforms import ProjectType
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..context import AppContext, get
|
|
16
|
+
from ..core.config import PLATFORMS
|
|
17
|
+
from ..core.contents import find_contents
|
|
18
|
+
from ..core.errors import AppError
|
|
19
|
+
from ..core.platforms import explained
|
|
20
|
+
from ..core.refs import parse_project
|
|
21
|
+
from ..core.registry import resolve_instance, split_target
|
|
22
|
+
from ..errors import report
|
|
23
|
+
from ..target import DryRun, InstanceName, Yes
|
|
24
|
+
from ..ui import cancellable, confirm, progress, show_plan
|
|
25
|
+
from .install import From, Overwrite, loader_label
|
|
26
|
+
|
|
27
|
+
SOURCES = {"pack": "pack", "addition": "added", "user": "yours"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AddedKind(StrEnum):
|
|
31
|
+
"""The kinds of projects that can be added to an instance."""
|
|
32
|
+
|
|
33
|
+
MOD = "mod"
|
|
34
|
+
RESOURCEPACK = "resourcepack"
|
|
35
|
+
SHADER = "shader"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ADDED_KINDS: dict[AddedKind, ProjectType] = {
|
|
39
|
+
AddedKind.MOD: "mod",
|
|
40
|
+
AddedKind.RESOURCEPACK: "resourcepack",
|
|
41
|
+
AddedKind.SHADER: "shader",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Items = Annotated[
|
|
45
|
+
list[str],
|
|
46
|
+
typer.Argument(
|
|
47
|
+
help="The instance, then what to act on. Leave the instance out if you "
|
|
48
|
+
"have only one.",
|
|
49
|
+
show_default=False,
|
|
50
|
+
),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _heading(name: str, instance: Instance) -> str:
|
|
55
|
+
state = instance.state
|
|
56
|
+
if state is None:
|
|
57
|
+
return name
|
|
58
|
+
loader = loader_label(state.loader_kind, state.loader_version)
|
|
59
|
+
pack = f", from {state.pack_name} {state.pack_version}" if state.pack_name else ""
|
|
60
|
+
return f"{name}: Minecraft {state.minecraft} with {loader}{pack}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def show_command(ctx: typer.Context, name: InstanceName = None) -> None:
|
|
64
|
+
"""List the mods, resource packs and shaders in an instance."""
|
|
65
|
+
app = get(ctx)
|
|
66
|
+
instance, _ = resolve_instance(name, app.config, app.paths)
|
|
67
|
+
app.out.print(Text(_heading(instance.path.name, instance), style="bold"))
|
|
68
|
+
contents = instance.contents()
|
|
69
|
+
if not contents:
|
|
70
|
+
app.out.print("It holds no mods, resource packs or shaders yet.")
|
|
71
|
+
return
|
|
72
|
+
table = Table(box=None, pad_edge=False)
|
|
73
|
+
for column in ("Name", "Version", "From", "State"):
|
|
74
|
+
table.add_column(column, no_wrap=column != "Name")
|
|
75
|
+
table.add_column("File", overflow="fold")
|
|
76
|
+
for item in contents:
|
|
77
|
+
try:
|
|
78
|
+
info = metadata.read(instance.path / item.location)
|
|
79
|
+
except OSError:
|
|
80
|
+
info = None
|
|
81
|
+
label = (item.addition.name if item.addition else None) or (
|
|
82
|
+
info.name if info else item.path.rsplit("/", 1)[-1]
|
|
83
|
+
)
|
|
84
|
+
flags = [] if item.enabled else ["off"]
|
|
85
|
+
if item.addition and item.addition.pinned:
|
|
86
|
+
flags.append("pinned")
|
|
87
|
+
table.add_row(
|
|
88
|
+
label,
|
|
89
|
+
(info.version if info else None) or "",
|
|
90
|
+
SOURCES[item.source],
|
|
91
|
+
", ".join(flags),
|
|
92
|
+
item.location,
|
|
93
|
+
)
|
|
94
|
+
app.out.print(table)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _notes(instance: Instance, plan: Plan) -> list[Text]:
|
|
98
|
+
"""Say what a plan leaves out, and why it adds what nobody asked for."""
|
|
99
|
+
state = instance.state
|
|
100
|
+
held = {item.path for item in state.additions} if state else set[str]()
|
|
101
|
+
changing = {*plan.changes.added, *plan.changes.updated}
|
|
102
|
+
notes = [
|
|
103
|
+
Text(f" = {item.name} is already in the pack", style="dim")
|
|
104
|
+
for item in plan.skipped
|
|
105
|
+
]
|
|
106
|
+
for item in plan.additions:
|
|
107
|
+
if item.path in changing and not item.requested:
|
|
108
|
+
notes.append(
|
|
109
|
+
Text(f" {item.name} comes too, because a mod needs it", style="dim")
|
|
110
|
+
)
|
|
111
|
+
elif item.requested and item.path not in changing and item.path in held:
|
|
112
|
+
notes.append(Text(f" = {item.name} is already added", style="dim"))
|
|
113
|
+
notes += [
|
|
114
|
+
Text(f" ? {name} has no release for this instance", style="yellow")
|
|
115
|
+
for name in plan.unavailable
|
|
116
|
+
]
|
|
117
|
+
return notes
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def add_command(
|
|
121
|
+
ctx: typer.Context,
|
|
122
|
+
items: Items,
|
|
123
|
+
kind: Annotated[
|
|
124
|
+
AddedKind, typer.Option("--type", "-t", help="The kind of project.")
|
|
125
|
+
] = AddedKind.MOD,
|
|
126
|
+
optional: Annotated[
|
|
127
|
+
bool, typer.Option("--optional", help="Also add optional dependencies.")
|
|
128
|
+
] = False,
|
|
129
|
+
pre: Annotated[
|
|
130
|
+
bool, typer.Option("--pre", help="Allow alpha and beta releases.")
|
|
131
|
+
] = False,
|
|
132
|
+
overwrite: Overwrite = False,
|
|
133
|
+
from_folders: From = None,
|
|
134
|
+
yes: Yes = False,
|
|
135
|
+
dry_run: DryRun = False,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Add mods, resource packs or shaders to an instance, with what they need."""
|
|
138
|
+
app = get(ctx)
|
|
139
|
+
instance, _, names = split_target(items, app.config, app.paths)
|
|
140
|
+
if not names:
|
|
141
|
+
raise AppError("Name at least one project to add.")
|
|
142
|
+
refs = [parse_project(name, app.config.default_platform) for name in names]
|
|
143
|
+
release_only = not (pre or app.config.allow_prereleases)
|
|
144
|
+
plans: list[tuple[str, Plan]] = []
|
|
145
|
+
for platform_name in PLATFORMS:
|
|
146
|
+
wanted = [ref.name for ref in refs if ref.platform == platform_name]
|
|
147
|
+
if not wanted:
|
|
148
|
+
continue
|
|
149
|
+
with (
|
|
150
|
+
explained(platform_name, wanted),
|
|
151
|
+
app.platform(platform_name) as platform,
|
|
152
|
+
):
|
|
153
|
+
plan = instance.plan(
|
|
154
|
+
platform,
|
|
155
|
+
wanted,
|
|
156
|
+
project_type=ADDED_KINDS[kind],
|
|
157
|
+
include_optional=optional,
|
|
158
|
+
release_only=release_only,
|
|
159
|
+
)
|
|
160
|
+
plans.append((platform_name, plan))
|
|
161
|
+
|
|
162
|
+
title = f"Add to {instance.path.name}"
|
|
163
|
+
changes = Changes(
|
|
164
|
+
added=tuple(path for _, plan in plans for path in plan.changes.added),
|
|
165
|
+
updated=tuple(path for _, plan in plans for path in plan.changes.updated),
|
|
166
|
+
removed=tuple(path for _, plan in plans for path in plan.changes.removed),
|
|
167
|
+
conflicts=tuple(path for _, plan in plans for path in plan.changes.conflicts),
|
|
168
|
+
)
|
|
169
|
+
sizes = {item.path: item.entry.size for _, plan in plans for item in plan.additions}
|
|
170
|
+
for _, plan in plans:
|
|
171
|
+
if not plan.ok:
|
|
172
|
+
raise DependencyError(plan)
|
|
173
|
+
notes = [note for _, plan in plans for note in _notes(instance, plan)]
|
|
174
|
+
pending = show_plan(app, changes, sizes, title=title, notes=notes)
|
|
175
|
+
if not pending or dry_run:
|
|
176
|
+
return
|
|
177
|
+
confirm(app, "Add them?", yes=yes)
|
|
178
|
+
with cancellable() as token, progress(app) as update:
|
|
179
|
+
for _, plan in plans:
|
|
180
|
+
instance.add(
|
|
181
|
+
plan,
|
|
182
|
+
overwrite=overwrite,
|
|
183
|
+
adopt_from=from_folders or (),
|
|
184
|
+
progress=update,
|
|
185
|
+
cancel=token,
|
|
186
|
+
)
|
|
187
|
+
app.out.print(f"Added to {instance.path.name}.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def remove_command(
|
|
191
|
+
ctx: typer.Context,
|
|
192
|
+
items: Items,
|
|
193
|
+
keep_dependencies: Annotated[
|
|
194
|
+
bool,
|
|
195
|
+
typer.Option(
|
|
196
|
+
"--keep-deps", help="Keep dependencies even if nothing needs them."
|
|
197
|
+
),
|
|
198
|
+
] = False,
|
|
199
|
+
yes: Yes = False,
|
|
200
|
+
dry_run: DryRun = False,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Remove projects you added, and the dependencies nothing else needs."""
|
|
203
|
+
app = get(ctx)
|
|
204
|
+
instance, _, names = split_target(items, app.config, app.paths)
|
|
205
|
+
if not names:
|
|
206
|
+
raise AppError("Name at least one project to remove.")
|
|
207
|
+
try:
|
|
208
|
+
preview = instance.remove(
|
|
209
|
+
*names, keep_dependencies=keep_dependencies, dry_run=True
|
|
210
|
+
)
|
|
211
|
+
except InstanceError as e:
|
|
212
|
+
raise AppError(
|
|
213
|
+
str(e) + ".",
|
|
214
|
+
hint="Files from the pack cannot be removed, but you can turn them off "
|
|
215
|
+
"with: modsmith disable",
|
|
216
|
+
) from e
|
|
217
|
+
show_plan(app, preview, title=f"Remove from {instance.path.name}")
|
|
218
|
+
if dry_run:
|
|
219
|
+
return
|
|
220
|
+
confirm(app, "Remove them?", yes=yes)
|
|
221
|
+
instance.remove(*names, keep_dependencies=keep_dependencies)
|
|
222
|
+
app.out.print(f"Removed from {instance.path.name}.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _toggle(ctx: typer.Context, items: list[str], *, enabled: bool) -> None:
|
|
226
|
+
app = get(ctx)
|
|
227
|
+
instance, _, names = split_target(items, app.config, app.paths)
|
|
228
|
+
if not names:
|
|
229
|
+
raise AppError("Name at least one file or project.")
|
|
230
|
+
targets = find_contents(instance, names)
|
|
231
|
+
paths = [item.path for item in targets]
|
|
232
|
+
if enabled:
|
|
233
|
+
instance.enable(*paths)
|
|
234
|
+
else:
|
|
235
|
+
instance.disable(*paths)
|
|
236
|
+
verb = "Turned on" if enabled else "Turned off"
|
|
237
|
+
for path in paths:
|
|
238
|
+
app.out.print(f"{verb} {path}.")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def enable_command(ctx: typer.Context, items: Items) -> None:
|
|
242
|
+
"""Turn mods, resource packs or shaders back on."""
|
|
243
|
+
_toggle(ctx, items, enabled=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def disable_command(ctx: typer.Context, items: Items) -> None:
|
|
247
|
+
"""Turn mods, resource packs or shaders off, without removing them."""
|
|
248
|
+
_toggle(ctx, items, enabled=False)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _pin(ctx: typer.Context, items: list[str], *, pinned: bool) -> None:
|
|
252
|
+
app = get(ctx)
|
|
253
|
+
instance, _, names = split_target(items, app.config, app.paths)
|
|
254
|
+
if not names:
|
|
255
|
+
raise AppError("Name at least one project.")
|
|
256
|
+
for name in names:
|
|
257
|
+
try:
|
|
258
|
+
addition = instance.pin(name, pinned)
|
|
259
|
+
except ModsmithError as e:
|
|
260
|
+
raise AppError(
|
|
261
|
+
f"{name!r} was not added on its own, so it cannot be pinned.",
|
|
262
|
+
hint="Pack files follow the pack's own updates.",
|
|
263
|
+
) from e
|
|
264
|
+
state = "stays at its release" if pinned else "follows updates again"
|
|
265
|
+
app.out.print(f"{addition.name} {state}.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def pin_command(ctx: typer.Context, items: Items) -> None:
|
|
269
|
+
"""Keep projects you added at their current release."""
|
|
270
|
+
_pin(ctx, items, pinned=True)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def unpin_command(ctx: typer.Context, items: Items) -> None:
|
|
274
|
+
"""Let pinned projects update again."""
|
|
275
|
+
_pin(ctx, items, pinned=False)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class AdoptFrom(StrEnum):
|
|
279
|
+
"""Which platforms may recognise files."""
|
|
280
|
+
|
|
281
|
+
MODRINTH = "modrinth"
|
|
282
|
+
CURSEFORGE = "curseforge"
|
|
283
|
+
ALL = "all"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def adopt_files(app: AppContext, instance: Instance, names: Sequence[str]) -> int:
|
|
287
|
+
"""Ask each platform to recognise the player's files, and say what it found.
|
|
288
|
+
|
|
289
|
+
With several platforms, one that cannot be used is skipped with a note.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
How many files were recognised.
|
|
293
|
+
"""
|
|
294
|
+
found = 0
|
|
295
|
+
for platform_name in names:
|
|
296
|
+
try:
|
|
297
|
+
with (
|
|
298
|
+
cancellable() as token,
|
|
299
|
+
progress(app) as update,
|
|
300
|
+
explained(platform_name),
|
|
301
|
+
app.platform(platform_name) as platform,
|
|
302
|
+
):
|
|
303
|
+
adopted = instance.adopt(platform, progress=update, cancel=token)
|
|
304
|
+
except AppError as e:
|
|
305
|
+
if len(names) == 1:
|
|
306
|
+
raise
|
|
307
|
+
report(e, app.err, prefix=f"Skipped {platform_name}: ")
|
|
308
|
+
continue
|
|
309
|
+
for item in adopted:
|
|
310
|
+
app.out.print(f" {item.name}: {item.path}")
|
|
311
|
+
found += len(adopted)
|
|
312
|
+
if found:
|
|
313
|
+
files = "1 file" if found == 1 else f"{found} files"
|
|
314
|
+
app.out.print(f"{files} can now be updated and removed like added ones.")
|
|
315
|
+
else:
|
|
316
|
+
app.out.print("No unrecognised files were found on these platforms.")
|
|
317
|
+
return found
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def adopt_command(
|
|
321
|
+
ctx: typer.Context,
|
|
322
|
+
name: InstanceName = None,
|
|
323
|
+
where: Annotated[
|
|
324
|
+
AdoptFrom,
|
|
325
|
+
typer.Option("--platform", "-p", help="Which platforms may recognise files."),
|
|
326
|
+
] = AdoptFrom.ALL,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Recognise files you put in an instance, so they can be updated and removed."""
|
|
329
|
+
app = get(ctx)
|
|
330
|
+
instance, _ = resolve_instance(name, app.config, app.paths)
|
|
331
|
+
names = list(PLATFORMS) if where == AdoptFrom.ALL else [where.value]
|
|
332
|
+
adopt_files(app, instance, names)
|