git-worktree-wrapper 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.
gww/cli/context.py ADDED
@@ -0,0 +1,382 @@
1
+ """Shared CLI command context: tag parsing, config loading, source resolution.
2
+
3
+ This module collects the boilerplate that every command used to inline:
4
+
5
+ * :class:`CommandContext` — small dataclass carrying the per-invocation state
6
+ (``verbose``, ``quiet``, ``tags``) plus command-specific fields (URI,
7
+ branch, …) that was previously pulled off ``argparse.Namespace`` with
8
+ ``getattr`` calls.
9
+ * :class:`CommandExit` — control-flow exception used by ``…_or_exit``
10
+ helpers to abort a command with a specific exit code.
11
+ * :func:`exit_on_error` — decorator that turns :class:`CommandExit` raised
12
+ inside a command into a printed error + return-code, preserving the
13
+ contract that ``run_<command>(ctx) -> int``.
14
+ * :func:`parse_tags` — turn a list of ``"key=value"`` strings into a dict.
15
+ * :func:`parse_uri_or_exit` — parse and validate a repository URI.
16
+ * :func:`load_config_or_exit` — load + validate the gww config, raising
17
+ :class:`CommandExit(2, ...)` on any failure so callers do not have to repeat
18
+ the tri-except pattern.
19
+ * :func:`resolve_source_repo_or_exit` — detect the current repo, walk from a
20
+ worktree back to its source, and return ``(source_path, remote_uri)``. Raises
21
+ :class:`CommandExit(1, ...)` on any failure so callers do not have to repeat
22
+ the detect-or-walk pattern.
23
+ * :func:`resolve_source_repo` — same detect-or-walk pattern, but without the
24
+ remote-origin requirement. Used by commands that only need the source path
25
+ (e.g. ``pull``).
26
+ * :class:`RuleFailure` / :func:`print_action_failure_summary` — shared
27
+ per-rule failure record and the grouped stderr summary used by
28
+ ``clone``/``add`` at the end of the action loop.
29
+
30
+ Commands still own their own control flow. They raise :class:`CommandExit`
31
+ when they want to bail out with a specific exit code; the ``@exit_on_error``
32
+ decorator on the public ``run_<command>`` entry points converts that exception
33
+ into the return code that the old ``return N`` pattern used to produce.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import functools
40
+ import sys
41
+ from dataclasses import dataclass, field
42
+ from pathlib import Path
43
+ from typing import Any, Callable, Optional, TypeVar
44
+
45
+ from gww.actions import Action, ActionError, RuleActions
46
+ from gww.config.loader import ConfigLoadError, ConfigNotFoundError, load_config
47
+ from gww.config.validator import Config, ConfigValidationError, validate_config
48
+ from gww.git.repository import (
49
+ GitCommandError,
50
+ NotGitRepositoryError,
51
+ detect_repository,
52
+ get_source_repository,
53
+ )
54
+ from gww.utils.uri import ParsedURI, parse_uri
55
+
56
+
57
+ _F = TypeVar("_F", bound=Callable[..., Any])
58
+
59
+
60
+ def exit_on_error(func: _F) -> _F:
61
+ """Decorator that converts :class:`CommandExit` into a return code.
62
+
63
+ The command's body can freely ``raise CommandExit(code, message)``;
64
+ the wrapper prints ``message`` to stderr and returns ``code``. Any
65
+ other exception propagates unchanged so the global handler in
66
+ :func:`gww.cli.main:main` (or the test framework) sees it.
67
+ """
68
+
69
+ @functools.wraps(func)
70
+ def wrapper(*args: Any, **kwargs: Any) -> int:
71
+ try:
72
+ result = func(*args, **kwargs)
73
+ except CommandExit as e:
74
+ if e.message:
75
+ print(e.message, file=sys.stderr)
76
+ return e.code
77
+ return int(result)
78
+
79
+ return wrapper # type: ignore[return-value]
80
+
81
+
82
+ class CommandExit(Exception):
83
+ """Raised when a command needs to terminate early with a specific exit code.
84
+
85
+ Attributes:
86
+ code: Exit code to return from the CLI. ``1`` for a runtime error,
87
+ ``2`` for a configuration error.
88
+ message: Human-readable error message. Written to stderr by
89
+ :func:`gww.cli.main:main` before the process exits.
90
+ """
91
+
92
+ def __init__(self, code: int, message: str = "") -> None:
93
+ super().__init__(message)
94
+ self.code = code
95
+ self.message = message
96
+
97
+
98
+ def parse_tags(tag_args: Optional[list[str]]) -> dict[str, str]:
99
+ """Parse ``--tag key=value`` arguments into a dictionary.
100
+
101
+ Args:
102
+ tag_args: List of tag strings in format ``"key=value"`` or ``"key"``.
103
+
104
+ Returns:
105
+ Dictionary mapping tag keys to values (empty string if no value).
106
+ """
107
+ tags: dict[str, str] = {}
108
+ for tag_arg in tag_args or []:
109
+ if "=" in tag_arg:
110
+ key, value = tag_arg.split("=", 1)
111
+ tags[key] = value
112
+ else:
113
+ tags[tag_arg] = ""
114
+ return tags
115
+
116
+
117
+ def parse_uri_or_exit(uri_str: str) -> ParsedURI:
118
+ """Parse a repository URI, raising :class:`CommandExit` on failure.
119
+
120
+ Args:
121
+ uri_str: Raw URI string from the command line.
122
+
123
+ Returns:
124
+ Validated :class:`ParsedURI` object.
125
+
126
+ Raises:
127
+ CommandExit: With code ``1`` if the URI is malformed.
128
+ """
129
+ try:
130
+ return parse_uri(uri_str)
131
+ except ValueError as e:
132
+ raise CommandExit(1, f"Error: Invalid repository URI: {e}") from e
133
+
134
+
135
+ @dataclass
136
+ class CommandContext:
137
+ """Per-invocation context shared by all CLI commands.
138
+
139
+ Replaces the ``getattr(args, "verbose", 0)`` / ``getattr(args, "quiet", False)``
140
+ pattern with a typed container. Built once from the parsed
141
+ :class:`argparse.Namespace` and threaded into helpers and commands.
142
+
143
+ Command-specific fields (``uri``, ``branch``, ``branch_or_path``, …) are
144
+ optional and only populated for the commands that need them.
145
+
146
+ Attributes:
147
+ verbose: Verbosity level (``-v`` count).
148
+ quiet: Whether to suppress non-error output (``-q``).
149
+ tags: Tag key-value pairs from ``--tag`` options.
150
+ uri: Source URI string (clone command).
151
+ branch: Branch name (add command).
152
+ branch_or_path: Branch name or worktree path (remove command).
153
+ create_branch: Whether to create the branch if missing (add command).
154
+ force: Whether to force the operation past safety checks.
155
+ old_repos: Source directories to scan for migration (migrate command).
156
+ dry_run: Whether to only report what would happen (migrate command).
157
+ inplace: Whether to move in place instead of copying (migrate command).
158
+ init_command: ``"config"`` or ``"shell"`` for ``gww init``.
159
+ shell: Shell name for ``gww init shell``.
160
+ """
161
+
162
+ verbose: int = 0
163
+ quiet: bool = False
164
+ tags: dict[str, str] = field(default_factory=dict)
165
+ uri: Optional[str] = None
166
+ branch: Optional[str] = None
167
+ branch_or_path: Optional[str] = None
168
+ create_branch: bool = False
169
+ force: bool = False
170
+ old_repos: list[str] = field(default_factory=list)
171
+ dry_run: bool = False
172
+ inplace: bool = False
173
+ init_command: Optional[str] = None
174
+ shell: Optional[str] = None
175
+
176
+ @classmethod
177
+ def from_args(cls, args: argparse.Namespace) -> CommandContext:
178
+ """Build a :class:`CommandContext` from parsed argparse args.
179
+
180
+ Args:
181
+ args: Parsed command-line arguments.
182
+
183
+ Returns:
184
+ Populated context.
185
+ """
186
+ old_repos_raw = getattr(args, "old_repos", None) or []
187
+ if isinstance(old_repos_raw, str):
188
+ old_repos = [old_repos_raw]
189
+ else:
190
+ old_repos = list(old_repos_raw)
191
+ return cls(
192
+ verbose=getattr(args, "verbose", 0),
193
+ quiet=getattr(args, "quiet", False),
194
+ tags=parse_tags(getattr(args, "tag", None)),
195
+ uri=getattr(args, "uri", None),
196
+ branch=getattr(args, "branch", None),
197
+ branch_or_path=getattr(args, "branch_or_path", None),
198
+ create_branch=getattr(args, "create_branch", False),
199
+ force=getattr(args, "force", False),
200
+ old_repos=old_repos,
201
+ dry_run=getattr(args, "dry_run", False),
202
+ inplace=getattr(args, "inplace", False),
203
+ init_command=getattr(args, "init_command", None),
204
+ shell=getattr(args, "shell", None),
205
+ )
206
+
207
+ def say(self, message: str) -> None:
208
+ """Print a status message unless quiet mode is set.
209
+
210
+ Args:
211
+ message: Message to write to stdout.
212
+ """
213
+ if not self.quiet:
214
+ print(message)
215
+
216
+ def verbose_msg(self, message: str) -> None:
217
+ """Print a verbose status message.
218
+
219
+ Args:
220
+ message: Message to write to stderr (only when ``verbose > 0``).
221
+ """
222
+ if self.verbose > 0 and not self.quiet:
223
+ print(message, file=sys.stderr)
224
+
225
+
226
+ def load_config_or_exit() -> Config:
227
+ """Load and validate the gww configuration.
228
+
229
+ Returns:
230
+ Validated :class:`Config` object.
231
+
232
+ Raises:
233
+ CommandExit: With code ``2`` if the config is missing, malformed, or
234
+ fails validation.
235
+ """
236
+ try:
237
+ raw_config = load_config()
238
+ except ConfigNotFoundError as e:
239
+ raise CommandExit(
240
+ 2,
241
+ "Error: Config file not found. Run 'gww init config' to create one.",
242
+ ) from e
243
+ except ConfigLoadError as e:
244
+ raise CommandExit(2, f"Error: {e}") from e
245
+
246
+ try:
247
+ return validate_config(raw_config)
248
+ except ConfigValidationError as e:
249
+ raise CommandExit(2, f"Config validation error: {e}") from e
250
+
251
+
252
+ def resolve_source_repo_or_exit(cwd: Path) -> tuple[Path, str]:
253
+ """Detect the repo at ``cwd`` and walk back to its source.
254
+
255
+ If ``cwd`` is inside a worktree, the source (main) repository path is
256
+ returned along with that source repo's remote URI. If ``cwd`` is inside a
257
+ source repository, that path and its remote URI are returned.
258
+
259
+ Args:
260
+ cwd: Directory to start the detection from.
261
+
262
+ Returns:
263
+ Tuple of ``(source_path, remote_uri)``.
264
+
265
+ Raises:
266
+ CommandExit: With code ``1`` if ``cwd`` is not in a git repository, the
267
+ source cannot be found, or the repo has no remote ``origin``.
268
+ """
269
+ try:
270
+ repo = detect_repository(cwd)
271
+ except NotGitRepositoryError as e:
272
+ raise CommandExit(1, "Error: Not in a git repository.") from e
273
+
274
+ if repo.is_worktree:
275
+ try:
276
+ source_path = get_source_repository(repo.path)
277
+ except (NotGitRepositoryError, GitCommandError) as e:
278
+ raise CommandExit(1, f"Error finding source repository: {e}") from e
279
+ else:
280
+ source_path = repo.path
281
+
282
+ if not repo.remote_uri:
283
+ raise CommandExit(
284
+ 1,
285
+ "Error: Repository has no remote origin. "
286
+ "Cannot determine worktree path.",
287
+ )
288
+
289
+ return source_path, repo.remote_uri
290
+
291
+
292
+ def resolve_source_repo(cwd: Path) -> Path:
293
+ """Detect repo at ``cwd`` and walk back to its source.
294
+
295
+ Unlike :func:`resolve_source_repo_or_exit` this helper does not require
296
+ a remote origin — it returns the source path regardless of remote state.
297
+ Used by commands that operate on the local source (e.g. ``pull``).
298
+
299
+ Args:
300
+ cwd: Directory to start the detection from.
301
+
302
+ Returns:
303
+ Path to the source repository.
304
+
305
+ Raises:
306
+ CommandExit: With code ``1`` if ``cwd`` is not in a git repository or
307
+ the source repo cannot be found.
308
+ """
309
+ try:
310
+ repo = detect_repository(cwd)
311
+ except NotGitRepositoryError as e:
312
+ raise CommandExit(1, "Error: Not in a git repository.") from e
313
+
314
+ if repo.is_worktree:
315
+ try:
316
+ return get_source_repository(repo.path)
317
+ except (NotGitRepositoryError, GitCommandError) as e:
318
+ raise CommandExit(1, f"Error finding source repository: {e}") from e
319
+ return repo.path
320
+
321
+
322
+ @dataclass
323
+ class RuleFailure:
324
+ """A single failing action in the clone/add action loop.
325
+
326
+ Pairs the failing :class:`Action` with the :class:`RuleActions` bundle it
327
+ came from and the :class:`ActionError` it raised. Used by
328
+ :func:`print_action_failure_summary` to produce the grouped stderr block,
329
+ and by the CLI to choose the exit code (any failure with
330
+ ``bundle.critical`` set → exit 1).
331
+
332
+ Attributes:
333
+ bundle: The rule that produced the action.
334
+ action: The action whose ``run()`` raised.
335
+ error: The exception raised by ``action.run()``.
336
+ """
337
+
338
+ bundle: RuleActions
339
+ action: Action
340
+ error: ActionError
341
+
342
+
343
+ def _describe_action(action: Action) -> str:
344
+ """Return a short human-readable label for an action in failure messages.
345
+
346
+ Uses the action's concrete class name (e.g. ``"copy"``, ``"command"``) —
347
+ no need to add a method to the action classes just for diagnostics. The
348
+ ``"Action"`` suffix is stripped and the result lowercased, so
349
+ ``CopyAction`` → ``"copy"`` and ``CommandAction`` → ``"command"``.
350
+ """
351
+ name = type(action).__name__
352
+ if name.endswith("Action"):
353
+ name = name[: -len("Action")]
354
+ return name.lower()
355
+
356
+
357
+ def print_action_failure_summary(failures: list[RuleFailure]) -> None:
358
+ """Print the grouped action-execution summary to stderr.
359
+
360
+ Lists one entry per failing action, identified by the rule's config index,
361
+ the rule's criticality, the failing action's type, and the error text.
362
+ Critical rules' loops break after the first failure, so each critical rule
363
+ appears at most once; non-critical rules appear once per failing action.
364
+ Suppressing the ``say()`` success line is the caller's responsibility — the
365
+ helper only renders the block.
366
+
367
+ Args:
368
+ failures: Failures collected by the CLI action loop, in order.
369
+ """
370
+ print(
371
+ f"Action execution summary: {len(failures)} failure(s):",
372
+ file=sys.stderr,
373
+ )
374
+ for failure in failures:
375
+ criticality = "critical" if failure.bundle.critical else "non-critical"
376
+ action_label = _describe_action(failure.action)
377
+ print(
378
+ f" Rule {failure.bundle.index} ({criticality}, "
379
+ f"when: {failure.bundle.predicate}): "
380
+ f"failed at {action_label}: {failure.error}",
381
+ file=sys.stderr,
382
+ )
gww/cli/main.py ADDED
@@ -0,0 +1,285 @@
1
+ """CLI entry point and argument parser structure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Optional, Sequence
8
+
9
+ from gww import __version__
10
+ from gww.cli.context import CommandContext, CommandExit
11
+
12
+
13
+ def create_parser() -> argparse.ArgumentParser:
14
+ """Create the main argument parser with all subcommands.
15
+
16
+ The returned parser has ``init_parser`` attached as an attribute so
17
+ callers (notably :func:`main`) can print help for the ``init`` command
18
+ without re-creating the parser.
19
+
20
+ Returns:
21
+ Configured ArgumentParser.
22
+ """
23
+ parser = argparse.ArgumentParser(
24
+ prog="gww",
25
+ description="Git Worktree Wrapper - manage git worktrees with configurable paths",
26
+ epilog="Run 'gww <command> --help' for more information on a specific command.",
27
+ )
28
+
29
+ parser.add_argument(
30
+ "--version",
31
+ action="version",
32
+ version=f"%(prog)s {__version__}",
33
+ )
34
+
35
+ parser.add_argument(
36
+ "-v", "--verbose",
37
+ action="count",
38
+ default=0,
39
+ help="Increase verbosity (can be repeated)",
40
+ )
41
+
42
+ parser.add_argument(
43
+ "-q", "--quiet",
44
+ action="store_true",
45
+ help="Suppress non-error output",
46
+ )
47
+
48
+ parser.add_argument(
49
+ "-t", "--tag",
50
+ action="append",
51
+ default=[],
52
+ help="Tag in format key=value or just key (can be specified multiple times)",
53
+ metavar="TAG",
54
+ )
55
+
56
+ # Create subparsers for commands
57
+ subparsers = parser.add_subparsers(
58
+ dest="command",
59
+ title="commands",
60
+ metavar="<command>",
61
+ )
62
+
63
+ # clone command
64
+ clone_parser = subparsers.add_parser(
65
+ "clone",
66
+ help="Clone a repository to configured location",
67
+ description="Clone a git repository to the location determined by configuration rules.",
68
+ )
69
+ clone_parser.add_argument(
70
+ "uri",
71
+ help="Git repository URI (HTTP, HTTPS, SSH, or file://)",
72
+ )
73
+ clone_parser.add_argument(
74
+ "-t", "--tag",
75
+ action="append",
76
+ default=[],
77
+ help="Tag in format key=value or just key (can be specified multiple times)",
78
+ metavar="TAG",
79
+ )
80
+
81
+ # add command
82
+ add_parser = subparsers.add_parser(
83
+ "add",
84
+ help="Add a worktree for a branch",
85
+ description="Add a worktree for the specified branch.",
86
+ )
87
+ add_parser.add_argument(
88
+ "branch",
89
+ help="Branch name to checkout in worktree",
90
+ )
91
+ add_parser.add_argument(
92
+ "-c", "--create-branch",
93
+ action="store_true",
94
+ help="Create branch from current commit if it doesn't exist",
95
+ )
96
+ add_parser.add_argument(
97
+ "-t", "--tag",
98
+ action="append",
99
+ default=[],
100
+ help="Tag in format key=value or just key (can be specified multiple times)",
101
+ metavar="TAG",
102
+ )
103
+
104
+ # remove command
105
+ remove_parser = subparsers.add_parser(
106
+ "remove",
107
+ help="Remove a worktree",
108
+ description="Remove a worktree by branch name or path.",
109
+ )
110
+ remove_parser.add_argument(
111
+ "branch_or_path",
112
+ help="Branch name or absolute path to worktree",
113
+ )
114
+ remove_parser.add_argument(
115
+ "-f", "--force",
116
+ action="store_true",
117
+ help="Force removal even if worktree has uncommitted changes",
118
+ )
119
+ remove_parser.add_argument(
120
+ "-t", "--tag",
121
+ action="append",
122
+ default=[],
123
+ help="Tag in format key=value or just key (can be specified multiple times)",
124
+ metavar="TAG",
125
+ )
126
+
127
+ # pull command
128
+ pull_parser = subparsers.add_parser(
129
+ "pull",
130
+ help="Update source repository",
131
+ description="Pull changes from remote in the source repository.",
132
+ )
133
+
134
+ # migrate command
135
+ migrate_parser = subparsers.add_parser(
136
+ "migrate",
137
+ help="Migrate repositories to new locations",
138
+ description="Scan directory for repositories and migrate them based on current configuration.",
139
+ )
140
+ migrate_parser.add_argument(
141
+ "old_repos",
142
+ nargs="+",
143
+ help="Path(s) to directory(ies) containing old repositories",
144
+ )
145
+ migrate_parser.add_argument(
146
+ "-n", "--dry-run",
147
+ action="store_true",
148
+ help="Show what would be migrated without making changes",
149
+ )
150
+ migrate_group = migrate_parser.add_mutually_exclusive_group()
151
+ migrate_group.add_argument(
152
+ "--copy",
153
+ action="store_true",
154
+ help="Copy repositories to new locations (default)",
155
+ )
156
+ migrate_group.add_argument(
157
+ "--inplace",
158
+ action="store_true",
159
+ help="Move repositories in place and clean empty source folders",
160
+ )
161
+
162
+ # init command (with subcommands)
163
+ init_parser = subparsers.add_parser(
164
+ "init",
165
+ help="Initialize config or shell completion",
166
+ description="Create default configuration file or install shell completion.",
167
+ )
168
+
169
+ init_subparsers = init_parser.add_subparsers(
170
+ dest="init_command",
171
+ title="init commands",
172
+ metavar="<init_command>",
173
+ )
174
+
175
+ # init config
176
+ init_config_parser = init_subparsers.add_parser(
177
+ "config",
178
+ help="Create default configuration file",
179
+ description="Create a default configuration file with examples and documentation.",
180
+ )
181
+
182
+ # init shell
183
+ init_shell_parser = init_subparsers.add_parser(
184
+ "shell",
185
+ help="Install shell completion",
186
+ description="Generate and install shell autocompletion script.",
187
+ )
188
+ init_shell_parser.add_argument(
189
+ "shell",
190
+ choices=["bash", "zsh", "fish"],
191
+ help="Shell to install completion for",
192
+ )
193
+
194
+ parser.init_parser = init_parser # type: ignore[attr-defined]
195
+ return parser
196
+
197
+
198
+ def main(argv: Optional[Sequence[str]] = None) -> int:
199
+ """Main entry point for gww CLI.
200
+
201
+ Args:
202
+ argv: Command line arguments (defaults to sys.argv[1:]).
203
+
204
+ Returns:
205
+ Exit code (0 for success, 1 for error, 2 for config error).
206
+ """
207
+ parser = create_parser()
208
+ args = parser.parse_args(argv)
209
+
210
+ if args.command is None:
211
+ parser.print_help()
212
+ return 0
213
+
214
+ # Remind the user if their installed shell alias predates the current
215
+ # source. Skipped for `gww init shell` since that's the command that
216
+ # rewrites the alias file (no point nagging mid-regeneration).
217
+ if not (args.command == "init" and getattr(args, "init_command", None) == "shell"):
218
+ from gww.cli.commands.init import detect_user_shell, warn_if_alias_is_stale
219
+
220
+ shell = detect_user_shell()
221
+ if shell is not None:
222
+ warn_if_alias_is_stale(shell)
223
+
224
+ ctx = CommandContext.from_args(args)
225
+
226
+ # Import and run command handlers
227
+ try:
228
+ if args.command == "clone":
229
+ from gww.cli.commands.clone import run_clone
230
+ return run_clone(ctx)
231
+
232
+ elif args.command == "add":
233
+ from gww.cli.commands.add import run_add
234
+ return run_add(ctx)
235
+
236
+ elif args.command == "remove":
237
+ from gww.cli.commands.remove import run_remove
238
+ return run_remove(ctx)
239
+
240
+ elif args.command == "pull":
241
+ from gww.cli.commands.pull import run_pull
242
+ return run_pull(ctx)
243
+
244
+ elif args.command == "migrate":
245
+ from gww.cli.commands.migrate import run_migrate
246
+ return run_migrate(ctx)
247
+
248
+ elif args.command == "init":
249
+ if args.init_command is None:
250
+ parser.init_parser.print_help() # type: ignore[attr-defined]
251
+ return 0
252
+
253
+ if args.init_command == "config":
254
+ from gww.cli.commands.init import run_init_config
255
+ return run_init_config(ctx)
256
+
257
+ elif args.init_command == "shell":
258
+ from gww.cli.commands.init import run_init_shell
259
+ return run_init_shell(ctx)
260
+
261
+ else:
262
+ parser.print_help()
263
+ return 1
264
+
265
+ except CommandExit as e:
266
+ if e.message:
267
+ print(e.message, file=sys.stderr)
268
+ return e.code
269
+
270
+ except KeyboardInterrupt:
271
+ print("\nOperation cancelled.", file=sys.stderr)
272
+ return 130
273
+
274
+ except Exception as e:
275
+ print(f"Error: {e}", file=sys.stderr)
276
+ if ctx.verbose > 0:
277
+ import traceback
278
+ traceback.print_exc()
279
+ return 1
280
+
281
+ return 0
282
+
283
+
284
+ if __name__ == "__main__":
285
+ sys.exit(main())
gww/config/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Configuration loading and management."""