mod custom_field;
mod project;
mod section;
mod tag;
mod task;
mod user;
mod workspace;
use crate::api::{ApiClient, ApiError, AuthToken};
use crate::config::Config;
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use custom_field::CustomFieldCommand;
use project::ProjectCommand;
use secrecy::SecretString;
use section::SectionCommand;
use serde_json::Value;
use tag::TagCommand;
use task::TaskCommand;
use tftio_cli_common::{
AgentCapability, AgentSurfaceSpec, CommandSelector, DoctorCheck, DoctorChecks, FatalCliError,
FlagSelector, JsonOutput, LicenseType, MetaCommand, RepoInfo, StandardCommand, ToolSpec,
map_standard_command, run_cli_from, workspace_tool,
};
use tokio::runtime::Builder as RuntimeBuilder;
use tracing::debug;
use user::UserCommand;
use workspace::WorkspaceCommand;
const VERSION: &str = match option_env!("CARGO_PKG_VERSION") {
Some(version) => version,
None => "unknown",
};
const CONFIG_COMMAND: CommandSelector = CommandSelector::new(&["config"]);
const TASK_COMMAND: CommandSelector = CommandSelector::new(&["task"]);
const PROJECT_COMMAND: CommandSelector = CommandSelector::new(&["project"]);
const SECTION_COMMAND: CommandSelector = CommandSelector::new(&["section"]);
const TAG_COMMAND: CommandSelector = CommandSelector::new(&["tag"]);
const CUSTOM_FIELD_COMMAND: CommandSelector = CommandSelector::new(&["custom-field"]);
const WORKSPACE_COMMAND: CommandSelector = CommandSelector::new(&["workspace"]);
const USER_COMMAND: CommandSelector = CommandSelector::new(&["user"]);
const CONFIG_TOKEN_FLAG: FlagSelector = FlagSelector::new(&["config", "set", "token"], "token");
const CONFIG_WORKSPACE_FLAG: FlagSelector =
FlagSelector::new(&["config", "set", "workspace"], "workspace");
const TASK_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["task"], "workspace");
const TASK_PARENT_FLAG: FlagSelector = FlagSelector::new(&["task", "list"], "parent");
const PROJECT_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["project"], "workspace");
const SECTION_PROJECT_FLAG: FlagSelector = FlagSelector::new(&["section"], "project");
const TAG_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["tag"], "workspace");
const CUSTOM_FIELD_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["custom-field"], "workspace");
const USER_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["user"], "workspace");
const MANAGE_CONFIG_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-config",
"Read or update persisted Asana CLI configuration",
&[CONFIG_COMMAND],
&[CONFIG_TOKEN_FLAG, CONFIG_WORKSPACE_FLAG],
)
.with_examples(&[
"asana-cli config get",
"asana-cli config set token --token <PAT>",
"asana-cli config set workspace --workspace <GID>",
"asana-cli config set workspace --clear-workspace",
"asana-cli config set assignee --assignee <USER>",
"asana-cli config test",
])
.with_output("prints confirmation lines or redacted stored configuration values")
.with_constraints("writes use the configured config home and config test calls the Asana API")
.with_when_to_use(
"the user needs to configure or inspect the asana-cli personal access token or default workspace",
)
.with_when_not_to_use(
"the user is performing operational Asana work; use the task, project, or workspace capabilities instead",
);
const MANAGE_TASKS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-tasks",
"Create, inspect, and update Asana tasks",
&[TASK_COMMAND],
&[TASK_WORKSPACE_FLAG, TASK_PARENT_FLAG],
)
.with_examples(&[
"asana-cli task list --workspace <GID>",
"asana-cli task list --parent <TASK>",
"asana-cli task show <TASK>",
"asana-cli task create --workspace <GID> --name <NAME>",
"asana-cli task update <TASK> --completed true",
"asana-cli task update <TASK> --parent <TASK2>",
"asana-cli task update <TASK> --clear-due-on",
"asana-cli task delete <TASK>",
"asana-cli task search --workspace <GID> --query <TEXT>",
"asana-cli task <TASK> tags add --tag <TAG>",
"asana-cli task <TASK> tags list",
"asana-cli task <TASK> projects add --project <PROJECT>",
"asana-cli task <TASK> followers add --follower <USER>",
"asana-cli task <TASK> depends-on add --dependency <TASK2>",
"asana-cli task <TASK> blocks list",
"asana-cli task <TASK> comments create --text <BODY>",
"asana-cli task <TASK> comments list",
"asana-cli task <TASK> attachments create --file <PATH>",
"asana-cli task <TASK> attachments download <ATTACHMENT> --output <PATH>",
"asana-cli task <TASK> move-to-section <SECTION>",
])
.with_output("prints task tables, summaries, or JSON payloads produced by task commands")
.with_constraints("task commands require a stored personal access token and valid task identifiers")
.with_when_to_use("the user wants to list, inspect, create, or update Asana tasks")
.with_when_not_to_use(
"the user is asking about projects, sections, tags, or workspaces rather than tasks",
);
const MANAGE_PROJECTS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-projects",
"Inspect and manage Asana projects",
&[PROJECT_COMMAND],
&[PROJECT_WORKSPACE_FLAG],
)
.with_examples(&[
"asana-cli project list --workspace <GID>",
"asana-cli project show <PROJECT>",
"asana-cli project create --workspace <GID> --name <NAME> --color light-green",
"asana-cli project update <PROJECT> --archived true",
"asana-cli project update <PROJECT> --clear-due-on",
"asana-cli project delete <PROJECT>",
"asana-cli project <PROJECT> members list",
"asana-cli project <PROJECT> members add <USER>",
"asana-cli project <PROJECT> members update --member <USER> --role commenter",
])
.with_output("prints project listings, detail blocks, and mutation confirmations")
.with_constraints("project commands require API-authenticated access to the target workspace")
.with_when_to_use("the user wants to list, inspect, or modify Asana projects")
.with_when_not_to_use(
"the user is asking about tasks within a project, sections, or workspace-level metadata",
);
const MANAGE_SECTIONS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-sections",
"List or modify sections within Asana projects",
&[SECTION_COMMAND],
&[SECTION_PROJECT_FLAG],
)
.with_examples(&[
"asana-cli section list --project <PROJECT>",
"asana-cli section show <SECTION>",
"asana-cli section create --project <PROJECT> --name <NAME>",
"asana-cli section update <SECTION> --name <NAME>",
"asana-cli section delete <SECTION>",
"asana-cli section <SECTION> tasks list",
])
.with_output("prints section records and success messages from section operations")
.with_constraints("section commands operate inside a project and require a resolvable project gid")
.with_when_to_use("the user wants to list or modify sections inside a known Asana project")
.with_when_not_to_use(
"the user does not have a project gid or wants to operate on tasks rather than sections",
);
const MANAGE_TAGS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-tags",
"Inspect and maintain Asana tags",
&[TAG_COMMAND],
&[TAG_WORKSPACE_FLAG],
)
.with_examples(&[
"asana-cli tag list --workspace <GID>",
"asana-cli tag show <TAG>",
"asana-cli tag create --workspace <GID> --name <NAME> --color dark-blue",
"asana-cli tag update <TAG> --name <NAME>",
"asana-cli tag update <TAG> --clear-notes",
"asana-cli tag delete <TAG>",
])
.with_output("prints tag collections, tag detail records, or mutation confirmations")
.with_constraints("tag commands require workspace access and valid tag identifiers")
.with_when_to_use("the user wants to inspect or maintain Asana tags within a workspace")
.with_when_not_to_use(
"the user wants to add or remove tags on a specific task; use the task capability for that",
);
const MANAGE_CUSTOM_FIELDS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-custom-fields",
"Inspect and manage Asana custom fields",
&[CUSTOM_FIELD_COMMAND],
&[CUSTOM_FIELD_WORKSPACE_FLAG],
)
.with_examples(&[
"asana-cli custom-field list --workspace <GID>",
"asana-cli custom-field show <FIELD>",
])
.with_output("prints custom field definitions and update confirmations")
.with_constraints("custom field commands require workspace-scoped API access")
.with_when_to_use(
"the user wants to inspect or update the custom field schema for an Asana workspace",
)
.with_when_not_to_use(
"the user wants to set custom field values on a task; use the task capability for that",
);
const MANAGE_WORKSPACES_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-workspaces",
"Inspect available Asana workspaces",
&[WORKSPACE_COMMAND],
&[],
)
.with_examples(&[
"asana-cli workspace list",
"asana-cli workspace show <WORKSPACE>",
])
.with_output("prints workspace listings or detail records from the API")
.with_constraints("workspace commands require a valid stored personal access token")
.with_when_to_use("the user wants to list available Asana workspaces or inspect a workspace by gid")
.with_when_not_to_use(
"the user wants to operate on entities inside a workspace rather than enumerate workspaces",
);
const MANAGE_USERS_CAPABILITY: AgentCapability = AgentCapability::new(
"manage-users",
"Inspect Asana users and memberships",
&[USER_COMMAND],
&[USER_WORKSPACE_FLAG],
)
.with_examples(&[
"asana-cli user show me",
"asana-cli user show <USER>",
"asana-cli user list --workspace <GID>",
])
.with_output("prints user records, user lists, and membership-related summaries")
.with_constraints("user commands require API-authenticated access to the target workspace")
.with_when_to_use("the user wants to inspect Asana users or memberships in a workspace")
.with_when_not_to_use("the user wants to assign tasks to users; use the task capability for that");
const ASANA_AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[
MANAGE_CONFIG_CAPABILITY,
MANAGE_TASKS_CAPABILITY,
MANAGE_PROJECTS_CAPABILITY,
MANAGE_SECTIONS_CAPABILITY,
MANAGE_TAGS_CAPABILITY,
MANAGE_CUSTOM_FIELDS_CAPABILITY,
MANAGE_WORKSPACES_CAPABILITY,
MANAGE_USERS_CAPABILITY,
]);
struct AsanaCliDoctor;
impl DoctorChecks for AsanaCliDoctor {
fn repo_info() -> RepoInfo {
RepoInfo::new("tftio-stuff", "tools")
}
fn current_version() -> &'static str {
VERSION
}
fn tool_checks(&self) -> Vec<DoctorCheck> {
crate::doctor::tool_specific_checks()
}
}
const TOOL_SPEC: ToolSpec = workspace_tool(
"asana-cli",
"Asana CLI",
VERSION,
LicenseType::MIT,
false,
true,
)
.with_agent_surface(&ASANA_AGENT_SURFACE);
#[derive(Parser, Debug)]
#[command(name = "asana-cli")]
#[command(about = "An interface to the Asana API")]
#[command(version = VERSION)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Meta {
#[command(subcommand)]
command: MetaCommand,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Task {
#[command(subcommand)]
command: Box<TaskCommand>,
},
Project {
#[command(subcommand)]
command: Box<ProjectCommand>,
},
Section {
#[command(subcommand)]
command: Box<SectionCommand>,
},
Tag {
#[command(subcommand)]
command: Box<TagCommand>,
},
#[command(name = "custom-field")]
CustomField {
#[command(subcommand)]
command: Box<CustomFieldCommand>,
},
Workspace {
#[command(subcommand)]
command: Box<WorkspaceCommand>,
},
User {
#[command(subcommand)]
command: Box<UserCommand>,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommand {
Set {
#[command(subcommand)]
command: ConfigSetCommand,
},
Get,
Test,
}
#[derive(Subcommand, Debug)]
enum ConfigSetCommand {
Token {
#[arg(long)]
token: Option<String>,
},
Workspace {
#[arg(long, value_name = "GID")]
workspace: Option<String>,
#[arg(long = "clear-workspace")]
clear_workspace: bool,
},
Assignee {
#[arg(long, value_name = "ID")]
assignee: Option<String>,
#[arg(long = "clear-assignee")]
clear_assignee: bool,
},
Project {
#[arg(long, value_name = "GID")]
project: Option<String>,
#[arg(long = "clear-project")]
clear_project: bool,
},
}
#[must_use]
pub fn run_cli_entrypoint() -> i32 {
let env = process_env();
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
let argv = rewrite_subject_first(argv);
run_cli_from::<Cli, _, AsanaCliDoctor, _, _>(
&TOOL_SPEC,
&env,
argv,
&AsanaCliDoctor,
|cli| metadata_command(&cli.command),
|cli| {
run_domain(cli).map_err(|err| {
tracing::error!(error = %err, "command execution failed");
FatalCliError::new("asana-cli", JsonOutput::Text, err.to_string())
})
},
)
}
#[allow(
clippy::disallowed_methods,
reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
)]
fn process_env() -> tftio_cli_common::ProcessEnv {
tftio_cli_common::ProcessEnv {
agent: tftio_cli_common::AgentModeContext::from_tokens(
std::env::var(tftio_cli_common::AGENT_TOKEN_ENV).ok(),
std::env::var(tftio_cli_common::AGENT_TOKEN_EXPECTED_ENV).ok(),
),
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
}
}
fn rewrite_subject_first(mut argv: Vec<std::ffi::OsString>) -> Vec<std::ffi::OsString> {
if argv.len() < 4 {
return argv;
}
let top_owned: String = match argv.get(1).and_then(|a| a.to_str()) {
Some(value) => value.to_string(),
None => return argv,
};
let top = top_owned.as_str();
let direct: &[&str] = match top {
"task" => &[
"list",
"show",
"create",
"update",
"delete",
"search",
"create-batch",
"update-batch",
"complete-batch",
"help",
"--help",
"-h",
],
"project" | "section" => &[
"list", "show", "create", "update", "delete", "help", "--help", "-h",
],
_ => return argv,
};
let instance: &[&str] = match top {
"task" => &[
"tags",
"projects",
"followers",
"depends-on",
"blocks",
"comments",
"attachments",
"move-to-section",
],
"project" => &["members"],
"section" => &["tasks"],
_ => return argv,
};
let token_owned: String = match argv.get(2).and_then(|a| a.to_str()) {
Some(value) => value.to_string(),
None => return argv,
};
let token = token_owned.as_str();
if direct.contains(&token) || token.starts_with('-') {
return argv;
}
let verb_owned: String = match argv.get(3).and_then(|a| a.to_str()) {
Some(value) => value.to_string(),
None => return argv,
};
let verb = verb_owned.as_str();
if !instance.contains(&verb) {
return argv;
}
let sub_owned: Option<String> = argv.get(4).and_then(|v| v.to_str()).map(str::to_string);
let drop_subject = matches!(
(top, verb, sub_owned.as_deref()),
("task", "comments", Some("show" | "update" | "delete"))
| ("task", "attachments", Some("show" | "download" | "delete"))
);
let subject = argv.remove(2);
if drop_subject {
return argv;
}
let insert_at = match (top, verb) {
("task", "move-to-section") => 3,
_ => 4,
};
if insert_at <= argv.len() {
argv.insert(insert_at, subject);
} else {
argv.push(subject);
}
argv
}
fn run_domain(cli: Cli) -> Result<i32> {
debug!(?cli, "parsed CLI arguments");
let mut config = Config::load(&crate::config::EnvInputs::from_env())?;
debug!(
config_path = %config.path().display(),
"configuration handle prepared"
);
let exit_code = match cli.command {
Commands::Meta { .. } => unreachable!("metadata commands are routed before dispatch"),
Commands::Config { command } => {
handle_config_command(command, &mut config)?;
0
}
Commands::Task { command } => {
task::handle_task_command(*command, &config)?;
0
}
Commands::Project { command } => {
handle_project_command(*command, &config)?;
0
}
Commands::Section { command } => {
handle_section_command(*command, &config)?;
0
}
Commands::Tag { command } => {
handle_tag_command(*command, &config)?;
0
}
Commands::CustomField { command } => {
handle_custom_field_command(*command, &config)?;
0
}
Commands::Workspace { command } => {
handle_workspace_command(*command, &config)?;
0
}
Commands::User { command } => {
handle_user_command(*command, &config)?;
0
}
};
Ok(exit_code)
}
fn metadata_command(command: &Commands) -> Option<StandardCommand> {
match command {
Commands::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
Commands::Config { .. }
| Commands::Task { .. }
| Commands::Project { .. }
| Commands::Section { .. }
| Commands::Tag { .. }
| Commands::CustomField { .. }
| Commands::Workspace { .. }
| Commands::User { .. } => None,
}
}
fn handle_config_command(command: ConfigCommand, config: &mut Config) -> Result<()> {
match command {
ConfigCommand::Set { command } => handle_config_set(command, config),
ConfigCommand::Get => {
handle_config_get(config);
Ok(())
}
ConfigCommand::Test => handle_config_test(config),
}
}
fn handle_config_set(command: ConfigSetCommand, config: &mut Config) -> Result<()> {
match command {
ConfigSetCommand::Token { token } => {
let value = match token {
Some(value) => value,
None => rpassword::prompt_password("Enter Personal Access Token: ")
.context("failed to read token from prompt")?,
};
if value.trim().is_empty() {
return Err(anyhow!("token value cannot be empty"));
}
let secret = SecretString::new(value.into());
config
.store_personal_access_token(&secret)
.context("failed to store Personal Access Token")?;
println!("Personal Access Token stored in configuration file.");
Ok(())
}
ConfigSetCommand::Workspace {
workspace,
clear_workspace,
} => {
if clear_workspace {
config
.set_default_workspace(None)
.context("failed to clear default workspace")?;
println!("Default workspace cleared.");
return Ok(());
}
let value = workspace
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("provide --workspace <gid> or use --clear"))?;
config
.set_default_workspace(Some(value.to_string()))
.context("failed to store default workspace")?;
println!("Default workspace stored in configuration file.");
Ok(())
}
ConfigSetCommand::Assignee {
assignee,
clear_assignee,
} => {
if clear_assignee {
config
.set_default_assignee(None)
.context("failed to clear default assignee")?;
println!("Default assignee cleared.");
return Ok(());
}
let value = assignee
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("provide --assignee <id> or use --clear"))?;
config
.set_default_assignee(Some(value.to_string()))
.context("failed to store default assignee")?;
println!("Default assignee stored in configuration file.");
Ok(())
}
ConfigSetCommand::Project {
project,
clear_project,
} => {
if clear_project {
config
.set_default_project(None)
.context("failed to clear default project")?;
println!("Default project cleared.");
return Ok(());
}
let value = project
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("provide --project <gid> or use --clear"))?;
config
.set_default_project(Some(value.to_string()))
.context("failed to store default project")?;
println!("Default project stored in configuration file.");
Ok(())
}
}
}
fn handle_config_get(config: &Config) {
println!("Configuration file: {}", config.path().display());
println!("API base URL: {}", config.effective_api_base_url());
println!(
"Default workspace: {}",
config
.default_workspace()
.filter(|workspace| !workspace.is_empty())
.unwrap_or("not set")
);
println!(
"Default assignee: {}",
config
.default_assignee()
.filter(|assignee| !assignee.is_empty())
.unwrap_or("not set")
);
println!(
"Default project: {}",
config
.default_project()
.filter(|project| !project.is_empty())
.unwrap_or("not set")
);
if let Some(_token) = config.personal_access_token() {
let status = if config.environment_token_available() {
"provided via environment variable"
} else if config.has_persisted_token() {
"stored in configuration file"
} else {
"available"
};
println!("Personal Access Token: {status}");
} else {
println!("Personal Access Token: not set");
}
}
fn handle_config_test(config: &Config) -> Result<()> {
let client = build_api_client(config)?;
let runtime = RuntimeBuilder::new_current_thread()
.enable_all()
.build()
.context("failed to initialise async runtime")?;
runtime.block_on(async move {
match client.get_current_user().await {
Ok(payload) => {
let user_name = payload
.get("data")
.and_then(|data| data.get("name"))
.and_then(Value::as_str)
.unwrap_or("unknown user");
println!("Personal Access Token validated for {user_name}.");
Ok(())
}
Err(ApiError::Authentication(_)) => Err(anyhow!(
"authentication failed; verify your Personal Access Token"
)),
Err(ApiError::RateLimited { retry_after, .. }) => Err(anyhow!(
"Asana rate limited the request. Retry after {:.1} seconds",
retry_after.as_secs_f32()
)),
Err(ApiError::Offline { .. }) => Err(anyhow!(
"offline mode enabled; disable offline mode to contact Asana"
)),
Err(err) => Err(anyhow!(err)),
}
})
}
pub(super) fn build_api_client(config: &Config) -> Result<ApiClient> {
let token = config.personal_access_token().ok_or_else(|| {
anyhow!("no Personal Access Token found; run `asana-cli config set token`")
})?;
let auth_token = AuthToken::new(token);
let cache_dir = config.cache_dir().to_path_buf();
let client = ApiClient::builder(auth_token)
.base_url(config.effective_api_base_url().to_string())
.cache_dir(cache_dir)
.build()?;
Ok(client)
}
fn handle_project_command(command: ProjectCommand, config: &Config) -> Result<()> {
project::handle_project_command(command, config)
}
fn handle_section_command(command: SectionCommand, config: &Config) -> Result<()> {
section::execute_section_command(command, config)
}
fn handle_tag_command(command: TagCommand, config: &Config) -> Result<()> {
tag::handle_tag_command(command, config)
}
fn handle_custom_field_command(command: CustomFieldCommand, config: &Config) -> Result<()> {
custom_field::handle_custom_field_command(command, config)
}
fn handle_workspace_command(command: WorkspaceCommand, config: &Config) -> Result<()> {
workspace::handle_workspace_command(command, config)
}
fn handle_user_command(command: UserCommand, config: &Config) -> Result<()> {
user::handle_user_command(command, config)
}