From d3500f7f1e6a7a78903e398ec7602b407f8e41e2 Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sun, 2 Aug 2026 11:02:18 -0400 Subject: [PATCH] feat: add game list, mark configured games in game detect (#205) Add `lmm game list` (table of id/name/paths/deploy mode/sources, marks the default game, --json support) and mark already-configured games `[configured]` in `lmm game detect`, excluding them from the default "all" selection while keeping them listed and still repairable via explicit selection. Cross-link README's game-detection mention to the existing steam-games.yaml docs section. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 8 + README.md | 131 ++++++++-------- cmd/lmm/game.go | 113 ++++++++++---- cmd/lmm/game_detect_selection_test.go | 179 ++++++++++++++++++++++ cmd/lmm/game_list.go | 135 ++++++++++++++++ cmd/lmm/game_list_test.go | 174 +++++++++++++++++++++ cmd/lmm/root.go | 2 +- docs/man/man1/lmm-auth-login.1 | 2 +- docs/man/man1/lmm-auth-logout.1 | 2 +- docs/man/man1/lmm-auth-status.1 | 2 +- docs/man/man1/lmm-auth.1 | 2 +- docs/man/man1/lmm-completion-bash.1 | 2 +- docs/man/man1/lmm-completion-fish.1 | 2 +- docs/man/man1/lmm-completion-powershell.1 | 2 +- docs/man/man1/lmm-completion-zsh.1 | 2 +- docs/man/man1/lmm-completion.1 | 2 +- docs/man/man1/lmm-conflicts.1 | 2 +- docs/man/man1/lmm-deploy.1 | 2 +- docs/man/man1/lmm-game-add.1 | 2 +- docs/man/man1/lmm-game-clear-default.1 | 2 +- docs/man/man1/lmm-game-detect.1 | 16 +- docs/man/man1/lmm-game-list.1 | 62 ++++++++ docs/man/man1/lmm-game-set-default.1 | 2 +- docs/man/man1/lmm-game-show-default.1 | 2 +- docs/man/man1/lmm-game.1 | 9 +- docs/man/man1/lmm-import.1 | 2 +- docs/man/man1/lmm-install.1 | 2 +- docs/man/man1/lmm-list.1 | 2 +- docs/man/man1/lmm-mod-disable.1 | 2 +- docs/man/man1/lmm-mod-edit.1 | 2 +- docs/man/man1/lmm-mod-enable.1 | 2 +- docs/man/man1/lmm-mod-files.1 | 2 +- docs/man/man1/lmm-mod-lock.1 | 2 +- docs/man/man1/lmm-mod-set-update.1 | 2 +- docs/man/man1/lmm-mod-show.1 | 2 +- docs/man/man1/lmm-mod-unlock.1 | 2 +- docs/man/man1/lmm-mod.1 | 2 +- docs/man/man1/lmm-profile-apply.1 | 2 +- docs/man/man1/lmm-profile-create.1 | 2 +- docs/man/man1/lmm-profile-delete.1 | 2 +- docs/man/man1/lmm-profile-export.1 | 2 +- docs/man/man1/lmm-profile-import.1 | 2 +- docs/man/man1/lmm-profile-list.1 | 2 +- docs/man/man1/lmm-profile-reorder.1 | 2 +- docs/man/man1/lmm-profile-switch.1 | 2 +- docs/man/man1/lmm-profile-sync.1 | 2 +- docs/man/man1/lmm-profile.1 | 2 +- docs/man/man1/lmm-purge.1 | 2 +- docs/man/man1/lmm-search.1 | 2 +- docs/man/man1/lmm-source-list.1 | 2 +- docs/man/man1/lmm-source-validate.1 | 2 +- docs/man/man1/lmm-source.1 | 2 +- docs/man/man1/lmm-status.1 | 2 +- docs/man/man1/lmm-tui.1 | 2 +- docs/man/man1/lmm-uninstall.1 | 2 +- docs/man/man1/lmm-update-rollback.1 | 2 +- docs/man/man1/lmm-update.1 | 2 +- docs/man/man1/lmm-verify.1 | 2 +- docs/man/man1/lmm.1 | 2 +- 59 files changed, 777 insertions(+), 150 deletions(-) create mode 100644 cmd/lmm/game_detect_selection_test.go create mode 100644 cmd/lmm/game_list.go create mode 100644 cmd/lmm/game_list_test.go create mode 100644 docs/man/man1/lmm-game-list.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 71048fa..41085af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `lmm game list` — a table of every configured game (ID, name, install path, mod path, deploy mode, and a compact `source:id` rendering of its sources), marking the default game (see `lmm game show-default`) and pointing at `lmm game add`/`lmm game detect` when nothing is configured yet. Supports `--json` like `list`/`search`/`source list` (#205) + +### Changed + +- `lmm game detect` now marks a game already present in `games.yaml` as `[configured]` (mirroring `search`'s `[installed]` convention) and excludes it from the default "all" selection, since it needs no re-offering. It stays listed, and naming its number explicitly still selects it — the same re-add/repair path `lmm game add` has always used unconditionally (games.yaml entry + a fresh empty default profile, replacing any existing one) (#205) + ## [1.28.0] - 2026-08-02 ### Added diff --git a/README.md b/README.md index cd6d3e5..f11ea5a 100644 --- a/README.md +++ b/README.md @@ -877,71 +877,72 @@ Output is colorized by default whenever stdout is a terminal (headers, status ac ### Commands -| Command | Description | -| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `lmm search ` | Search all configured sources concurrently | -| `lmm search --source ID` | Search a single source instead of all configured ones | -| `lmm search --category ID` | Filter by category (NexusMods and CurseForge) | -| `lmm search --tag TAG` | Filter by tag (NexusMods only; repeat for multiple) | -| `lmm install [query]` | Search and install a mod (query optional with `--id`) | -| `lmm install --id ` | Install by mod ID | -| `lmm install --id --file ` | Install a specific file, skipping file selection | -| `lmm install --version ` | Install the exact-match version (archived files searched automatically) | -| `lmm install --show-archived` | Include archived/old files when selecting a file | -| `lmm install --no-deps` | Skip automatic dependency installation | -| `lmm install --source ID` / `-s` | Use a specific source (default: sole configured source; prompts when several are configured, `-y` picks the first alphabetically) | -| `lmm uninstall ` | Uninstall a mod | -| `lmm uninstall --keep-cache` | Uninstall but keep the cached mod files | -| `lmm import` | Scan `mod_path` for untracked mods and import them (see [Import](#import) below) | -| `lmm import ` | Import one local mod archive | -| `lmm list` | List installed mods | -| `lmm list --profiles` | List profiles for the game | -| `lmm status` | Show current status | -| `lmm update` | Check for and apply auto-updates | -| `lmm update ` | Update a specific mod | -| `lmm update --all` | Apply all available updates | -| `lmm update --dry-run` | Preview what would update | -| `lmm update rollback ` | Rollback to previous version | -| `lmm verify` | Verify cached mod files (see below) | -| `lmm verify --fix` | Re-download missing files, populate missing checksums, repair version-record mismatches | -| `lmm mod enable ` | Enable a disabled mod | -| `lmm mod disable ` | Disable mod (keep in cache) | -| `lmm mod set-update --auto` | Enable auto-updates for mod | -| `lmm mod set-update --notify` | Notify only (default) | -| `lmm mod set-update --pin` | Mute update checks for mod (does not hold a version — see [Locking](#locking-mods-to-a-version)) | -| `lmm mod lock [version]` | Lock mod's profile entry to its current or a specific version | -| `lmm mod unlock ` | Clear a mod's lock (recorded version is left untouched) | -| `lmm mod show ` | Show mod details (description, image, etc.) | -| `lmm mod files ` | List files deployed by mod | -| `lmm mod edit ` | Edit mod details (name, version, author, source, ID) | -| `lmm game set-default ` | Set the default game | -| `lmm game show-default` | Show current default game | -| `lmm game clear-default` | Clear the default game setting | -| `lmm game add` | Interactively add a new game configuration | -| `lmm game detect` | Scan Steam libraries for known moddable games | -| `lmm auth login [source]` | Authenticate with a source (any source declaring auth; nexusmods/curseforge validated live) | -| `lmm auth logout [source]` | Remove stored credentials | -| `lmm auth status` | Show authentication status | -| `lmm profile list` | List profiles | -| `lmm profile create ` | Create a profile | -| `lmm profile switch ` | Switch to a profile (installs missing mods) | -| `lmm profile delete ` | Delete a profile | -| `lmm profile export ` | Export profile to YAML | -| `lmm profile import ` | Import profile from YAML | -| `lmm profile import --force` | Import and overwrite existing | -| `lmm profile reorder [mod-id ...]` | Show or set load order | -| `lmm profile sync` | Update profile to match installed mods | -| `lmm profile apply` | Install/enable mods to match profile | -| `lmm deploy` | Deploy all enabled mods from cache | -| `lmm deploy ` | Deploy specific mod from cache | -| `lmm deploy --method hardlink` | Deploy using different link method | -| `lmm deploy --purge` | Purge then deploy all mods | -| `lmm purge` | Remove all mods from game directory | -| `lmm conflicts` | Show file conflicts in current profile | -| `lmm source list` | List built-in and user-defined mod sources | -| `lmm source validate ` | Validate a user-defined source definition | -| `lmm source validate --probe ` | Also live-smoke-test the definition (scan/fetch/API call) | -| `lmm source validate --probe --id ` | Probe an `api` definition that has no `search` endpoint | +| Command | Description | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lmm search ` | Search all configured sources concurrently | +| `lmm search --source ID` | Search a single source instead of all configured ones | +| `lmm search --category ID` | Filter by category (NexusMods and CurseForge) | +| `lmm search --tag TAG` | Filter by tag (NexusMods only; repeat for multiple) | +| `lmm install [query]` | Search and install a mod (query optional with `--id`) | +| `lmm install --id ` | Install by mod ID | +| `lmm install --id --file ` | Install a specific file, skipping file selection | +| `lmm install --version ` | Install the exact-match version (archived files searched automatically) | +| `lmm install --show-archived` | Include archived/old files when selecting a file | +| `lmm install --no-deps` | Skip automatic dependency installation | +| `lmm install --source ID` / `-s` | Use a specific source (default: sole configured source; prompts when several are configured, `-y` picks the first alphabetically) | +| `lmm uninstall ` | Uninstall a mod | +| `lmm uninstall --keep-cache` | Uninstall but keep the cached mod files | +| `lmm import` | Scan `mod_path` for untracked mods and import them (see [Import](#import) below) | +| `lmm import ` | Import one local mod archive | +| `lmm list` | List installed mods | +| `lmm list --profiles` | List profiles for the game | +| `lmm status` | Show current status | +| `lmm update` | Check for and apply auto-updates | +| `lmm update ` | Update a specific mod | +| `lmm update --all` | Apply all available updates | +| `lmm update --dry-run` | Preview what would update | +| `lmm update rollback ` | Rollback to previous version | +| `lmm verify` | Verify cached mod files (see below) | +| `lmm verify --fix` | Re-download missing files, populate missing checksums, repair version-record mismatches | +| `lmm mod enable ` | Enable a disabled mod | +| `lmm mod disable ` | Disable mod (keep in cache) | +| `lmm mod set-update --auto` | Enable auto-updates for mod | +| `lmm mod set-update --notify` | Notify only (default) | +| `lmm mod set-update --pin` | Mute update checks for mod (does not hold a version — see [Locking](#locking-mods-to-a-version)) | +| `lmm mod lock [version]` | Lock mod's profile entry to its current or a specific version | +| `lmm mod unlock ` | Clear a mod's lock (recorded version is left untouched) | +| `lmm mod show ` | Show mod details (description, image, etc.) | +| `lmm mod files ` | List files deployed by mod | +| `lmm mod edit ` | Edit mod details (name, version, author, source, ID) | +| `lmm game set-default ` | Set the default game | +| `lmm game show-default` | Show current default game | +| `lmm game clear-default` | Clear the default game setting | +| `lmm game add` | Interactively add a new game configuration | +| `lmm game list` | List configured games (ID, name, paths, deploy mode, sources; marks the default) | +| `lmm game detect` | Scan Steam libraries for known moddable games (extend the known-games list via [`steam-games.yaml`](docs/configuration.md#steam-gamesyaml-optional)) | +| `lmm auth login [source]` | Authenticate with a source (any source declaring auth; nexusmods/curseforge validated live) | +| `lmm auth logout [source]` | Remove stored credentials | +| `lmm auth status` | Show authentication status | +| `lmm profile list` | List profiles | +| `lmm profile create ` | Create a profile | +| `lmm profile switch ` | Switch to a profile (installs missing mods) | +| `lmm profile delete ` | Delete a profile | +| `lmm profile export ` | Export profile to YAML | +| `lmm profile import ` | Import profile from YAML | +| `lmm profile import --force` | Import and overwrite existing | +| `lmm profile reorder [mod-id ...]` | Show or set load order | +| `lmm profile sync` | Update profile to match installed mods | +| `lmm profile apply` | Install/enable mods to match profile | +| `lmm deploy` | Deploy all enabled mods from cache | +| `lmm deploy ` | Deploy specific mod from cache | +| `lmm deploy --method hardlink` | Deploy using different link method | +| `lmm deploy --purge` | Purge then deploy all mods | +| `lmm purge` | Remove all mods from game directory | +| `lmm conflicts` | Show file conflicts in current profile | +| `lmm source list` | List built-in and user-defined mod sources | +| `lmm source validate ` | Validate a user-defined source definition | +| `lmm source validate --probe ` | Also live-smoke-test the definition (scan/fetch/API call) | +| `lmm source validate --probe --id ` | Probe an `api` definition that has no `search` endpoint | `lmm install --version ` resolves the exact version against the mod's full file list — archived/old files are searched automatically, no `--show-archived` needed — and the matching file(s) become the pool for `--file`/`-y`/the interactive prompt; when the mod has dependencies, `--version` and `--file` apply to the named mod only (`--file` picks from the version's matches when both are given, and the whole install aborts up front if either fails to resolve) — dependencies are unaffected, still installing at latest with their primary file auto-selected. An unknown version fails with an error listing the versions the source actually has (`version not found: version "..." (available: ...)`). A source whose files carry no version information fails with the standard "not supported" gap instead, same as any other missing capability — this is decided dynamically from the actual file data returned for that mod, not from the source's advertised `versions` capability flag (a source can declare `versions` support and still hit this gap for a mod whose files happen to lack version strings). Omitting `--version` installs the latest, unchanged. diff --git a/cmd/lmm/game.go b/cmd/lmm/game.go index 1c14e5e..fd2abe2 100644 --- a/cmd/lmm/game.go +++ b/cmd/lmm/game.go @@ -23,8 +23,9 @@ var gameCmd = &cobra.Command{ lmm, their install/mod paths and configured sources (games.yaml), and the default game used when --game/-g is omitted. -Use 'lmm game add' to configure a game interactively, or 'lmm game -detect' to find Steam installs automatically.`, +Use 'lmm game add' to configure a game interactively, 'lmm game detect' +to find Steam installs automatically, or 'lmm game list' to see what's +already configured.`, } var gameSetDefaultCmd = &cobra.Command{ @@ -70,10 +71,16 @@ var gameDetectCmd = &cobra.Command{ Short: "Detect Steam games and add them to config", Long: `Scan Steam libraries for known moddable games and optionally add them to games.yaml. -Prompts for which games to add (e.g. 1,2 or all or none). Each added -game gets a NexusMods source mapping, the symlink link method, and an -empty default profile; edit games.yaml afterwards for anything more -specific, including the NexusMods slug if none was detected. +Prompts for which games to add (e.g. 1,2 or all or none). A game already +configured (present in games.yaml) is marked "[configured]" and is +excluded from the default "all" selection, since it needs no re-offering +- but it stays listed, and you can still name its number explicitly to +re-add/repair it (this replays the same games.yaml + default-profile +overwrite 'lmm game add' always performs, so a repair also resets the +default profile's mod list). Each added game gets a NexusMods source +mapping, the symlink link method, and an empty default profile; edit +games.yaml afterwards for anything more specific, including the +NexusMods slug if none was detected. Examples: lmm game detect`, @@ -188,48 +195,62 @@ func runGameDetect(cmd *cobra.Command, args []string) error { for _, w := range warnings { fmt.Fprintf(os.Stderr, "Warning: %s\n", w) } + reader := bufio.NewReader(os.Stdin) + return doGameDetect(cmd, reader, svcCfg.ConfigDir, games) +} + +// doGameDetect drives the interactive detect-and-select flow against an +// already-detected games list, so it can be tested without a real Steam +// library scan. configDir is used both for the existing-games lookup (to +// mark/exclude already-configured games, #205 item 2) and for saving newly +// selected ones. +func doGameDetect(cmd *cobra.Command, reader *bufio.Reader, configDir string, games []steam.DetectedGame) error { if len(games) == 0 { cmd.Println("No moddable Steam games found.") return nil } + + existingGames, err := config.LoadGames(configDir) + if err != nil { + return fmt.Errorf("loading games: %w", err) + } + cmd.Printf("Found %d moddable game(s):\n", len(games)) for i, g := range games { - cmd.Printf(" %d. %s (%s)\n", i+1, g.Name, g.Slug) + marker := "" + if _, ok := existingGames[g.Slug]; ok { + marker = " " + colorGreen("[configured]") + } + cmd.Printf(" %d. %s (%s)%s\n", i+1, g.Name, g.Slug, marker) cmd.Printf(" Path: %s\n", g.InstallPath) } cmd.Print("Add games to config? [1,2/all/none]: ") - reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("reading input: %w", err) } line = strings.TrimSpace(strings.ToLower(line)) - if line == "" || line == "n" || line == "none" { - cmd.Println("No games added.") - return nil + + indices, err := gameDetectSelectionIndices(line, games, existingGames) + if err != nil { + return err } - var indices []int - if line == "all" || line == "a" { - for i := 1; i <= len(games); i++ { - indices = append(indices, i) - } - } else { - for _, part := range strings.Split(line, ",") { - part = strings.TrimSpace(part) - n, err := strconv.Atoi(part) - if err != nil || n < 1 || n > len(games) { - return fmt.Errorf("invalid selection: %q (use numbers 1-%d, all, or none)", part, len(games)) - } - indices = append(indices, n) + if len(indices) == 0 { + if line == "all" || line == "a" { + cmd.Println("All detected games are already configured. No new games added.") + } else { + cmd.Println("No games added.") } + return nil } + for _, n := range indices { g := games[n-1] game, err := gameFromDetected(g) if err != nil { return fmt.Errorf("converting detected game %s: %w", g.Slug, err) } - if err := config.SaveGame(svcCfg.ConfigDir, game); err != nil { + if err := config.SaveGame(configDir, game); err != nil { return fmt.Errorf("saving game %s: %w", g.Slug, err) } // No LinkMethod: a detected game's default profile should inherit the @@ -240,7 +261,7 @@ func runGameDetect(cmd *cobra.Command, args []string) error { Mods: nil, IsDefault: true, } - if err := config.SaveProfile(svcCfg.ConfigDir, defaultProfile); err != nil { + if err := config.SaveProfile(configDir, defaultProfile); err != nil { return fmt.Errorf("creating default profile for %s: %w", g.Slug, err) } cmd.Printf("Added: %s (%s)\n", g.Name, g.Slug) @@ -248,6 +269,46 @@ func runGameDetect(cmd *cobra.Command, args []string) error { return nil } +// gameDetectSelectionIndices parses the detect prompt's answer into the +// 1-based indices into games to add/repair. +// +// "all"/"a" defaults to every NOT-yet-configured game (#205 item 2): a game +// already in games.yaml doesn't need re-offering by default, since silently +// re-selecting it would replay doGameDetect's unconditional games.yaml + +// default-profile overwrite against a game the user already set up - +// possibly wiping its default profile's installed-mod list for no reason +// the user asked for. An explicit numeric selection (e.g. "2,5") is NOT +// filtered: naming an already-configured game's number is how a user +// deliberately repairs/re-adds it, mirroring the same overwrite 'lmm game +// add' has always performed unconditionally (it has no existing-ID guard +// either) - #205 asks only for visibility into what's already configured, +// not a merge-preserving repair. +func gameDetectSelectionIndices(line string, games []steam.DetectedGame, existingGames map[string]*domain.Game) ([]int, error) { + line = strings.TrimSpace(strings.ToLower(line)) + if line == "" || line == "n" || line == "none" { + return nil, nil + } + var indices []int + if line == "all" || line == "a" { + for i, g := range games { + if _, ok := existingGames[g.Slug]; ok { + continue + } + indices = append(indices, i+1) + } + return indices, nil + } + for _, part := range strings.Split(line, ",") { + part = strings.TrimSpace(part) + n, err := strconv.Atoi(part) + if err != nil || n < 1 || n > len(games) { + return nil, fmt.Errorf("invalid selection: %q (use numbers 1-%d, all, or none)", part, len(games)) + } + indices = append(indices, n) + } + return indices, nil +} + // gameFromDetected converts one steam.DetectedGame into the domain.Game // runGameDetect saves. g.Sources, when the known-games entry supplied one // (#177: games with a non-NexusMods or multi-source setup, e.g. Icarus), diff --git a/cmd/lmm/game_detect_selection_test.go b/cmd/lmm/game_detect_selection_test.go new file mode 100644 index 0000000..66c2660 --- /dev/null +++ b/cmd/lmm/game_detect_selection_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "bufio" + "strings" + "testing" + + "github.com/DonovanMods/linux-mod-manager/internal/domain" + "github.com/DonovanMods/linux-mod-manager/internal/source/steam" + "github.com/DonovanMods/linux-mod-manager/internal/storage/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func detectedGamesFixture() []steam.DetectedGame { + return []steam.DetectedGame{ + {Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"}, + {Slug: "starrupture", Name: "Star Rupture", InstallPath: "/games/starrupture", NexusID: "starrupture"}, + {Slug: "icarus", Name: "Icarus", InstallPath: "/games/icarus", Sources: map[string]string{"icarus": "icarus"}}, + } +} + +// TestGameDetectSelectionIndices_AllExcludesConfigured pins #205 item 2: +// "all" defaults to only the NOT-yet-configured games. +func TestGameDetectSelectionIndices_AllExcludesConfigured(t *testing.T) { + games := detectedGamesFixture() + existing := map[string]*domain.Game{"skyrim-se": {ID: "skyrim-se"}} + + indices, err := gameDetectSelectionIndices("all", games, existing) + require.NoError(t, err) + assert.Equal(t, []int{2, 3}, indices) +} + +// TestGameDetectSelectionIndices_AllWhenNoneConfigured is the pre-#205 +// baseline: nothing configured yet, "all" still selects everything. +func TestGameDetectSelectionIndices_AllWhenNoneConfigured(t *testing.T) { + games := detectedGamesFixture() + + indices, err := gameDetectSelectionIndices("all", games, map[string]*domain.Game{}) + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, indices) +} + +// TestGameDetectSelectionIndices_AllWhenEveryGameConfigured pins the empty +// result when every detected game is already configured. +func TestGameDetectSelectionIndices_AllWhenEveryGameConfigured(t *testing.T) { + games := detectedGamesFixture() + existing := map[string]*domain.Game{ + "skyrim-se": {ID: "skyrim-se"}, + "starrupture": {ID: "starrupture"}, + "icarus": {ID: "icarus"}, + } + + indices, err := gameDetectSelectionIndices("all", games, existing) + require.NoError(t, err) + assert.Empty(t, indices) +} + +// TestGameDetectSelectionIndices_ExplicitSelectionIncludesConfigured pins +// the repair path (#205 item 2): explicitly naming an already-configured +// game's number still selects it - this is how a user deliberately +// re-adds/repairs a game, mirroring 'lmm game add's unconditional overwrite. +func TestGameDetectSelectionIndices_ExplicitSelectionIncludesConfigured(t *testing.T) { + games := detectedGamesFixture() + existing := map[string]*domain.Game{"skyrim-se": {ID: "skyrim-se"}} + + indices, err := gameDetectSelectionIndices("1,2", games, existing) + require.NoError(t, err) + assert.Equal(t, []int{1, 2}, indices) +} + +func TestGameDetectSelectionIndices_NoneAndEmpty(t *testing.T) { + games := detectedGamesFixture() + for _, in := range []string{"", "n", "none", "NONE"} { + indices, err := gameDetectSelectionIndices(in, games, nil) + require.NoError(t, err) + assert.Empty(t, indices, "input %q", in) + } +} + +func TestGameDetectSelectionIndices_InvalidSelection(t *testing.T) { + games := detectedGamesFixture() + _, err := gameDetectSelectionIndices("99", games, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid selection") +} + +// TestDoGameDetect_MarksConfiguredGamesAndExcludesFromAll drives the full +// interactive flow with a stubbed detected-games list (no real Steam scan) +// against a configDir that already has skyrim-se configured: the printed +// list marks it [configured], and answering "all" adds only starrupture. +func TestDoGameDetect_MarksConfiguredGamesAndExcludesFromAll(t *testing.T) { + configDir = t.TempDir() + require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Skyrim Special Edition"})) + + games := []steam.DetectedGame{ + {Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"}, + {Slug: "starrupture", Name: "Star Rupture", InstallPath: "/games/starrupture", NexusID: "starrupture"}, + } + + var buf strings.Builder + cmd := &cobra.Command{} + cmd.SetOut(&buf) + reader := bufio.NewReader(strings.NewReader("all\n")) + + err := doGameDetect(cmd, reader, configDir, games) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, lineContaining(out, "skyrim-se"), "[configured]") + assert.NotContains(t, lineContaining(out, "starrupture"), "[configured]") + assert.Contains(t, out, "Added: Star Rupture (starrupture)") + assert.NotContains(t, out, "Added: Skyrim Special Edition") + + saved, err := config.LoadGames(configDir) + require.NoError(t, err) + _, ok := saved["starrupture"] + assert.True(t, ok) +} + +// TestDoGameDetect_ExplicitSelectionRepairsConfiguredGame pins the repair +// path end to end: naming an already-configured game's number re-saves it. +func TestDoGameDetect_ExplicitSelectionRepairsConfiguredGame(t *testing.T) { + configDir = t.TempDir() + require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Stale Name"})) + + games := []steam.DetectedGame{ + {Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"}, + } + + var buf strings.Builder + cmd := &cobra.Command{} + cmd.SetOut(&buf) + reader := bufio.NewReader(strings.NewReader("1\n")) + + err := doGameDetect(cmd, reader, configDir, games) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Added: Skyrim Special Edition (skyrim-se)") + + saved, err := config.LoadGames(configDir) + require.NoError(t, err) + require.Contains(t, saved, "skyrim-se") + assert.Equal(t, "Skyrim Special Edition", saved["skyrim-se"].Name) +} + +// TestDoGameDetect_AllExcludedPrintsFriendlyMessage guards the fully- +// configured case: "all" selects nothing, and the message says so instead +// of the generic "No games added." (which reads as if the user declined). +func TestDoGameDetect_AllExcludedPrintsFriendlyMessage(t *testing.T) { + configDir = t.TempDir() + require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Skyrim Special Edition"})) + + games := []steam.DetectedGame{ + {Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"}, + } + + var buf strings.Builder + cmd := &cobra.Command{} + cmd.SetOut(&buf) + reader := bufio.NewReader(strings.NewReader("all\n")) + + err := doGameDetect(cmd, reader, configDir, games) + require.NoError(t, err) + assert.Contains(t, buf.String(), "already configured") +} + +func TestDoGameDetect_NoGamesFound(t *testing.T) { + configDir = t.TempDir() + + var buf strings.Builder + cmd := &cobra.Command{} + cmd.SetOut(&buf) + reader := bufio.NewReader(strings.NewReader("")) + + err := doGameDetect(cmd, reader, configDir, nil) + require.NoError(t, err) + assert.Contains(t, buf.String(), "No moddable Steam games found.") +} diff --git a/cmd/lmm/game_list.go b/cmd/lmm/game_list.go new file mode 100644 index 0000000..d836fdc --- /dev/null +++ b/cmd/lmm/game_list.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/DonovanMods/linux-mod-manager/internal/core" + "github.com/DonovanMods/linux-mod-manager/internal/storage/config" + + "github.com/spf13/cobra" +) + +// gameListJSON is the --json row shape for 'game list': Default is an +// explicit boolean (rather than embedding a marker in ID, as the table view +// does) so a JSON consumer doesn't need to string-match the ID field. +type gameListJSON struct { + ID string `json:"id"` + Name string `json:"name"` + InstallPath string `json:"install_path"` + ModPath string `json:"mod_path"` + DeployMode string `json:"deploy_mode"` + Sources map[string]string `json:"sources"` + Default bool `json:"default"` +} + +var gameListCmd = &cobra.Command{ + Use: "list", + Short: "List configured games", + Long: `List every game configured in games.yaml: ID, name, install path, mod +path, deploy mode, and configured sources. The default game (see 'lmm game +show-default') is marked "(default)" next to its ID. + +Examples: + lmm game list + lmm game list --json`, + Args: cobra.NoArgs, + RunE: runGameList, +} + +func init() { + gameCmd.AddCommand(gameListCmd) +} + +func runGameList(cmd *cobra.Command, args []string) error { + return withService(cmd, func(ctx context.Context, service *core.Service) error { + return doGameList(cmd, service) + }) +} + +func doGameList(cmd *cobra.Command, service *core.Service) error { + games := service.ListGames() + sort.Slice(games, func(i, j int) bool { return games[i].ID < games[j].ID }) + + cfg, err := config.Load(service.ConfigDir()) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + defaultGame := cfg.DefaultGame + + if jsonOutput { + rows := make([]gameListJSON, len(games)) + for i, g := range games { + rows[i] = gameListJSON{ + ID: g.ID, + Name: g.Name, + InstallPath: g.InstallPath, + ModPath: g.ModPath, + DeployMode: g.DeployMode.String(), + Sources: g.SourceIDs, + Default: g.ID == defaultGame, + } + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(rows); err != nil { + return fmt.Errorf("encoding json: %w", err) + } + return nil + } + + if len(games) == 0 { + fmt.Println("No games configured.") + fmt.Println("Use 'lmm game add' to configure one interactively, or 'lmm game detect' to scan Steam libraries for known games.") + return nil + } + + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "ID\tNAME\tINSTALL PATH\tMOD PATH\tDEPLOY MODE\tSOURCES"); err != nil { + return fmt.Errorf("writing header: %w", err) + } + if _, err := fmt.Fprintln(w, "--\t----\t------------\t--------\t-----------\t-------"); err != nil { + return fmt.Errorf("writing separator: %w", err) + } + for _, g := range games { + id := g.ID + if g.ID == defaultGame { + id += " (default)" + } + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + id, g.Name, g.InstallPath, g.ModPath, g.DeployMode.String(), formatGameSources(g.SourceIDs)); err != nil { + return fmt.Errorf("writing row: %w", err) + } + } + if err := w.Flush(); err != nil { + return fmt.Errorf("flushing output: %w", err) + } + + return printTable(&buf, 2, nil) +} + +// formatGameSources renders a game's SourceIDs map as a compact, +// deterministically-ordered "key:value,key:value" string for table display +// ("-" when the game has no sources configured). +func formatGameSources(sources map[string]string) string { + if len(sources) == 0 { + return "-" + } + keys := make([]string, 0, len(sources)) + for k := range sources { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+":"+sources[k]) + } + return strings.Join(parts, ",") +} diff --git a/cmd/lmm/game_list_test.go b/cmd/lmm/game_list_test.go new file mode 100644 index 0000000..636565f --- /dev/null +++ b/cmd/lmm/game_list_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DonovanMods/linux-mod-manager/internal/domain" + "github.com/DonovanMods/linux-mod-manager/internal/storage/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGameListCmd_Structure(t *testing.T) { + assert.Equal(t, "list", gameListCmd.Use) + assert.NotEmpty(t, gameListCmd.Short) + assert.NotEmpty(t, gameListCmd.Long) +} + +// TestDoGameList_EmptyState pins the friendly empty-state message (#205): +// no games configured should point the user at 'game add'/'game detect', +// not just print nothing or a bare "no games" line. +func TestDoGameList_EmptyState(t *testing.T) { + svc := setupGameAddTest(t) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + assert.Contains(t, out, "game add") + assert.Contains(t, out, "game detect") +} + +// TestDoGameList_ShowsConfiguredGames pins the table's column set (#205): +// ID, name, install path, mod path, deploy mode, sources. +func TestDoGameList_ShowsConfiguredGames(t *testing.T) { + svc := setupGameAddTest(t) + require.NoError(t, svc.AddGame(&domain.Game{ + ID: "skyrim-se", + Name: "Skyrim Special Edition", + InstallPath: "/games/skyrim", + ModPath: "/games/skyrim/Data", + SourceIDs: map[string]string{"nexusmods": "skyrimspecialedition"}, + DeployMode: domain.DeployExtract, + })) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + assert.Contains(t, out, "ID") + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "INSTALL PATH") + assert.Contains(t, out, "MOD PATH") + assert.Contains(t, out, "DEPLOY MODE") + assert.Contains(t, out, "SOURCES") + assert.Contains(t, out, "skyrim-se") + assert.Contains(t, out, "Skyrim Special Edition") + assert.Contains(t, out, "/games/skyrim") + assert.Contains(t, out, "/games/skyrim/Data") + assert.Contains(t, out, "extract") + assert.Contains(t, out, "nexusmods:skyrimspecialedition") +} + +// TestDoGameList_MarksDefaultGame pins the "(default)" marker (#205 item 1). +func TestDoGameList_MarksDefaultGame(t *testing.T) { + svc := setupGameAddTest(t) + require.NoError(t, svc.AddGame(&domain.Game{ID: "skyrim-se", Name: "Skyrim SE"})) + require.NoError(t, svc.AddGame(&domain.Game{ID: "starrupture", Name: "Star Rupture"})) + + cfg := &config.Config{DefaultGame: "starrupture"} + require.NoError(t, cfg.Save(svc.ConfigDir())) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + starLine := lineContaining(out, "starrupture") + skyrimLine := lineContaining(out, "skyrim-se") + assert.Contains(t, starLine, "(default)") + assert.NotContains(t, skyrimLine, "(default)") +} + +// TestDoGameList_MultipleSourcesCompactKV pins the "k:v,k:v" compact +// rendering, sorted by key for determinism (#205 item 1). +func TestDoGameList_MultipleSourcesCompactKV(t *testing.T) { + svc := setupGameAddTest(t) + require.NoError(t, svc.AddGame(&domain.Game{ + ID: "icarus", + Name: "Icarus", + SourceIDs: map[string]string{ + "icarus": "icarus", + "curseforge": "12345", + }, + })) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + line := lineContaining(out, "icarus") + assert.Contains(t, line, "curseforge:12345,icarus:icarus") +} + +// TestDoGameList_NoSourcesShowsPlaceholder guards against an empty +// SourceIDs map rendering as a blank cell (ambiguous with a parse error); +// a "-" placeholder makes "no sources configured" explicit. +func TestDoGameList_NoSourcesShowsPlaceholder(t *testing.T) { + svc := setupGameAddTest(t) + require.NoError(t, svc.AddGame(&domain.Game{ID: "bare-game", Name: "Bare Game"})) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + line := lineContaining(out, "bare-game") + assert.Contains(t, line, "-") +} + +// TestDoGameList_JSONOutput pins the --json contract: an array (never +// null, even when empty) with an explicit "default" boolean per entry. +func TestDoGameList_JSONOutput(t *testing.T) { + svc := setupGameAddTest(t) + require.NoError(t, svc.AddGame(&domain.Game{ + ID: "skyrim-se", + Name: "Skyrim SE", + InstallPath: "/games/skyrim", + ModPath: "/games/skyrim/Data", + SourceIDs: map[string]string{"nexusmods": "skyrimspecialedition"}, + })) + cfg := &config.Config{DefaultGame: "skyrim-se"} + require.NoError(t, cfg.Save(svc.ConfigDir())) + + oldJSON := jsonOutput + jsonOutput = true + t.Cleanup(func() { jsonOutput = oldJSON }) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + var rows []gameListJSON + require.NoError(t, json.Unmarshal([]byte(out), &rows)) + require.Len(t, rows, 1) + assert.Equal(t, "skyrim-se", rows[0].ID) + assert.True(t, rows[0].Default) + assert.Equal(t, map[string]string{"nexusmods": "skyrimspecialedition"}, rows[0].Sources) +} + +// TestDoGameList_JSONOutput_EmptyIsArrayNotNull mirrors search/list's +// contract: --json with nothing configured still emits "[]", not "null". +func TestDoGameList_JSONOutput_EmptyIsArrayNotNull(t *testing.T) { + svc := setupGameAddTest(t) + + oldJSON := jsonOutput + jsonOutput = true + t.Cleanup(func() { jsonOutput = oldJSON }) + + out := captureStdout(t, func() error { + return doGameList(&cobra.Command{}, svc) + }) + + assert.Equal(t, "[]", strings.TrimSpace(out)) +} + +func lineContaining(out, needle string) string { + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, needle) { + return l + } + } + return "" +} diff --git a/cmd/lmm/root.go b/cmd/lmm/root.go index 92de74f..12bfcac 100644 --- a/cmd/lmm/root.go +++ b/cmd/lmm/root.go @@ -90,7 +90,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&gameID, "game", "g", "", "game ID to operate on") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVar(&noHooks, "no-hooks", false, "disable all hooks") - rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format (list, status, search, update, conflicts, verify, mod show, source list)") + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list)") rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output (NO_COLOR env is also honored)") } diff --git a/docs/man/man1/lmm-auth-login.1 b/docs/man/man1/lmm-auth-login.1 index 590e59e..785cf0f 100644 --- a/docs/man/man1/lmm-auth-login.1 +++ b/docs/man/man1/lmm-auth-login.1 @@ -69,7 +69,7 @@ underscores) - lmm reads that on every run. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-auth-logout.1 b/docs/man/man1/lmm-auth-logout.1 index 601c41e..35bb4d0 100644 --- a/docs/man/man1/lmm-auth-logout.1 +++ b/docs/man/man1/lmm-auth-logout.1 @@ -46,7 +46,7 @@ Built-in sources: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-auth-status.1 b/docs/man/man1/lmm-auth-status.1 index 0d42bf7..412849f 100644 --- a/docs/man/man1/lmm-auth-status.1 +++ b/docs/man/man1/lmm-auth-status.1 @@ -32,7 +32,7 @@ Show authentication status for all sources .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-auth.1 b/docs/man/man1/lmm-auth.1 index 9c384aa..2254fd9 100644 --- a/docs/man/man1/lmm-auth.1 +++ b/docs/man/man1/lmm-auth.1 @@ -47,7 +47,7 @@ Use 'lmm auth status' to check authentication status. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-completion-bash.1 b/docs/man/man1/lmm-completion-bash.1 index 910ced9..cf497d2 100644 --- a/docs/man/man1/lmm-completion-bash.1 +++ b/docs/man/man1/lmm-completion-bash.1 @@ -63,7 +63,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-completion-fish.1 b/docs/man/man1/lmm-completion-fish.1 index f3cb121..54b02a6 100644 --- a/docs/man/man1/lmm-completion-fish.1 +++ b/docs/man/man1/lmm-completion-fish.1 @@ -53,7 +53,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-completion-powershell.1 b/docs/man/man1/lmm-completion-powershell.1 index a18117f..5ea942b 100644 --- a/docs/man/man1/lmm-completion-powershell.1 +++ b/docs/man/man1/lmm-completion-powershell.1 @@ -47,7 +47,7 @@ to your powershell profile. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-completion-zsh.1 b/docs/man/man1/lmm-completion-zsh.1 index ca60452..3bb838c 100644 --- a/docs/man/man1/lmm-completion-zsh.1 +++ b/docs/man/man1/lmm-completion-zsh.1 @@ -67,7 +67,7 @@ You will need to start a new shell for this setup to take effect. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-completion.1 b/docs/man/man1/lmm-completion.1 index ec7196d..fd45645 100644 --- a/docs/man/man1/lmm-completion.1 +++ b/docs/man/man1/lmm-completion.1 @@ -33,7 +33,7 @@ See each sub-command's help for details on how to use the generated script. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-conflicts.1 b/docs/man/man1/lmm-conflicts.1 index e346b16..e649d3c 100644 --- a/docs/man/man1/lmm-conflicts.1 +++ b/docs/man/man1/lmm-conflicts.1 @@ -59,7 +59,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-deploy.1 b/docs/man/man1/lmm-deploy.1 index 469f990..b2e0891 100644 --- a/docs/man/man1/lmm-deploy.1 +++ b/docs/man/man1/lmm-deploy.1 @@ -84,7 +84,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game-add.1 b/docs/man/man1/lmm-game-add.1 index 7614879..02e406f 100644 --- a/docs/man/man1/lmm-game-add.1 +++ b/docs/man/man1/lmm-game-add.1 @@ -51,7 +51,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game-clear-default.1 b/docs/man/man1/lmm-game-clear-default.1 index db5b92e..e7919cb 100644 --- a/docs/man/man1/lmm-game-clear-default.1 +++ b/docs/man/man1/lmm-game-clear-default.1 @@ -40,7 +40,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game-detect.1 b/docs/man/man1/lmm-game-detect.1 index 29b1609..c72ef8d 100644 --- a/docs/man/man1/lmm-game-detect.1 +++ b/docs/man/man1/lmm-game-detect.1 @@ -13,10 +13,16 @@ lmm-game-detect - Detect Steam games and add them to config Scan Steam libraries for known moddable games and optionally add them to games.yaml. .PP -Prompts for which games to add (e.g. 1,2 or all or none). Each added -game gets a NexusMods source mapping, the symlink link method, and an -empty default profile; edit games.yaml afterwards for anything more -specific, including the NexusMods slug if none was detected. +Prompts for which games to add (e.g. 1,2 or all or none). A game already +configured (present in games.yaml) is marked "[configured]" and is +excluded from the default "all" selection, since it needs no re-offering +- but it stays listed, and you can still name its number explicitly to +re-add/repair it (this replays the same games.yaml + default-profile +overwrite 'lmm game add' always performs, so a repair also resets the +default profile's mod list). Each added game gets a NexusMods source +mapping, the symlink link method, and an empty default profile; edit +games.yaml afterwards for anything more specific, including the +NexusMods slug if none was detected. .PP Examples: @@ -42,7 +48,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game-list.1 b/docs/man/man1/lmm-game-list.1 new file mode 100644 index 0000000..1840693 --- /dev/null +++ b/docs/man/man1/lmm-game-list.1 @@ -0,0 +1,62 @@ +.nh +.TH "LMM" "1" "Jul 2026" "lmm 1.28.0" "User Commands" + +.SH NAME +lmm-game-list - List configured games + + +.SH SYNOPSIS +\fBlmm game list [flags]\fP + + +.SH DESCRIPTION +List every game configured in games.yaml: ID, name, install path, mod +path, deploy mode, and configured sources. The default game (see 'lmm game +show-default') is marked "(default)" next to its ID. + +.PP +Examples: + lmm game list + lmm game list --json + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for list + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB--config\fP="" + config directory (default: ~/.config/lmm) + +.PP +\fB--data\fP="" + data directory (default: ~/.local/share/lmm) + +.PP +\fB-g\fP, \fB--game\fP="" + game ID to operate on + +.PP +\fB--json\fP[=false] + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) + +.PP +\fB--no-color\fP[=false] + disable colored output (NO_COLOR env is also honored) + +.PP +\fB--no-hooks\fP[=false] + disable all hooks + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBlmm-game(1)\fP + + +.SH HISTORY +27-Jul-2026 Auto generated by spf13/cobra diff --git a/docs/man/man1/lmm-game-set-default.1 b/docs/man/man1/lmm-game-set-default.1 index 4bf4b92..5564b95 100644 --- a/docs/man/man1/lmm-game-set-default.1 +++ b/docs/man/man1/lmm-game-set-default.1 @@ -37,7 +37,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game-show-default.1 b/docs/man/man1/lmm-game-show-default.1 index 3088809..cdd5e3a 100644 --- a/docs/man/man1/lmm-game-show-default.1 +++ b/docs/man/man1/lmm-game-show-default.1 @@ -37,7 +37,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-game.1 b/docs/man/man1/lmm-game.1 index 9cd96c7..d64f3ec 100644 --- a/docs/man/man1/lmm-game.1 +++ b/docs/man/man1/lmm-game.1 @@ -15,8 +15,9 @@ lmm, their install/mod paths and configured sources (games.yaml), and the default game used when --game/-g is omitted. .PP -Use 'lmm game add' to configure a game interactively, or 'lmm game -detect' to find Steam installs automatically. +Use 'lmm game add' to configure a game interactively, 'lmm game detect' +to find Steam installs automatically, or 'lmm game list' to see what's +already configured. .SH OPTIONS @@ -38,7 +39,7 @@ detect' to find Steam installs automatically. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] @@ -54,7 +55,7 @@ detect' to find Steam installs automatically. .SH SEE ALSO -\fBlmm(1)\fP, \fBlmm-game-add(1)\fP, \fBlmm-game-clear-default(1)\fP, \fBlmm-game-detect(1)\fP, \fBlmm-game-set-default(1)\fP, \fBlmm-game-show-default(1)\fP +\fBlmm(1)\fP, \fBlmm-game-add(1)\fP, \fBlmm-game-clear-default(1)\fP, \fBlmm-game-detect(1)\fP, \fBlmm-game-list(1)\fP, \fBlmm-game-set-default(1)\fP, \fBlmm-game-show-default(1)\fP .SH HISTORY diff --git a/docs/man/man1/lmm-import.1 b/docs/man/man1/lmm-import.1 index 4be706e..857b312 100644 --- a/docs/man/man1/lmm-import.1 +++ b/docs/man/man1/lmm-import.1 @@ -91,7 +91,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-install.1 b/docs/man/man1/lmm-install.1 index e64b778..d5774fa 100644 --- a/docs/man/man1/lmm-install.1 +++ b/docs/man/man1/lmm-install.1 @@ -106,7 +106,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-list.1 b/docs/man/man1/lmm-list.1 index 4936c7d..5f9cb44 100644 --- a/docs/man/man1/lmm-list.1 +++ b/docs/man/man1/lmm-list.1 @@ -58,7 +58,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-disable.1 b/docs/man/man1/lmm-mod-disable.1 index c878b71..03d6e15 100644 --- a/docs/man/man1/lmm-mod-disable.1 +++ b/docs/man/man1/lmm-mod-disable.1 @@ -41,7 +41,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-edit.1 b/docs/man/man1/lmm-mod-edit.1 index 1e0261b..e5e986a 100644 --- a/docs/man/man1/lmm-mod-edit.1 +++ b/docs/man/man1/lmm-mod-edit.1 @@ -84,7 +84,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-enable.1 b/docs/man/man1/lmm-mod-enable.1 index ed84d9a..5d82fc8 100644 --- a/docs/man/man1/lmm-mod-enable.1 +++ b/docs/man/man1/lmm-mod-enable.1 @@ -41,7 +41,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-files.1 b/docs/man/man1/lmm-mod-files.1 index 2d4a75e..93525e8 100644 --- a/docs/man/man1/lmm-mod-files.1 +++ b/docs/man/man1/lmm-mod-files.1 @@ -41,7 +41,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-lock.1 b/docs/man/man1/lmm-mod-lock.1 index a48975a..c0a0da8 100644 --- a/docs/man/man1/lmm-mod-lock.1 +++ b/docs/man/man1/lmm-mod-lock.1 @@ -52,7 +52,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-set-update.1 b/docs/man/man1/lmm-mod-set-update.1 index bb5781f..1ec2a8d 100644 --- a/docs/man/man1/lmm-mod-set-update.1 +++ b/docs/man/man1/lmm-mod-set-update.1 @@ -56,7 +56,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-show.1 b/docs/man/man1/lmm-mod-show.1 index ec65ae1..5af6952 100644 --- a/docs/man/man1/lmm-mod-show.1 +++ b/docs/man/man1/lmm-mod-show.1 @@ -40,7 +40,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod-unlock.1 b/docs/man/man1/lmm-mod-unlock.1 index 88d172b..b9e3e37 100644 --- a/docs/man/man1/lmm-mod-unlock.1 +++ b/docs/man/man1/lmm-mod-unlock.1 @@ -41,7 +41,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-mod.1 b/docs/man/man1/lmm-mod.1 index f57ba9d..fdb7ec4 100644 --- a/docs/man/man1/lmm-mod.1 +++ b/docs/man/man1/lmm-mod.1 @@ -40,7 +40,7 @@ Commands for managing individual mod settings. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-apply.1 b/docs/man/man1/lmm-profile-apply.1 index e951f89..eb21aba 100644 --- a/docs/man/man1/lmm-profile-apply.1 +++ b/docs/man/man1/lmm-profile-apply.1 @@ -47,7 +47,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-create.1 b/docs/man/man1/lmm-profile-create.1 index fcff8f2..e2bd107 100644 --- a/docs/man/man1/lmm-profile-create.1 +++ b/docs/man/man1/lmm-profile-create.1 @@ -36,7 +36,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-delete.1 b/docs/man/man1/lmm-profile-delete.1 index 88494ce..b5353ca 100644 --- a/docs/man/man1/lmm-profile-delete.1 +++ b/docs/man/man1/lmm-profile-delete.1 @@ -39,7 +39,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-export.1 b/docs/man/man1/lmm-profile-export.1 index 1010096..7c7f57e 100644 --- a/docs/man/man1/lmm-profile-export.1 +++ b/docs/man/man1/lmm-profile-export.1 @@ -39,7 +39,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-import.1 b/docs/man/man1/lmm-profile-import.1 index 0a40f8b..f463097 100644 --- a/docs/man/man1/lmm-profile-import.1 +++ b/docs/man/man1/lmm-profile-import.1 @@ -52,7 +52,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-list.1 b/docs/man/man1/lmm-profile-list.1 index 7752284..73bb3c4 100644 --- a/docs/man/man1/lmm-profile-list.1 +++ b/docs/man/man1/lmm-profile-list.1 @@ -36,7 +36,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-reorder.1 b/docs/man/man1/lmm-profile-reorder.1 index f2dbc27..acd7814 100644 --- a/docs/man/man1/lmm-profile-reorder.1 +++ b/docs/man/man1/lmm-profile-reorder.1 @@ -53,7 +53,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-switch.1 b/docs/man/man1/lmm-profile-switch.1 index 1484abd..d1750b7 100644 --- a/docs/man/man1/lmm-profile-switch.1 +++ b/docs/man/man1/lmm-profile-switch.1 @@ -39,7 +39,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile-sync.1 b/docs/man/man1/lmm-profile-sync.1 index 84a0596..4be4dd2 100644 --- a/docs/man/man1/lmm-profile-sync.1 +++ b/docs/man/man1/lmm-profile-sync.1 @@ -41,7 +41,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-profile.1 b/docs/man/man1/lmm-profile.1 index 54b6925..c296adb 100644 --- a/docs/man/man1/lmm-profile.1 +++ b/docs/man/man1/lmm-profile.1 @@ -36,7 +36,7 @@ For example, you might have a "vanilla plus" profile and a "total conversion" pr .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-purge.1 b/docs/man/man1/lmm-purge.1 index 3e67854..06cc366 100644 --- a/docs/man/man1/lmm-purge.1 +++ b/docs/man/man1/lmm-purge.1 @@ -64,7 +64,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-search.1 b/docs/man/man1/lmm-search.1 index b96f163..a8b6d50 100644 --- a/docs/man/man1/lmm-search.1 +++ b/docs/man/man1/lmm-search.1 @@ -71,7 +71,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-source-list.1 b/docs/man/man1/lmm-source-list.1 index 1811c43..8a2b226 100644 --- a/docs/man/man1/lmm-source-list.1 +++ b/docs/man/man1/lmm-source-list.1 @@ -50,7 +50,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-source-validate.1 b/docs/man/man1/lmm-source-validate.1 index 0f00cb4..4e0fd56 100644 --- a/docs/man/man1/lmm-source-validate.1 +++ b/docs/man/man1/lmm-source-validate.1 @@ -51,7 +51,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-source.1 b/docs/man/man1/lmm-source.1 index c3cbcd1..5416176 100644 --- a/docs/man/man1/lmm-source.1 +++ b/docs/man/man1/lmm-source.1 @@ -38,7 +38,7 @@ section of the project README for the file format. .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-status.1 b/docs/man/man1/lmm-status.1 index d67b523..f48d10a 100644 --- a/docs/man/man1/lmm-status.1 +++ b/docs/man/man1/lmm-status.1 @@ -37,7 +37,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-tui.1 b/docs/man/man1/lmm-tui.1 index 5a1f2ff..97ad6f4 100644 --- a/docs/man/man1/lmm-tui.1 +++ b/docs/man/man1/lmm-tui.1 @@ -77,7 +77,7 @@ lmm tui --prototype --theme amber .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-uninstall.1 b/docs/man/man1/lmm-uninstall.1 index d3b0368..a261e89 100644 --- a/docs/man/man1/lmm-uninstall.1 +++ b/docs/man/man1/lmm-uninstall.1 @@ -65,7 +65,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-update-rollback.1 b/docs/man/man1/lmm-update-rollback.1 index bb9cc0c..a29a3d8 100644 --- a/docs/man/man1/lmm-update-rollback.1 +++ b/docs/man/man1/lmm-update-rollback.1 @@ -59,7 +59,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-update.1 b/docs/man/man1/lmm-update.1 index eedbec9..5c391bd 100644 --- a/docs/man/man1/lmm-update.1 +++ b/docs/man/man1/lmm-update.1 @@ -98,7 +98,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm-verify.1 b/docs/man/man1/lmm-verify.1 index f221a09..157f8e2 100644 --- a/docs/man/man1/lmm-verify.1 +++ b/docs/man/man1/lmm-verify.1 @@ -163,7 +163,7 @@ Examples: .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false] diff --git a/docs/man/man1/lmm.1 b/docs/man/man1/lmm.1 index b0e1318..47914f9 100644 --- a/docs/man/man1/lmm.1 +++ b/docs/man/man1/lmm.1 @@ -60,7 +60,7 @@ FILES .PP \fB--json\fP[=false] - output in JSON format (list, status, search, update, conflicts, verify, mod show, source list) + output in JSON format (list, status, search, update, conflicts, verify, mod show, source list, game list) .PP \fB--no-color\fP[=false]