feat: add interactive import 55966b71
Steve Simkins · 2026-08-10 22:01 2 file(s) · +229 −13
README.md +2 −2
10 10
11 11
## Features
12 12
13 -
- **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled.
13 +
- **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled. Import walks you through auth (bearer / api key / oauth2), picking an active server, and display preferences; pass `-y` to skip it.
14 14
- **Ad-hoc collections** — no spec needed; paste a full URL and it's split into server, path, and query params for you.
15 15
- **Vim-style TUI** — three panes (requests / editor / response), `j`/`k` navigation, `:` command line, `/` incremental search.
16 16
- **Variables** — `{{name}}` from the collection, plus dynamic ones like `{{uuid}}`, `{{timestamp}}`, `{{randomInt(1,100)}}`.
60 60
```sh
61 61
cielago                          # open the last-used collection in the TUI
62 62
cielago open [name]              # open a specific collection
63 -
cielago import <spec|url>        # import an OpenAPI 3.x spec
63 +
cielago import <spec|url> [-y]   # import an OpenAPI 3.x spec (-y skips setup)
64 64
cielago new <name> [--server u]  # create an empty collection and open it
65 65
cielago list [-l]                # list collections (-l adds counts + paths)
66 66
cielago info <name>              # servers, counts, auth, groups
src/main.rs +227 −11
1 1
use std::collections::BTreeMap;
2 2
use std::fs;
3 -
use std::io::{self, Write};
3 +
use std::io::{self, IsTerminal, Write};
4 4
use std::process::Command as ProcessCommand;
5 5
6 6
use anyhow::{Context, Result, bail};
7 7
use clap::{Parser, Subcommand};
8 8
9 9
use cielago::app;
10 -
use cielago::model::Collection;
10 +
use cielago::model::{
11 +
    AuthKind, AuthStyle, Collection, DEFAULT_API_KEY_HEADER, LabelMode, OAuthConfig,
12 +
};
11 13
use cielago::openapi;
12 14
use cielago::store::{self, AppConfig};
13 15
43 45
        /// Collection name (defaults to the spec's info.title)
44 46
        #[arg(long)]
45 47
        name: Option<String>,
48 +
        /// Skip interactive setup; keep spec-derived auth/servers and defaults
49 +
        #[arg(short = 'y', long)]
50 +
        yes: bool,
46 51
    },
47 52
    /// Create an empty collection and open it in the TUI
48 53
    New {
88 93
async fn main() -> Result<()> {
89 94
    let cli = Cli::parse();
90 95
    match cli.command {
91 -
        Some(Command::Import { source, name }) => cmd_import(&source, name).await,
96 +
        Some(Command::Import { source, name, yes }) => cmd_import(&source, name, yes).await,
92 97
        Some(Command::New { name, server }) => cmd_new(&name, server).await,
93 98
        Some(Command::List { long }) => cmd_list(long),
94 99
        Some(Command::Open { name }) => cmd_open(name).await,
102 107
    }
103 108
}
104 109
105 -
async fn cmd_import(source: &str, name: Option<String>) -> Result<()> {
110 +
async fn cmd_import(source: &str, name: Option<String>, yes: bool) -> Result<()> {
106 111
    let doc = openapi::load_spec(source).await?;
107 112
    let name = name
108 113
        .or_else(|| {
112 117
        })
113 118
        .unwrap_or_else(|| "imported".to_string());
114 119
115 -
    let collection = openapi::import_spec(&doc, &name, Some(source.to_string()));
120 +
    // Walk the user through the name, auth, server and display preferences
121 +
    // unless they opted out with `--yes` or stdin isn't a terminal (e.g. a
122 +
    // script or pipe), in which case the spec-derived defaults stand.
123 +
    let interactive = !yes && io::stdin().is_terminal();
124 +
    let name = if interactive {
125 +
        ask_default("\nCollection name", &name)?
126 +
    } else {
127 +
        name
128 +
    };
129 +
130 +
    let mut collection = openapi::import_spec(&doc, &name, Some(source.to_string()));
131 +
132 +
    if interactive {
133 +
        prompt_auth(&mut collection)?;
134 +
        prompt_server(&mut collection)?;
135 +
        prompt_preferences(&mut collection)?;
136 +
    }
137 +
116 138
    let path = store::save_collection(&collection)?;
117 139
118 140
    println!(
121 143
        path.display()
122 144
    );
123 145
    println!("  {} requests", collection.requests.len());
124 -
    if !collection.servers.is_empty() {
125 -
        println!("  servers: {}", collection.servers.join(", "));
126 -
    }
127 -
    if let Some(auth) = &collection.auth {
146 +
    if collection.servers.is_empty() {
147 +
        println!("  servers: (none)");
148 +
    } else {
128 149
        println!(
129 -
            "  oauth2 client-credentials: {} (set client id/secret with A in the TUI)",
130 -
            auth.token_url
150 +
            "  servers: {} (active: {})",
151 +
            collection.servers.join(", "),
152 +
            collection
153 +
                .base_url()
154 +
                .unwrap_or(collection.servers[0].as_str())
131 155
        );
132 156
    }
157 +
    match &collection.auth {
158 +
        Some(auth) => println!("  auth: {}", auth_summary(auth)),
159 +
        None => println!("  auth: none"),
160 +
    }
161 +
    Ok(())
162 +
}
163 +
164 +
/// One-line description of a configured auth scheme for the import summary.
165 +
fn auth_summary(auth: &OAuthConfig) -> String {
166 +
    match auth.kind {
167 +
        AuthKind::Bearer => {
168 +
            let state = if auth.token.is_empty() {
169 +
                " (no token set — set it with A in the TUI)"
170 +
            } else {
171 +
                ""
172 +
            };
173 +
            format!("bearer{state}")
174 +
        }
175 +
        AuthKind::ApiKey => {
176 +
            let state = if auth.token.is_empty() {
177 +
                " (no value set — set it with A in the TUI)"
178 +
            } else {
179 +
                ""
180 +
            };
181 +
            format!("api key in {}{state}", auth.api_key_header())
182 +
        }
183 +
        AuthKind::Oauth2 => {
184 +
            let state = if auth.client_id.is_empty() {
185 +
                " (set client id/secret with A in the TUI)"
186 +
            } else {
187 +
                ""
188 +
            };
189 +
            format!("oauth2 client-credentials, token url {}{state}", auth.token_url)
190 +
        }
191 +
    }
192 +
}
193 +
194 +
/// Print `prompt`, then read one trimmed line from stdin. An empty reply (or
195 +
/// EOF) comes back as an empty string, which every caller treats as "leave the
196 +
/// current value" or "skip".
197 +
fn ask(prompt: &str) -> Result<String> {
198 +
    print!("{prompt}");
199 +
    io::stdout().flush()?;
200 +
    let mut line = String::new();
201 +
    io::stdin().read_line(&mut line)?;
202 +
    Ok(line.trim().to_string())
203 +
}
204 +
205 +
/// Like [`ask`], but shows `current` as the value kept when the reply is blank.
206 +
fn ask_default(label: &str, current: &str) -> Result<String> {
207 +
    let suffix = if current.is_empty() {
208 +
        String::new()
209 +
    } else {
210 +
        format!(" [{current}]")
211 +
    };
212 +
    let answer = ask(&format!("{label}{suffix}: "))?;
213 +
    Ok(if answer.is_empty() {
214 +
        current.to_string()
215 +
    } else {
216 +
        answer
217 +
    })
218 +
}
219 +
220 +
/// Choose the collection's auth scheme, then collect that scheme's values. Any
221 +
/// value may be left blank and filled in later from the TUI. A scheme the spec
222 +
/// implied (e.g. oauth2 from a `clientCredentials` flow) is offered as default
223 +
/// and seeds the oauth2 field prompts.
224 +
fn prompt_auth(collection: &mut Collection) -> Result<()> {
225 +
    let detected = collection.auth.as_ref().map(|a| a.kind);
226 +
    println!("\nAuthentication:");
227 +
    println!("  1) none");
228 +
    println!("  2) bearer token");
229 +
    println!("  3) api key");
230 +
    println!("  4) oauth2 client-credentials");
231 +
    let default_choice = match detected {
232 +
        Some(AuthKind::Bearer) => 2,
233 +
        Some(AuthKind::ApiKey) => 3,
234 +
        Some(AuthKind::Oauth2) => 4,
235 +
        None => 1,
236 +
    };
237 +
    let raw = ask(&format!("  choose [1-4] (default {default_choice}): "))?;
238 +
    let choice = if raw.is_empty() {
239 +
        default_choice
240 +
    } else {
241 +
        raw.parse().unwrap_or(default_choice)
242 +
    };
243 +
244 +
    collection.auth = match choice {
245 +
        2 => {
246 +
            let token = ask("  bearer token (blank to set later): ")?;
247 +
            Some(OAuthConfig {
248 +
                kind: AuthKind::Bearer,
249 +
                token,
250 +
                ..Default::default()
251 +
            })
252 +
        }
253 +
        3 => {
254 +
            let header = ask(&format!(
255 +
                "  header name (blank for {DEFAULT_API_KEY_HEADER}): "
256 +
            ))?;
257 +
            let token = ask("  api key value (blank to set later): ")?;
258 +
            Some(OAuthConfig {
259 +
                kind: AuthKind::ApiKey,
260 +
                header,
261 +
                token,
262 +
                ..Default::default()
263 +
            })
264 +
        }
265 +
        4 => {
266 +
            // Reuse spec-derived token url/scopes as defaults when present.
267 +
            let mut cfg = collection.auth.clone().unwrap_or_default();
268 +
            cfg.kind = AuthKind::Oauth2;
269 +
            cfg.token_url = ask_default("  token url", &cfg.token_url)?;
270 +
            cfg.client_id = ask("  client id (blank to set later): ")?;
271 +
            cfg.client_secret = ask("  client secret (blank to set later): ")?;
272 +
            let scopes = ask_default("  scopes (space-separated)", &cfg.scopes.join(" "))?;
273 +
            cfg.scopes = scopes.split_whitespace().map(String::from).collect();
274 +
            let style = ask("  send credentials via [1] basic header or [2] form body (default 1): ")?;
275 +
            cfg.auth_style = if style == "2" {
276 +
                AuthStyle::Post
277 +
            } else {
278 +
                AuthStyle::Basic
279 +
            };
280 +
            Some(cfg)
281 +
        }
282 +
        _ => None,
283 +
    };
284 +
    Ok(())
285 +
}
286 +
287 +
/// Pick the active base URL. Spec-derived servers are offered by number; a
288 +
/// typed URL is added and made active; a blank reply keeps the first (or none).
289 +
fn prompt_server(collection: &mut Collection) -> Result<()> {
290 +
    println!("\nServer:");
291 +
    if collection.servers.is_empty() {
292 +
        let url = normalize_server(&ask("  base url (blank for none): ")?);
293 +
        if !url.is_empty() {
294 +
            collection.servers.push(url);
295 +
            collection.active_server = 0;
296 +
        }
297 +
        return Ok(());
298 +
    }
299 +
300 +
    println!("  detected:");
301 +
    for (i, s) in collection.servers.iter().enumerate() {
302 +
        println!("    {}) {s}", i + 1);
303 +
    }
304 +
    let answer = ask("  choose a number, type a new url, or blank for #1: ")?;
305 +
    if answer.is_empty() {
306 +
        collection.active_server = 0;
307 +
    } else if let Ok(n) = answer.parse::<usize>() {
308 +
        if (1..=collection.servers.len()).contains(&n) {
309 +
            collection.active_server = n - 1;
310 +
        }
311 +
    } else {
312 +
        let url = normalize_server(&answer);
313 +
        let idx = collection
314 +
            .servers
315 +
            .iter()
316 +
            .position(|s| *s == url)
317 +
            .unwrap_or_else(|| {
318 +
                collection.servers.push(url);
319 +
                collection.servers.len() - 1
320 +
            });
321 +
        collection.active_server = idx;
322 +
    }
323 +
    Ok(())
324 +
}
325 +
326 +
/// Trim surrounding whitespace and a trailing slash so a typed URL matches the
327 +
/// form imported servers are stored in (and doesn't duplicate one).
328 +
fn normalize_server(url: &str) -> String {
329 +
    url.trim().trim_end_matches('/').to_string()
330 +
}
331 +
332 +
/// Collection display preferences: how the sidebar labels requests, and whether
333 +
/// tag groups start collapsed.
334 +
fn prompt_preferences(collection: &mut Collection) -> Result<()> {
335 +
    println!("\nPreferences:");
336 +
    println!("  label requests by:");
337 +
    println!("    1) name");
338 +
    println!("    2) summary");
339 +
    println!("    3) path");
340 +
    let answer = ask("  choose [1-3] (default 1): ")?;
341 +
    collection.label_mode = match answer.as_str() {
342 +
        "2" => LabelMode::Summary,
343 +
        "3" => LabelMode::Path,
344 +
        _ => LabelMode::Name,
345 +
    };
346 +
347 +
    let answer = ask("  collapse tag groups on open? [y/N]: ")?;
348 +
    collection.groups_collapsed = matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes");
133 349
    Ok(())
134 350
}
135 351