Skip to main content

steamroom_cli/
cli.rs

1use clap::Parser;
2use clap::Subcommand;
3use clap::ValueEnum;
4
5#[derive(Parser, Debug)]
6#[command(
7    name = "steamroom",
8    about = "Steam depot downloader",
9    after_help = "Set DD_COMPAT=1 for flat-argument compatibility with the original DepotDownloader."
10)]
11pub struct Cli {
12    #[command(subcommand)]
13    pub command: Command,
14
15    #[command(flatten)]
16    pub auth: AuthOptions,
17
18    /// Enable debug logging
19    #[arg(long, global = true)]
20    pub debug: bool,
21
22    /// Show full error chains on failure
23    #[arg(long, global = true)]
24    pub raw_errors: bool,
25
26    /// Steam CDN cell ID to prefer
27    #[arg(long, global = true)]
28    pub cell_id: Option<u32>,
29
30    /// Capture network traffic to a file for replay
31    #[arg(long, global = true)]
32    pub capture: Option<std::path::PathBuf>,
33
34    /// Disable progress bars
35    #[arg(long, global = true)]
36    pub no_progress: bool,
37
38    /// Suppress all output except errors
39    #[arg(short, long, global = true)]
40    pub quiet: bool,
41
42    /// Never prompt; fail loudly if interactive auth is required. Also
43    /// implied automatically when stdin is not a TTY.
44    #[arg(long, env = "STEAMROOM_NON_INTERACTIVE", global = true)]
45    pub non_interactive: bool,
46
47    /// Send this command to the running daemon instead of executing
48    /// directly. Pair with any subcommand. Start a daemon with
49    /// `steamroom daemon start`.
50    #[arg(long = "use-daemon", global = true)]
51    pub use_daemon: bool,
52
53    /// Resume daemon execution after fork+exec. Set by the parent
54    /// during `daemon start`; not intended to be passed by the user.
55    #[arg(long, hide = true)]
56    pub daemon_resume: Option<String>,
57
58    /// Push this request to the front of the daemon queue.
59    /// Only valid with --use-daemon.
60    #[arg(long, global = true)]
61    pub priority: bool,
62
63    /// Return immediately after the daemon accepts the job; do not
64    /// stream progress. Only valid with --use-daemon.
65    #[arg(long, global = true)]
66    pub detach: bool,
67}
68
69/// Legacy flat-argument CLI compatible with the original DepotDownloader.
70/// Activated with DD_COMPAT=1 environment variable.
71///
72/// DepotDownloader uses single-dash flags (`-app`, `-depot`, etc.).
73/// The arg preprocessor in main() converts these to double-dash before parsing.
74#[derive(Parser, Debug)]
75#[command(name = "steamroom", about = "Steam depot downloader (DD_COMPAT mode)")]
76pub struct CompatCli {
77    #[arg(long = "app")]
78    pub app_id: Option<u32>,
79    #[arg(long = "depot")]
80    pub depot_id: Option<u32>,
81    #[arg(long = "manifest")]
82    pub manifest_id: Option<u64>,
83    #[arg(long = "username")]
84    pub username: Option<String>,
85    #[arg(long = "password")]
86    pub password: Option<String>,
87    #[arg(long = "dir")]
88    pub output: Option<std::path::PathBuf>,
89    #[arg(long = "branch")]
90    pub branch: Option<String>,
91    #[arg(long = "betapassword")]
92    pub beta_password: Option<String>,
93    #[arg(long)]
94    pub qr: bool,
95    #[arg(long = "remember-password")]
96    pub remember_password: bool,
97    #[arg(long = "filelist")]
98    pub filelist: Option<std::path::PathBuf>,
99    #[arg(long = "regex")]
100    pub file_regex: Option<String>,
101    #[arg(long = "validate")]
102    pub verify: bool,
103    #[arg(long)]
104    pub os: Option<String>,
105    #[arg(long)]
106    pub arch: Option<String>,
107    #[arg(long)]
108    pub language: Option<String>,
109    #[arg(long = "max-downloads")]
110    pub max_downloads: Option<usize>,
111    #[arg(long = "cellid")]
112    pub cell_id: Option<u32>,
113    #[arg(long)]
114    pub debug: bool,
115    #[arg(long = "device-name", env = "DD_DEVICE_NAME")]
116    pub device_name: Option<String>,
117}
118
119impl CompatCli {
120    pub fn into_cli(self) -> Cli {
121        let app = self.app_id.unwrap_or(0);
122        Cli {
123            command: Command::Download(DownloadArgs {
124                app,
125                depot: self.depot_id,
126                manifest: self.manifest_id,
127                filelist: self.filelist,
128                file_regex: self.file_regex,
129                output: self.output,
130                verify: self.verify,
131                os: self.os,
132                arch: self.arch,
133                language: self.language,
134                login_id: None,
135                all_platforms: false,
136                all_architectures: false,
137                all_languages: false,
138                lancache: false,
139                max_downloads: self.max_downloads,
140                branch: self.branch,
141                branch_password: self.beta_password,
142                local_keys: false,
143                non_atomic: false,
144                save_manifests: false,
145                bytes: false,
146            }),
147            auth: AuthOptions {
148                username: self.username,
149                password: self.password,
150                qr: self.qr,
151                use_steam_token: false,
152                remember_password: self.remember_password,
153                device_name: self.device_name,
154            },
155            debug: self.debug,
156            raw_errors: false,
157            cell_id: self.cell_id,
158            capture: None,
159            no_progress: false,
160            quiet: false,
161            non_interactive: false,
162            use_daemon: false,
163            daemon_resume: None,
164            priority: false,
165            detach: false,
166        }
167    }
168}
169
170#[derive(Parser, Debug)]
171pub struct AuthOptions {
172    /// Steam username (or set STEAM_USER)
173    #[arg(short, long, env = "STEAM_USER", global = true)]
174    pub username: Option<String>,
175
176    /// Steam password (or set STEAM_PASS)
177    #[arg(short, long, env = "STEAM_PASS", global = true)]
178    pub password: Option<String>,
179
180    /// Login via QR code (scan with Steam mobile app)
181    #[arg(long, global = true)]
182    pub qr: bool,
183
184    /// Use cached token from local Steam installation
185    #[arg(long, global = true)]
186    pub use_steam_token: bool,
187
188    /// Save login token for future use
189    #[arg(long, global = true)]
190    pub remember_password: bool,
191
192    /// Device name for Steam Guard (or set DD_DEVICE_NAME)
193    #[arg(long, env = "DD_DEVICE_NAME", global = true)]
194    pub device_name: Option<String>,
195}
196
197#[derive(Subcommand, Debug)]
198pub enum Command {
199    /// Daemon control: start a background daemon, then stop, observe,
200    /// or attach to it.
201    Daemon(DaemonArgs),
202    /// Compare two manifests and show added, removed, and changed files
203    Diff(DiffArgs),
204    /// Download depot content to a local directory
205    Download(DownloadArgs),
206    /// List files in a depot manifest
207    Files(FilesArgs),
208    /// Show app metadata: name, type, depots, branches
209    Info(InfoArgs),
210    /// Show locally cached depot keys and beta branches from Steam's config.vdf
211    LocalInfo(LocalInfoArgs),
212    /// List depot manifest IDs for a branch
213    Manifests(ManifestsArgs),
214    /// Query Steam package (sub) details by ID
215    Packages(PackagesArgs),
216    /// Download and save a depot manifest without downloading content
217    SaveManifest(SaveManifestArgs),
218    /// Download a Steam Workshop item
219    Workshop(WorkshopArgs),
220}
221
222#[derive(Parser, Debug)]
223pub struct DaemonArgs {
224    #[command(subcommand)]
225    pub command: DaemonSub,
226}
227
228#[derive(Subcommand, Debug)]
229pub enum DaemonSub {
230    /// Authenticate (interactively if needed), fork into the background,
231    /// and serve RPC. Auth flags (`--username`, `--qr`, etc.) go on the
232    /// top-level command, e.g. `steamroom --username foo daemon start`.
233    Start,
234    /// Stop the running daemon.
235    Stop {
236        /// Cancel the active job immediately instead of waiting for it.
237        #[arg(long)]
238        force: bool,
239    },
240    /// Print queue, active job, and recent history. Default: TUI dashboard.
241    Status {
242        /// Print a one-shot text snapshot to stdout instead of opening
243        /// the TUI.
244        #[arg(long)]
245        text: bool,
246        /// Output format. Anything other than the default implies
247        /// `--text` (JSON in a TUI is not meaningful).
248        #[arg(long, value_enum)]
249        format: Option<OutputFormat>,
250    },
251    /// Print the daemon's PID, socket path, and stop command. Does not
252    /// contact the daemon, so it works even when the daemon is wedged.
253    Info,
254    /// Reconnect to an in-flight or recently-finished job by its ID and
255    /// stream its events to this terminal. Use after `--use-daemon
256    /// --detach <job>` to come back and watch progress, or to resume an
257    /// attach session you exited with Ctrl-C. If the job has finished,
258    /// `attach` writes its exit code from the daemon's recent-jobs ring
259    /// and exits. If the ID is unknown, errors with `JobNotFound`.
260    Attach {
261        /// The job ID printed by `--use-daemon --detach` or shown in
262        /// `daemon status`.
263        job_id: u64,
264    },
265}
266
267#[derive(Parser, Debug)]
268pub struct DownloadArgs {
269    /// Steam app ID
270    #[arg(long)]
271    pub app: u32,
272    /// Depot ID (auto-detected if omitted)
273    #[arg(long)]
274    pub depot: Option<u32>,
275    /// Manifest ID (uses latest for branch if omitted)
276    #[arg(long)]
277    pub manifest: Option<u64>,
278    /// File containing paths to download (one per line, prefix with regex: for patterns)
279    #[arg(long)]
280    pub filelist: Option<std::path::PathBuf>,
281    /// Regex pattern to filter files
282    #[arg(long)]
283    pub file_regex: Option<String>,
284    /// Output directory
285    #[arg(long, short)]
286    pub output: Option<std::path::PathBuf>,
287    /// Skip files that already match the manifest
288    #[arg(long)]
289    pub verify: bool,
290    /// Filter depots by OS (e.g. windows, linux, macos)
291    #[arg(long)]
292    pub os: Option<String>,
293    /// Filter depots by architecture (e.g. 32, 64)
294    #[arg(long)]
295    pub arch: Option<String>,
296    /// Filter depots by language
297    #[arg(long)]
298    pub language: Option<String>,
299    /// Login ID for concurrent sessions
300    #[arg(long)]
301    pub login_id: Option<u32>,
302    /// Download all platform depots
303    #[arg(long)]
304    pub all_platforms: bool,
305    /// Download all architecture depots
306    #[arg(long)]
307    pub all_architectures: bool,
308    /// Download all language depots
309    #[arg(long)]
310    pub all_languages: bool,
311    /// Use lancache-compatible CDN requests
312    #[arg(long)]
313    pub lancache: bool,
314    /// Maximum concurrent chunk downloads
315    #[arg(long)]
316    pub max_downloads: Option<usize>,
317    /// Branch to download (default: public)
318    #[arg(long)]
319    pub branch: Option<String>,
320    /// Password for beta branch access
321    #[arg(long)]
322    pub branch_password: Option<String>,
323    /// Use depot decryption keys from Steam's local config.vdf instead of requesting from server
324    #[arg(long)]
325    pub local_keys: bool,
326    /// Write chunks directly to target files instead of staging + rename
327    #[arg(long)]
328    pub non_atomic: bool,
329    /// Save raw and decompressed manifests alongside downloaded files
330    #[arg(long)]
331    pub save_manifests: bool,
332    /// Show file sizes in raw bytes
333    #[arg(long)]
334    pub bytes: bool,
335}
336
337#[derive(Parser, Debug)]
338pub struct FilesArgs {
339    /// Steam app ID (not needed with --manifest-file)
340    #[arg(long)]
341    pub app: Option<u32>,
342    /// Depot ID (auto-detected if omitted)
343    #[arg(long)]
344    pub depot: Option<u32>,
345    /// Manifest ID (uses latest for branch if omitted)
346    #[arg(long)]
347    pub manifest: Option<u64>,
348    /// Read from a local manifest file instead of fetching from CDN
349    #[arg(long, value_name = "PATH")]
350    pub manifest_file: Option<std::path::PathBuf>,
351    /// Depot key for filename decryption (hex). Auto-detected from depot.json if available
352    #[arg(long, value_name = "HEX")]
353    pub depot_key: Option<String>,
354    /// Branch to list files for (default: public)
355    #[arg(long)]
356    pub branch: Option<String>,
357    /// Password for beta branch access
358    #[arg(long)]
359    pub branch_password: Option<String>,
360    /// Filter depots by OS
361    #[arg(long)]
362    pub os: Option<String>,
363    /// Output format
364    #[arg(long, value_enum)]
365    pub format: Option<OutputFormat>,
366    /// Show raw encrypted filenames
367    #[arg(long)]
368    pub raw: bool,
369    /// Show file sizes in raw bytes
370    #[arg(long)]
371    pub bytes: bool,
372}
373
374#[derive(Parser, Debug)]
375pub struct LocalInfoArgs {
376    /// Output format
377    #[arg(long, value_enum)]
378    pub format: Option<OutputFormat>,
379    /// Show info for a specific Steam user
380    #[arg(long)]
381    pub user: Option<String>,
382    /// List all local Steam users
383    #[arg(long)]
384    pub users: bool,
385}
386
387#[derive(Parser, Debug)]
388pub struct SaveManifestArgs {
389    /// Steam app ID
390    #[arg(long)]
391    pub app: u32,
392    /// Depot ID
393    #[arg(long)]
394    pub depot: u32,
395    /// Manifest ID (uses latest for branch if omitted)
396    #[arg(long)]
397    pub manifest: Option<u64>,
398    /// Branch (default: public)
399    #[arg(long)]
400    pub branch: Option<String>,
401    /// Output directory for saved manifests
402    #[arg(long, short)]
403    pub output: std::path::PathBuf,
404}
405
406#[derive(Parser, Debug)]
407pub struct InfoArgs {
408    /// Steam app ID
409    #[arg(long)]
410    pub app: u32,
411    /// Output format
412    #[arg(long, value_enum)]
413    pub format: Option<OutputFormat>,
414    /// Filter depots by OS (e.g. windows, linux, macos)
415    #[arg(long)]
416    pub os: Option<String>,
417    /// Show redistributable depots
418    #[arg(long)]
419    pub show_all: bool,
420}
421
422#[derive(Parser, Debug)]
423pub struct ManifestsArgs {
424    /// Steam app ID
425    #[arg(long)]
426    pub app: u32,
427    /// Branch to list manifests for (default: public)
428    #[arg(long)]
429    pub branch: Option<String>,
430    /// Password for beta branch access
431    #[arg(long)]
432    pub branch_password: Option<String>,
433    /// Output format
434    #[arg(long, value_enum)]
435    pub format: Option<OutputFormat>,
436}
437
438#[derive(Parser, Debug)]
439pub struct WorkshopArgs {
440    /// Steam app ID
441    #[arg(long)]
442    pub app: u32,
443    /// Workshop item ID
444    #[arg(long)]
445    pub item: u64,
446    /// Output directory
447    #[arg(long, short)]
448    pub output: Option<std::path::PathBuf>,
449}
450
451#[derive(Parser, Debug)]
452pub struct DiffArgs {
453    /// Steam app ID
454    #[arg(long)]
455    pub app: u32,
456    /// Depot ID
457    #[arg(long)]
458    pub depot: u32,
459    /// Old manifest ID
460    #[arg(long)]
461    pub from: u64,
462    /// New manifest ID
463    #[arg(long)]
464    pub to: u64,
465    /// Branch (used for manifest request codes)
466    #[arg(long)]
467    pub branch: Option<String>,
468    /// Output format
469    #[arg(long, value_enum)]
470    pub format: Option<OutputFormat>,
471}
472
473#[derive(Parser, Debug)]
474pub struct PackagesArgs {
475    /// Package (sub) IDs to query
476    #[arg(value_name = "PACKAGE", required = true, num_args = 1..)]
477    pub packages: Vec<u32>,
478    /// Output format
479    #[arg(long, value_enum)]
480    pub format: Option<OutputFormat>,
481}
482
483#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
484pub enum OutputFormat {
485    Table,
486    Json,
487    Plain,
488}
489
490use crate::daemon::proto::Request;
491use crate::errors::CliError;
492
493impl Cli {
494    /// Lower a parsed `Cli` into the wire-typed `Request`. Flags that
495    /// the daemon cannot honor (auth flags, `--capture`) are warned
496    /// about on stderr and ignored; only structural mistakes (a daemon
497    /// control subcommand) are returned as errors.
498    pub fn into_rpc_request(self) -> Result<Request, CliError> {
499        let auth = &self.auth;
500        let has_auth = auth.username.is_some()
501            || auth.password.is_some()
502            || auth.qr
503            || auth.use_steam_token
504            || auth.remember_password
505            || auth.device_name.is_some();
506        if has_auth {
507            eprintln!(
508                "warning: auth flags are ignored under --use-daemon; \
509                 the daemon serves the account it was started with"
510            );
511        }
512        if self.capture.is_some() {
513            eprintln!("warning: --capture is ignored under --use-daemon");
514        }
515
516        let priority = self.priority;
517        match self.command {
518            Command::Download(a) => Ok(Request::Download {
519                args: crate::daemon::proto::DownloadParams::from(a),
520                priority,
521            }),
522            Command::Info(a) => Ok(Request::Info {
523                args: crate::daemon::proto::InfoParams::from(a),
524                priority,
525            }),
526            Command::Files(a) => Ok(Request::Files {
527                args: crate::daemon::proto::FilesParams::from(a),
528                priority,
529            }),
530            Command::Manifests(a) => Ok(Request::Manifests {
531                args: crate::daemon::proto::ManifestsParams::from(a),
532                priority,
533            }),
534            Command::Diff(a) => Ok(Request::Diff {
535                args: crate::daemon::proto::DiffParams::from(a),
536                priority,
537            }),
538            Command::Packages(a) => Ok(Request::Packages {
539                args: crate::daemon::proto::PackagesParams::from(a),
540                priority,
541            }),
542            Command::SaveManifest(a) => Ok(Request::SaveManifest {
543                args: crate::daemon::proto::SaveManifestParams::from(a),
544                priority,
545            }),
546            Command::Workshop(a) => Ok(Request::Workshop {
547                args: crate::daemon::proto::WorkshopParams::from(a),
548                priority,
549            }),
550            Command::LocalInfo(a) => Ok(Request::LocalInfo {
551                args: crate::daemon::proto::LocalInfoParams::from(a),
552                priority,
553            }),
554            Command::Daemon(_) => Err(CliError::DaemonRejectedFlag("daemon subcommand")),
555        }
556    }
557
558    /// Belt-and-suspenders flag validation that clap can't express.
559    pub fn validate(&self) -> Result<(), CliError> {
560        if self.priority && !self.use_daemon {
561            return Err(CliError::PriorityWithoutDaemon);
562        }
563        if self.detach && !self.use_daemon {
564            return Err(CliError::DetachWithoutDaemon);
565        }
566        // `daemon start` and `--use-daemon` together are nonsensical:
567        // start launches a daemon, --use-daemon talks to one.
568        if self.use_daemon
569            && matches!(
570                self.command,
571                Command::Daemon(DaemonArgs {
572                    command: DaemonSub::Start
573                })
574            )
575        {
576            return Err(CliError::DaemonModeConflict);
577        }
578        Ok(())
579    }
580}