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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 Mcp {
137 #[arg(long)]
139 http: bool,
140 #[arg(long, default_value = "127.0.0.1:8000", requires = "http")]
142 bind: String,
143 },
144 Skill {
146 #[command(subcommand)]
147 command: Option<SkillCommands>,
148 },
149 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 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}