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}
150
151pub fn run_cli<I, T>(args: I) -> Result<()>
152where
153    I: IntoIterator<Item = T>,
154    T: Into<OsString> + Clone,
155{
156    let cli = CliParser::parse_from(args);
157
158    let Some(command) = cli.command else {
159        CliParser::command().print_help()?;
160        return Ok(());
161    };
162
163    // Commands that don't require authentication
164    if let Commands::Skill { command } = command {
165        return crate::skill::handle_skill(command);
166    }
167
168    if let Commands::Mcp { http, bind } = command {
169        let config = Config::with_overrides(
170            cli.service_account_id.as_deref(),
171            cli.service_account_key.as_deref(),
172            cli.instance_api_url.as_deref(),
173        );
174        let rt = tokio::runtime::Runtime::new()?;
175        return if http {
176            rt.block_on(ascend_tools_mcp::run_http(config, &bind))
177        } else {
178            rt.block_on(ascend_tools_mcp::run_stdio(config))
179        };
180    }
181
182    let config = Config::with_overrides(
183        cli.service_account_id.as_deref(),
184        cli.service_account_key.as_deref(),
185        cli.instance_api_url.as_deref(),
186    )?;
187
188    let client = AscendClient::new(config)?;
189
190    match command {
191        Commands::Workspace { command } => {
192            crate::workspace::handle_workspace(&client, command, &cli.output)
193        }
194        Commands::Deployment { command } => {
195            crate::deployment::handle_deployment(&client, command, &cli.output)
196        }
197        Commands::Environment { command } => {
198            crate::environment::handle_environment(&client, command, &cli.output)
199        }
200        Commands::Project { command } => {
201            crate::project::handle_project(&client, command, &cli.output)
202        }
203        Commands::Profile { command } => {
204            crate::profile::handle_profile(&client, command, &cli.output)
205        }
206        Commands::Flow { command } => crate::flow::handle_flow(&client, command, &cli.output),
207        Commands::Otto { command } => crate::otto::handle_otto_cmd(&client, command, &cli.output),
208        Commands::Mcp { .. } | Commands::Skill { .. } => unreachable!(),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::common;
216    use crate::otto;
217
218    #[test]
219    fn test_cli_parses_workspace_list() {
220        let cli = CliParser::parse_from(["ascend-tools", "workspace", "list"]);
221        assert!(matches!(
222            cli.command,
223            Some(Commands::Workspace {
224                command: Some(WorkspaceCommands::List { .. })
225            })
226        ));
227    }
228
229    #[test]
230    fn test_cli_parses_workspace_list_with_environment() {
231        let cli = CliParser::parse_from([
232            "ascend-tools",
233            "workspace",
234            "list",
235            "--environment",
236            "Production",
237        ]);
238        match cli.command {
239            Some(Commands::Workspace {
240                command:
241                    Some(WorkspaceCommands::List {
242                        environment,
243                        project,
244                        ..
245                    }),
246            }) => {
247                assert_eq!(environment.as_deref(), Some("Production"));
248                assert!(project.is_none());
249            }
250            _ => panic!("expected Workspace List command"),
251        }
252    }
253
254    #[test]
255    fn test_cli_parses_workspace_list_with_project() {
256        let cli = CliParser::parse_from([
257            "ascend-tools",
258            "workspace",
259            "list",
260            "--project",
261            "MyProject",
262        ]);
263        match cli.command {
264            Some(Commands::Workspace {
265                command:
266                    Some(WorkspaceCommands::List {
267                        project,
268                        environment,
269                        ..
270                    }),
271            }) => {
272                assert_eq!(project.as_deref(), Some("MyProject"));
273                assert!(environment.is_none());
274            }
275            _ => panic!("expected Workspace List command"),
276        }
277    }
278
279    #[test]
280    fn test_cli_parses_workspace_get() {
281        let cli = CliParser::parse_from(["ascend-tools", "workspace", "get", "my-workspace"]);
282        match cli.command {
283            Some(Commands::Workspace {
284                command: Some(WorkspaceCommands::Get { title, uuid }),
285            }) => {
286                assert_eq!(title, "my-workspace");
287                assert!(uuid.is_none());
288            }
289            _ => panic!("expected Workspace Get command"),
290        }
291    }
292
293    #[test]
294    fn test_cli_parses_workspace_get_with_uuid() {
295        let cli = CliParser::parse_from([
296            "ascend-tools",
297            "workspace",
298            "get",
299            "ignored",
300            "--uuid",
301            "abc-123",
302        ]);
303        match cli.command {
304            Some(Commands::Workspace {
305                command: Some(WorkspaceCommands::Get { uuid, .. }),
306            }) => {
307                assert_eq!(uuid.as_deref(), Some("abc-123"));
308            }
309            _ => panic!("expected Workspace Get command"),
310        }
311    }
312
313    #[test]
314    fn test_cli_parses_workspace_create() {
315        let cli = CliParser::parse_from([
316            "ascend-tools",
317            "workspace",
318            "create",
319            "--title",
320            "My WS",
321            "--environment",
322            "Production",
323            "--project",
324            "MyProject",
325            "--profile",
326            "default",
327            "--git-branch",
328            "main",
329            "--size",
330            "Medium",
331        ]);
332        match cli.command {
333            Some(Commands::Workspace {
334                command:
335                    Some(WorkspaceCommands::Create {
336                        title,
337                        environment,
338                        project,
339                        size,
340                        profile,
341                        ..
342                    }),
343            }) => {
344                assert_eq!(title, "My WS");
345                assert_eq!(environment, "Production");
346                assert_eq!(project, "MyProject");
347                assert_eq!(size.as_deref(), Some("Medium"));
348                assert_eq!(profile, "default");
349            }
350            _ => panic!("expected Workspace Create command"),
351        }
352    }
353
354    #[test]
355    fn test_cli_parses_workspace_pause() {
356        let cli = CliParser::parse_from(["ascend-tools", "workspace", "pause", "my-workspace"]);
357        assert!(matches!(
358            cli.command,
359            Some(Commands::Workspace {
360                command: Some(WorkspaceCommands::Pause { .. })
361            })
362        ));
363    }
364
365    #[test]
366    fn test_cli_parses_workspace_delete_yes() {
367        let cli = CliParser::parse_from(["ascend-tools", "workspace", "delete", "old-ws", "--yes"]);
368        match cli.command {
369            Some(Commands::Workspace {
370                command: Some(WorkspaceCommands::Delete { title, yes, .. }),
371            }) => {
372                assert_eq!(title, "old-ws");
373                assert!(yes);
374            }
375            _ => panic!("expected Workspace Delete command"),
376        }
377    }
378
379    #[test]
380    fn test_cli_parses_deployment_list() {
381        let cli = CliParser::parse_from(["ascend-tools", "deployment", "list"]);
382        assert!(matches!(
383            cli.command,
384            Some(Commands::Deployment {
385                command: Some(DeploymentCommands::List { .. })
386            })
387        ));
388    }
389
390    #[test]
391    fn test_cli_parses_deployment_create() {
392        let cli = CliParser::parse_from([
393            "ascend-tools",
394            "deployment",
395            "create",
396            "--title",
397            "prod",
398            "--environment",
399            "Production",
400            "--project",
401            "MyProject",
402            "--profile",
403            "default",
404            "--git-branch",
405            "main",
406        ]);
407        match cli.command {
408            Some(Commands::Deployment {
409                command:
410                    Some(DeploymentCommands::Create {
411                        title,
412                        environment,
413                        project,
414                        ..
415                    }),
416            }) => {
417                assert_eq!(title, "prod");
418                assert_eq!(environment, "Production");
419                assert_eq!(project, "MyProject");
420            }
421            _ => panic!("expected Deployment Create command"),
422        }
423    }
424
425    #[test]
426    fn test_cli_parses_deployment_pause_automations() {
427        let cli =
428            CliParser::parse_from(["ascend-tools", "deployment", "pause-automations", "prod"]);
429        match cli.command {
430            Some(Commands::Deployment {
431                command: Some(DeploymentCommands::PauseAutomations { title, .. }),
432            }) => {
433                assert_eq!(title, "prod");
434            }
435            _ => panic!("expected Deployment PauseAutomations command"),
436        }
437    }
438
439    #[test]
440    fn test_cli_parses_deployment_resume_automations() {
441        let cli =
442            CliParser::parse_from(["ascend-tools", "deployment", "resume-automations", "prod"]);
443        assert!(matches!(
444            cli.command,
445            Some(Commands::Deployment {
446                command: Some(DeploymentCommands::ResumeAutomations { .. })
447            })
448        ));
449    }
450
451    #[test]
452    fn test_cli_parses_flow_list_with_workspace() {
453        let cli = CliParser::parse_from(["ascend-tools", "flow", "list", "--workspace", "my-ws"]);
454        match cli.command {
455            Some(Commands::Flow {
456                command: Some(FlowCommands::List { workspace, .. }),
457            }) => {
458                assert_eq!(workspace.as_deref(), Some("my-ws"));
459            }
460            _ => panic!("expected Flow List command"),
461        }
462    }
463
464    #[test]
465    fn test_cli_parses_flow_run_with_deployment() {
466        let cli = CliParser::parse_from([
467            "ascend-tools",
468            "flow",
469            "run",
470            "sales",
471            "--deployment",
472            "prod",
473        ]);
474        match cli.command {
475            Some(Commands::Flow {
476                command:
477                    Some(FlowCommands::Run {
478                        flow, deployment, ..
479                    }),
480            }) => {
481                assert_eq!(flow, "sales");
482                assert_eq!(deployment.as_deref(), Some("prod"));
483            }
484            _ => panic!("expected Flow Run command"),
485        }
486    }
487
488    #[test]
489    fn test_cli_no_subcommand_is_none() {
490        let cli = CliParser::parse_from(["ascend-tools"]);
491        assert!(cli.command.is_none());
492    }
493
494    #[test]
495    fn test_cli_parses_output_json() {
496        let cli = CliParser::parse_from(["ascend-tools", "-o", "json", "workspace", "list"]);
497        assert!(matches!(cli.output, OutputMode::Json));
498    }
499
500    #[test]
501    fn test_cli_default_output_is_text() {
502        let cli = CliParser::parse_from(["ascend-tools", "workspace", "list"]);
503        assert!(cli.output == OutputMode::Text);
504    }
505
506    #[test]
507    fn test_parse_spec_valid() {
508        let result = common::parse_spec(Some(r#"{"key": "value"}"#.to_string()));
509        assert!(result.is_ok());
510        assert!(result.unwrap().is_some());
511    }
512
513    #[test]
514    fn test_parse_spec_none() {
515        let result = common::parse_spec(None);
516        assert!(result.is_ok());
517        assert!(result.unwrap().is_none());
518    }
519
520    #[test]
521    fn test_parse_spec_invalid() {
522        let result = common::parse_spec(Some("not json".to_string()));
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn test_print_table_empty() {
528        common::print_table(&["A", "B"], &[]);
529    }
530
531    #[test]
532    fn test_print_table_rows() {
533        common::print_table(
534            &["ID", "NAME"],
535            &[
536                vec!["1".into(), "alice".into()],
537                vec!["1000".into(), "b".into()],
538            ],
539        );
540    }
541
542    #[test]
543    fn test_cli_parses_skill_install() {
544        let cli = CliParser::parse_from([
545            "ascend-tools",
546            "skill",
547            "install",
548            "--target",
549            "./.claude/skills",
550        ]);
551        assert!(matches!(
552            cli.command,
553            Some(Commands::Skill {
554                command: Some(SkillCommands::Install { .. })
555            })
556        ));
557    }
558
559    #[test]
560    fn test_cli_parses_mcp_stdio() {
561        let cli = CliParser::parse_from(["ascend-tools", "mcp"]);
562        assert!(matches!(
563            cli.command,
564            Some(Commands::Mcp { http: false, .. })
565        ));
566    }
567
568    #[test]
569    fn test_cli_parses_mcp_http() {
570        let cli = CliParser::parse_from(["ascend-tools", "mcp", "--http"]);
571        assert!(matches!(
572            cli.command,
573            Some(Commands::Mcp { http: true, .. })
574        ));
575    }
576
577    #[test]
578    fn test_cli_parses_environment_list() {
579        let cli = CliParser::parse_from(["ascend-tools", "environment", "list"]);
580        assert!(matches!(
581            cli.command,
582            Some(Commands::Environment {
583                command: Some(EnvironmentCommands::List)
584            })
585        ));
586    }
587
588    #[test]
589    fn test_cli_parses_environment_bare_is_none() {
590        let cli = CliParser::parse_from(["ascend-tools", "environment"]);
591        assert!(matches!(
592            cli.command,
593            Some(Commands::Environment { command: None })
594        ));
595    }
596
597    #[test]
598    fn test_cli_parses_project_list() {
599        let cli = CliParser::parse_from(["ascend-tools", "project", "list"]);
600        assert!(matches!(
601            cli.command,
602            Some(Commands::Project {
603                command: Some(ProjectCommands::List)
604            })
605        ));
606    }
607
608    #[test]
609    fn test_cli_parses_profile_list_with_workspace() {
610        let cli = CliParser::parse_from(["ascend-tools", "profile", "list", "--workspace", "Cody"]);
611        match cli.command {
612            Some(Commands::Profile {
613                command:
614                    Some(ProfileCommands::List {
615                        workspace,
616                        deployment,
617                        project,
618                        ..
619                    }),
620            }) => {
621                assert_eq!(workspace.as_deref(), Some("Cody"));
622                assert!(deployment.is_none());
623                assert!(project.is_none());
624            }
625            _ => panic!("expected Profile List command"),
626        }
627    }
628
629    #[test]
630    fn test_cli_parses_profile_list_with_project_and_branch() {
631        let cli = CliParser::parse_from([
632            "ascend-tools",
633            "profile",
634            "list",
635            "--project",
636            "MyProject",
637            "--git-branch",
638            "main",
639        ]);
640        match cli.command {
641            Some(Commands::Profile {
642                command:
643                    Some(ProfileCommands::List {
644                        project, branch, ..
645                    }),
646            }) => {
647                assert_eq!(project.as_deref(), Some("MyProject"));
648                assert_eq!(branch.as_deref(), Some("main"));
649            }
650            _ => panic!("expected Profile List command"),
651        }
652    }
653
654    #[test]
655    fn test_cli_parses_otto_run() {
656        let cli = CliParser::parse_from([
657            "ascend-tools",
658            "otto",
659            "run",
660            "send me an email",
661            "--workspace",
662            "my-ws",
663            "--model",
664            "gpt-4o",
665        ]);
666        match cli.command {
667            Some(Commands::Otto {
668                command:
669                    Some(OttoCommands::Run {
670                        prompt,
671                        workspace,
672                        model,
673                        ..
674                    }),
675            }) => {
676                assert_eq!(prompt, "send me an email");
677                assert_eq!(workspace.as_deref(), Some("my-ws"));
678                assert_eq!(model.as_deref(), Some("gpt-4o"));
679            }
680            _ => panic!("expected Otto Run command"),
681        }
682    }
683
684    #[test]
685    fn test_cli_parses_otto_provider() {
686        let cli = CliParser::parse_from(["ascend-tools", "otto", "provider", "list"]);
687        assert!(matches!(
688            cli.command,
689            Some(Commands::Otto {
690                command: Some(OttoCommands::Provider {
691                    command: Some(otto::ProviderCommands::List)
692                })
693            })
694        ));
695    }
696
697    #[test]
698    fn test_cli_parses_otto_model() {
699        let cli = CliParser::parse_from([
700            "ascend-tools",
701            "otto",
702            "model",
703            "list",
704            "--provider",
705            "openai",
706        ]);
707        match cli.command {
708            Some(Commands::Otto {
709                command:
710                    Some(OttoCommands::Model {
711                        command: Some(otto::ModelCommands::List { provider }),
712                    }),
713            }) => {
714                assert_eq!(provider.as_deref(), Some("openai"));
715            }
716            _ => panic!("expected Otto Model List command"),
717        }
718    }
719}