exist-shell 0.1.0__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,183 @@
1
+ """Group management commands (ls, list, add, rm)."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.client import ExistClient
6
+ from exist_shell.completions import server_at_completer, server_nick_completer, user_arg_completer
7
+ from exist_shell.config import Config
8
+ from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistQueryError
9
+ from exist_shell.utils import parse_user_at_server
10
+
11
+ app = typer.Typer(help="Manage eXist-db groups.", no_args_is_help=True)
12
+
13
+
14
+ def _resolve_server(
15
+ config: Config,
16
+ inline_server: str | None,
17
+ flag_server: str | None,
18
+ ) -> str:
19
+ """Resolve the effective server nick from an inline @server and --server flag.
20
+
21
+ Args:
22
+ config: The loaded configuration.
23
+ inline_server: Server nick extracted from a ``group@server`` or ``@server``
24
+ argument; ``None`` if no ``@`` suffix was present.
25
+ flag_server: Server nick provided via the ``--server`` option; ``None``
26
+ if the flag was omitted.
27
+
28
+ Returns:
29
+ The resolved server nick, guaranteed to exist in ``config.servers``.
30
+
31
+ Raises:
32
+ typer.Exit: If both sources conflict, no server can be determined, or
33
+ the resolved nick does not exist in the configuration.
34
+ """
35
+ if not config.servers:
36
+ typer.echo("Error: no servers configured. Use 'exsh server add' first.", err=True)
37
+ raise typer.Exit(1)
38
+ if inline_server and flag_server:
39
+ typer.echo(
40
+ "Error: conflicting server specifications: use @server or --server, not both.",
41
+ err=True,
42
+ )
43
+ raise typer.Exit(1)
44
+ server = inline_server or flag_server
45
+ if server is None:
46
+ if len(config.servers) != 1:
47
+ typer.echo(
48
+ "Error: --server is required when multiple servers are configured.",
49
+ err=True,
50
+ )
51
+ raise typer.Exit(1)
52
+ server = next(iter(config.servers))
53
+ if server not in config.servers:
54
+ typer.echo(f"Error: server '{server}' not found.", err=True)
55
+ raise typer.Exit(1)
56
+ return server
57
+
58
+
59
+ @app.command("ls")
60
+ def group_ls(
61
+ at_server: str | None = typer.Argument(None, metavar="@SERVER", help="Server in @nick form (e.g. @prod).", autocompletion=server_at_completer),
62
+ server: str | None = typer.Option(None, "--server", help="Server nick to query.", autocompletion=server_nick_completer),
63
+ ) -> None:
64
+ """List all groups and their members.
65
+
66
+ The server may be specified as a bare ``@nick`` positional argument
67
+ (e.g. ``group ls @prod``) or via ``--server``. When omitted and only one
68
+ server is configured it is selected automatically.
69
+
70
+ Args:
71
+ at_server: Optional server nick in ``@nick`` form.
72
+ server: Server nick to query. Auto-selected when only one server is
73
+ configured; required when multiple servers are configured.
74
+ """
75
+ inline_server: str | None = None
76
+ if at_server is not None:
77
+ if not at_server.startswith("@"):
78
+ typer.echo("Error: positional server argument must be in @nick form (e.g. @prod).", err=True)
79
+ raise typer.Exit(1)
80
+ inline_server = at_server[1:]
81
+ if not inline_server:
82
+ typer.echo("Error: server nick cannot be empty.", err=True)
83
+ raise typer.Exit(1)
84
+
85
+ config = Config.load()
86
+ resolved = _resolve_server(config, inline_server, server)
87
+
88
+ try:
89
+ with ExistClient(config.servers[resolved]) as client:
90
+ groups = client.list_groups()
91
+ except ExistAuthError:
92
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
93
+ raise typer.Exit(1)
94
+ except ExistConnectionError as e:
95
+ typer.echo(f"Error: {e}", err=True)
96
+ raise typer.Exit(1)
97
+
98
+ for g in groups:
99
+ typer.echo(f"{g.name}\t{', '.join(g.members)}")
100
+
101
+
102
+ @app.command("add")
103
+ def group_add(
104
+ groupname: str = typer.Argument(help="Group name to create, optionally as group@server.", autocompletion=user_arg_completer),
105
+ server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
106
+ ) -> None:
107
+ """Create a new group on the server.
108
+
109
+ The group name may include an inline server nick using ``group@server``
110
+ syntax (e.g. ``editors@prod``).
111
+
112
+ Args:
113
+ groupname: The new group name, optionally suffixed with ``@server_nick``.
114
+ server: Server nick to target. Auto-selected when only one server is
115
+ configured; required when multiple servers are configured.
116
+ """
117
+ bare_groupname, inline_server = parse_user_at_server(groupname)
118
+ if not bare_groupname:
119
+ typer.echo("Error: group name cannot be empty.", err=True)
120
+ raise typer.Exit(1)
121
+
122
+ config = Config.load()
123
+ resolved = _resolve_server(config, inline_server, server)
124
+
125
+ try:
126
+ with ExistClient(config.servers[resolved]) as client:
127
+ client.create_group(bare_groupname)
128
+ except ExistAuthError:
129
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
130
+ raise typer.Exit(1)
131
+ except ExistConnectionError as e:
132
+ typer.echo(f"Error: {e}", err=True)
133
+ raise typer.Exit(1)
134
+ except ExistQueryError as e:
135
+ typer.echo(f"Error: {e}", err=True)
136
+ raise typer.Exit(1)
137
+
138
+ typer.echo(f"Group '{bare_groupname}' created.")
139
+
140
+
141
+ @app.command("rm")
142
+ def group_rm(
143
+ groupname: str = typer.Argument(help="Group name to remove, optionally as group@server.", autocompletion=user_arg_completer),
144
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
145
+ server: str | None = typer.Option(None, "--server", help="Server nick to target.", autocompletion=server_nick_completer),
146
+ ) -> None:
147
+ """Remove a group from the server.
148
+
149
+ The group name may include an inline server nick using ``group@server``
150
+ syntax (e.g. ``editors@prod``). Prompts for confirmation unless ``--yes``
151
+ is supplied.
152
+
153
+ Args:
154
+ groupname: The group name to remove, optionally suffixed with ``@server_nick``.
155
+ yes: When True, skip the confirmation prompt.
156
+ server: Server nick to target. Auto-selected when only one server is
157
+ configured; required when multiple servers are configured.
158
+ """
159
+ bare_groupname, inline_server = parse_user_at_server(groupname)
160
+ if not bare_groupname:
161
+ typer.echo("Error: group name cannot be empty.", err=True)
162
+ raise typer.Exit(1)
163
+
164
+ config = Config.load()
165
+ resolved = _resolve_server(config, inline_server, server)
166
+
167
+ if not yes:
168
+ typer.confirm(f"Remove group '{bare_groupname}'?", abort=True)
169
+
170
+ try:
171
+ with ExistClient(config.servers[resolved]) as client:
172
+ client.delete_group(bare_groupname)
173
+ except ExistAuthError:
174
+ typer.echo(f"Error: authentication failed for server '{resolved}'.", err=True)
175
+ raise typer.Exit(1)
176
+ except ExistConnectionError as e:
177
+ typer.echo(f"Error: {e}", err=True)
178
+ raise typer.Exit(1)
179
+ except ExistQueryError as e:
180
+ typer.echo(f"Error: {e}", err=True)
181
+ raise typer.Exit(1)
182
+
183
+ typer.echo(f"Group '{bare_groupname}' removed.")
@@ -0,0 +1,66 @@
1
+ """ls command — list contents of an eXist collection path."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.client import ExistClient
6
+ from exist_shell.completions import collection_target_completer
7
+ from exist_shell.models import CollectionEntry, CollectionItem, ResourceEntry
8
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
9
+
10
+
11
+ def _sort_key_time(item: CollectionItem) -> str:
12
+ """Return the best available timestamp for sorting."""
13
+ if isinstance(item, ResourceEntry):
14
+ return item.last_modified or item.created or ""
15
+ return item.created or ""
16
+
17
+
18
+ def ls(
19
+ target: str = typer.Argument(
20
+ help="Collection and path: <nick>[:<path>].",
21
+ autocompletion=collection_target_completer("any"),
22
+ ),
23
+ sort: str = typer.Option("name", "--sort", "-s", help="Sort by: name, time."),
24
+ reverse: bool = typer.Option(False, "--reverse", "-r", help="Reverse sort order."),
25
+ names_only: bool = typer.Option(False, "--names-only", help="Print only names, one per line."),
26
+ ) -> None:
27
+ """List subcollections and resources at a collection path."""
28
+ nick, path = parse_target(target, path_required=False)
29
+ collection, server, full_path = resolve_collection(nick, path)
30
+
31
+ with handle_exist_errors(path, nick, collection.server_nick):
32
+ with ExistClient(server) as client:
33
+ items = client.list_collection(full_path)
34
+
35
+ if sort == "time":
36
+ items = sorted(items, key=_sort_key_time, reverse=reverse)
37
+ else:
38
+ items = sorted(items, key=lambda x: x.name, reverse=reverse)
39
+
40
+ if names_only:
41
+ for item in items:
42
+ display_name = f"{item.name}/" if isinstance(item, CollectionEntry) else item.name
43
+ typer.echo(display_name)
44
+ return
45
+
46
+ rows: list[tuple[str, str, str, str]] = []
47
+ for item in items:
48
+ display_name = f"{item.name}/" if isinstance(item, CollectionEntry) else item.name
49
+ if isinstance(item, CollectionEntry):
50
+ rows.append((display_name, item.permissions or "", item.owner or "", item.created or ""))
51
+ else:
52
+ assert isinstance(item, ResourceEntry)
53
+ rows.append((
54
+ display_name,
55
+ item.permissions or "",
56
+ item.owner or "",
57
+ item.last_modified or item.created or "",
58
+ ))
59
+
60
+ if not rows:
61
+ return
62
+
63
+ widths = [max(len(row[col]) for row in rows) for col in range(len(rows[0]))]
64
+ for row in rows:
65
+ padded = " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row[:-1]))
66
+ typer.echo(f"{padded} {row[-1]}")
@@ -0,0 +1,24 @@
1
+ """mkdir command — create a collection in eXist-db."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.cache import invalidate
6
+ from exist_shell.client import ExistClient
7
+ from exist_shell.completions import collection_target_completer
8
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
9
+
10
+
11
+ def mkdir(
12
+ target: str = typer.Argument(
13
+ help="Collection and new path: <nick>:<path>.",
14
+ autocompletion=collection_target_completer("collection"),
15
+ ),
16
+ ) -> None:
17
+ """Create a collection at a path inside a registered collection."""
18
+ nick, path = parse_target(target)
19
+ collection, server, full_path = resolve_collection(nick, path)
20
+
21
+ with handle_exist_errors(path, nick, collection.server_nick):
22
+ with ExistClient(server) as client:
23
+ client.create_collection(full_path)
24
+ invalidate(nick)
@@ -0,0 +1,124 @@
1
+ """mv command — move or rename a document or collection on an eXist server."""
2
+
3
+ from pathlib import PurePosixPath
4
+
5
+ import typer
6
+
7
+ from exist_shell.cache import invalidate
8
+ from exist_shell.client import ExistClient
9
+ from exist_shell.completions import collection_target_completer
10
+ from exist_shell.models import CollectionEntry
11
+ from exist_shell.utils import handle_exist_errors, is_remote, parse_target, resolve_collection
12
+
13
+
14
+ def _dest_path(target_path: str, source_name: str) -> str:
15
+ """Resolve the destination path, appending source name when target ends with '/'.
16
+
17
+ Args:
18
+ target_path: The path component of the target argument.
19
+ source_name: Name to append when path ends with '/'.
20
+
21
+ Returns:
22
+ Final destination path without a trailing slash.
23
+ """
24
+ if target_path.endswith("/"):
25
+ return target_path.rstrip("/") + "/" + source_name
26
+ return target_path
27
+
28
+
29
+ def _collect_docs(client: ExistClient, base: str, rel_prefix: str = "") -> list[tuple[str, str]]:
30
+ """Recursively collect all (rel_path, full_path) document pairs under base.
31
+
32
+ Args:
33
+ client: Active ExistClient.
34
+ base: Full eXist path to walk.
35
+ rel_prefix: Relative path prefix accumulated during recursion.
36
+
37
+ Returns:
38
+ List of (relative_path, full_path) tuples for every document found.
39
+ """
40
+ items = client.list_collection(base)
41
+ result: list[tuple[str, str]] = []
42
+ for item in items:
43
+ rel = f"{rel_prefix}/{item.name}" if rel_prefix else item.name
44
+ if isinstance(item, CollectionEntry):
45
+ result.extend(_collect_docs(client, f"{base}/{item.name}", rel))
46
+ else:
47
+ result.append((rel, f"{base}/{item.name}"))
48
+ return result
49
+
50
+
51
+ def _copy_then_delete_collection(client: ExistClient, src: str, dst: str) -> None:
52
+ """Move a collection via REST: copy all contents first, then delete the source.
53
+
54
+ All documents and subcollections are created at the destination before any
55
+ deletion, so a partial failure leaves the source intact.
56
+
57
+ Args:
58
+ client: Active ExistClient.
59
+ src: Full eXist path of the source collection.
60
+ dst: Full eXist path of the destination collection.
61
+ """
62
+ docs = _collect_docs(client, src)
63
+
64
+ # Create destination root and any needed subcollection paths.
65
+ # create_collection is idempotent and handles intermediate levels.
66
+ client.create_collection(dst)
67
+ seen: set[str] = set()
68
+ for rel_path, _ in docs:
69
+ parent = str(PurePosixPath(rel_path).parent)
70
+ if parent != "." and parent not in seen:
71
+ client.create_collection(f"{dst}/{parent}")
72
+ seen.add(parent)
73
+
74
+ # Upload all documents (add-before-remove).
75
+ for rel_path, src_full_path in docs:
76
+ doc = client.get_document(src_full_path)
77
+ client.put_document(f"{dst}/{rel_path}", doc.content, doc.mime_type)
78
+
79
+ # Delete source only after all uploads succeed.
80
+ client.delete_collection(src)
81
+
82
+
83
+ def mv(
84
+ source: str = typer.Argument(
85
+ help="Source remote path: <nick>:<path>.",
86
+ autocompletion=collection_target_completer("any"),
87
+ ),
88
+ target: str = typer.Argument(
89
+ help="Target remote path: <nick>:<path>. Trailing '/' moves source into that collection.",
90
+ autocompletion=collection_target_completer("any"),
91
+ ),
92
+ ) -> None:
93
+ """Move or rename a document or collection on the server."""
94
+ if not is_remote(source) or not is_remote(target):
95
+ typer.echo(
96
+ "Error: both source and target must be remote paths (nick:path).",
97
+ err=True,
98
+ )
99
+ raise typer.Exit(1)
100
+
101
+ src_nick, src_path = parse_target(source)
102
+ src_collection, src_server, src_full = resolve_collection(src_nick, src_path)
103
+
104
+ tgt_nick, tgt_path = parse_target(target)
105
+ resolved_dest = _dest_path(tgt_path, PurePosixPath(src_path).name)
106
+ tgt_collection, tgt_server, tgt_full = resolve_collection(tgt_nick, resolved_dest)
107
+
108
+ if src_server.host != tgt_server.host or src_server.port != tgt_server.port:
109
+ typer.echo(
110
+ "Error: cross-server mv is not supported. Use cp + rm instead.",
111
+ err=True,
112
+ )
113
+ raise typer.Exit(1)
114
+
115
+ with handle_exist_errors(src_path, src_nick, src_collection.server_nick):
116
+ with ExistClient(src_server) as client:
117
+ if client.is_collection(src_full):
118
+ _copy_then_delete_collection(client, src_full, tgt_full)
119
+ else:
120
+ client.move_document(src_full, tgt_full)
121
+
122
+ invalidate(src_nick)
123
+ if tgt_nick != src_nick:
124
+ invalidate(tgt_nick)
@@ -0,0 +1,63 @@
1
+ """put command — upload a document to an eXist collection path."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from exist_shell.cache import invalidate
9
+ from exist_shell.client import ExistClient
10
+ from exist_shell.completions import collection_target_completer
11
+ from exist_shell.utils import check_xml_wellformed, guess_mime, handle_exist_errors, parse_target, resolve_collection
12
+
13
+
14
+ def _resolve_mime(file: Path | None, mime: str | None) -> str:
15
+ """Determine the MIME type to use for the upload.
16
+
17
+ Args:
18
+ file: Local file path, or None when reading from stdin.
19
+ mime: Explicit MIME type from --mime flag, or None.
20
+
21
+ Returns:
22
+ Resolved MIME type string.
23
+ """
24
+ if mime is not None:
25
+ return mime
26
+ if file is not None:
27
+ return guess_mime(file)
28
+ return "application/xml"
29
+
30
+
31
+ def put(
32
+ target: str = typer.Argument(
33
+ help="Collection and document path: <nick>:<path>.",
34
+ autocompletion=collection_target_completer("any"),
35
+ ),
36
+ file: Path | None = typer.Option(None, "-f", "--file", help="Local file to upload (default: stdin)."),
37
+ mime: str | None = typer.Option(None, "--mime", help="MIME type (default: application/xml, or guessed from file extension)."),
38
+ ) -> None:
39
+ """Upload a document to a collection path from a file or stdin."""
40
+ nick, path = parse_target(target)
41
+ collection, server, full_path = resolve_collection(nick, path)
42
+ resolved_mime = _resolve_mime(file, mime)
43
+
44
+ if file is not None:
45
+ try:
46
+ content = file.read_bytes()
47
+ except OSError as e:
48
+ typer.echo(f"Error: cannot read '{file}': {e}", err=True)
49
+ raise typer.Exit(1)
50
+ else:
51
+ content = sys.stdin.buffer.read()
52
+
53
+ if xml_error := check_xml_wellformed(content, resolved_mime):
54
+ source = str(file) if file is not None else "stdin"
55
+ typer.echo(f"Error: '{source}' is not well-formed XML: {xml_error}", err=True)
56
+ raise typer.Exit(1)
57
+
58
+ # PUT silently overwrites existing documents. A --no-clobber flag could
59
+ # check existence first (HEAD request) and abort if the resource is found.
60
+ with handle_exist_errors(path, nick, collection.server_nick):
61
+ with ExistClient(server) as client:
62
+ client.put_document(full_path, content, resolved_mime)
63
+ invalidate(nick)
@@ -0,0 +1,23 @@
1
+ """rm command — delete documents from an eXist collection path."""
2
+
3
+ import typer
4
+
5
+ from exist_shell.client import ExistClient
6
+ from exist_shell.completions import collection_target_completer
7
+ from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
8
+
9
+
10
+ def rm(
11
+ targets: list[str] = typer.Argument(
12
+ help="One or more collection and document paths: <nick>:<path>.",
13
+ autocompletion=collection_target_completer("resource"),
14
+ ),
15
+ ) -> None:
16
+ """Delete one or more documents from a collection path."""
17
+ for target in targets:
18
+ nick, path = parse_target(target)
19
+ collection, server, full_path = resolve_collection(nick, path)
20
+
21
+ with handle_exist_errors(path, nick, collection.server_nick):
22
+ with ExistClient(server) as client:
23
+ client.delete_document(full_path)
@@ -0,0 +1,114 @@
1
+ """Server management commands (add, ls, rm, rename)."""
2
+
3
+ import re
4
+
5
+ import typer
6
+
7
+ from pydantic import SecretStr
8
+
9
+ from exist_shell.client import ExistClient
10
+ from exist_shell.config import NICK_PATTERN, Config, Server
11
+ from exist_shell.exceptions import ExistAuthError, ExistConnectionError
12
+
13
+ app = typer.Typer(help="Manage servers.", no_args_is_help=True)
14
+
15
+
16
+ def _default_nick(host: str) -> str:
17
+ return host.split(".")[0]
18
+
19
+
20
+ def _complete_server_nick(incomplete: str) -> list[str]:
21
+ try:
22
+ servers = Config.load().servers
23
+ except Exception:
24
+ return []
25
+ return [nick for nick in servers if nick.startswith(incomplete)]
26
+
27
+
28
+ def _list() -> None:
29
+ config = Config.load()
30
+ for nick, s in config.servers.items():
31
+ typer.echo(f"{nick}\t{s.user}@{s.host}:{s.port}")
32
+
33
+
34
+ app.command("ls", help="List configured servers.")(_list)
35
+ app.command("list", help="List configured servers.", hidden=True)(_list)
36
+
37
+
38
+ @app.command("add")
39
+ def server_add(
40
+ host: str = typer.Argument(help="Hostname or IP of the eXist server."),
41
+ port: int = typer.Option(8080, help="HTTP port."),
42
+ user: str = typer.Option("admin", help="Username."),
43
+ password: str = typer.Option(
44
+ "",
45
+ "--password",
46
+ envvar="EXIST_PASSWORD",
47
+ hide_input=True,
48
+ prompt="Password (leave empty for none)",
49
+ help="Password.",
50
+ ),
51
+ nick: str | None = typer.Option(None, help="Nickname (default: hostname without domain)."),
52
+ ) -> None:
53
+ """Add a server and verify connectivity before saving."""
54
+ resolved_nick = nick or _default_nick(host)
55
+ config = Config.load()
56
+ if resolved_nick in config.servers:
57
+ typer.echo(f"Error: server nick '{resolved_nick}' already exists.", err=True)
58
+ raise typer.Exit(1)
59
+ server = Server(nick=resolved_nick, host=host, port=port, user=user, password=SecretStr(password))
60
+ try:
61
+ with ExistClient(server) as client:
62
+ client.check_connection()
63
+ except ExistAuthError:
64
+ typer.echo(f"Error: authentication failed for {host}:{port}.", err=True)
65
+ raise typer.Exit(1)
66
+ except ExistConnectionError as e:
67
+ typer.echo(f"Error: {e}", err=True)
68
+ raise typer.Exit(1)
69
+ config.add_server(server)
70
+ typer.echo(f"Server '{resolved_nick}' added.")
71
+
72
+
73
+ @app.command("rename")
74
+ def server_rename(
75
+ old_nick: str = typer.Argument(
76
+ help="Current nickname of the server.", autocompletion=_complete_server_nick
77
+ ),
78
+ new_nick: str = typer.Argument(help="New nickname for the server."),
79
+ ) -> None:
80
+ """Rename a server nick, updating all collection references."""
81
+ if old_nick == new_nick:
82
+ typer.echo("Error: new nick is the same as the old nick.", err=True)
83
+ raise typer.Exit(1)
84
+ if not re.match(NICK_PATTERN, new_nick):
85
+ typer.echo(f"Error: '{new_nick}' is not a valid server nick.", err=True)
86
+ raise typer.Exit(1)
87
+ config = Config.load()
88
+ if old_nick not in config.servers:
89
+ typer.echo(f"Error: server nick '{old_nick}' not found.", err=True)
90
+ raise typer.Exit(1)
91
+ if new_nick in config.servers:
92
+ typer.echo(f"Error: server nick '{new_nick}' already exists.", err=True)
93
+ raise typer.Exit(1)
94
+ updated = config.rename_server(old_nick, new_nick)
95
+ if updated:
96
+ noun = "collection" if len(updated) == 1 else "collections"
97
+ typer.echo(f"Also updated {len(updated)} {noun}: {', '.join(updated)}.")
98
+ typer.echo(f"Server '{old_nick}' renamed to '{new_nick}'.")
99
+
100
+
101
+ @app.command("rm")
102
+ def server_rm(
103
+ nick: str = typer.Argument(help="Nickname of the server to remove."),
104
+ ) -> None:
105
+ """Remove a server and all its registered collections from the config."""
106
+ config = Config.load()
107
+ if nick not in config.servers:
108
+ typer.echo(f"Error: server nick '{nick}' not found.", err=True)
109
+ raise typer.Exit(1)
110
+ cascaded = config.remove_server(nick)
111
+ if cascaded:
112
+ noun = "collection" if len(cascaded) == 1 else "collections"
113
+ typer.echo(f"Also removed {len(cascaded)} {noun}: {', '.join(cascaded)}.")
114
+ typer.echo(f"Server '{nick}' removed.")