Skip to main content

skiff_cli/cli/
bake.rs

1//! `skiff bake` subcommand handlers.
2
3use std::ffi::OsString;
4use std::path::PathBuf;
5
6use clap::{Parser, Subcommand};
7
8use crate::bake::{
9    create_baked, default_install_dir, install_wrapper, load_baked_all, parse_auth_header_raw,
10    parse_env_raw, remove_baked, require_baked, split_csv_list, split_methods, update_baked,
11    BakedTool,
12};
13use crate::error::{Error, Result};
14use crate::paths::DEFAULT_CACHE_TTL;
15
16#[derive(Debug, Parser)]
17#[command(
18    name = "skiff bake",
19    about = "Manage saved connection settings",
20    disable_help_subcommand = true
21)]
22struct BakeCli {
23    #[command(subcommand)]
24    command: Option<BakeCommand>,
25}
26
27#[derive(Debug, Subcommand)]
28#[allow(clippy::large_enum_variant)]
29enum BakeCommand {
30    /// Save connection settings as a named baked tool
31    Create(CreateArgs),
32    /// List all baked tools
33    List,
34    /// Show config for a baked tool (secrets masked)
35    Show { name: String },
36    /// Delete a baked tool
37    Remove { name: String },
38    /// Update settings on an existing baked tool
39    Update(UpdateArgs),
40    /// Create a ~/.local/bin wrapper script
41    Install(InstallArgs),
42}
43
44#[derive(Debug, Parser)]
45struct CreateArgs {
46    /// Name for the baked tool
47    name: String,
48    #[arg(long)]
49    spec: Option<String>,
50    #[arg(long)]
51    mcp: Option<String>,
52    #[arg(long)]
53    mcp_stdio: Option<String>,
54    #[arg(long)]
55    graphql: Option<String>,
56    #[arg(long)]
57    base_url: Option<String>,
58    #[arg(long = "auth-header", value_name = "Name:Value")]
59    auth_header: Vec<String>,
60    #[arg(long = "env", value_name = "KEY=VALUE")]
61    env: Vec<String>,
62    #[arg(long, default_value_t = DEFAULT_CACHE_TTL)]
63    cache_ttl: u64,
64    #[arg(long, default_value = "auto", value_parser = ["auto", "sse", "streamable"])]
65    transport: String,
66    #[arg(long)]
67    oauth: bool,
68    #[arg(long)]
69    oauth_client_id: Option<String>,
70    #[arg(long)]
71    oauth_client_secret: Option<String>,
72    #[arg(long, default_value = "skiff")]
73    oauth_client_name: String,
74    #[arg(long)]
75    oauth_scope: Option<String>,
76    #[arg(long)]
77    oauth_redirect_uri: Option<String>,
78    #[arg(long, default_value = "auto", value_parser = ["auto", "authorization_code", "client_credentials"])]
79    oauth_flow: String,
80    /// Prefer this named session when using @name
81    #[arg(long)]
82    session: Option<String>,
83    #[arg(long, default_value = "")]
84    include: String,
85    #[arg(long, default_value = "")]
86    exclude: String,
87    #[arg(long, default_value = "")]
88    methods: String,
89    #[arg(long, default_value = "")]
90    description: String,
91    /// Overwrite existing
92    #[arg(long)]
93    force: bool,
94}
95
96#[derive(Debug, Parser)]
97struct UpdateArgs {
98    name: String,
99    #[arg(long)]
100    cache_ttl: Option<u64>,
101    #[arg(long)]
102    include: Option<String>,
103    #[arg(long)]
104    exclude: Option<String>,
105    #[arg(long)]
106    methods: Option<String>,
107    #[arg(long)]
108    description: Option<String>,
109    #[arg(long)]
110    base_url: Option<String>,
111    #[arg(long, value_parser = ["auto", "sse", "streamable"])]
112    transport: Option<String>,
113}
114
115#[derive(Debug, Parser)]
116struct InstallArgs {
117    name: String,
118    /// Directory to install wrapper into (default: ~/.local/bin)
119    #[arg(long)]
120    dir: Option<PathBuf>,
121}
122
123pub fn handle_bake(argv: &[OsString]) -> Result<()> {
124    let mut clap_argv = vec![OsString::from("skiff bake")];
125    clap_argv.extend_from_slice(argv);
126
127    let cli = BakeCli::try_parse_from(&clap_argv).map_err(|e| {
128        let _ = e.print();
129        if e.kind() == clap::error::ErrorKind::DisplayHelp
130            || e.kind() == clap::error::ErrorKind::DisplayVersion
131        {
132            Error::usage("__printed__")
133        } else {
134            Error::usage(e.to_string())
135        }
136    })?;
137
138    match cli.command {
139        None => {
140            print_bake_help();
141            Err(Error::usage("__printed__"))
142        }
143        Some(BakeCommand::Create(args)) => bake_create(args),
144        Some(BakeCommand::List) => {
145            bake_list();
146            Ok(())
147        }
148        Some(BakeCommand::Show { name }) => bake_show(&name),
149        Some(BakeCommand::Remove { name }) => {
150            remove_baked(&name)?;
151            println!("Baked tool '{name}' removed.");
152            Ok(())
153        }
154        Some(BakeCommand::Update(args)) => bake_update(args),
155        Some(BakeCommand::Install(args)) => bake_install(args),
156    }
157}
158
159fn print_bake_help() {
160    println!("Usage: skiff bake [options]\n");
161    println!("Commands:");
162    println!("  create    Save connection settings as a named baked tool");
163    println!("  list      List all baked tools");
164    println!("  show      Show config for a baked tool (secrets masked)");
165    println!("  remove    Delete a baked tool");
166    println!("  update    Update settings on an existing baked tool");
167    println!("  install   Create a ~/.local/bin wrapper script");
168    println!("\nRun 'skiff bake <command> --help' for command-specific help.");
169}
170
171fn bake_create(args: CreateArgs) -> Result<()> {
172    let modes = [
173        args.spec.is_some(),
174        args.mcp.is_some(),
175        args.mcp_stdio.is_some(),
176        args.graphql.is_some(),
177    ];
178    let active = modes.iter().filter(|x| **x).count();
179    if active == 0 {
180        return Err(Error::usage(
181            "one of --spec, --mcp, --mcp-stdio, or --graphql is required.",
182        ));
183    }
184    if active > 1 {
185        return Err(Error::usage(
186            "--spec, --mcp, --mcp-stdio, and --graphql are mutually exclusive.",
187        ));
188    }
189
190    let (source_type, source) = if let Some(s) = args.spec {
191        ("spec", s)
192    } else if let Some(s) = args.mcp {
193        ("mcp", s)
194    } else if let Some(s) = args.graphql {
195        ("graphql", s)
196    } else {
197        ("mcp_stdio", args.mcp_stdio.expect("checked above"))
198    };
199
200    let tool = BakedTool {
201        source_type: source_type.into(),
202        source,
203        base_url: args.base_url,
204        auth_headers: parse_auth_header_raw(&args.auth_header)?,
205        env_vars: parse_env_raw(&args.env)?,
206        cache_ttl: args.cache_ttl,
207        transport: args.transport,
208        oauth: args.oauth,
209        oauth_client_id: args.oauth_client_id,
210        oauth_client_secret: args.oauth_client_secret,
211        oauth_client_name: args.oauth_client_name,
212        oauth_scope: args.oauth_scope,
213        oauth_redirect_uri: args.oauth_redirect_uri,
214        oauth_flow: args.oauth_flow,
215        session: args.session,
216        include: split_csv_list(&args.include),
217        exclude: split_csv_list(&args.exclude),
218        methods: split_methods(&args.methods),
219        description: args.description,
220        ..Default::default()
221    };
222
223    if let Some(sec) = &tool.oauth_client_secret {
224        if !sec.starts_with("env:") && !sec.starts_with("file:") {
225            use std::io::IsTerminal;
226            if std::io::stdout().is_terminal() {
227                eprintln!(
228                    "warning: --oauth-client-secret is a literal value; prefer env:VAR or file:PATH so secrets stay off the process list and bake config"
229                );
230            }
231        }
232    }
233
234    create_baked(&args.name, tool, args.force)?;
235    println!("Baked tool '{}' created.", args.name);
236    Ok(())
237}
238
239fn bake_list() {
240    let configs = match load_baked_all() {
241        Ok(c) => c,
242        Err(_) => {
243            println!("No baked tools.");
244            return;
245        }
246    };
247    if configs.is_empty() {
248        println!("No baked tools.");
249        return;
250    }
251    println!("{:<20} {:<10} {:<50}", "Name", "Type", "Source");
252    println!("{}", "-".repeat(80));
253    for (name, cfg) in &configs {
254        let st = cfg.source_type.as_str();
255        let mut src = cfg.source.clone();
256        if src.len() > 48 {
257            src = format!("{}...", &src[..45]);
258        }
259        println!("{name:<20} {st:<10} {src:<50}");
260    }
261}
262
263fn bake_show(name: &str) -> Result<()> {
264    let cfg = require_baked(name)?;
265    let display = cfg.masked_for_display();
266    println!("{}", serde_json::to_string_pretty(&display)?);
267    Ok(())
268}
269
270fn bake_update(args: UpdateArgs) -> Result<()> {
271    update_baked(&args.name, |cfg| {
272        if let Some(ttl) = args.cache_ttl {
273            cfg.cache_ttl = ttl;
274        }
275        if let Some(include) = &args.include {
276            cfg.include = split_csv_list(include);
277        }
278        if let Some(exclude) = &args.exclude {
279            cfg.exclude = split_csv_list(exclude);
280        }
281        if let Some(methods) = &args.methods {
282            cfg.methods = split_methods(methods);
283        }
284        if let Some(desc) = &args.description {
285            cfg.description = desc.clone();
286        }
287        if let Some(base) = &args.base_url {
288            cfg.base_url = Some(base.clone());
289        }
290        if let Some(transport) = &args.transport {
291            cfg.transport = transport.clone();
292        }
293    })?;
294    println!("Baked tool '{}' updated.", args.name);
295    Ok(())
296}
297
298fn bake_install(args: InstallArgs) -> Result<()> {
299    let wrapper = install_wrapper(&args.name, args.dir.as_deref())?;
300    println!("Installed wrapper: {}", wrapper.display());
301    if args.dir.is_none() {
302        if let Some(default_dir) = default_install_dir() {
303            let path_env = std::env::var("PATH").unwrap_or_default();
304            let in_path = std::env::split_paths(&path_env).any(|p| p == default_dir);
305            if !in_path {
306                println!("  Note: {} may not be in your PATH", default_dir.display());
307            }
308        }
309    }
310    Ok(())
311}