1use 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 Create(CreateArgs),
32 List,
34 Show { name: String },
36 Remove { name: String },
38 Update(UpdateArgs),
40 Install(InstallArgs),
42}
43
44#[derive(Debug, Parser)]
45struct CreateArgs {
46 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 #[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 #[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 #[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}