Skip to main content

agent_first_http/cli/cmd/
profile.rs

1//! `afhttp profile` subcommand. Local profile lifecycle.
2
3use std::path::PathBuf;
4
5use clap::{Args as ClapArgs, Subcommand};
6
7use crate::cli::output;
8use crate::sdk::profile;
9use crate::shared::error::Error;
10use crate::shared::time::parse_duration;
11
12#[derive(ClapArgs, Debug)]
13pub struct Args {
14    #[command(subcommand)]
15    pub sub: ProfileSub,
16}
17
18#[derive(Subcommand, Debug)]
19#[command(disable_help_subcommand = true)]
20pub enum ProfileSub {
21    /// List on-disk profiles under the profiles root.
22    List(ListArgs),
23    /// Show metadata for one profile (size, last use, lock state).
24    Info(InfoArgs),
25    /// Report whether a profile is currently locked by a running host.
26    LockStatus(InfoArgs),
27    /// List files captured in the profile's browser download directory.
28    Downloads(InfoArgs),
29    /// Delete a profile and all of its on-disk state.
30    Delete(DeleteArgs),
31    /// Delete profiles whose last use is older than a cutoff.
32    Prune(PruneArgs),
33    /// Show the non-expired cookies in a profile's jar (values redacted).
34    Cookies(InfoArgs),
35}
36
37#[derive(ClapArgs, Debug)]
38pub struct ListArgs {
39    /// Profiles root directory. Defaults to `$XDG_DATA_HOME/afhttp/profiles`.
40    #[arg(long)]
41    pub profile_root: Option<PathBuf>,
42}
43
44#[derive(ClapArgs, Debug)]
45pub struct InfoArgs {
46    /// Profile name.
47    pub name: String,
48    /// Filter by browser backend.
49    ///
50    /// For example: chromium, brave, or camoufox.
51    /// Required when the same profile name exists under multiple backends.
52    #[arg(long)]
53    pub backend: Option<String>,
54    /// Profiles root directory. Defaults to `$XDG_DATA_HOME/afhttp/profiles`.
55    #[arg(long)]
56    pub profile_root: Option<PathBuf>,
57}
58
59#[derive(ClapArgs, Debug)]
60pub struct DeleteArgs {
61    /// Profile name to delete.
62    pub name: String,
63    /// Filter by browser backend.
64    ///
65    /// For example: chromium, brave, or camoufox.
66    /// Required when the same profile name exists under multiple backends.
67    #[arg(long)]
68    pub backend: Option<String>,
69    /// Confirmation guard: must equal the profile name for the delete to proceed.
70    #[arg(long)]
71    pub confirm: String,
72    /// Profiles root directory. Defaults to `$XDG_DATA_HOME/afhttp/profiles`.
73    #[arg(long)]
74    pub profile_root: Option<PathBuf>,
75}
76
77#[derive(ClapArgs, Debug)]
78pub struct PruneArgs {
79    /// Age cutoff (e.g. `30d`, `12h`); profiles last used before this are removed.
80    #[arg(long)]
81    pub older_than: String,
82    /// Report what would be deleted without deleting anything.
83    #[arg(long, default_value_t = false)]
84    pub dry_run: bool,
85    /// Profiles root directory. Defaults to `$XDG_DATA_HOME/afhttp/profiles`.
86    #[arg(long)]
87    pub profile_root: Option<PathBuf>,
88}
89
90pub async fn run(args: Args) -> Result<(), Error> {
91    match args.sub {
92        ProfileSub::List(a) => {
93            let root = profile_root_for_output(a.profile_root.as_deref());
94            let entries = profile::list(a.profile_root.as_deref())?;
95            output::emit(
96                "profile_list",
97                &serde_json::json!({
98                    "profile_root": root.display().to_string(),
99                    "profiles": entries,
100                }),
101            )
102        }
103        ProfileSub::Info(a) => {
104            let entry = profile::info(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
105            output::emit("profile_info", &entry)
106        }
107        ProfileSub::LockStatus(a) => {
108            let status =
109                profile::lock_status(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
110            output::emit("profile_lock_status", &status)
111        }
112        ProfileSub::Downloads(a) => {
113            let entry = profile::info(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
114            let download_dir = entry.path.join("downloads");
115            let download_dir = download_dir.canonicalize().unwrap_or(download_dir);
116            let downloads =
117                profile::downloads(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
118            output::emit(
119                "profile_downloads",
120                &serde_json::json!({
121                    "backend": entry.backend,
122                    "name": a.name,
123                    "download_dir": download_dir.display().to_string(),
124                    "downloads": downloads,
125                }),
126            )
127        }
128        ProfileSub::Delete(a) => {
129            let entry = profile::info(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
130            profile::delete(
131                &a.name,
132                &a.confirm,
133                a.backend.as_deref(),
134                a.profile_root.as_deref(),
135            )?;
136            output::emit(
137                "profile_delete",
138                &serde_json::json!({"backend": entry.backend, "name": a.name, "deleted": true}),
139            )
140        }
141        ProfileSub::Prune(a) => {
142            let root = profile_root_for_output(a.profile_root.as_deref());
143            let older_than = parse_duration(&a.older_than)?;
144            let removed = profile::prune(older_than, a.dry_run, a.profile_root.as_deref())?;
145            output::emit(
146                "profile_prune",
147                &serde_json::json!({
148                    "profile_root": root.display().to_string(),
149                    "dry_run": a.dry_run,
150                    "profiles": removed,
151                }),
152            )
153        }
154        ProfileSub::Cookies(a) => {
155            let entry = profile::info(&a.name, a.backend.as_deref(), a.profile_root.as_deref())?;
156            let jar_path = entry.path.join("cookies.jar.json");
157            let jar = crate::sdk::profile::cookie_jar::CookieJar::load(&jar_path)?;
158            let cookies = jar.cookies_redacted();
159            output::emit(
160                "profile_cookies",
161                &serde_json::json!({
162                    "backend": entry.backend,
163                    "name": a.name,
164                    "jar_path": jar_path.display().to_string(),
165                    "count": cookies.len(),
166                    "cookies": cookies,
167                }),
168            )
169        }
170    }
171}
172
173fn profile_root_for_output(root: Option<&std::path::Path>) -> PathBuf {
174    root.map(PathBuf::from)
175        .unwrap_or_else(profile::paths::default_root)
176}