vaporcli 1.0.2__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.
vaporcli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.0.1"
2
+ from . import ui
vaporcli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
vaporcli/cli.py ADDED
@@ -0,0 +1,441 @@
1
+ """CLI entry point: steamcli "Game Name" / steamcli 730 / steamcli --list ..."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ from . import __version__, ui
13
+ from .config import get_config_path, get_os_name, load_config, save_config
14
+ from .launcher import GameNotFoundError, LaunchConfigError, find_game, launch_game, list_games
15
+ from .steam_scanner import get_steam_install_paths, scan_installed_games
16
+
17
+ try:
18
+ import argcomplete
19
+ HAS_ARGCOMPLETE = True
20
+ except ImportError:
21
+ HAS_ARGCOMPLETE = False
22
+
23
+
24
+ def _complete_game(prefix: str, parsed_args, **kwargs) -> list[str]:
25
+ """Hints for the game positional argument: game names + their AppIDs."""
26
+ config = load_config()
27
+ games = config.get("games", {})
28
+ candidates = list(games.keys())
29
+ candidates += [str(data["app_id"]) for data in games.values() if "app_id" in data]
30
+ return [c for c in candidates if c.lower().startswith(prefix.lower())]
31
+
32
+
33
+ _COMPLETION_HOOK_LINE = 'eval "$(register-python-argcomplete steamcli)"'
34
+ _COMPLETION_MARKER = "# steamcli tab-completion"
35
+
36
+
37
+ _POSIX_SHELL_CONFIG: dict[str, dict[str, Any]] = {
38
+ "bash": {
39
+ "rc_path": lambda: Path.home() / ".bashrc",
40
+ "lines": [_COMPLETION_HOOK_LINE],
41
+ },
42
+ "zsh": {
43
+ "rc_path": lambda: Path.home() / ".zshrc",
44
+ "lines": ["autoload -U bashcompinit", "bashcompinit", _COMPLETION_HOOK_LINE],
45
+ },
46
+ }
47
+
48
+
49
+ def _detect_shell() -> str:
50
+ """
51
+ Cross-platform shell detection.
52
+
53
+ NOTE: On Windows there is no $SHELL env var (that's Unix-only), so we
54
+ branch on os.name first. cmd.exe vs PowerShell can't be told apart with
55
+ 100% certainty from inside a child process, but PowerShell (both
56
+ Windows PowerShell and pwsh core) sets PSModulePath in its process
57
+ environment and that variable propagates to child processes; cmd.exe
58
+ does not set it unless it was itself launched from PowerShell. This is
59
+ the same heuristic tools like nvm-windows and other cross-shell CLIs use.
60
+ """
61
+ if os.name == "nt":
62
+ if os.environ.get("PSModulePath"):
63
+ return "powershell"
64
+ return "cmd"
65
+
66
+ shell = os.environ.get("SHELL", "")
67
+ if "zsh" in shell:
68
+ return "zsh"
69
+ if "fish" in shell:
70
+ return "fish"
71
+ return "bash"
72
+
73
+
74
+ def _completion_marker_path() -> Path:
75
+ return get_config_path().parent / ".completion_installed"
76
+
77
+
78
+ def _powershell_profile_path() -> Path | None:
79
+ """
80
+ Ask PowerShell itself for $PROFILE, since the correct path differs by
81
+ host (Windows PowerShell vs pwsh, Console vs ISE vs VS Code terminal)
82
+ """
83
+ for exe in ("pwsh", "powershell"):
84
+ try:
85
+ result = subprocess.run(
86
+ [exe, "-NoLogo", "-NoProfile", "-Command",
87
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $PROFILE"],
88
+ capture_output=True, encoding="utf-8", timeout=10,
89
+ )
90
+ except FileNotFoundError:
91
+ continue
92
+ if result.returncode == 0 and result.stdout.strip():
93
+ return Path(result.stdout.strip())
94
+ return None
95
+
96
+
97
+ def is_completion_installed() -> bool:
98
+ shell = _detect_shell()
99
+
100
+ if shell == "cmd":
101
+ return True # not supported at all - don't nag about something impossible
102
+
103
+ if shell == "powershell":
104
+ profile_path = _powershell_profile_path()
105
+ if profile_path is None or not profile_path.exists():
106
+ return False
107
+ return "Register-ArgumentCompleter" in profile_path.read_text(encoding="utf-8", errors="ignore")
108
+
109
+ if shell in _POSIX_SHELL_CONFIG:
110
+ if _completion_marker_path().exists():
111
+ return True
112
+ rc_path = _POSIX_SHELL_CONFIG[shell]["rc_path"]()
113
+ return rc_path.exists() and _COMPLETION_HOOK_LINE in rc_path.read_text()
114
+
115
+ if shell == "fish":
116
+ return (Path.home() / ".config" / "fish" / "completions" / "steamcli.fish").exists()
117
+
118
+ return True
119
+
120
+
121
+ def install_completion() -> int:
122
+ """steamcli --install-completion - one-time Tab-completion setup."""
123
+ if not HAS_ARGCOMPLETE:
124
+ ui.error("Error: argcomplete is not installed.")
125
+ ui.dim("Install it with: pip install argcomplete")
126
+ return 1
127
+
128
+ shell = _detect_shell()
129
+
130
+ if shell == "cmd":
131
+ ui.error("Tab-completion is not supported in cmd.exe.")
132
+ ui.dim("This is a Windows limitation, not a steamcli issue: cmd.exe has")
133
+ ui.dim("no mechanism for programs to hook into Tab at all.")
134
+ ui.dim("Use PowerShell, Git Bash, or WSL instead for autocompletion.")
135
+ return 1
136
+
137
+ if shell == "powershell":
138
+ return _install_completion_powershell()
139
+
140
+ if shell == "fish":
141
+ return _install_completion_fish()
142
+
143
+ if shell in _POSIX_SHELL_CONFIG:
144
+ return _install_completion_posix(shell)
145
+
146
+ ui.error(f'Shell "{shell}" is not supported for automatic setup.')
147
+ return 1
148
+
149
+
150
+ def _install_completion_posix(shell: str) -> int:
151
+ cfg = _POSIX_SHELL_CONFIG[shell]
152
+ rc_path: Path = cfg["rc_path"]()
153
+ lines_to_add: list[str] = cfg["lines"]
154
+
155
+ existing = rc_path.read_text() if rc_path.exists() else ""
156
+
157
+ if _COMPLETION_HOOK_LINE in existing:
158
+ _completion_marker_path().touch()
159
+ ui.success(f"Tab-completion is already set up in {rc_path}")
160
+ return 0
161
+
162
+ with rc_path.open("a") as f:
163
+ f.write(f"\n{_COMPLETION_MARKER}\n")
164
+ for line in lines_to_add:
165
+ f.write(line + "\n")
166
+
167
+ _completion_marker_path().touch()
168
+ ui.success(f"Tab-completion added to {rc_path}")
169
+ ui.dim(f"Restart your terminal or run: source {rc_path}")
170
+ return 0
171
+
172
+
173
+ def _install_completion_fish() -> int:
174
+ rc_path = Path.home() / ".config" / "fish" / "completions" / "steamcli.fish"
175
+ rc_path.parent.mkdir(parents=True, exist_ok=True)
176
+ result = subprocess.run(
177
+ ["register-python-argcomplete", "--shell", "fish", "steamcli"],
178
+ capture_output=True, text=True,
179
+ )
180
+ if result.returncode != 0:
181
+ ui.error("Error: could not generate fish completion script.")
182
+ ui.dim(result.stderr)
183
+ return 1
184
+ rc_path.write_text(result.stdout, encoding="utf-8")
185
+ _completion_marker_path().touch()
186
+ ui.success(f"Tab-completion installed for fish: {rc_path}")
187
+ return 0
188
+
189
+
190
+ def _install_completion_powershell() -> int:
191
+ profile_path = _powershell_profile_path()
192
+ if profile_path is None:
193
+ ui.error("Could not find PowerShell (pwsh/powershell) to determine $PROFILE.")
194
+ return 1
195
+ try:
196
+ result = subprocess.run(
197
+ ["register-python-argcomplete", "--shell", "powershell", "steamcli"],
198
+ capture_output=True, encoding="utf-8", timeout=10,
199
+ )
200
+ except FileNotFoundError:
201
+ ui.error("Error: 'register-python-argcomplete' was not found in PATH.")
202
+ ui.dim("It should be installed alongside argcomplete, in the same Scripts")
203
+ ui.dim("folder as steamcli.exe. Check with:")
204
+ ui.dim(" Get-Command steamcli, register-python-argcomplete")
205
+ ui.dim("If only steamcli.exe is found, reinstall argcomplete:")
206
+ ui.dim(" pip install --force-reinstall argcomplete")
207
+ return 1
208
+ except subprocess.TimeoutExpired:
209
+ ui.error("Error: register-python-argcomplete timed out.")
210
+ return 1
211
+
212
+ if result.returncode != 0:
213
+ ui.error("Error: could not generate PowerShell completion script.")
214
+ ui.dim(result.stderr)
215
+ return 1
216
+
217
+ script = result.stdout
218
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
219
+ existing = profile_path.read_text(encoding="utf-8") if profile_path.exists() else ""
220
+
221
+ if _COMPLETION_MARKER not in existing:
222
+ with profile_path.open("a", encoding="utf-8") as f:
223
+ f.write(f"\n{_COMPLETION_MARKER}\n")
224
+ f.write(script)
225
+ if not profile_path.exists() or _COMPLETION_MARKER not in profile_path.read_text(encoding="utf-8"):
226
+ ui.error(f"Something went wrong: {profile_path} still doesn't contain the completion block.")
227
+ return 1
228
+
229
+ _completion_marker_path().touch()
230
+ ui.success(f"Tab-completion added to {profile_path}")
231
+ ui.dim(f"Restart PowerShell or run: . $PROFILE")
232
+ ui.dim("If nothing happens on Tab, PowerShell's execution policy may be")
233
+ ui.dim("blocking your profile script. Check with: Get-ExecutionPolicy")
234
+ ui.dim('and if needed: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned')
235
+ return 0
236
+
237
+
238
+ def build_parser() -> argparse.ArgumentParser:
239
+ parser = argparse.ArgumentParser(
240
+ prog="steamcli",
241
+ description="Launch Steam games from the command line.",
242
+ )
243
+
244
+ game_arg = parser.add_argument(
245
+ "game",
246
+ nargs="?",
247
+ help='Game name or AppID to launch, e.g. steamcli "Dota 2" or steamcli 570',
248
+ )
249
+ if HAS_ARGCOMPLETE:
250
+ game_arg.completer = _complete_game
251
+ parser.add_argument(
252
+ "-l", "--list", action="store_true", help="show the list of available games"
253
+ )
254
+ parser.add_argument(
255
+ "-c", "--config-path", action="store_true", help="print the path to the config file"
256
+ )
257
+ parser.add_argument(
258
+ "-s", "--scan",
259
+ action="store_true",
260
+ help="auto-detect installed Steam games (via .acf files) and add/update "
261
+ "them in the config; existing entries not found on disk are kept as-is",
262
+ )
263
+ parser.add_argument(
264
+ "-S", "--sync",
265
+ action="store_true",
266
+ help="like --scan, but also removes auto-detected games that are no "
267
+ "longer installed. Games added manually with --add are never removed",
268
+ )
269
+ parser.add_argument(
270
+ "-v", "--version", action="version", version=f"steamcli {__version__}"
271
+ )
272
+
273
+ advanced_group = parser.add_argument_group("")
274
+
275
+ advanced_group.add_argument(
276
+ "--add",
277
+ nargs=2,
278
+ metavar=("NAME", "APP_ID"),
279
+ help='manually add a game to the config: --add "Half-Life 2" 220',
280
+ )
281
+ advanced_group.add_argument(
282
+ "--remove",
283
+ metavar="NAME_OR_APPID",
284
+ help='remove a game from the config, by name or AppID: --remove "Half-Life 2" '
285
+ "or --remove 220",
286
+ )
287
+ advanced_group.add_argument(
288
+ "--steam-path",
289
+ metavar="PATH",
290
+ help="manually specify a Steam install folder, used with --scan/--sync "
291
+ "if auto-detection fails",
292
+ )
293
+ advanced_group.add_argument(
294
+ "--install-completion",
295
+ action="store_true",
296
+ help="set up Tab-completion of game names in your shell (one-time setup)",
297
+ )
298
+
299
+ return parser
300
+
301
+
302
+ def _resolve_steam_paths(args: argparse.Namespace) -> list[Path]:
303
+ """
304
+ Returns every Steam install folder to scan.
305
+
306
+ NOTE: get_steam_install_paths() can legitimately return more than one
307
+ path (e.g. a native install *and* a Flatpak install side by side), so
308
+ every caller here scans and merges all of them instead of picking just
309
+ the first one - that's what used to make --scan silently miss games
310
+ for Flatpak users who also had a leftover native ~/.steam/steam folder.
311
+ """
312
+ if args.steam_path:
313
+ manual_path = Path(args.steam_path)
314
+ return [manual_path] if manual_path.exists() else []
315
+ return get_steam_install_paths(get_os_name())
316
+
317
+
318
+ def _run_scan(config: dict, args: argparse.Namespace, *, remove_missing: bool) -> int:
319
+ steam_paths = _resolve_steam_paths(args)
320
+ if not steam_paths:
321
+ ui.error("Error: could not find a Steam installation folder.")
322
+ ui.dim("Specify it manually: steamcli --scan --steam-path <path>")
323
+ return 1
324
+
325
+ found: dict[str, dict[str, Any]] = {}
326
+ for steam_path in steam_paths:
327
+ found.update(scan_installed_games(steam_path))
328
+
329
+ games = config.setdefault("games", {})
330
+
331
+ removed: list[str] = []
332
+ if remove_missing:
333
+ installed_ids = {data["app_id"] for data in found.values()}
334
+ removed = [
335
+ name
336
+ for name, data in games.items()
337
+ if data.get("source", "scan") == "scan" and data.get("app_id") not in installed_ids
338
+ ]
339
+ for name in removed:
340
+ del games[name]
341
+
342
+ added = sum(1 for name in found if name not in games)
343
+ updated = sum(1 for name in found if name in games and games[name] != found[name])
344
+ games.update(found)
345
+ save_config(config)
346
+
347
+ if not found and not removed:
348
+ locations = ", ".join(str(p) for p in steam_paths)
349
+ ui.warning(f"No installed games found in {locations}.")
350
+ return 0
351
+
352
+ ui.heading("Scan complete")
353
+ if len(steam_paths) > 1:
354
+ ui.dim(f"Checked {len(steam_paths)} Steam installs:")
355
+ for p in steam_paths:
356
+ ui.dim(f" - {p}")
357
+ print(f" Found: {len(found)}")
358
+ ui.success(f" Added: {added}")
359
+ if updated:
360
+ ui.warning(f" Updated: {updated}")
361
+ else:
362
+ print(f" Updated: {updated}")
363
+ if remove_missing:
364
+ removed_suffix = f" ({', '.join(removed)})" if removed else ""
365
+ if removed:
366
+ ui.warning(f" Removed: {len(removed)}{removed_suffix}")
367
+ else:
368
+ print(f" Removed: {len(removed)}")
369
+ ui.dim(f"Config saved: {get_config_path()}")
370
+ return 0
371
+
372
+
373
+ def main(argv: list[str] | None = None) -> int:
374
+ parser = build_parser()
375
+ if HAS_ARGCOMPLETE:
376
+ argcomplete.autocomplete(parser)
377
+ args = parser.parse_args(argv)
378
+
379
+ if args.install_completion:
380
+ return install_completion()
381
+
382
+ if args.config_path:
383
+ print(get_config_path())
384
+ return 0
385
+
386
+ config = load_config()
387
+
388
+ if args.sync:
389
+ return _run_scan(config, args, remove_missing=True)
390
+
391
+ if args.scan:
392
+ return _run_scan(config, args, remove_missing=False)
393
+
394
+ if args.remove:
395
+ try:
396
+ name, _ = find_game(args.remove, config)
397
+ except GameNotFoundError:
398
+ ui.error(f'Error: game "{args.remove}" was not found in the config.')
399
+ ui.dim("Check the list with: steamcli --list")
400
+ return 1
401
+ del config["games"][name]
402
+ save_config(config)
403
+ ui.success(f'Removed "{name}" from the config.')
404
+ return 0
405
+
406
+ if args.add:
407
+ name, app_id_raw = args.add
408
+ try:
409
+ app_id = int(app_id_raw)
410
+ except ValueError:
411
+ ui.error(f'Error: APP_ID must be a number, got "{app_id_raw}".')
412
+ return 1
413
+ config.setdefault("games", {})[name] = {"app_id": app_id, "source": "manual"}
414
+ save_config(config)
415
+ ui.success(f'Added "{name}" (AppID: {app_id}) to the config.')
416
+ return 0
417
+
418
+ if args.list or not args.game:
419
+ ui.banner()
420
+ list_games(config)
421
+ if not args.game:
422
+ ui.dim('\nTip: launch a game with steamcli "Game Name" or steamcli <AppID>')
423
+ if HAS_ARGCOMPLETE and not is_completion_installed():
424
+ ui.dim('Tip: run "steamcli --install-completion" for Tab-completion of game names')
425
+ return 0
426
+
427
+ try:
428
+ launch_game(args.game, config)
429
+ except GameNotFoundError:
430
+ ui.error(f'Error: game "{args.game}" was not found in the list of available games.')
431
+ ui.dim("Check the list with: steamcli --list")
432
+ return 1
433
+ except LaunchConfigError as exc:
434
+ ui.error(f"Configuration error: {exc}")
435
+ return 1
436
+
437
+ return 0
438
+
439
+
440
+ if __name__ == "__main__":
441
+ sys.exit(main())
vaporcli/config.py ADDED
@@ -0,0 +1,131 @@
1
+ """
2
+ steamcli configuration handling.
3
+
4
+ The config lives in the user's per-OS config directory (detected via
5
+ sys.platform) and can be either JSON or YAML.
6
+
7
+ JSON (config.json) is used by default and needs no extra dependency.
8
+ If a config.yaml sits next to it, it is used instead (requires pyyaml).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import ui
20
+
21
+ APP_NAME = "vaporcli"
22
+
23
+ try:
24
+ import yaml # type: ignore
25
+
26
+ _HAS_YAML = True
27
+ except ImportError:
28
+ _HAS_YAML = False
29
+
30
+
31
+ def get_os_name() -> str:
32
+ """Detects the current OS via sys.platform."""
33
+ if sys.platform.startswith("win"):
34
+ return "windows"
35
+ if sys.platform == "darwin":
36
+ return "macos"
37
+ if sys.platform.startswith("linux"):
38
+ return "linux"
39
+ return "unknown"
40
+
41
+
42
+ def get_config_dir() -> Path:
43
+ """Returns the standard config directory for the current OS."""
44
+ os_name = get_os_name()
45
+ if os_name == "windows":
46
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
47
+ elif os_name == "macos":
48
+ base = Path.home() / "Library" / "Application Support"
49
+ else:
50
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
51
+ return base / APP_NAME
52
+
53
+
54
+ def get_config_path() -> Path:
55
+ """
56
+ Returns the path to the active config file.
57
+
58
+ If config.yaml/.yml exists (and pyyaml is available), it is used.
59
+ Otherwise config.json is used (and created if missing).
60
+ """
61
+ config_dir = get_config_dir()
62
+ yaml_path = config_dir / "config.yaml"
63
+ yml_path = config_dir / "config.yml"
64
+ json_path = config_dir / "config.json"
65
+
66
+ if yaml_path.exists() and _HAS_YAML:
67
+ return yaml_path
68
+ if yml_path.exists() and _HAS_YAML:
69
+ return yml_path
70
+ return json_path
71
+
72
+
73
+ def default_config() -> dict[str, Any]:
74
+ """Base config created on first run."""
75
+ return {
76
+ "steam_path": {
77
+ "windows": r"C:\Program Files (x86)\Steam\steam.exe",
78
+ "linux": "steam",
79
+ "macos": "/Applications/Steam.app/Contents/MacOS/steam_osx",
80
+ },
81
+ "launch_method": "executable",
82
+ "games": {},
83
+ }
84
+
85
+
86
+ def create_default_config(path: Path) -> None:
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ data = default_config()
89
+ if path.suffix in (".yaml", ".yml"):
90
+ if not _HAS_YAML:
91
+ raise RuntimeError(
92
+ "pyyaml is required to use a YAML config: pip install pyyaml"
93
+ )
94
+ with path.open("w", encoding="utf-8") as f:
95
+ yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
96
+ else:
97
+ with path.open("w", encoding="utf-8") as f:
98
+ json.dump(data, f, ensure_ascii=False, indent=2)
99
+
100
+
101
+ def load_config() -> dict[str, Any]:
102
+ """Loads the config, creating it with default values if missing."""
103
+ path = get_config_path()
104
+ if not path.exists():
105
+ create_default_config(path)
106
+ ui.success(f"Created default config: {path}")
107
+ ui.dim("Run 'steamcli --scan' to detect your installed games, or edit it manually.\n")
108
+
109
+ if path.suffix in (".yaml", ".yml"):
110
+ if not _HAS_YAML:
111
+ raise RuntimeError(
112
+ f"Found a YAML config ({path}) but pyyaml is not installed. "
113
+ "Install it with: pip install pyyaml"
114
+ )
115
+ with path.open("r", encoding="utf-8") as f:
116
+ return yaml.safe_load(f) or {}
117
+ else:
118
+ with path.open("r", encoding="utf-8") as f:
119
+ return json.load(f)
120
+
121
+
122
+ def save_config(data: dict[str, Any], path: Path | None = None) -> None:
123
+ path = path or get_config_path()
124
+ if path.suffix in (".yaml", ".yml"):
125
+ if not _HAS_YAML:
126
+ raise RuntimeError("pyyaml is required to use a YAML config: pip install pyyaml")
127
+ with path.open("w", encoding="utf-8") as f:
128
+ yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
129
+ else:
130
+ with path.open("w", encoding="utf-8") as f:
131
+ json.dump(data, f, ensure_ascii=False, indent=2)