Skip to main content

ascend_tools_cli/
cli.rs

1use anyhow::Result;
2use ascend_tools::client::AscendClient;
3use ascend_tools::config::Config;
4use clap::{CommandFactory, Parser, Subcommand};
5use std::ffi::OsString;
6
7use crate::common::OutputMode;
8use crate::deployment::DeploymentCommands;
9use crate::environment::EnvironmentCommands;
10use crate::flow::FlowCommands;
11use crate::otto::OttoCommands;
12use crate::profile::ProfileCommands;
13use crate::project::ProjectCommands;
14use crate::skill::SkillCommands;
15use crate::workspace::WorkspaceCommands;
16
17#[derive(Parser)]
18#[command(
19    name = "ascend-tools",
20    version,
21    about = "CLI for the Ascend Instance web API"
22)]
23pub(crate) struct CliParser {
24    #[arg(short, long, global = true, value_enum, default_value_t = OutputMode::Text)]
25    output: OutputMode,
26
27    /// Service account ID
28    #[arg(
29        long,
30        global = true,
31        env = "ASCEND_SERVICE_ACCOUNT_ID",
32        hide_env_values = true
33    )]
34    service_account_id: Option<String>,
35
36    /// Service account key
37    #[arg(
38        long,
39        global = true,
40        env = "ASCEND_SERVICE_ACCOUNT_KEY",
41        hide_env_values = true
42    )]
43    service_account_key: Option<String>,
44
45    /// Instance API URL
46    #[arg(long, global = true, env = "ASCEND_INSTANCE_API_URL")]
47    instance_api_url: Option<String>,
48
49    #[command(subcommand)]
50    command: Option<Commands>,
51}
52
53#[derive(Subcommand)]
54enum Commands {
55    /// Manage workspaces
56    #[command(long_about = "Manage Ascend workspaces.\n\n\
57            Examples:\n  \
58            ascend-tools workspace list\n  \
59            ascend-tools workspace list --environment Production\n  \
60            ascend-tools workspace get my-workspace\n  \
61            ascend-tools workspace create --title my-ws --environment Production --project MyProject --profile default --git-branch main\n  \
62            ascend-tools workspace pause my-workspace\n  \
63            ascend-tools workspace resume my-workspace")]
64    Workspace {
65        #[command(subcommand)]
66        command: Option<WorkspaceCommands>,
67    },
68    /// Manage deployments
69    #[command(long_about = "Manage Ascend deployments.\n\n\
70            Examples:\n  \
71            ascend-tools deployment list\n  \
72            ascend-tools deployment list --project MyProject\n  \
73            ascend-tools deployment get prod\n  \
74            ascend-tools deployment create --title prod --environment Production --project MyProject --profile default --git-branch main\n  \
75            ascend-tools deployment pause-automations prod\n  \
76            ascend-tools deployment resume-automations prod")]
77    Deployment {
78        #[command(subcommand)]
79        command: Option<DeploymentCommands>,
80    },
81    /// Manage flows and flow runs
82    #[command(
83        long_about = "Manage flows and flow runs in a workspace or deployment.\n\n\
84            Examples:\n  \
85            ascend-tools flow list --workspace my-ws\n  \
86            ascend-tools flow run sales --workspace my-ws\n  \
87            ascend-tools flow run sales --deployment prod --resume\n  \
88            ascend-tools flow list-runs --workspace my-ws --status running\n  \
89            ascend-tools flow get-run fr-abc123 --workspace my-ws"
90    )]
91    Flow {
92        #[command(subcommand)]
93        command: Option<FlowCommands>,
94    },
95    /// Manage environments
96    #[command(long_about = "Manage Ascend environments.\n\n\
97            Examples:\n  \
98            ascend-tools environment list\n  \
99            ascend-tools environment get Production")]
100    Environment {
101        #[command(subcommand)]
102        command: Option<EnvironmentCommands>,
103    },
104    /// Manage projects
105    #[command(long_about = "Manage Ascend projects.\n\n\
106            Examples:\n  \
107            ascend-tools project list\n  \
108            ascend-tools project get \"My Project\"")]
109    Project {
110        #[command(subcommand)]
111        command: Option<ProjectCommands>,
112    },
113    /// Manage profiles
114    #[command(long_about = "Manage Ascend profiles.\n\n\
115            Examples:\n  \
116            ascend-tools profile list --workspace my-ws\n  \
117            ascend-tools profile list --project MyProject --git-branch main")]
118    Profile {
119        #[command(subcommand)]
120        command: Option<ProfileCommands>,
121    },
122    /// Chat with Otto AI assistant
123    #[command(long_about = "Chat with Otto AI assistant.\n\n\
124            Examples:\n  \
125            ascend-tools otto run \"What tables are in my project?\"\n  \
126            ascend-tools otto run \"Describe the sales flow\" --workspace my-ws\n  \
127            ascend-tools otto run \"Hello\" --model gpt-4o\n  \
128            ascend-tools otto run \"Hello\" --provider \"OpenAI\" --model gpt-4o\n  \
129            ascend-tools otto provider list\n  \
130            ascend-tools otto model list")]
131    Otto {
132        #[command(subcommand)]
133        command: Option<OttoCommands>,
134    },
135    /// Start an MCP server
136    Mcp {
137        /// Use HTTP transport instead of stdio
138        #[arg(long)]
139        http: bool,
140        /// Bind address for HTTP transport
141        #[arg(long, default_value = "127.0.0.1:8000", requires = "http")]
142        bind: String,
143    },
144    /// Manage skills
145    Skill {
146        #[command(subcommand)]
147        command: Option<SkillCommands>,
148    },
149    /// Open the Ascend signup page in your browser
150    Signup,
151}
152
153pub fn run_cli<I, T>(args: I) -> Result<()>
154where
155    I: IntoIterator<Item = T>,
156    T: Into<OsString> + Clone,
157{
158    let cli = CliParser::parse_from(args);
159
160    let Some(command) = cli.command else {
161        CliParser::command().print_help()?;
162        return Ok(());
163    };
164
165    // Commands that don't require authentication
166    if let Commands::Signup = command {
167        eprintln!("Opening https://app.ascend.io/signup in your browser...");
168        open::that("https://app.ascend.io/signup")?;
169        return Ok(());
170    }
171
172    if let Commands::Skill { command } = command {
173        return crate::skill::handle_skill(command);
174    }
175
176    if let Commands::Mcp { http, bind } = command {
177        let config = Config::with_overrides(
178            cli.service_account_id.as_deref(),
179            cli.service_account_key.as_deref(),
180            cli.instance_api_url.as_deref(),
181        );
182        let rt = tokio::runtime::Runtime::new()?;
183        return if http {
184            rt.block_on(ascend_tools_mcp::run_http(config, &bind))
185        } else {
186            rt.block_on(ascend_tools_mcp::run_stdio(config))
187        };
188    }
189
190    let config = Config::with_overrides(
191        cli.service_account_id.as_deref(),
192        cli.service_account_key.as_deref(),
193        cli.instance_api_url.as_deref(),
194    )?;
195
196    let client = AscendClient::new(config)?;
197
198    match command {
199        Commands::Workspace { command } => {
200            crate::workspace::handle_workspace(&client, command, &cli.output)
201        }
202        Commands::Deployment { command } => {
203            crate::deployment::handle_deployment(&client, command, &cli.output)
204        }
205        Commands::Environment { command } => {
206            crate::environment::handle_environment(&client, command, &cli.output)
207        }
208        Commands::Project { command } => {
209            crate::project::handle_project(&client, command, &cli.output)
210        }
211        Commands::Profile { command } => {
212            crate::profile::handle_profile(&client, command, &cli.output)
213        }
214        Commands::Flow { command } => crate::flow::handle_flow(&client, command, &cli.output),
215        Commands::Otto { command } => crate::otto::handle_otto_cmd(&client, command, &cli.output),
216        Commands::Mcp { .. } | Commands::Skill { .. } | Commands::Signup => unreachable!(),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::common;
224    use crate::otto;
225
226    #[test]
227    fn test_cli_parses_workspace_list() {
228        let cli = CliParser::parse_from(["ascend-tools", "workspace", "list"]);
229        assert!(matches!(
230            cli.command,
231            Some(Commands::Workspace {
232                command: Some(WorkspaceCommands::List { .. })
233            })
234        ));
235    }
236
237    #[test]
238    fn test_cli_parses_workspace_list_with_environment() {
239        let cli = CliParser::parse_from([
240            "ascend-tools",
241            "workspace",
242            "list",
243            "--environment",
244            "Production",
245        ]);
246        match cli.command {
247            Some(Commands::Workspace {
248                command:
249                    Some(WorkspaceCommands::List {
250                        environment,
251                        project,
252                        ..
253                    }),
254            }) => {
255                assert_eq!(environment.as_deref(), Some("Production"));
256                assert!(project.is_none());
257            }
258            _ => panic!("expected Workspace List command"),
259        }
260    }
261
262    #[test]
263    fn test_cli_parses_workspace_list_with_project() {
264        let cli = CliParser::parse_from([
265            "ascend-tools",
266            "workspace",
267            "list",
268            "--project",
269            "MyProject",
270        ]);
271        match cli.command {
272            Some(Commands::Workspace {
273                command:
274                    Some(WorkspaceCommands::List {
275                        project,
276                        environment,
277                        ..
278                    }),
279            }) => {
280                assert_eq!(project.as_deref(), Some("MyProject"));
281                assert!(environment.is_none());
282            }
283            _ => panic!("expected Workspace List command"),
284        }
285    }
286
287    #[test]
288    fn test_cli_parses_workspace_get() {
289        let cli = CliParser::parse_from(["ascend-tools", "workspace", "get", "my-workspace"]);
290        match cli.command {
291            Some(Commands::Workspace {
292                command: Some(WorkspaceCommands::Get { title, uuid }),
293            }) => {
294                assert_eq!(title, "my-workspace");
295                assert!(uuid.is_none());
296            }
297            _ => panic!("expected Workspace Get command"),
298        }
299    }
300
301    #[test]
302    fn test_cli_parses_workspace_get_with_uuid() {
303        let cli = CliParser::parse_from([
304            "ascend-tools",
305            "workspace",
306            "get",
307            "ignored",
308            "--uuid",
309            "abc-123",
310        ]);
311        match cli.command {
312            Some(Commands::Workspace {
313                command: Some(WorkspaceCommands::Get { uuid, .. }),
314            }) => {
315                assert_eq!(uuid.as_deref(), Some("abc-123"));
316            }
317            _ => panic!("expected Workspace Get command"),
318        }
319    }
320
321    #[test]
322    fn test_cli_parses_workspace_create() {
323        let cli = CliParser::parse_from([
324            "ascend-tools",
325            "workspace",
326            "create",
327            "--title",
328            "My WS",
329            "--environment",
330            "Production",
331            "--project",
332            "MyProject",
333            "--profile",
334            "default",
335            "--git-branch",
336            "main",
337            "--size",
338            "Medium",
339        ]);
340        match cli.command {
341            Some(Commands::Workspace {
342                command:
343                    Some(WorkspaceCommands::Create {
344                        title,
345                        environment,
346                        project,
347                        size,
348                        profile,
349                        ..
350                    }),
351            }) => {
352                assert_eq!(title, "My WS");
353                assert_eq!(environment, "Production");
354                assert_eq!(project, "MyProject");
355                assert_eq!(size.as_deref(), Some("Medium"));
356                assert_eq!(profile, "default");
357            }
358            _ => panic!("expected Workspace Create command"),
359        }
360    }
361
362    #[test]
363    fn test_cli_parses_workspace_pause() {
364        let cli = CliParser::parse_from(["ascend-tools", "workspace", "pause", "my-workspace"]);
365        assert!(matches!(
366            cli.command,
367            Some(Commands::Workspace {
368                command: Some(WorkspaceCommands::Pause { .. })
369            })
370        ));
371    }
372
373    #[test]
374    fn test_cli_parses_workspace_delete_yes() {
375        let cli = CliParser::parse_from(["ascend-tools", "workspace", "delete", "old-ws", "--yes"]);
376        match cli.command {
377            Some(Commands::Workspace {
378                command: Some(WorkspaceCommands::Delete { title, yes, .. }),
379            }) => {
380                assert_eq!(title, "old-ws");
381                assert!(yes);
382            }
383            _ => panic!("expected Workspace Delete command"),
384        }
385    }
386
387    #[test]
388    fn test_cli_parses_deployment_list() {
389        let cli = CliParser::parse_from(["ascend-tools", "deployment", "list"]);
390        assert!(matches!(
391            cli.command,
392            Some(Commands::Deployment {
393                command: Some(DeploymentCommands::List { .. })
394            })
395        ));
396    }
397
398    #[test]
399    fn test_cli_parses_deployment_create() {
400        let cli = CliParser::parse_from([
401            "ascend-tools",
402            "deployment",
403            "create",
404            "--title",
405            "prod",
406            "--environment",
407            "Production",
408            "--project",
409            "MyProject",
410            "--profile",
411            "default",
412            "--git-branch",
413            "main",
414        ]);
415        match cli.command {
416            Some(Commands::Deployment {
417                command:
418                    Some(DeploymentCommands::Create {
419                        title,
420                        environment,
421                        project,
422                        ..
423                    }),
424            }) => {
425                assert_eq!(title, "prod");
426                assert_eq!(environment, "Production");
427                assert_eq!(project, "MyProject");
428            }
429            _ => panic!("expected Deployment Create command"),
430        }
431    }
432
433    #[test]
434    fn test_cli_parses_deployment_pause_automations() {
435        let cli =
436            CliParser::parse_from(["ascend-tools", "deployment", "pause-automations", "prod"]);
437        match cli.command {
438            Some(Commands::Deployment {
439                command: Some(DeploymentCommands::PauseAutomations { title, .. }),
440            }) => {
441                assert_eq!(title, "prod");
442            }
443            _ => panic!("expected Deployment PauseAutomations command"),
444        }
445    }
446
447    #[test]
448    fn test_cli_parses_deployment_resume_automations() {
449        let cli =
450            CliParser::parse_from(["ascend-tools", "deployment", "resume-automations", "prod"]);
451        assert!(matches!(
452            cli.command,
453            Some(Commands::Deployment {
454                command: Some(DeploymentCommands::ResumeAutomations { .. })
455            })
456        ));
457    }
458
459    #[test]
460    fn test_cli_parses_flow_list_with_workspace() {
461        let cli = CliParser::parse_from(["ascend-tools", "flow", "list", "--workspace", "my-ws"]);
462        match cli.command {
463            Some(Commands::Flow {
464                command: Some(FlowCommands::List { workspace, .. }),
465            }) => {
466                assert_eq!(workspace.as_deref(), Some("my-ws"));
467            }
468            _ => panic!("expected Flow List command"),
469        }
470    }
471
472    #[test]
473    fn test_cli_parses_flow_run_with_deployment() {
474        let cli = CliParser::parse_from([
475            "ascend-tools",
476            "flow",
477            "run",
478            "sales",
479            "--deployment",
480            "prod",
481        ]);
482        match cli.command {
483            Some(Commands::Flow {
484                command:
485                    Some(FlowCommands::Run {
486                        flow, deployment, ..
487                    }),
488            }) => {
489                assert_eq!(flow, "sales");
490                assert_eq!(deployment.as_deref(), Some("prod"));
491            }
492            _ => panic!("expected Flow Run command"),
493        }
494    }
495
496    #[test]
497    fn test_cli_no_subcommand_is_none() {
498        let cli = CliParser::parse_from(["ascend-tools"]);
499        assert!(cli.command.is_none());
500    }
501
502    #[test]
503    fn test_cli_parses_output_json() {
504        let cli = CliParser::parse_from(["ascend-tools", "-o", "json", "workspace", "list"]);
505        assert!(matches!(cli.output, OutputMode::Json));
506    }
507
508    #[test]
509    fn test_cli_default_output_is_text() {
510        let cli = CliParser::parse_from(["ascend-tools", "workspace", "list"]);
511        assert!(cli.output == OutputMode::Text);
512    }
513
514    #[test]
515    fn test_parse_spec_valid() {
516        let result = common::parse_spec(Some(r#"{"key": "value"}"#.to_string()));
517        assert!(result.is_ok());
518        assert!(result.unwrap().is_some());
519    }
520
521    #[test]
522    fn test_parse_spec_none() {
523        let result = common::parse_spec(None);
524        assert!(result.is_ok());
525        assert!(result.unwrap().is_none());
526    }
527
528    #[test]
529    fn test_parse_spec_invalid() {
530        let result = common::parse_spec(Some("not json".to_string()));
531        assert!(result.is_err());
532    }
533
534    #[test]
535    fn test_print_table_empty() {
536        common::print_table(&["A", "B"], &[]);
537    }
538
539    #[test]
540    fn test_print_table_rows() {
541        common::print_table(
542            &["ID", "NAME"],
543            &[
544                vec!["1".into(), "alice".into()],
545                vec!["1000".into(), "b".into()],
546            ],
547        );
548    }
549
550    #[test]
551    fn test_cli_parses_skill_install() {
552        let cli = CliParser::parse_from([
553            "ascend-tools",
554            "skill",
555            "install",
556            "--target",
557            "./.claude/skills",
558        ]);
559        assert!(matches!(
560            cli.command,
561            Some(Commands::Skill {
562                command: Some(SkillCommands::Install { .. })
563            })
564        ));
565    }
566
567    #[test]
568    fn test_cli_parses_mcp_stdio() {
569        let cli = CliParser::parse_from(["ascend-tools", "mcp"]);
570        assert!(matches!(
571            cli.command,
572            Some(Commands::Mcp { http: false, .. })
573        ));
574    }
575
576    #[test]
577    fn test_cli_parses_mcp_http() {
578        let cli = CliParser::parse_from(["ascend-tools", "mcp", "--http"]);
579        assert!(matches!(
580            cli.command,
581            Some(Commands::Mcp { http: true, .. })
582        ));
583    }
584
585    #[test]
586    fn test_cli_parses_signup() {
587        let cli = CliParser::parse_from(["ascend-tools", "signup"]);
588        assert!(matches!(cli.command, Some(Commands::Signup)));
589    }
590
591    #[test]
592    fn test_cli_parses_environment_list() {
593        let cli = CliParser::parse_from(["ascend-tools", "environment", "list"]);
594        assert!(matches!(
595            cli.command,
596            Some(Commands::Environment {
597                command: Some(EnvironmentCommands::List)
598            })
599        ));
600    }
601
602    #[test]
603    fn test_cli_parses_environment_bare_is_none() {
604        let cli = CliParser::parse_from(["ascend-tools", "environment"]);
605        assert!(matches!(
606            cli.command,
607            Some(Commands::Environment { command: None })
608        ));
609    }
610
611    #[test]
612    fn test_cli_parses_project_list() {
613        let cli = CliParser::parse_from(["ascend-tools", "project", "list"]);
614        assert!(matches!(
615            cli.command,
616            Some(Commands::Project {
617                command: Some(ProjectCommands::List)
618            })
619        ));
620    }
621
622    #[test]
623    fn test_cli_parses_profile_list_with_workspace() {
624        let cli = CliParser::parse_from(["ascend-tools", "profile", "list", "--workspace", "Cody"]);
625        match cli.command {
626            Some(Commands::Profile {
627                command:
628                    Some(ProfileCommands::List {
629                        workspace,
630                        deployment,
631                        project,
632                        ..
633                    }),
634            }) => {
635                assert_eq!(workspace.as_deref(), Some("Cody"));
636                assert!(deployment.is_none());
637                assert!(project.is_none());
638            }
639            _ => panic!("expected Profile List command"),
640        }
641    }
642
643    #[test]
644    fn test_cli_parses_profile_list_with_project_and_branch() {
645        let cli = CliParser::parse_from([
646            "ascend-tools",
647            "profile",
648            "list",
649            "--project",
650            "MyProject",
651            "--git-branch",
652            "main",
653        ]);
654        match cli.command {
655            Some(Commands::Profile {
656                command:
657                    Some(ProfileCommands::List {
658                        project, branch, ..
659                    }),
660            }) => {
661                assert_eq!(project.as_deref(), Some("MyProject"));
662                assert_eq!(branch.as_deref(), Some("main"));
663            }
664            _ => panic!("expected Profile List command"),
665        }
666    }
667
668    #[test]
669    fn test_cli_parses_otto_run() {
670        let cli = CliParser::parse_from([
671            "ascend-tools",
672            "otto",
673            "run",
674            "send me an email",
675            "--workspace",
676            "my-ws",
677            "--model",
678            "gpt-4o",
679        ]);
680        match cli.command {
681            Some(Commands::Otto {
682                command:
683                    Some(OttoCommands::Run {
684                        prompt,
685                        workspace,
686                        model,
687                        ..
688                    }),
689            }) => {
690                assert_eq!(prompt, "send me an email");
691                assert_eq!(workspace.as_deref(), Some("my-ws"));
692                assert_eq!(model.as_deref(), Some("gpt-4o"));
693            }
694            _ => panic!("expected Otto Run command"),
695        }
696    }
697
698    #[test]
699    fn test_cli_parses_otto_provider() {
700        let cli = CliParser::parse_from(["ascend-tools", "otto", "provider", "list"]);
701        assert!(matches!(
702            cli.command,
703            Some(Commands::Otto {
704                command: Some(OttoCommands::Provider {
705                    command: Some(otto::ProviderCommands::List)
706                })
707            })
708        ));
709    }
710
711    #[test]
712    fn test_cli_parses_otto_model() {
713        let cli = CliParser::parse_from([
714            "ascend-tools",
715            "otto",
716            "model",
717            "list",
718            "--provider",
719            "openai",
720        ]);
721        match cli.command {
722            Some(Commands::Otto {
723                command:
724                    Some(OttoCommands::Model {
725                        command: Some(otto::ModelCommands::List { provider }),
726                    }),
727            }) => {
728                assert_eq!(provider.as_deref(), Some("openai"));
729            }
730            _ => panic!("expected Otto Model List command"),
731        }
732    }
733}