Skip to content

Commit 7dc2748

Browse files
committed
Release GABS 1.0.8
1 parent 944a373 commit 7dc2748

18 files changed

Lines changed: 1480 additions & 52 deletions

README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ to real games.
1717
- Configure games once with `gabs games add`
1818
- Keep everything local by default
1919
- Work with Claude Desktop, Codex CLI, and other MCP clients
20-
- Support direct executables, Steam App IDs, Epic App IDs, and custom commands
20+
- Support direct executables, managed Steam games, Epic App IDs, and custom commands
2121
- Mirror game-specific tools into MCP when the bridge connects
2222

2323
## Quick Start
@@ -70,13 +70,15 @@ gabs games show factory
7070
The setup is interactive. In most cases you only need to answer:
7171

7272
- **Game name**: a label you recognize
73-
- **Launch mode**: direct path, Steam App ID, Epic App ID, or custom command
73+
- **Launch mode**: direct path, managed Steam, Epic App ID, or custom command
7474
- **Target**: the executable path or store App ID
7575
- **Stop process name**: the real game process name used by `games.stop` and
7676
`games.kill`
7777

78-
For Steam and Epic games, `stopProcessName` is required. Example values:
79-
`GameName.exe`, `AdventureGame`, or `java`.
78+
For `SteamManaged`, GABS resolves the Steam app to the installed executable and
79+
starts Steam if needed. For launcher URL modes such as `SteamAppId` and
80+
`EpicAppId`, `stopProcessName` is required. Example values: `GameName.exe`,
81+
`AdventureGame`, or `java`.
8082

8183
### 3. Add GABS to your AI client
8284

@@ -136,6 +138,11 @@ If you want a download-to-working walkthrough, use the
136138

137139
- **Steam/Epic stopping**: use the real game process name, not the launcher
138140
name.
141+
- **Steam bridge games**: prefer `SteamManaged` launch. It resolves the Steam
142+
app manifest to the installed game executable, starts Steam if needed, and
143+
launches the real game process with GABP environment variables. Use
144+
`gabs games doctor <id>` to inspect a config and `gabs games repair <id>` to
145+
convert an older `SteamAppId` launcher URL config.
139146
- **More than one AI session**: that is fine. GABS coordinates ownership per
140147
game with a short active-owner lease. You can hop between live sessions:
141148
`games_connect` takes over naturally after the previous session goes idle,
@@ -151,9 +158,9 @@ If you want a download-to-working walkthrough, use the
151158
`games_start` with `resetEndpoint: true` only after confirming the cache
152159
should be rotated for a new process.
153160
- **Confusing bridge state**: start with `games_status`. It compares the
154-
runtime state and process environment where the OS allows it. If a launcher
155-
reused old GABP environment, GABS can connect through the running process
156-
endpoint with `games_connect`.
161+
runtime state and process environment where the OS allows it. If a Steam
162+
launcher URL config reused old GABP environment, run
163+
`gabs games repair <id>` to switch to managed Steam launch.
157164

158165
## How It Works
159166

cmd/gabs/main.go

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
"github.com/pardeike/gabs/internal/config"
2323
"github.com/pardeike/gabs/internal/mcp"
24+
"github.com/pardeike/gabs/internal/steam"
2425
"github.com/pardeike/gabs/internal/util"
2526
"github.com/pardeike/gabs/internal/version"
2627
)
@@ -190,6 +191,8 @@ Game management:
190191
gabs games add <id> Add a new game configuration (interactive)
191192
gabs games remove <id> Remove a game configuration
192193
gabs games show <id> Show details for a game
194+
gabs games doctor <id> Diagnose one game configuration
195+
gabs games repair <id> Apply safe repairs for one game configuration
193196
194197
Examples:
195198
# Start GABS MCP server (stdio)
@@ -305,6 +308,18 @@ func manageGames(ctx context.Context, log util.Logger, opts options, args []stri
305308
return 2
306309
}
307310
return showGame(log, args[1], opts.configDir)
311+
case "doctor":
312+
if len(args) < 2 {
313+
fmt.Fprintf(os.Stderr, "games doctor requires a game ID\n")
314+
return 2
315+
}
316+
return doctorGame(log, args[1], opts.configDir)
317+
case "repair":
318+
if len(args) < 2 {
319+
fmt.Fprintf(os.Stderr, "games repair requires a game ID\n")
320+
return 2
321+
}
322+
return repairGame(log, args[1], opts.configDir)
308323
default:
309324
fmt.Fprintf(os.Stderr, "unknown games action: %s\n", action)
310325
return 2
@@ -370,18 +385,21 @@ func addGame(log util.Logger, gameID string, configDir string) int {
370385
game := config.GameConfig{
371386
ID: gameID,
372387
Name: promptString("Game Name", gameID),
373-
LaunchMode: promptChoice("Launch Mode", "DirectPath", []string{"DirectPath", "SteamAppId", "EpicAppId", "CustomCommand"}),
388+
LaunchMode: promptChoice("Launch Mode", "DirectPath", []string{"DirectPath", "SteamManaged", "SteamAppId", "EpicAppId", "CustomCommand"}),
374389
}
375390

376391
// Enhance target prompt for DirectPath mode with platform-specific help
377392
var targetPrompt string
378-
if game.LaunchMode == "DirectPath" {
393+
switch game.LaunchMode {
394+
case "DirectPath":
379395
if runtime.GOOS == "darwin" {
380396
targetPrompt = "Target (executable path or .app bundle)"
381397
} else {
382398
targetPrompt = "Target (executable path)"
383399
}
384-
} else {
400+
case "SteamManaged", "SteamAppId":
401+
targetPrompt = "Target (Steam App ID)"
402+
default:
385403
targetPrompt = "Target (path/id)"
386404
}
387405

@@ -395,7 +413,7 @@ func addGame(log util.Logger, gameID string, configDir string) int {
395413
}
396414
}
397415

398-
if game.LaunchMode == "DirectPath" || game.LaunchMode == "CustomCommand" {
416+
if game.LaunchMode == "DirectPath" || game.LaunchMode == "SteamManaged" || game.LaunchMode == "CustomCommand" {
399417
workingDir := promptString("Working Directory (optional)", "")
400418
if workingDir != "" {
401419
game.WorkingDir = workingDir
@@ -486,13 +504,133 @@ func showGame(log util.Logger, gameID string, configDir string) int {
486504
if game.StopProcessName != "" {
487505
fmt.Printf(" Stop Process Name: %s\n", game.StopProcessName)
488506
}
507+
if game.GABPMode != "" {
508+
fmt.Printf(" GABP Mode: %s\n", game.GABPMode)
509+
}
489510
if game.Description != "" {
490511
fmt.Printf(" Description: %s\n", game.Description)
491512
}
492513

493514
return 0
494515
}
495516

517+
func doctorGame(log util.Logger, gameID string, configDir string) int {
518+
gamesConfig, err := config.LoadGamesConfigFromDir(configDir)
519+
if err != nil {
520+
log.Errorw("failed to load games config", "error", err)
521+
return 1
522+
}
523+
524+
game, exists := gamesConfig.GetGame(gameID)
525+
if !exists {
526+
fmt.Printf("Game '%s' not found.\n", gameID)
527+
return 1
528+
}
529+
530+
fmt.Printf("Game: %s\n", game.ID)
531+
fmt.Printf("Launch Mode: %s\n", game.LaunchMode)
532+
if game.Target != "" {
533+
fmt.Printf("Target: %s\n", game.Target)
534+
}
535+
536+
if err := game.Validate(); err != nil {
537+
fmt.Printf("Configuration: invalid (%v)\n", err)
538+
} else {
539+
fmt.Println("Configuration: valid")
540+
}
541+
542+
switch game.LaunchMode {
543+
case "SteamAppId":
544+
fmt.Println("Steam launch: launcher URL mode")
545+
fmt.Println("Bridge environment: not guaranteed on the real game process")
546+
app, err := steam.ResolveApp(game.Target)
547+
if err != nil {
548+
fmt.Printf("Managed Steam readiness: failed (%v)\n", err)
549+
return 1
550+
}
551+
printSteamAppResolution(app)
552+
fmt.Printf("Recommended repair: gabs games repair %s\n", game.ID)
553+
case "SteamManaged":
554+
fmt.Println("Steam launch: managed executable mode")
555+
app, err := steam.ResolveApp(game.Target)
556+
if err != nil {
557+
fmt.Printf("Managed Steam readiness: failed (%v)\n", err)
558+
return 1
559+
}
560+
printSteamAppResolution(app)
561+
ok, content, err := steam.CheckAppIDFile(app)
562+
if err != nil {
563+
fmt.Printf("Steam app id file: unreadable (%v)\n", err)
564+
return 1
565+
}
566+
if ok {
567+
fmt.Printf("Steam app id file: ready (%s)\n", app.AppIDFilePath)
568+
} else if content == "" {
569+
fmt.Printf("Steam app id file: missing (%s)\n", app.AppIDFilePath)
570+
fmt.Printf("Recommended repair: gabs games repair %s\n", game.ID)
571+
} else {
572+
fmt.Printf("Steam app id file: wrong id %q at %s\n", content, app.AppIDFilePath)
573+
return 1
574+
}
575+
default:
576+
if game.Target != "" {
577+
if _, err := os.Stat(game.Target); err != nil {
578+
fmt.Printf("Target path: not found (%v)\n", err)
579+
return 1
580+
}
581+
fmt.Println("Target path: found")
582+
}
583+
}
584+
585+
return 0
586+
}
587+
588+
func repairGame(log util.Logger, gameID string, configDir string) int {
589+
gamesConfig, err := config.LoadGamesConfigFromDir(configDir)
590+
if err != nil {
591+
log.Errorw("failed to load games config", "error", err)
592+
return 1
593+
}
594+
595+
game, exists := gamesConfig.GetGame(gameID)
596+
if !exists {
597+
fmt.Printf("Game '%s' not found.\n", gameID)
598+
return 1
599+
}
600+
601+
switch game.LaunchMode {
602+
case "SteamAppId", "SteamManaged":
603+
app, err := steam.ResolveApp(game.Target)
604+
if err != nil {
605+
fmt.Printf("Steam repair failed: %v\n", err)
606+
return 1
607+
}
608+
if err := steam.EnsureAppIDFile(app); err != nil {
609+
fmt.Printf("Steam app id repair failed: %v\n", err)
610+
return 1
611+
}
612+
if game.LaunchMode == "SteamAppId" {
613+
game.LaunchMode = "SteamManaged"
614+
gamesConfig.Games[game.ID] = *game
615+
if err := backupGamesConfig(configDir); err != nil {
616+
fmt.Printf("Failed to back up config: %v\n", err)
617+
return 1
618+
}
619+
if err := config.SaveGamesConfigToDir(gamesConfig, configDir); err != nil {
620+
log.Errorw("failed to save games config", "error", err)
621+
return 1
622+
}
623+
fmt.Printf("Updated '%s' from SteamAppId to SteamManaged.\n", game.ID)
624+
}
625+
fmt.Printf("Steam app id file ready: %s\n", app.AppIDFilePath)
626+
printSteamAppResolution(app)
627+
return 0
628+
default:
629+
fmt.Printf("No automatic repair available for launch mode %s.\n", game.LaunchMode)
630+
return 0
631+
}
632+
}
633+
496634
// === Helper Functions ===
497635

498636
func showGamesUsage() {
@@ -501,15 +639,44 @@ func showGamesUsage() {
501639
gabs games add <id> Add a new game configuration (interactive)
502640
gabs games remove <id> Remove a game configuration
503641
gabs games show <id> Show details for a game
642+
gabs games doctor <id> Diagnose one game configuration
643+
gabs games repair <id> Apply safe repairs for one game configuration
504644
505645
Examples:
506646
gabs games list # See game IDs only (AI-friendly)
507647
gabs games add factory # Add a new game called 'factory'
508648
gabs games show factory # View configuration for 'factory'
649+
gabs games doctor factory # Diagnose launch configuration
650+
gabs games repair factory # Apply safe launch repairs
509651
gabs games remove factory # Remove the 'factory' configuration
510652
`)
511653
}
512654

655+
func printSteamAppResolution(app steam.App) {
656+
fmt.Printf("Steam app: %s", app.AppID)
657+
if app.Name != "" {
658+
fmt.Printf(" (%s)", app.Name)
659+
}
660+
fmt.Println()
661+
fmt.Printf("Install path: %s\n", app.InstallPath)
662+
fmt.Printf("Executable: %s\n", app.Executable)
663+
fmt.Printf("Working directory: %s\n", app.WorkingDir)
664+
}
665+
666+
func backupGamesConfig(configDir string) error {
667+
cp, err := config.NewConfigPaths(configDir)
668+
if err != nil {
669+
return err
670+
}
671+
configPath := cp.GetMainConfigPath()
672+
data, err := os.ReadFile(configPath)
673+
if err != nil {
674+
return err
675+
}
676+
backupPath := fmt.Sprintf("%s.bak.%s", configPath, time.Now().Format("20060102-150405"))
677+
return os.WriteFile(backupPath, data, 0644)
678+
}
679+
513680
// isInteractive checks if the program is running in an interactive terminal
514681
func isInteractive() bool {
515682
// Check if stdin is a terminal

docs/AI_CLIENT_SETUP.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ Examples of `stopProcessName`:
9191
- AdventureGame on macOS/Linux: `AdventureGame`
9292
- Java-based FactorySim setups: `java`
9393

94-
For Steam and Epic launch modes, `stopProcessName` is required. Without it,
95-
GABS can launch the game but cannot stop the real game process reliably.
94+
For Steam bridge games, prefer `SteamManaged`; it resolves the Steam app
95+
manifest to the installed executable and starts Steam if needed. For launcher
96+
URL modes such as `SteamAppId` and `EpicAppId`, `stopProcessName` is required.
97+
Without it, GABS can launch the game but cannot stop the real game process
98+
reliably.
9699

97100
## What Success Looks Like
98101

0 commit comments

Comments
 (0)