dosctl 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.
- dosctl/__init__.py +0 -0
- dosctl/collections/__init__.py +0 -0
- dosctl/collections/archive_org.py +227 -0
- dosctl/collections/base.py +21 -0
- dosctl/collections/factory.py +35 -0
- dosctl/commands/delete.py +42 -0
- dosctl/commands/inspect.py +47 -0
- dosctl/commands/list.py +25 -0
- dosctl/commands/refresh.py +25 -0
- dosctl/commands/run.py +119 -0
- dosctl/commands/search.py +47 -0
- dosctl/config.py +23 -0
- dosctl/lib/__init__.py +0 -0
- dosctl/lib/config_store.py +44 -0
- dosctl/lib/decorators.py +23 -0
- dosctl/lib/display.py +19 -0
- dosctl/lib/executables.py +20 -0
- dosctl/lib/game.py +32 -0
- dosctl/lib/system.py +5 -0
- dosctl/lib/ui.py +405 -0
- dosctl/main.py +22 -0
- dosctl-0.1.0.dist-info/METADATA +238 -0
- dosctl-0.1.0.dist-info/RECORD +27 -0
- dosctl-0.1.0.dist-info/WHEEL +5 -0
- dosctl-0.1.0.dist-info/entry_points.txt +2 -0
- dosctl-0.1.0.dist-info/licenses/LICENSE +19 -0
- dosctl-0.1.0.dist-info/top_level.txt +1 -0
dosctl/lib/decorators.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
import click
|
|
3
|
+
from dosctl.collections.archive_org import TotalDOSCollectionRelease14
|
|
4
|
+
from dosctl.config import DEFAULT_COLLECTION_SOURCE, COLLECTION_CACHE_DIR, ensure_dirs_exist
|
|
5
|
+
|
|
6
|
+
def ensure_cache(f):
|
|
7
|
+
"""
|
|
8
|
+
A decorator that ensures the game cache is present and provides the
|
|
9
|
+
collection object to the command.
|
|
10
|
+
"""
|
|
11
|
+
@wraps(f)
|
|
12
|
+
def decorated_function(*args, **kwargs):
|
|
13
|
+
ensure_dirs_exist()
|
|
14
|
+
collection = TotalDOSCollectionRelease14(
|
|
15
|
+
source=DEFAULT_COLLECTION_SOURCE,
|
|
16
|
+
cache_dir=COLLECTION_CACHE_DIR
|
|
17
|
+
)
|
|
18
|
+
# This will auto-refresh if the cache is missing
|
|
19
|
+
collection.ensure_cache_is_present()
|
|
20
|
+
|
|
21
|
+
# Pass the collection object to the command
|
|
22
|
+
return f(collection, *args, **kwargs)
|
|
23
|
+
return decorated_function
|
dosctl/lib/display.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Utility functions for displaying game information."""
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
def sort_games(games, sort_by='name'):
|
|
5
|
+
"""Sort games by name or year."""
|
|
6
|
+
if sort_by == 'year':
|
|
7
|
+
return sorted(games, key=lambda g: int(g.get('year', 0) or 0))
|
|
8
|
+
else:
|
|
9
|
+
return sorted(games, key=lambda g: g['name'])
|
|
10
|
+
|
|
11
|
+
def display_games(games, title="Available Games:"):
|
|
12
|
+
"""Display a list of games in a consistent format."""
|
|
13
|
+
if not games:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
click.echo(title)
|
|
17
|
+
for game in games:
|
|
18
|
+
year_str = f"({game.get('year', '----')})"
|
|
19
|
+
click.echo(f" [{game['id']}] {year_str} {game['name']}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Utilities for handling DOS game executables."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
def find_executables(game_path: Path) -> List[str]:
|
|
6
|
+
"""Find all executable files in a game directory."""
|
|
7
|
+
executables = []
|
|
8
|
+
|
|
9
|
+
# Find .exe files
|
|
10
|
+
executables.extend([e.name for e in game_path.glob('**/*.[eE][xX][eE]')])
|
|
11
|
+
# Find .com files
|
|
12
|
+
executables.extend([e.name for e in game_path.glob('**/*.[cC][oO][mM]')])
|
|
13
|
+
# Find .bat files
|
|
14
|
+
executables.extend([e.name for e in game_path.glob('**/*.[bB][aA][tT]')])
|
|
15
|
+
|
|
16
|
+
return sorted(executables)
|
|
17
|
+
|
|
18
|
+
def executable_exists(game_path: Path, executable_name: str) -> bool:
|
|
19
|
+
"""Check if an executable exists in the game directory."""
|
|
20
|
+
return (game_path / executable_name).exists()
|
dosctl/lib/game.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import click
|
|
3
|
+
from dosctl.config import DOWNLOADS_DIR, INSTALLED_DIR
|
|
4
|
+
|
|
5
|
+
def install_game(collection, game_id):
|
|
6
|
+
"""
|
|
7
|
+
Ensures a game is installed, downloading and unzipping it if necessary.
|
|
8
|
+
Returns the game object and the installation path.
|
|
9
|
+
"""
|
|
10
|
+
# Find the game in the collection
|
|
11
|
+
game = collection._find_game(game_id)
|
|
12
|
+
if not game:
|
|
13
|
+
raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
|
|
14
|
+
|
|
15
|
+
game_install_path = INSTALLED_DIR / game_id
|
|
16
|
+
|
|
17
|
+
# If the game is already installed, we're done.
|
|
18
|
+
if game_install_path.exists():
|
|
19
|
+
click.echo(f"'{game['name']}' is already installed.")
|
|
20
|
+
return game, game_install_path
|
|
21
|
+
|
|
22
|
+
# Download the game if it's not already cached
|
|
23
|
+
download_path = collection.download_game(game_id, str(DOWNLOADS_DIR))
|
|
24
|
+
if not download_path:
|
|
25
|
+
# The download method will print an error, so we just exit.
|
|
26
|
+
return None, None
|
|
27
|
+
|
|
28
|
+
# Unzip the game to its final installation directory
|
|
29
|
+
collection.unzip_game(game_id, DOWNLOADS_DIR, game_install_path)
|
|
30
|
+
|
|
31
|
+
click.echo(f"ā
Successfully installed '{game['name']}'")
|
|
32
|
+
return game, game_install_path
|
dosctl/lib/system.py
ADDED
dosctl/lib/ui.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""User experience and configuration enhancements."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Any, Tuple
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ..config import get_dosctl_config_dir, get_dosctl_cache_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DOSCtlConfig:
|
|
14
|
+
"""Enhanced configuration management with user preferences."""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.config_dir = Path(get_dosctl_config_dir())
|
|
18
|
+
self.config_file = self.config_dir / "config.json"
|
|
19
|
+
|
|
20
|
+
# Check if this is the first run (before loading config)
|
|
21
|
+
self._is_first_run = not self.config_file.exists()
|
|
22
|
+
self._config = self._load_config()
|
|
23
|
+
|
|
24
|
+
# Create config file on first run
|
|
25
|
+
if self._is_first_run:
|
|
26
|
+
self._create_initial_config()
|
|
27
|
+
|
|
28
|
+
def _load_config(self) -> Dict:
|
|
29
|
+
"""Load main configuration."""
|
|
30
|
+
default_config = {
|
|
31
|
+
"display": {
|
|
32
|
+
"show_year": True,
|
|
33
|
+
"show_genre": True,
|
|
34
|
+
"progress_bars": True,
|
|
35
|
+
"name_column_width": 80, # Configurable name column width
|
|
36
|
+
"genre_column_width": 20, # Configurable genre column width
|
|
37
|
+
},
|
|
38
|
+
"ui": {
|
|
39
|
+
"confirm_deletion": True,
|
|
40
|
+
"show_tips": True,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if self.config_file.exists():
|
|
45
|
+
try:
|
|
46
|
+
with open(self.config_file, "r") as f:
|
|
47
|
+
user_config = json.load(f)
|
|
48
|
+
# Merge with defaults (preserve user settings, add new defaults)
|
|
49
|
+
return self._merge_configs(default_config, user_config)
|
|
50
|
+
except (json.JSONDecodeError, OSError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return default_config
|
|
54
|
+
|
|
55
|
+
def _merge_configs(self, default: Dict, user: Dict) -> Dict:
|
|
56
|
+
"""Recursively merge user config with defaults."""
|
|
57
|
+
result = default.copy()
|
|
58
|
+
for key, value in user.items():
|
|
59
|
+
if (
|
|
60
|
+
key in result
|
|
61
|
+
and isinstance(result[key], dict)
|
|
62
|
+
and isinstance(value, dict)
|
|
63
|
+
):
|
|
64
|
+
result[key] = self._merge_configs(result[key], value)
|
|
65
|
+
else:
|
|
66
|
+
result[key] = value
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def _create_initial_config(self):
|
|
70
|
+
"""Create initial config file and notify user."""
|
|
71
|
+
self.save_config()
|
|
72
|
+
print("ā
Created configuration file at ~/.config/dosctl/config.json")
|
|
73
|
+
print("š” Tip: Use 'dosctl config show' to view settings or 'dosctl config set <key> <value>' to customize")
|
|
74
|
+
|
|
75
|
+
def save_config(self):
|
|
76
|
+
"""Save configuration to file."""
|
|
77
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
with open(self.config_file, "w") as f:
|
|
79
|
+
json.dump(self._config, f, indent=2)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
84
|
+
"""Get configuration value by dot-separated path."""
|
|
85
|
+
keys = path.split(".")
|
|
86
|
+
value = self._config
|
|
87
|
+
for key in keys:
|
|
88
|
+
if isinstance(value, dict) and key in value:
|
|
89
|
+
value = value[key]
|
|
90
|
+
else:
|
|
91
|
+
return default
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
def _get_valid_config_paths(self) -> set:
|
|
95
|
+
"""Get all valid configuration paths from the default config structure."""
|
|
96
|
+
def _extract_paths(config_dict, prefix=""):
|
|
97
|
+
paths = set()
|
|
98
|
+
for key, value in config_dict.items():
|
|
99
|
+
current_path = f"{prefix}.{key}" if prefix else key
|
|
100
|
+
paths.add(current_path)
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
paths.update(_extract_paths(value, current_path))
|
|
103
|
+
return paths
|
|
104
|
+
|
|
105
|
+
default_config = {
|
|
106
|
+
"display": {
|
|
107
|
+
"show_year": True,
|
|
108
|
+
"show_genre": True,
|
|
109
|
+
"progress_bars": True,
|
|
110
|
+
"name_column_width": 80,
|
|
111
|
+
"genre_column_width": 20,
|
|
112
|
+
},
|
|
113
|
+
"ui": {
|
|
114
|
+
"confirm_deletion": True,
|
|
115
|
+
"show_tips": True,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
return _extract_paths(default_config)
|
|
119
|
+
|
|
120
|
+
def set(self, path: str, value: Any):
|
|
121
|
+
"""Set configuration value by dot-separated path."""
|
|
122
|
+
# Validate that the path is allowed
|
|
123
|
+
valid_paths = self._get_valid_config_paths()
|
|
124
|
+
if path not in valid_paths:
|
|
125
|
+
# Create a more helpful error message with better formatting
|
|
126
|
+
common_paths = [
|
|
127
|
+
"display.show_year", "display.show_genre", "display.name_column_width",
|
|
128
|
+
"dosbox.default_cycles", "dosbox.fullscreen",
|
|
129
|
+
"ui.confirm_deletion"
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
# Filter out root-level paths (those without dots) from valid_paths
|
|
133
|
+
full_paths = [p for p in sorted(valid_paths) if '.' in p]
|
|
134
|
+
|
|
135
|
+
# Format the error message with proper line breaks and indentation
|
|
136
|
+
suggestion = "\n\nCommon settings:\n " + "\n ".join(common_paths)
|
|
137
|
+
suggestion += "\n\nAll valid paths:\n " + "\n ".join(full_paths)
|
|
138
|
+
raise ValueError(f"Invalid configuration path '{path}'.{suggestion}")
|
|
139
|
+
|
|
140
|
+
keys = path.split(".")
|
|
141
|
+
config = self._config
|
|
142
|
+
for key in keys[:-1]:
|
|
143
|
+
if key not in config:
|
|
144
|
+
config[key] = {}
|
|
145
|
+
config = config[key]
|
|
146
|
+
config[keys[-1]] = value
|
|
147
|
+
self.save_config()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class UserInterface:
|
|
151
|
+
"""Enhanced user interface utilities."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, config: DOSCtlConfig):
|
|
154
|
+
self.config = config
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class UserInterface:
|
|
158
|
+
"""Enhanced user interface utilities."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, config: DOSCtlConfig):
|
|
161
|
+
self.config = config
|
|
162
|
+
|
|
163
|
+
def print_game_table(self, games: List[Dict], show_status: bool = False):
|
|
164
|
+
"""Print games in a formatted table."""
|
|
165
|
+
if not games:
|
|
166
|
+
click.echo("No games found.")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Determine what columns to show
|
|
170
|
+
show_year = self.config.get("display.show_year", True)
|
|
171
|
+
show_genre = self.config.get("display.show_genre", True)
|
|
172
|
+
|
|
173
|
+
# Calculate column widths
|
|
174
|
+
configured_width = self.config.get("display.name_column_width", 80)
|
|
175
|
+
genre_width = self.config.get("display.genre_column_width", 20)
|
|
176
|
+
max_name = max(len(game.get("name", "")) for game in games)
|
|
177
|
+
max_name = min(max_name, configured_width) # Use configured width
|
|
178
|
+
|
|
179
|
+
# Header
|
|
180
|
+
header_parts = [f"{'ID':<8}", f"{'Name':<{max_name}}"]
|
|
181
|
+
if show_year:
|
|
182
|
+
header_parts.append(f"{'Year':<4}")
|
|
183
|
+
if show_status:
|
|
184
|
+
header_parts.append(f"{'Status':<12}")
|
|
185
|
+
if show_genre:
|
|
186
|
+
header_parts.append(f"{'Genre':<{genre_width}}")
|
|
187
|
+
|
|
188
|
+
header = " | ".join(header_parts) + " |" # Add closing pipe
|
|
189
|
+
click.echo(header)
|
|
190
|
+
click.echo("-" * len(header))
|
|
191
|
+
|
|
192
|
+
# Games
|
|
193
|
+
for game in games:
|
|
194
|
+
game_id = game.get("id", "unknown")[:8] # Shorter ID display
|
|
195
|
+
name = game.get("name", "Unknown")[:max_name]
|
|
196
|
+
row_parts = [f"{game_id:<8}", f"{name:<{max_name}}"]
|
|
197
|
+
|
|
198
|
+
if show_year:
|
|
199
|
+
year = game.get("year") or "----" # Handle None gracefully
|
|
200
|
+
row_parts.append(f"{year:<4}")
|
|
201
|
+
|
|
202
|
+
if show_status:
|
|
203
|
+
# This would require integration with installation manager
|
|
204
|
+
status = "Unknown" # Placeholder
|
|
205
|
+
row_parts.append(f"{status:<12}")
|
|
206
|
+
|
|
207
|
+
if show_genre:
|
|
208
|
+
# Parse metadata to get genre
|
|
209
|
+
from ..lib.metadata import parse_game_metadata
|
|
210
|
+
|
|
211
|
+
metadata = parse_game_metadata(game.get("full_path", ""))
|
|
212
|
+
genre = metadata.get("genre") or "Unknown"
|
|
213
|
+
genre = genre[:genre_width] # Use configurable width
|
|
214
|
+
row_parts.append(f"{genre:<{genre_width}}")
|
|
215
|
+
|
|
216
|
+
click.echo(" | ".join(row_parts) + " |") # Add closing pipe
|
|
217
|
+
|
|
218
|
+
# Add closing line of dashes
|
|
219
|
+
click.echo("-" * len(header))
|
|
220
|
+
|
|
221
|
+
def print_game_table_with_scores(self, scored_games: List[Tuple[Dict, float]]):
|
|
222
|
+
"""Print games in a formatted table with relevance scores."""
|
|
223
|
+
if not scored_games:
|
|
224
|
+
click.echo("No games found.")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
games = [game for game, score in scored_games]
|
|
228
|
+
|
|
229
|
+
# Determine what columns to show
|
|
230
|
+
show_year = self.config.get("display.show_year", True)
|
|
231
|
+
show_genre = self.config.get("display.show_genre", True)
|
|
232
|
+
|
|
233
|
+
# Calculate column widths
|
|
234
|
+
configured_width = self.config.get("display.name_column_width", 80)
|
|
235
|
+
genre_width = self.config.get("display.genre_column_width", 20)
|
|
236
|
+
max_name = max(len(game.get("name", "")) for game in games)
|
|
237
|
+
max_name = min(max_name, configured_width) # Use configured width
|
|
238
|
+
|
|
239
|
+
# Header
|
|
240
|
+
header_parts = [f"{'ID':<8}", f"{'Name':<{max_name}}"]
|
|
241
|
+
if show_year:
|
|
242
|
+
header_parts.append(f"{'Year':<4}")
|
|
243
|
+
if show_genre:
|
|
244
|
+
header_parts.append(f"{'Genre':<{genre_width}}")
|
|
245
|
+
header_parts.append(f"{'Score':<5}") # Add score column
|
|
246
|
+
|
|
247
|
+
header = " | ".join(header_parts) + " |" # Add closing pipe
|
|
248
|
+
click.echo(header)
|
|
249
|
+
click.echo("-" * len(header))
|
|
250
|
+
|
|
251
|
+
# Games with scores
|
|
252
|
+
for game, score in scored_games:
|
|
253
|
+
game_id = game.get("id", "unknown")[:8] # Shorter ID display
|
|
254
|
+
name = game.get("name", "Unknown")[:max_name]
|
|
255
|
+
row_parts = [f"{game_id:<8}", f"{name:<{max_name}}"]
|
|
256
|
+
|
|
257
|
+
if show_year:
|
|
258
|
+
year = game.get("year") or "----" # Handle None gracefully
|
|
259
|
+
row_parts.append(f"{year:<4}")
|
|
260
|
+
|
|
261
|
+
if show_genre:
|
|
262
|
+
# Parse metadata to get genre
|
|
263
|
+
from ..lib.metadata import parse_game_metadata
|
|
264
|
+
|
|
265
|
+
metadata = parse_game_metadata(game.get("full_path", ""))
|
|
266
|
+
genre = metadata.get("genre") or "Unknown"
|
|
267
|
+
genre = genre[:genre_width] # Use configurable width
|
|
268
|
+
row_parts.append(f"{genre:<{genre_width}}")
|
|
269
|
+
|
|
270
|
+
# Add score
|
|
271
|
+
row_parts.append(f"{score:<5.1f}")
|
|
272
|
+
|
|
273
|
+
click.echo(" | ".join(row_parts) + " |") # Add closing pipe
|
|
274
|
+
|
|
275
|
+
# Add closing line of dashes
|
|
276
|
+
click.echo("-" * len(header))
|
|
277
|
+
|
|
278
|
+
def print_progress_bar(self, current: int, total: int, description: str = ""):
|
|
279
|
+
"""Print a progress bar."""
|
|
280
|
+
if not self.config.get("display.progress_bars", True):
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
width = 40
|
|
284
|
+
progress = current / total if total > 0 else 0
|
|
285
|
+
filled = int(width * progress)
|
|
286
|
+
bar = "ā" * filled + "ā" * (width - filled)
|
|
287
|
+
percent = int(progress * 100)
|
|
288
|
+
|
|
289
|
+
status = f"\r{description} [{bar}] {percent}% ({current}/{total})"
|
|
290
|
+
click.echo(status, nl=False)
|
|
291
|
+
|
|
292
|
+
if current >= total:
|
|
293
|
+
click.echo() # New line when complete
|
|
294
|
+
|
|
295
|
+
def confirm_action(self, message: str, default: bool = False) -> bool:
|
|
296
|
+
"""Confirm an action with the user."""
|
|
297
|
+
if not self.config.get("ui.confirm_deletion", True):
|
|
298
|
+
return True
|
|
299
|
+
return click.confirm(message, default=default)
|
|
300
|
+
|
|
301
|
+
def print_tip(self, tip: str):
|
|
302
|
+
"""Print a helpful tip."""
|
|
303
|
+
if self.config.get("ui.show_tips", True):
|
|
304
|
+
click.echo(f"\nš” Tip: {tip}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class CommandAliases:
|
|
308
|
+
"""Command aliases and shortcuts."""
|
|
309
|
+
|
|
310
|
+
ALIASES = {
|
|
311
|
+
"ls": "list",
|
|
312
|
+
"find": "search",
|
|
313
|
+
"play": "run",
|
|
314
|
+
"start": "run",
|
|
315
|
+
"launch": "run",
|
|
316
|
+
"info": "inspect",
|
|
317
|
+
"details": "inspect",
|
|
318
|
+
"remove": "delete",
|
|
319
|
+
"rm": "delete",
|
|
320
|
+
"del": "delete",
|
|
321
|
+
"update": "refresh",
|
|
322
|
+
"sync": "refresh",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def resolve_alias(cls, command: str) -> str:
|
|
327
|
+
"""Resolve command alias to actual command."""
|
|
328
|
+
return cls.ALIASES.get(command, command)
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def list_aliases(cls) -> List[Tuple[str, str]]:
|
|
332
|
+
"""List all available aliases."""
|
|
333
|
+
return list(cls.ALIASES.items())
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def setup_config_command():
|
|
337
|
+
"""Create a config command for managing settings."""
|
|
338
|
+
|
|
339
|
+
@click.group()
|
|
340
|
+
def config():
|
|
341
|
+
"""Manage dosctl configuration."""
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
@config.command()
|
|
345
|
+
@click.argument("key")
|
|
346
|
+
@click.argument("value", required=False)
|
|
347
|
+
def set(key, value):
|
|
348
|
+
"""Set a configuration value."""
|
|
349
|
+
config_manager = DOSCtlConfig()
|
|
350
|
+
|
|
351
|
+
if value is None:
|
|
352
|
+
# Show current value
|
|
353
|
+
current = config_manager.get(key)
|
|
354
|
+
if current is not None:
|
|
355
|
+
click.echo(f"{key} = {current}")
|
|
356
|
+
else:
|
|
357
|
+
click.echo(f"Configuration key '{key}' not found.")
|
|
358
|
+
else:
|
|
359
|
+
# Set new value (try to parse as JSON for complex types)
|
|
360
|
+
try:
|
|
361
|
+
parsed_value = json.loads(value)
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
parsed_value = value
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
config_manager.set(key, parsed_value)
|
|
367
|
+
click.echo(f"Set {key} = {parsed_value}")
|
|
368
|
+
except ValueError as e:
|
|
369
|
+
click.echo(f"ā Error: {e}", err=True)
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
@config.command()
|
|
373
|
+
def show():
|
|
374
|
+
"""Show current configuration."""
|
|
375
|
+
config_manager = DOSCtlConfig()
|
|
376
|
+
click.echo(json.dumps(config_manager._config, indent=2))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
return config
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_system_info() -> Dict:
|
|
384
|
+
"""Get system information for diagnostics."""
|
|
385
|
+
info = {
|
|
386
|
+
"os": os.name,
|
|
387
|
+
"platform": os.uname() if hasattr(os, "uname") else "unknown",
|
|
388
|
+
"python_version": f"{os.sys.version_info.major}.{os.sys.version_info.minor}.{os.sys.version_info.micro}",
|
|
389
|
+
"dosctl_cache_dir": get_dosctl_cache_dir(),
|
|
390
|
+
"dosctl_config_dir": get_dosctl_config_dir(),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
# Check for DOSBox
|
|
394
|
+
dosbox_path = shutil.which("dosbox")
|
|
395
|
+
info["dosbox_available"] = dosbox_path is not None
|
|
396
|
+
info["dosbox_path"] = dosbox_path
|
|
397
|
+
|
|
398
|
+
# Check disk space
|
|
399
|
+
try:
|
|
400
|
+
cache_stat = shutil.disk_usage(get_dosctl_cache_dir())
|
|
401
|
+
info["cache_disk_free_gb"] = round(cache_stat.free / (1024**3), 2)
|
|
402
|
+
except OSError:
|
|
403
|
+
info["cache_disk_free_gb"] = "unknown"
|
|
404
|
+
|
|
405
|
+
return info
|
dosctl/main.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from .commands.list import list_games
|
|
3
|
+
from .commands.search import search
|
|
4
|
+
from .commands.run import run
|
|
5
|
+
from .commands.inspect import inspect
|
|
6
|
+
from .commands.delete import delete
|
|
7
|
+
from .commands.refresh import refresh
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def cli():
|
|
11
|
+
"""A command-line tool to manage and play DOS games."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
cli.add_command(list_games)
|
|
15
|
+
cli.add_command(search)
|
|
16
|
+
cli.add_command(run)
|
|
17
|
+
cli.add_command(inspect)
|
|
18
|
+
cli.add_command(delete)
|
|
19
|
+
cli.add_command(refresh)
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
cli()
|