Skip to main content

skiff_cli/cli/
dispatch.rs

1//! Top-level CLI dispatch: bake / `@name` / sessions / OpenAPI / MCP / GraphQL.
2//!
3//! Session lifecycle and `--session` IPC run before source-mode mutual exclusion
4//! so agents can stop/list without repeating `--mcp*`.
5
6use std::ffi::OsString;
7
8use clap::Parser;
9use serde_json::{Map, Value};
10
11use crate::bake::require_baked;
12use crate::cache::cache_key_for;
13use crate::cli::args::GlobalArgs;
14use crate::cli::bake::handle_bake;
15use crate::cli::dynamic::{parse_tool_args, read_stdin_json};
16use crate::cli::list::{
17    apply_list_options, describe_tool, filter_by_search, list_commands, maybe_tool_help,
18    ListOptions, ListStyle,
19};
20use crate::cli::{global_option_sets, split_at_subcommand};
21use crate::error::{Error, Result};
22use crate::filter::filter_commands;
23use crate::graphql::{execute_graphql, extract_graphql_commands, load_graphql_schema};
24use crate::mcp::{
25    call_tool_http, call_tool_stdio, fetch_mcp_tools_http, fetch_mcp_tools_stdio,
26    tools_to_commands, TransportMode,
27};
28use crate::model::{BakeConfig, ListDetail};
29use crate::oauth::{
30    clear_oauth_credentials, discovery_url_from_args, oauth_wanted, options_from_args,
31    setup_from_args, OAuthReady,
32};
33use crate::openapi::{
34    execute_openapi, extract_openapi_commands, load_openapi_spec, resolve_base_url,
35};
36use crate::output::output_result;
37use crate::tools_index::{tools_to_light_commands, try_commands_from_index};
38use crate::usage::{record_usage, source_hash_for};
39
40pub fn dispatch(argv: Vec<OsString>) -> Result<()> {
41    if argv.first().and_then(|a| a.to_str()) == Some("bake") {
42        return handle_bake(&argv[1..]);
43    }
44    if let Some(first) = argv.first().and_then(|a| a.to_str()) {
45        if let Some(name) = first.strip_prefix('@') {
46            return run_baked(name, &argv[1..]);
47        }
48    }
49
50    dispatch_impl(argv, None)
51}
52
53fn run_baked(name: &str, rest: &[OsString]) -> Result<()> {
54    let cfg = require_baked(name)?;
55    let bake_config = cfg.bake_config();
56    let mut synthetic: Vec<OsString> = cfg.to_argv().into_iter().map(OsString::from).collect();
57    synthetic.extend_from_slice(rest);
58    dispatch_impl(synthetic, Some(bake_config))
59}
60
61fn dispatch_impl(argv: Vec<OsString>, bake_config: Option<BakeConfig>) -> Result<()> {
62    let (value_opts, bool_opts) = global_option_sets();
63    let (global_argv, tool_argv) = split_at_subcommand(&argv, &value_opts, &bool_opts);
64
65    let mut clap_argv = vec![OsString::from("skiff")];
66    clap_argv.extend(global_argv);
67
68    let mut pre_args = GlobalArgs::try_parse_from(&clap_argv).map_err(|e| {
69        let msg = e.to_string();
70        if msg.contains("help")
71            || e.kind() == clap::error::ErrorKind::DisplayHelp
72            || e.kind() == clap::error::ErrorKind::DisplayVersion
73        {
74            e.print().ok();
75            Error::usage("__printed__")
76        } else {
77            Error::usage(msg)
78        }
79    })?;
80
81    pre_args.apply_agent_defaults();
82
83    if pre_args.spool_clean {
84        let n = crate::spool::clean_spool(crate::spool::DEFAULT_SPOOL_TTL_SECS)?;
85        println!("Removed {n} expired spool file(s)");
86        return Ok(());
87    }
88
89    if pre_args.search_pattern.is_some() {
90        pre_args.list_commands = true;
91    }
92
93    if pre_args.describe.is_some() {
94        pre_args.list_commands = false;
95    }
96
97    // --oauth-clear can run with just a discovery URL source flag.
98    if pre_args.oauth_clear {
99        let url = discovery_url_from_args(&pre_args)?;
100        clear_oauth_credentials(&url)?;
101        println!("Cleared OAuth credentials for {url}");
102        return Ok(());
103    }
104
105    // Validate oauth flags early (e.g. secret without id, stdio+oauth).
106    let _ = options_from_args(&pre_args)?;
107
108    let tool_argv: Vec<String> = tool_argv
109        .iter()
110        .map(|a| a.to_string_lossy().into_owned())
111        .collect();
112
113    // Session management that does not require a source mode.
114    #[cfg(unix)]
115    {
116        if pre_args.session_list {
117            return dispatch_session_list(&pre_args);
118        }
119        if let Some(name) = &pre_args.session_stop {
120            return crate::session::session_stop(name);
121        }
122        if let Some(name) = &pre_args.session_start {
123            return dispatch_session_start(&pre_args, name);
124        }
125        if let Some(name) = &pre_args.session {
126            return dispatch_session(&pre_args, name, &tool_argv, bake_config.as_ref());
127        }
128    }
129    #[cfg(not(unix))]
130    {
131        if pre_args.session_list
132            || pre_args.session_stop.is_some()
133            || pre_args.session_start.is_some()
134            || pre_args.session.is_some()
135        {
136            return Err(crate::session::sessions_unsupported());
137        }
138    }
139
140    let modes = [
141        pre_args.spec.is_some(),
142        pre_args.mcp.is_some(),
143        pre_args.mcp_stdio.is_some(),
144        pre_args.graphql.is_some(),
145    ];
146    let active = modes.iter().filter(|x| **x).count();
147    if active == 0 {
148        print_help();
149        return Err(Error::usage(
150            "one of --spec, --mcp, --mcp-stdio, or --graphql is required.",
151        ));
152    }
153    if active > 1 {
154        return Err(Error::usage(
155            "--spec, --mcp, --mcp-stdio, and --graphql are mutually exclusive.",
156        ));
157    }
158
159    if let Some(cmd) = &pre_args.mcp_stdio {
160        if oauth_wanted(&pre_args) {
161            return Err(Error::usage(
162                "OAuth is not supported with --mcp-stdio (HTTP discovery required)",
163            ));
164        }
165        return dispatch_mcp_stdio(&pre_args, cmd, &tool_argv, bake_config.as_ref());
166    }
167
168    if let Some(url) = &pre_args.mcp {
169        return dispatch_mcp_http(&pre_args, url, &tool_argv, bake_config.as_ref());
170    }
171
172    if let Some(spec) = &pre_args.spec {
173        return dispatch_openapi(&pre_args, spec, &tool_argv, bake_config.as_ref());
174    }
175
176    if let Some(url) = &pre_args.graphql {
177        return dispatch_graphql(&pre_args, url, &tool_argv, bake_config.as_ref());
178    }
179
180    Err(Error::usage("no source mode selected"))
181}
182
183fn apply_bake_filter(
184    commands: Vec<crate::CommandDef>,
185    bake: Option<&BakeConfig>,
186) -> Vec<crate::CommandDef> {
187    match bake {
188        Some(b) => filter_commands(commands, &b.include, &b.exclude, &b.methods),
189        None => commands,
190    }
191}
192
193/// Resolve commands for MCP `--list`/`--search` with warm index + light extract when possible.
194fn mcp_discovery_commands(
195    tools: Option<&[Value]>,
196    cache_key: &str,
197    pre: &GlobalArgs,
198    bake: Option<&BakeConfig>,
199) -> Result<Vec<crate::CommandDef>> {
200    let detail = pre.list_detail();
201    let light = matches!(detail, ListDetail::Names | ListDetail::Brief);
202    let tools_key = format!("{cache_key}_tools");
203    let require_descs = matches!(detail, ListDetail::Brief);
204
205    if light && !pre.refresh {
206        if let Some(cmds) = try_commands_from_index(
207            &tools_key,
208            pre.cache_ttl,
209            pre.search_pattern.as_deref(),
210            require_descs,
211        )? {
212            return Ok(apply_bake_filter(cmds, bake));
213        }
214    }
215
216    let tools = tools.ok_or_else(|| Error::runtime("internal: tools required when index miss"))?;
217    let mut commands = if light {
218        apply_bake_filter(tools_to_light_commands(tools), bake)
219    } else {
220        apply_bake_filter(tools_to_commands(tools), bake)
221    };
222    if let Some(pat) = &pre.search_pattern {
223        commands = filter_by_search(commands, pat);
224    }
225    Ok(commands)
226}
227
228fn emit_mcp_list(
229    commands: Vec<crate::CommandDef>,
230    pre: &GlobalArgs,
231    src_hash: &str,
232    list_opts: &ListOptions,
233) -> Result<()> {
234    if let Some(pat) = &pre.search_pattern {
235        if commands.is_empty() {
236            if pre.json_output || pre.agent {
237                list_commands(&commands, list_opts)?;
238            } else {
239                println!("\nNo tools matching '{pat}'.");
240            }
241            return Ok(());
242        }
243        if !pre.quiet_list() {
244            println!("\nTools matching '{pat}':");
245        }
246    } else if !pre.quiet_list() {
247        println!("\nAvailable tools:");
248    }
249    // Search already applied in mcp_discovery_commands when from index;
250    // when from tools with search in discovery, also applied. Avoid double-filter.
251    let commands = apply_list_options(commands, src_hash, pre.sort.as_deref(), pre.top);
252    list_commands(&commands, list_opts)
253}
254
255fn dispatch_openapi(
256    pre: &GlobalArgs,
257    spec_source: &str,
258    remaining: &[String],
259    bake: Option<&BakeConfig>,
260) -> Result<()> {
261    let mut auth = pre.parse_auth_headers()?;
262    let rt = tokio::runtime::Builder::new_multi_thread()
263        .enable_all()
264        .build()
265        .map_err(|e| Error::runtime(e.to_string()))?;
266    let oauth = rt.block_on(setup_from_args(pre))?;
267    if let Some(ref o) = oauth {
268        let token = rt.block_on(o.access_token())?;
269        auth.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization"));
270        auth.push(("Authorization".into(), format!("Bearer {token}")));
271    }
272
273    let src_hash = source_hash_for(spec_source);
274    let spec = load_openapi_spec(
275        spec_source,
276        &auth,
277        pre.cache_key.as_deref(),
278        Some(pre.cache_ttl),
279        pre.refresh,
280    )?;
281    let mut commands = extract_openapi_commands(&spec);
282    commands = apply_bake_filter(commands, bake);
283
284    let list_opts = ListOptions::from_global(pre, ListStyle::OpenApi);
285
286    if let Some(name) = &pre.describe {
287        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
288        return describe_tool(&commands, name, pre);
289    }
290
291    if pre.list_commands {
292        if let Some(pat) = &pre.search_pattern {
293            commands = filter_by_search(commands, pat);
294            if commands.is_empty() {
295                if pre.json_output || pre.agent {
296                    list_commands(&commands, &list_opts)?;
297                } else {
298                    println!("\nNo tools matching '{pat}'.");
299                }
300                return Ok(());
301            }
302            if !pre.quiet_list() {
303                println!("\nTools matching '{pat}':");
304            }
305        }
306        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
307        return list_commands(&commands, &list_opts);
308    }
309
310    if remaining.is_empty() {
311        print_help();
312        eprintln!("\nUse --list to see all available commands.");
313        return Err(Error::usage("__printed__"));
314    }
315
316    if maybe_tool_help(&commands, remaining, pre)? {
317        return Ok(());
318    }
319
320    let base_url = resolve_base_url(pre.base_url.as_deref(), &spec, spec_source)?;
321    let parsed = match parse_tool_args(&commands, remaining) {
322        Err(Error::Usage(msg)) if msg == "__help__" => return Ok(()),
323        other => other?,
324    };
325
326    execute_openapi(&parsed, &base_url, &auth, &pre.output_options())?;
327    let _ = record_usage(&src_hash, &parsed.command.name);
328    Ok(())
329}
330
331fn dispatch_graphql(
332    pre: &GlobalArgs,
333    url: &str,
334    remaining: &[String],
335    bake: Option<&BakeConfig>,
336) -> Result<()> {
337    let mut auth = pre.parse_auth_headers()?;
338    let rt = tokio::runtime::Builder::new_multi_thread()
339        .enable_all()
340        .build()
341        .map_err(|e| Error::runtime(e.to_string()))?;
342    let oauth = rt.block_on(setup_from_args(pre))?;
343    if let Some(ref o) = oauth {
344        let token = rt.block_on(o.access_token())?;
345        auth.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization"));
346        auth.push(("Authorization".into(), format!("Bearer {token}")));
347    }
348
349    let src_hash = source_hash_for(url);
350    let schema = load_graphql_schema(
351        url,
352        &auth,
353        pre.cache_key.as_deref(),
354        Some(pre.cache_ttl),
355        pre.refresh,
356    )?;
357    let mut commands = extract_graphql_commands(&schema);
358    commands = apply_bake_filter(commands, bake);
359
360    let list_opts = ListOptions::from_global(pre, ListStyle::Graphql);
361
362    if let Some(name) = &pre.describe {
363        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
364        return describe_tool(&commands, name, pre);
365    }
366
367    if pre.list_commands {
368        if let Some(pat) = &pre.search_pattern {
369            commands = filter_by_search(commands, pat);
370            if commands.is_empty() {
371                if pre.json_output || pre.agent {
372                    list_commands(&commands, &list_opts)?;
373                } else {
374                    println!("\nNo tools matching '{pat}'.");
375                }
376                return Ok(());
377            }
378            if !pre.quiet_list() {
379                println!("\nTools matching '{pat}':");
380            }
381        }
382        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
383        return list_commands(&commands, &list_opts);
384    }
385
386    if remaining.is_empty() {
387        if !pre.quiet_list() {
388            println!("Available operations:");
389        }
390        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
391        list_commands(&commands, &list_opts)?;
392        if !pre.quiet_list() {
393            println!("\nUse --list for the same output, or provide a subcommand.");
394        }
395        return Ok(());
396    }
397
398    if maybe_tool_help(&commands, remaining, pre)? {
399        return Ok(());
400    }
401
402    let parsed = match parse_tool_args(&commands, remaining) {
403        Err(Error::Usage(msg)) if msg == "__help__" => return Ok(()),
404        other => other?,
405    };
406
407    execute_graphql(
408        &parsed,
409        url,
410        &schema,
411        &auth,
412        pre.fields.as_deref(),
413        &pre.output_options(),
414    )?;
415    let usage_key = parsed
416        .command
417        .graphql_field_name
418        .as_deref()
419        .unwrap_or(&parsed.command.name);
420    let _ = record_usage(&src_hash, usage_key);
421    Ok(())
422}
423
424fn dispatch_mcp_http(
425    pre: &GlobalArgs,
426    url: &str,
427    remaining: &[String],
428    bake: Option<&BakeConfig>,
429) -> Result<()> {
430    let auth = pre.parse_auth_headers()?;
431    let transport = TransportMode::parse(&pre.transport)?;
432    let src_hash = source_hash_for(url);
433    let cache_key = pre.cache_key.clone().unwrap_or_else(|| {
434        cache_key_for(&serde_json::json!({
435            "source": url,
436            "is_stdio": false,
437            "auth_headers": auth,
438            "transport": pre.transport,
439        }))
440    });
441
442    let rt = tokio::runtime::Builder::new_multi_thread()
443        .enable_all()
444        .build()
445        .map_err(|e| Error::runtime(e.to_string()))?;
446
447    let oauth: Option<OAuthReady> = rt.block_on(setup_from_args(pre))?;
448    let oauth_ref = oauth.as_ref();
449
450    let list_opts = ListOptions::from_global(pre, ListStyle::Mcp);
451    let tools_key = format!("{cache_key}_tools");
452    let detail = pre.list_detail();
453    let can_light =
454        matches!(detail, ListDetail::Names | ListDetail::Brief) && pre.describe.is_none();
455    let require_descs = matches!(detail, ListDetail::Brief);
456    let discovery = pre.list_commands || remaining.is_empty();
457
458    if discovery && can_light && !pre.refresh {
459        if let Some(cmds) = try_commands_from_index(
460            &tools_key,
461            pre.cache_ttl,
462            pre.search_pattern.as_deref(),
463            require_descs,
464        )? {
465            emit_mcp_list(apply_bake_filter(cmds, bake), pre, &src_hash, &list_opts)?;
466            if remaining.is_empty() && !pre.list_commands && !pre.quiet_list() {
467                println!("\nUse --list for the same output, or provide a subcommand.");
468            }
469            return Ok(());
470        }
471    }
472
473    let tools = rt.block_on(fetch_mcp_tools_http(
474        url,
475        &auth,
476        &cache_key,
477        pre.cache_ttl,
478        pre.refresh,
479        transport,
480        oauth_ref,
481    ))?;
482
483    if let Some(name) = &pre.describe {
484        let commands = apply_bake_filter(tools_to_commands(&tools), bake);
485        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
486        return describe_tool(&commands, name, pre);
487    }
488
489    if discovery {
490        let commands = mcp_discovery_commands(Some(&tools), &cache_key, pre, bake)?;
491        emit_mcp_list(commands, pre, &src_hash, &list_opts)?;
492        if remaining.is_empty() && !pre.list_commands && !pre.quiet_list() {
493            println!("\nUse --list for the same output, or provide a subcommand.");
494        }
495        return Ok(());
496    }
497
498    let commands = apply_bake_filter(tools_to_commands(&tools), bake);
499
500    if maybe_tool_help(&commands, remaining, pre)? {
501        return Ok(());
502    }
503
504    let parsed = match parse_tool_args(&commands, remaining) {
505        Err(Error::Usage(msg)) if msg == "__help__" => return Ok(()),
506        other => other?,
507    };
508
509    let arguments: Map<String, Value> = if parsed.stdin {
510        match read_stdin_json("MCP tool arguments")? {
511            Value::Object(map) => map,
512            other => {
513                return Err(Error::runtime(format!(
514                    "MCP --stdin expects a JSON object, got {}",
515                    other
516                )));
517            }
518        }
519    } else {
520        parsed.values.into_iter().collect()
521    };
522
523    let tool_name = parsed
524        .command
525        .tool_name
526        .clone()
527        .unwrap_or_else(|| parsed.command.name.clone());
528
529    let data = rt.block_on(call_tool_http(
530        url,
531        &auth,
532        &tool_name,
533        arguments,
534        pre.full_envelope(),
535        transport,
536        oauth_ref,
537    ))?;
538    output_result(data, &pre.output_options())?;
539    let _ = record_usage(&src_hash, &tool_name);
540    Ok(())
541}
542
543fn dispatch_mcp_stdio(
544    pre: &GlobalArgs,
545    command_str: &str,
546    remaining: &[String],
547    bake: Option<&BakeConfig>,
548) -> Result<()> {
549    let env_vars = pre.parse_env_vars()?;
550    let src_hash = source_hash_for(command_str);
551    let cache_key = pre.cache_key.clone().unwrap_or_else(|| {
552        cache_key_for(&serde_json::json!({
553            "source": command_str,
554            "is_stdio": true,
555            "env_vars": env_vars,
556        }))
557    });
558
559    let rt = tokio::runtime::Builder::new_multi_thread()
560        .enable_all()
561        .build()
562        .map_err(|e| Error::runtime(e.to_string()))?;
563
564    let list_opts = ListOptions::from_global(pre, ListStyle::Mcp);
565    let tools_key = format!("{cache_key}_tools");
566    let detail = pre.list_detail();
567    let can_light =
568        matches!(detail, ListDetail::Names | ListDetail::Brief) && pre.describe.is_none();
569    let require_descs = matches!(detail, ListDetail::Brief);
570    let discovery = pre.list_commands || remaining.is_empty();
571
572    if discovery && can_light && !pre.refresh {
573        if let Some(cmds) = try_commands_from_index(
574            &tools_key,
575            pre.cache_ttl,
576            pre.search_pattern.as_deref(),
577            require_descs,
578        )? {
579            emit_mcp_list(apply_bake_filter(cmds, bake), pre, &src_hash, &list_opts)?;
580            if remaining.is_empty() && !pre.list_commands && !pre.quiet_list() {
581                println!("\nUse --list for the same output, or provide a subcommand.");
582            }
583            return Ok(());
584        }
585    }
586
587    let tools = rt.block_on(fetch_mcp_tools_stdio(
588        command_str,
589        &env_vars,
590        &cache_key,
591        pre.cache_ttl,
592        pre.refresh,
593    ))?;
594
595    if let Some(name) = &pre.describe {
596        let commands = apply_bake_filter(tools_to_commands(&tools), bake);
597        let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
598        return describe_tool(&commands, name, pre);
599    }
600
601    if discovery {
602        let commands = mcp_discovery_commands(Some(&tools), &cache_key, pre, bake)?;
603        emit_mcp_list(commands, pre, &src_hash, &list_opts)?;
604        if remaining.is_empty() && !pre.list_commands && !pre.quiet_list() {
605            println!("\nUse --list for the same output, or provide a subcommand.");
606        }
607        return Ok(());
608    }
609
610    let commands = apply_bake_filter(tools_to_commands(&tools), bake);
611
612    if maybe_tool_help(&commands, remaining, pre)? {
613        return Ok(());
614    }
615
616    let parsed = match parse_tool_args(&commands, remaining) {
617        Err(Error::Usage(msg)) if msg == "__help__" => return Ok(()),
618        other => other?,
619    };
620
621    let arguments: Map<String, Value> = if parsed.stdin {
622        match read_stdin_json("MCP tool arguments")? {
623            Value::Object(map) => map,
624            other => {
625                return Err(Error::runtime(format!(
626                    "MCP --stdin expects a JSON object, got {}",
627                    other
628                )));
629            }
630        }
631    } else {
632        parsed.values.into_iter().collect()
633    };
634
635    let tool_name = parsed
636        .command
637        .tool_name
638        .clone()
639        .unwrap_or_else(|| parsed.command.name.clone());
640
641    let data = rt.block_on(call_tool_stdio(
642        command_str,
643        &env_vars,
644        &tool_name,
645        arguments,
646        pre.full_envelope(),
647    ))?;
648    output_result(data, &pre.output_options())?;
649    let _ = record_usage(&src_hash, &tool_name);
650    Ok(())
651}
652
653#[cfg(unix)]
654fn dispatch_session_list(pre: &GlobalArgs) -> Result<()> {
655    let entries = crate::session::session_list()?;
656    if pre.json_output {
657        let v = serde_json::to_value(&entries)?;
658        output_result(v, &pre.output_options())?;
659        return Ok(());
660    }
661    if entries.is_empty() {
662        println!("No sessions.");
663        return Ok(());
664    }
665    for e in entries {
666        let status = if e.alive { "alive" } else { "dead" };
667        println!(
668            "  {:<20} {status:<6} pid={}  {} ({})",
669            e.name, e.pid, e.transport, e.source
670        );
671    }
672    Ok(())
673}
674
675#[cfg(unix)]
676fn dispatch_session_start(pre: &GlobalArgs, name: &str) -> Result<()> {
677    let (source, is_stdio) = if let Some(cmd) = &pre.mcp_stdio {
678        if oauth_wanted(pre) {
679            return Err(Error::usage(
680                "OAuth is not supported with --mcp-stdio (HTTP discovery required)",
681            ));
682        }
683        (cmd.clone(), true)
684    } else if let Some(url) = &pre.mcp {
685        (url.clone(), false)
686    } else {
687        return Err(Error::usage(
688            "--session-start requires --mcp or --mcp-stdio",
689        ));
690    };
691
692    let mut auth = pre.parse_auth_headers()?;
693    if !is_stdio && oauth_wanted(pre) {
694        let rt = tokio::runtime::Builder::new_multi_thread()
695            .enable_all()
696            .build()
697            .map_err(|e| Error::runtime(e.to_string()))?;
698        if let Some(o) = rt.block_on(setup_from_args(pre))? {
699            let token = rt.block_on(o.access_token())?;
700            auth.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization"));
701            auth.push(("Authorization".into(), format!("Bearer {token}")));
702        }
703    }
704
705    let idle_secs = crate::session::resolve_idle_secs(pre.session_idle_secs);
706    let config = crate::session::DaemonConfig {
707        name: name.to_string(),
708        source,
709        is_stdio,
710        auth_headers: auth,
711        env_vars: pre.parse_env_vars()?,
712        transport: pre.transport.clone(),
713        clean_env: pre.session_clean_env,
714        idle_secs,
715    };
716    crate::session::session_start(config)
717}
718
719#[cfg(unix)]
720fn dispatch_session(
721    pre: &GlobalArgs,
722    name: &str,
723    remaining: &[String],
724    bake: Option<&BakeConfig>,
725) -> Result<()> {
726    let _ = crate::session::clear_stale_session(name)?;
727    if !crate::session::session_sock_path(name).exists() {
728        return Err(Error::runtime(format!(
729            "session {name:?} is not running. Start with --session-start {name}"
730        )));
731    }
732
733    if pre.list_resources {
734        let v = crate::session::session_request(name, "list_resources", Value::Null)?;
735        output_result(v, &pre.output_options())?;
736        return Ok(());
737    }
738    if pre.list_resource_templates {
739        let v = crate::session::session_request(name, "list_resource_templates", Value::Null)?;
740        output_result(v, &pre.output_options())?;
741        return Ok(());
742    }
743    if let Some(uri) = &pre.read_resource {
744        let v = crate::session::session_request(
745            name,
746            "read_resource",
747            serde_json::json!({ "uri": uri }),
748        )?;
749        output_result(v, &pre.output_options())?;
750        return Ok(());
751    }
752    if pre.list_prompts {
753        let v = crate::session::session_request(name, "list_prompts", Value::Null)?;
754        output_result(v, &pre.output_options())?;
755        return Ok(());
756    }
757    if let Some(pname) = &pre.get_prompt {
758        let mut args = Map::new();
759        for item in &pre.prompt_arg {
760            let Some((k, v)) = item.split_once('=') else {
761                return Err(Error::usage(format!(
762                    "invalid --prompt-arg {item:?}; expected KEY=VALUE"
763                )));
764            };
765            args.insert(k.to_string(), Value::String(v.to_string()));
766        }
767        let v = crate::session::session_request(
768            name,
769            "get_prompt",
770            serde_json::json!({ "name": pname, "arguments": args }),
771        )?;
772        output_result(v, &pre.output_options())?;
773        return Ok(());
774    }
775
776    let src_hash = source_hash_for(&format!("session:{name}"));
777    let list_opts = ListOptions::from_global(pre, ListStyle::Mcp);
778    let detail = pre.list_detail();
779    let use_light_index = matches!(detail, ListDetail::Names) && pre.describe.is_none();
780    let needs_catalog = pre.list_commands
781        || pre.describe.is_some()
782        || remaining.is_empty()
783        || remaining.get(1).map(String::as_str) == Some("--help")
784        || remaining.get(1).map(String::as_str) == Some("-h");
785
786    let load_catalog = || -> Result<Vec<crate::CommandDef>> {
787        let tools_val = crate::session::session_request(
788            name,
789            "list_tools",
790            serde_json::json!({ "refresh": pre.refresh }),
791        )?;
792        let tools = tools_val
793            .as_array()
794            .cloned()
795            .ok_or_else(|| Error::runtime("session list_tools did not return an array"))?;
796        Ok(apply_bake_filter(tools_to_commands(&tools), bake))
797    };
798
799    let load_light_catalog = || -> Result<Vec<crate::CommandDef>> {
800        let mut params = serde_json::json!({ "refresh": pre.refresh });
801        if let Some(pat) = &pre.search_pattern {
802            params["search"] = Value::String(pat.clone());
803        }
804        let tools_val = crate::session::session_request(name, "list_tools_light", params)?;
805        let tools = tools_val
806            .as_array()
807            .cloned()
808            .ok_or_else(|| Error::runtime("session list_tools_light did not return an array"))?;
809        Ok(apply_bake_filter(tools_to_light_commands(&tools), bake))
810    };
811
812    if needs_catalog {
813        let mut commands = if use_light_index {
814            load_light_catalog()?
815        } else {
816            load_catalog()?
817        };
818
819        if let Some(dname) = &pre.describe {
820            let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
821            return describe_tool(&commands, dname, pre);
822        }
823
824        if pre.list_commands {
825            if use_light_index {
826                // Search already applied in the daemon via list_tools_light.
827                if commands.is_empty() {
828                    if let Some(pat) = &pre.search_pattern {
829                        if pre.json_output || pre.agent {
830                            list_commands(&commands, &list_opts)?;
831                        } else {
832                            println!("\nNo tools matching '{pat}'.");
833                        }
834                        return Ok(());
835                    }
836                } else if let Some(pat) = &pre.search_pattern {
837                    if !pre.quiet_list() {
838                        println!("\nTools matching '{pat}':");
839                    }
840                } else if !pre.quiet_list() {
841                    println!("\nAvailable tools:");
842                }
843            } else if let Some(pat) = &pre.search_pattern {
844                commands = filter_by_search(commands, pat);
845                if commands.is_empty() {
846                    if pre.json_output || pre.agent {
847                        list_commands(&commands, &list_opts)?;
848                    } else {
849                        println!("\nNo tools matching '{pat}'.");
850                    }
851                    return Ok(());
852                }
853                if !pre.quiet_list() {
854                    println!("\nTools matching '{pat}':");
855                }
856            } else if !pre.quiet_list() {
857                println!("\nAvailable tools:");
858            }
859            let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
860            return list_commands(&commands, &list_opts);
861        }
862
863        if remaining.is_empty() {
864            if !pre.quiet_list() {
865                println!("Available tools:");
866            }
867            let commands = apply_list_options(commands, &src_hash, pre.sort.as_deref(), pre.top);
868            list_commands(&commands, &list_opts)?;
869            if !pre.quiet_list() {
870                println!("\nUse --list for the same output, or provide a subcommand.");
871            }
872            return Ok(());
873        }
874
875        if maybe_tool_help(&commands, remaining, pre)? {
876            return Ok(());
877        }
878    }
879
880    // Call path: fetch one tool schema over IPC (daemon cache), not the full catalog.
881    let tname = remaining
882        .first()
883        .ok_or_else(|| Error::usage("no subcommand specified"))?;
884    let tool_val = crate::session::session_request(
885        name,
886        "get_tool",
887        serde_json::json!({ "name": tname, "refresh": pre.refresh }),
888    )?;
889    let commands = if tool_val.is_null() {
890        load_catalog()?
891    } else {
892        let one = tools_to_commands(std::slice::from_ref(&tool_val));
893        apply_bake_filter(one, bake)
894    };
895
896    let parsed = match parse_tool_args(&commands, remaining) {
897        Err(Error::Usage(msg)) if msg == "__help__" => return Ok(()),
898        other => other?,
899    };
900
901    let arguments: Map<String, Value> = if parsed.stdin {
902        match read_stdin_json("MCP tool arguments")? {
903            Value::Object(map) => map,
904            other => {
905                return Err(Error::runtime(format!(
906                    "MCP --stdin expects a JSON object, got {}",
907                    other
908                )));
909            }
910        }
911    } else {
912        parsed.values.into_iter().collect()
913    };
914
915    let tool_name = parsed
916        .command
917        .tool_name
918        .clone()
919        .unwrap_or_else(|| parsed.command.name.clone());
920
921    let data = crate::session::session_request(
922        name,
923        "call_tool",
924        serde_json::json!({
925            "name": tool_name,
926            "arguments": arguments,
927            "full_envelope": pre.full_envelope(),
928        }),
929    )?;
930    output_result(data, &pre.output_options())?;
931    let _ = record_usage(&src_hash, &tool_name);
932    Ok(())
933}
934
935fn print_help() {
936    eprintln!(
937        "\
938skiff {version} — Turn any MCP server, OpenAPI spec, or GraphQL endpoint into a CLI
939
940Usage:
941  skiff --spec <URL|FILE> [--list] [command]
942  skiff --mcp <URL> [--list] [command]
943  skiff --mcp-stdio <CMD> [--list] [command]
944  skiff --graphql <URL> [--list] [command]
945  skiff --mcp-stdio <CMD> --session-start <NAME>
946  skiff --session <NAME> [--list] [command]
947  skiff bake <create|list|show|remove|update|install> ...
948  skiff @<name> ...
949",
950        version = env!("CARGO_PKG_VERSION")
951    );
952}