1mod custom_field;
4mod project;
5mod section;
6mod tag;
7mod task;
8mod user;
9mod workspace;
10
11use crate::api::{ApiClient, ApiError, AuthToken};
12use crate::config::Config;
13use anyhow::{Context, Result, anyhow};
14use clap::{Parser, Subcommand};
15use custom_field::CustomFieldCommand;
16use project::ProjectCommand;
17use secrecy::SecretString;
18use section::SectionCommand;
19use serde_json::Value;
20use tag::TagCommand;
21use task::TaskCommand;
22use tftio_cli_common::{
23 AgentCapability, AgentSurfaceSpec, CommandSelector, DoctorCheck, DoctorChecks, FatalCliError,
24 FlagSelector, JsonOutput, LicenseType, MetaCommand, RepoInfo, StandardCommand, ToolSpec,
25 map_standard_command, run_cli_from, workspace_tool,
26};
27use tokio::runtime::Builder as RuntimeBuilder;
28use tracing::debug;
29use user::UserCommand;
30use workspace::WorkspaceCommand;
31
32const VERSION: &str = match option_env!("CARGO_PKG_VERSION") {
33 Some(version) => version,
34 None => "unknown",
35};
36
37const CONFIG_COMMAND: CommandSelector = CommandSelector::new(&["config"]);
38const TASK_COMMAND: CommandSelector = CommandSelector::new(&["task"]);
39const PROJECT_COMMAND: CommandSelector = CommandSelector::new(&["project"]);
40const SECTION_COMMAND: CommandSelector = CommandSelector::new(&["section"]);
41const TAG_COMMAND: CommandSelector = CommandSelector::new(&["tag"]);
42const CUSTOM_FIELD_COMMAND: CommandSelector = CommandSelector::new(&["custom-field"]);
43const WORKSPACE_COMMAND: CommandSelector = CommandSelector::new(&["workspace"]);
44const USER_COMMAND: CommandSelector = CommandSelector::new(&["user"]);
45
46const CONFIG_TOKEN_FLAG: FlagSelector = FlagSelector::new(&["config", "set", "token"], "token");
47const CONFIG_WORKSPACE_FLAG: FlagSelector =
48 FlagSelector::new(&["config", "set", "workspace"], "workspace");
49const TASK_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["task"], "workspace");
50const TASK_PARENT_FLAG: FlagSelector = FlagSelector::new(&["task", "list"], "parent");
51const PROJECT_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["project"], "workspace");
52const SECTION_PROJECT_FLAG: FlagSelector = FlagSelector::new(&["section"], "project");
53const TAG_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["tag"], "workspace");
54const CUSTOM_FIELD_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["custom-field"], "workspace");
55const USER_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["user"], "workspace");
56
57const MANAGE_CONFIG_CAPABILITY: AgentCapability = AgentCapability::new(
58 "manage-config",
59 "Read or update persisted Asana CLI configuration",
60 &[CONFIG_COMMAND],
61 &[CONFIG_TOKEN_FLAG, CONFIG_WORKSPACE_FLAG],
62)
63.with_examples(&[
64 "asana-cli config get",
65 "asana-cli config set token --token <PAT>",
66 "asana-cli config set workspace --workspace <GID>",
67 "asana-cli config set workspace --clear-workspace",
68 "asana-cli config set assignee --assignee <USER>",
69 "asana-cli config test",
70])
71.with_output("prints confirmation lines or redacted stored configuration values")
72.with_constraints("writes use the configured config home and config test calls the Asana API")
73.with_when_to_use(
74 "the user needs to configure or inspect the asana-cli personal access token or default workspace",
75)
76.with_when_not_to_use(
77 "the user is performing operational Asana work; use the task, project, or workspace capabilities instead",
78);
79
80const MANAGE_TASKS_CAPABILITY: AgentCapability = AgentCapability::new(
81 "manage-tasks",
82 "Create, inspect, and update Asana tasks",
83 &[TASK_COMMAND],
84 &[TASK_WORKSPACE_FLAG, TASK_PARENT_FLAG],
85)
86.with_examples(&[
87 "asana-cli task list --workspace <GID>",
88 "asana-cli task list --parent <TASK>",
89 "asana-cli task show <TASK>",
90 "asana-cli task create --workspace <GID> --name <NAME>",
91 "asana-cli task update <TASK> --completed true",
92 "asana-cli task update <TASK> --parent <TASK2>",
93 "asana-cli task update <TASK> --clear-due-on",
94 "asana-cli task delete <TASK>",
95 "asana-cli task search --workspace <GID> --query <TEXT>",
96 "asana-cli task <TASK> tags add --tag <TAG>",
97 "asana-cli task <TASK> tags list",
98 "asana-cli task <TASK> projects add --project <PROJECT>",
99 "asana-cli task <TASK> followers add --follower <USER>",
100 "asana-cli task <TASK> depends-on add --dependency <TASK2>",
101 "asana-cli task <TASK> blocks list",
102 "asana-cli task <TASK> comments create --text <BODY>",
103 "asana-cli task <TASK> comments list",
104 "asana-cli task <TASK> attachments create --file <PATH>",
105 "asana-cli task <TASK> attachments download <ATTACHMENT> --output <PATH>",
106 "asana-cli task <TASK> move-to-section <SECTION>",
107])
108.with_output("prints task tables, summaries, or JSON payloads produced by task commands")
109.with_constraints("task commands require a stored personal access token and valid task identifiers")
110.with_when_to_use("the user wants to list, inspect, create, or update Asana tasks")
111.with_when_not_to_use(
112 "the user is asking about projects, sections, tags, or workspaces rather than tasks",
113);
114
115const MANAGE_PROJECTS_CAPABILITY: AgentCapability = AgentCapability::new(
116 "manage-projects",
117 "Inspect and manage Asana projects",
118 &[PROJECT_COMMAND],
119 &[PROJECT_WORKSPACE_FLAG],
120)
121.with_examples(&[
122 "asana-cli project list --workspace <GID>",
123 "asana-cli project show <PROJECT>",
124 "asana-cli project create --workspace <GID> --name <NAME> --color light-green",
125 "asana-cli project update <PROJECT> --archived true",
126 "asana-cli project update <PROJECT> --clear-due-on",
127 "asana-cli project delete <PROJECT>",
128 "asana-cli project <PROJECT> members list",
129 "asana-cli project <PROJECT> members add <USER>",
130 "asana-cli project <PROJECT> members update --member <USER> --role commenter",
131])
132.with_output("prints project listings, detail blocks, and mutation confirmations")
133.with_constraints("project commands require API-authenticated access to the target workspace")
134.with_when_to_use("the user wants to list, inspect, or modify Asana projects")
135.with_when_not_to_use(
136 "the user is asking about tasks within a project, sections, or workspace-level metadata",
137);
138
139const MANAGE_SECTIONS_CAPABILITY: AgentCapability = AgentCapability::new(
140 "manage-sections",
141 "List or modify sections within Asana projects",
142 &[SECTION_COMMAND],
143 &[SECTION_PROJECT_FLAG],
144)
145.with_examples(&[
146 "asana-cli section list --project <PROJECT>",
147 "asana-cli section show <SECTION>",
148 "asana-cli section create --project <PROJECT> --name <NAME>",
149 "asana-cli section update <SECTION> --name <NAME>",
150 "asana-cli section delete <SECTION>",
151 "asana-cli section <SECTION> tasks list",
152])
153.with_output("prints section records and success messages from section operations")
154.with_constraints("section commands operate inside a project and require a resolvable project gid")
155.with_when_to_use("the user wants to list or modify sections inside a known Asana project")
156.with_when_not_to_use(
157 "the user does not have a project gid or wants to operate on tasks rather than sections",
158);
159
160const MANAGE_TAGS_CAPABILITY: AgentCapability = AgentCapability::new(
161 "manage-tags",
162 "Inspect and maintain Asana tags",
163 &[TAG_COMMAND],
164 &[TAG_WORKSPACE_FLAG],
165)
166.with_examples(&[
167 "asana-cli tag list --workspace <GID>",
168 "asana-cli tag show <TAG>",
169 "asana-cli tag create --workspace <GID> --name <NAME> --color dark-blue",
170 "asana-cli tag update <TAG> --name <NAME>",
171 "asana-cli tag update <TAG> --clear-notes",
172 "asana-cli tag delete <TAG>",
173])
174.with_output("prints tag collections, tag detail records, or mutation confirmations")
175.with_constraints("tag commands require workspace access and valid tag identifiers")
176.with_when_to_use("the user wants to inspect or maintain Asana tags within a workspace")
177.with_when_not_to_use(
178 "the user wants to add or remove tags on a specific task; use the task capability for that",
179);
180
181const MANAGE_CUSTOM_FIELDS_CAPABILITY: AgentCapability = AgentCapability::new(
182 "manage-custom-fields",
183 "Inspect and manage Asana custom fields",
184 &[CUSTOM_FIELD_COMMAND],
185 &[CUSTOM_FIELD_WORKSPACE_FLAG],
186)
187.with_examples(&[
188 "asana-cli custom-field list --workspace <GID>",
189 "asana-cli custom-field show <FIELD>",
190])
191.with_output("prints custom field definitions and update confirmations")
192.with_constraints("custom field commands require workspace-scoped API access")
193.with_when_to_use(
194 "the user wants to inspect or update the custom field schema for an Asana workspace",
195)
196.with_when_not_to_use(
197 "the user wants to set custom field values on a task; use the task capability for that",
198);
199
200const MANAGE_WORKSPACES_CAPABILITY: AgentCapability = AgentCapability::new(
201 "manage-workspaces",
202 "Inspect available Asana workspaces",
203 &[WORKSPACE_COMMAND],
204 &[],
205)
206.with_examples(&[
207 "asana-cli workspace list",
208 "asana-cli workspace show <WORKSPACE>",
209])
210.with_output("prints workspace listings or detail records from the API")
211.with_constraints("workspace commands require a valid stored personal access token")
212.with_when_to_use("the user wants to list available Asana workspaces or inspect a workspace by gid")
213.with_when_not_to_use(
214 "the user wants to operate on entities inside a workspace rather than enumerate workspaces",
215);
216
217const MANAGE_USERS_CAPABILITY: AgentCapability = AgentCapability::new(
218 "manage-users",
219 "Inspect Asana users and memberships",
220 &[USER_COMMAND],
221 &[USER_WORKSPACE_FLAG],
222)
223.with_examples(&[
224 "asana-cli user show me",
225 "asana-cli user show <USER>",
226 "asana-cli user list --workspace <GID>",
227])
228.with_output("prints user records, user lists, and membership-related summaries")
229.with_constraints("user commands require API-authenticated access to the target workspace")
230.with_when_to_use("the user wants to inspect Asana users or memberships in a workspace")
231.with_when_not_to_use("the user wants to assign tasks to users; use the task capability for that");
232
233const ASANA_AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[
234 MANAGE_CONFIG_CAPABILITY,
235 MANAGE_TASKS_CAPABILITY,
236 MANAGE_PROJECTS_CAPABILITY,
237 MANAGE_SECTIONS_CAPABILITY,
238 MANAGE_TAGS_CAPABILITY,
239 MANAGE_CUSTOM_FIELDS_CAPABILITY,
240 MANAGE_WORKSPACES_CAPABILITY,
241 MANAGE_USERS_CAPABILITY,
242]);
243
244struct AsanaCliDoctor;
245
246impl DoctorChecks for AsanaCliDoctor {
247 fn repo_info() -> RepoInfo {
248 RepoInfo::new("tftio-stuff", "tools")
249 }
250
251 fn current_version() -> &'static str {
252 VERSION
253 }
254
255 fn tool_checks(&self) -> Vec<DoctorCheck> {
256 crate::doctor::tool_specific_checks()
257 }
258}
259
260const TOOL_SPEC: ToolSpec = workspace_tool(
261 "asana-cli",
262 "Asana CLI",
263 VERSION,
264 LicenseType::MIT,
265 false,
266 true,
267)
268.with_agent_surface(&ASANA_AGENT_SURFACE);
269
270#[derive(Parser, Debug)]
271#[command(name = "asana-cli")]
272#[command(about = "An interface to the Asana API")]
273#[command(version = VERSION)]
274struct Cli {
275 #[command(subcommand)]
277 command: Commands,
278}
279
280#[derive(Subcommand, Debug)]
281enum Commands {
282 Meta {
284 #[command(subcommand)]
286 command: MetaCommand,
287 },
288 Config {
290 #[command(subcommand)]
291 command: ConfigCommand,
292 },
293 Task {
295 #[command(subcommand)]
296 command: Box<TaskCommand>,
297 },
298 Project {
300 #[command(subcommand)]
301 command: Box<ProjectCommand>,
302 },
303 Section {
305 #[command(subcommand)]
306 command: Box<SectionCommand>,
307 },
308 Tag {
310 #[command(subcommand)]
311 command: Box<TagCommand>,
312 },
313 #[command(name = "custom-field")]
315 CustomField {
316 #[command(subcommand)]
317 command: Box<CustomFieldCommand>,
318 },
319 Workspace {
321 #[command(subcommand)]
322 command: Box<WorkspaceCommand>,
323 },
324 User {
326 #[command(subcommand)]
327 command: Box<UserCommand>,
328 },
329}
330
331#[derive(Subcommand, Debug)]
332enum ConfigCommand {
333 Set {
335 #[command(subcommand)]
336 command: ConfigSetCommand,
337 },
338 Get,
340 Test,
342}
343
344#[derive(Subcommand, Debug)]
345enum ConfigSetCommand {
346 Token {
348 #[arg(long)]
350 token: Option<String>,
351 },
352 Workspace {
354 #[arg(long, value_name = "GID")]
356 workspace: Option<String>,
357 #[arg(long = "clear-workspace")]
359 clear_workspace: bool,
360 },
361 Assignee {
363 #[arg(long, value_name = "ID")]
365 assignee: Option<String>,
366 #[arg(long = "clear-assignee")]
368 clear_assignee: bool,
369 },
370 Project {
372 #[arg(long, value_name = "GID")]
374 project: Option<String>,
375 #[arg(long = "clear-project")]
377 clear_project: bool,
378 },
379}
380
381#[must_use]
388pub fn run_cli_entrypoint() -> i32 {
389 let env = process_env();
390 let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
391 let argv = rewrite_subject_first(argv);
392 run_cli_from::<Cli, _, AsanaCliDoctor, _, _>(
393 &TOOL_SPEC,
394 &env,
395 argv,
396 &AsanaCliDoctor,
397 |cli| metadata_command(&cli.command),
398 |cli| {
399 run_domain(cli).map_err(|err| {
400 tracing::error!(error = %err, "command execution failed");
401 FatalCliError::new("asana-cli", JsonOutput::Text, err.to_string())
402 })
403 },
404 )
405}
406
407#[allow(
409 clippy::disallowed_methods,
410 reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
411)]
412fn process_env() -> tftio_cli_common::ProcessEnv {
413 tftio_cli_common::ProcessEnv {
414 agent: tftio_cli_common::AgentModeContext::from_tokens(
415 std::env::var(tftio_cli_common::AGENT_TOKEN_ENV).ok(),
416 std::env::var(tftio_cli_common::AGENT_TOKEN_EXPECTED_ENV).ok(),
417 ),
418 home: std::env::var_os("HOME").map(std::path::PathBuf::from),
419 }
420}
421
422fn rewrite_subject_first(mut argv: Vec<std::ffi::OsString>) -> Vec<std::ffi::OsString> {
435 if argv.len() < 4 {
436 return argv;
437 }
438 let top_owned: String = match argv.get(1).and_then(|a| a.to_str()) {
439 Some(value) => value.to_string(),
440 None => return argv,
441 };
442 let top = top_owned.as_str();
443 let direct: &[&str] = match top {
444 "task" => &[
445 "list",
446 "show",
447 "create",
448 "update",
449 "delete",
450 "search",
451 "create-batch",
452 "update-batch",
453 "complete-batch",
454 "help",
455 "--help",
456 "-h",
457 ],
458 "project" | "section" => &[
459 "list", "show", "create", "update", "delete", "help", "--help", "-h",
460 ],
461 _ => return argv,
462 };
463 let instance: &[&str] = match top {
464 "task" => &[
465 "tags",
466 "projects",
467 "followers",
468 "depends-on",
469 "blocks",
470 "comments",
471 "attachments",
472 "move-to-section",
473 ],
474 "project" => &["members"],
475 "section" => &["tasks"],
476 _ => return argv,
477 };
478
479 let token_owned: String = match argv.get(2).and_then(|a| a.to_str()) {
480 Some(value) => value.to_string(),
481 None => return argv,
482 };
483 let token = token_owned.as_str();
484 if direct.contains(&token) || token.starts_with('-') {
485 return argv;
486 }
487 let verb_owned: String = match argv.get(3).and_then(|a| a.to_str()) {
488 Some(value) => value.to_string(),
489 None => return argv,
490 };
491 let verb = verb_owned.as_str();
492 if !instance.contains(&verb) {
493 return argv;
494 }
495
496 let sub_owned: Option<String> = argv.get(4).and_then(|v| v.to_str()).map(str::to_string);
497 let drop_subject = matches!(
498 (top, verb, sub_owned.as_deref()),
499 ("task", "comments", Some("show" | "update" | "delete"))
500 | ("task", "attachments", Some("show" | "download" | "delete"))
501 );
502
503 let subject = argv.remove(2);
504 if drop_subject {
505 return argv;
506 }
507
508 let insert_at = match (top, verb) {
514 ("task", "move-to-section") => 3,
515 _ => 4,
516 };
517 if insert_at <= argv.len() {
518 argv.insert(insert_at, subject);
519 } else {
520 argv.push(subject);
521 }
522 argv
523}
524
525fn run_domain(cli: Cli) -> Result<i32> {
526 debug!(?cli, "parsed CLI arguments");
527
528 let mut config = Config::load(&crate::config::EnvInputs::from_env())?;
529 debug!(
530 config_path = %config.path().display(),
531 "configuration handle prepared"
532 );
533
534 let exit_code = match cli.command {
535 Commands::Meta { .. } => unreachable!("metadata commands are routed before dispatch"),
536 Commands::Config { command } => {
537 handle_config_command(command, &mut config)?;
538 0
539 }
540 Commands::Task { command } => {
541 task::handle_task_command(*command, &config)?;
542 0
543 }
544 Commands::Project { command } => {
545 handle_project_command(*command, &config)?;
546 0
547 }
548 Commands::Section { command } => {
549 handle_section_command(*command, &config)?;
550 0
551 }
552 Commands::Tag { command } => {
553 handle_tag_command(*command, &config)?;
554 0
555 }
556 Commands::CustomField { command } => {
557 handle_custom_field_command(*command, &config)?;
558 0
559 }
560 Commands::Workspace { command } => {
561 handle_workspace_command(*command, &config)?;
562 0
563 }
564 Commands::User { command } => {
565 handle_user_command(*command, &config)?;
566 0
567 }
568 };
569
570 Ok(exit_code)
571}
572
573fn metadata_command(command: &Commands) -> Option<StandardCommand> {
574 match command {
575 Commands::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
576 Commands::Config { .. }
577 | Commands::Task { .. }
578 | Commands::Project { .. }
579 | Commands::Section { .. }
580 | Commands::Tag { .. }
581 | Commands::CustomField { .. }
582 | Commands::Workspace { .. }
583 | Commands::User { .. } => None,
584 }
585}
586
587fn handle_config_command(command: ConfigCommand, config: &mut Config) -> Result<()> {
588 match command {
589 ConfigCommand::Set { command } => handle_config_set(command, config),
590 ConfigCommand::Get => {
591 handle_config_get(config);
592 Ok(())
593 }
594 ConfigCommand::Test => handle_config_test(config),
595 }
596}
597
598fn handle_config_set(command: ConfigSetCommand, config: &mut Config) -> Result<()> {
599 match command {
600 ConfigSetCommand::Token { token } => {
601 let value = match token {
602 Some(value) => value,
603 None => rpassword::prompt_password("Enter Personal Access Token: ")
604 .context("failed to read token from prompt")?,
605 };
606
607 if value.trim().is_empty() {
608 return Err(anyhow!("token value cannot be empty"));
609 }
610
611 let secret = SecretString::new(value.into());
612 config
613 .store_personal_access_token(&secret)
614 .context("failed to store Personal Access Token")?;
615 println!("Personal Access Token stored in configuration file.");
616 Ok(())
617 }
618 ConfigSetCommand::Workspace {
619 workspace,
620 clear_workspace,
621 } => {
622 if clear_workspace {
623 config
624 .set_default_workspace(None)
625 .context("failed to clear default workspace")?;
626 println!("Default workspace cleared.");
627 return Ok(());
628 }
629
630 let value = workspace
631 .as_deref()
632 .map(str::trim)
633 .filter(|value| !value.is_empty())
634 .ok_or_else(|| anyhow!("provide --workspace <gid> or use --clear"))?;
635
636 config
637 .set_default_workspace(Some(value.to_string()))
638 .context("failed to store default workspace")?;
639 println!("Default workspace stored in configuration file.");
640 Ok(())
641 }
642 ConfigSetCommand::Assignee {
643 assignee,
644 clear_assignee,
645 } => {
646 if clear_assignee {
647 config
648 .set_default_assignee(None)
649 .context("failed to clear default assignee")?;
650 println!("Default assignee cleared.");
651 return Ok(());
652 }
653
654 let value = assignee
655 .as_deref()
656 .map(str::trim)
657 .filter(|value| !value.is_empty())
658 .ok_or_else(|| anyhow!("provide --assignee <id> or use --clear"))?;
659
660 config
661 .set_default_assignee(Some(value.to_string()))
662 .context("failed to store default assignee")?;
663 println!("Default assignee stored in configuration file.");
664 Ok(())
665 }
666 ConfigSetCommand::Project {
667 project,
668 clear_project,
669 } => {
670 if clear_project {
671 config
672 .set_default_project(None)
673 .context("failed to clear default project")?;
674 println!("Default project cleared.");
675 return Ok(());
676 }
677
678 let value = project
679 .as_deref()
680 .map(str::trim)
681 .filter(|value| !value.is_empty())
682 .ok_or_else(|| anyhow!("provide --project <gid> or use --clear"))?;
683
684 config
685 .set_default_project(Some(value.to_string()))
686 .context("failed to store default project")?;
687 println!("Default project stored in configuration file.");
688 Ok(())
689 }
690 }
691}
692
693fn handle_config_get(config: &Config) {
694 println!("Configuration file: {}", config.path().display());
695 println!("API base URL: {}", config.effective_api_base_url());
696 println!(
697 "Default workspace: {}",
698 config
699 .default_workspace()
700 .filter(|workspace| !workspace.is_empty())
701 .unwrap_or("not set")
702 );
703 println!(
704 "Default assignee: {}",
705 config
706 .default_assignee()
707 .filter(|assignee| !assignee.is_empty())
708 .unwrap_or("not set")
709 );
710 println!(
711 "Default project: {}",
712 config
713 .default_project()
714 .filter(|project| !project.is_empty())
715 .unwrap_or("not set")
716 );
717
718 if let Some(_token) = config.personal_access_token() {
719 let status = if config.environment_token_available() {
720 "provided via environment variable"
721 } else if config.has_persisted_token() {
722 "stored in configuration file"
723 } else {
724 "available"
725 };
726 println!("Personal Access Token: {status}");
727 } else {
728 println!("Personal Access Token: not set");
729 }
730}
731
732fn handle_config_test(config: &Config) -> Result<()> {
733 let client = build_api_client(config)?;
734
735 let runtime = RuntimeBuilder::new_current_thread()
736 .enable_all()
737 .build()
738 .context("failed to initialise async runtime")?;
739
740 runtime.block_on(async move {
741 match client.get_current_user().await {
742 Ok(payload) => {
743 let user_name = payload
744 .get("data")
745 .and_then(|data| data.get("name"))
746 .and_then(Value::as_str)
747 .unwrap_or("unknown user");
748 println!("Personal Access Token validated for {user_name}.");
749 Ok(())
750 }
751 Err(ApiError::Authentication(_)) => Err(anyhow!(
752 "authentication failed; verify your Personal Access Token"
753 )),
754 Err(ApiError::RateLimited { retry_after, .. }) => Err(anyhow!(
755 "Asana rate limited the request. Retry after {:.1} seconds",
756 retry_after.as_secs_f32()
757 )),
758 Err(ApiError::Offline { .. }) => Err(anyhow!(
759 "offline mode enabled; disable offline mode to contact Asana"
760 )),
761 Err(err) => Err(anyhow!(err)),
762 }
763 })
764}
765
766pub(super) fn build_api_client(config: &Config) -> Result<ApiClient> {
767 let token = config.personal_access_token().ok_or_else(|| {
768 anyhow!("no Personal Access Token found; run `asana-cli config set token`")
769 })?;
770
771 let auth_token = AuthToken::new(token);
772 let cache_dir = config.cache_dir().to_path_buf();
773
774 let client = ApiClient::builder(auth_token)
775 .base_url(config.effective_api_base_url().to_string())
776 .cache_dir(cache_dir)
777 .build()?;
778
779 Ok(client)
780}
781
782fn handle_project_command(command: ProjectCommand, config: &Config) -> Result<()> {
783 project::handle_project_command(command, config)
784}
785
786fn handle_section_command(command: SectionCommand, config: &Config) -> Result<()> {
787 section::execute_section_command(command, config)
788}
789
790fn handle_tag_command(command: TagCommand, config: &Config) -> Result<()> {
791 tag::handle_tag_command(command, config)
792}
793
794fn handle_custom_field_command(command: CustomFieldCommand, config: &Config) -> Result<()> {
795 custom_field::handle_custom_field_command(command, config)
796}
797
798fn handle_workspace_command(command: WorkspaceCommand, config: &Config) -> Result<()> {
799 workspace::handle_workspace_command(command, config)
800}
801
802fn handle_user_command(command: UserCommand, config: &Config) -> Result<()> {
803 user::handle_user_command(command, config)
804}