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}
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 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}