amouat/aslan

โ˜… 0Forks 0GitHub โ†—Compare

README

aslan ๐Ÿฆ

Agent-first CLI for Asana. Single-file entry point (aslan.py, Python 3 stdlib only), run as aslan once symlinked onto your PATH. Output is plain text โ€” one item per line (<name> <gid>), stable and trivial for an agent to parse; read commands take --json for JSON output.

Auth โ€” providing the token

aslan authenticates with an Asana Personal Access Token (PAT). Create one in Asana: Settings โ†’ Apps โ†’ Developer console โ†’ Personal access tokens โ†’ Create new token (direct link: https://app.asana.com/0/my-apps).

Don't paste the raw token into a dotfile or export โ€” resolve it from a secret manager at runtime instead. Two supported ways:

Command hook (recommended, manager-agnostic). Point $ASANA_PAT_CMD at any command that prints the token to stdout; aslan runs it fresh each call:

export ASANA_PAT_CMD="op read op://Private/Asana/token"        # 1Password
export ASANA_PAT_CMD="pass show asana/pat"                     # pass
export ASANA_PAT_CMD="vault kv get -field=token secret/asana"  # Vault

Injected env var. Let your manager populate $ASANA_PAT at exec time โ€” e.g. with 1Password, keep a reference (not the secret) in an env file and run under op run:

# asana.env โ€” holds a secret reference, not the token
ASANA_PAT=op://Private/Asana/token
op run --env-file=asana.env -- aslan whoami

Resolution order: $ASANA_PAT first, then $ASANA_PAT_CMD. The token is never written to disk by aslan.

Remember that plain-text tokens on the filesystem are bad for your health.

Install

ln -sfn "$(pwd)/aslan.py" ~/.local/bin/aslan
aslan whoami

Configure

Set your workspace once in your shell (required for my-tasks, search, and digest). It's a global flag too โ€” --workspace <gid> before the subcommand:

export ASANA_WORKSPACE=<your-workspace-gid>
Var Default Meaning
ASANA_PAT (unset) token; populate securely at runtime (see Auth)
ASANA_PAT_CMD (unset) command that prints the token to stdout, used if ASANA_PAT is unset
ASANA_WORKSPACE (unset) workspace gid; required for my-tasks / search / digest
ASANA_USER me task owner for my-tasks / digest
ASANA_GOAL_FIELD (unset) custom-field name surfaced inline by my-tasks / task; off if unset

Usage

aslan whoami                      # token owner (name, gid, email)
aslan my-tasks                    # tasks assigned to me
aslan my-tasks --open             # incomplete only
aslan task <gid>                  # one task's detail
aslan create "name"               # create a top-level task (unassigned)
aslan create "name" --assignee me --due 2026-07-15   # assign + due date
aslan create "name" --parent <gid>                   # create as a subtask
aslan subtask <parent-gid> "name" # create a subtask under a parent
aslan create "name" --dry-run     # print the request body, send nothing
aslan create "name" --field <field-gid>:<value>      # set a custom field
aslan create "name" --goal "Lead the Category"       # set the goal field by name
aslan subtasks <gid>              # list a task's subtasks
aslan set <gid> --name "new" --assignee me --due 2026-07-20   # edit an existing task
aslan set <gid> --assignee ""     # clear a field ("" unassigns; --due "" clears due)
aslan set <gid> --goal "Campaigns"                   # set the goal field on a task
aslan delete <gid> --yes          # delete a task (--yes required; --dry-run to preview)
aslan add-project <gid> <proj>    # add a task to a project
aslan remove-project <gid> <proj> # remove a task from a project
aslan done <gid>                  # mark completed (echoes before/after)
aslan reopen <gid>                # mark not completed
aslan comment <gid> "text"        # add a comment (story)
aslan comments <gid>              # read a task's comments (--all for system events)
aslan due <gid> 2026-07-01        # set due date (YYYY-MM-DD)
aslan search "query"              # typeahead task search

# Resolve names to gids (creates need gids for --project/--assignee/--field):
aslan projects                    # list projects (name -> gid); "query" to filter
aslan users "patrick"             # find users (name -> gid, email)
aslan fields                      # workspace custom fields + enum options (+ gids)
aslan fields <project-gid>        # custom fields attached to a project

aslan digest                      # recent activity on your assigned tasks (Inbox subset)
aslan digest --days 2             # tighter look-back window
aslan digest --comments-only      # human comments only, skip system events
aslan digest --all                # include completed tasks too

Every write command (create, subtask, set, delete, add-project, remove-project) takes --dry-run to print exactly what it would send without touching Asana. --dry-run is fully offline (no network) except when combined with --goal, which must read the field definitions to resolve the option name.

Tests

Stdlib unittest, no network or token needed:

python3 -m unittest discover -s tests

Notes

  • Asana tasks carry no built-in status field beyond the completed checkbox, so intermediate status (e.g. "in progress") is usually tracked in comments.
  • create makes a top-level task in $ASANA_WORKSPACE (or the subtask endpoint when --parent is given); subtask is the ergonomic front door for the same subtask path. Both default to unassigned โ€” pass --assignee me (or a user gid) to assign. --dry-run prints the request body and sends nothing, so an agent can preview a write before committing to it.
  • --field <field-gid>:<value> sets a custom field by raw gid. For enum fields the value must be the enum-option gid, not the visible label โ€” run aslan fields to list fields and their option gids.
  • --goal "value" is the friendly path for the field named by $ASANA_GOAL_FIELD: it resolves the field by name (exact match wins over substring) and, for enum/multi_enum fields, matches your value to an option name (exact, then case-insensitive substring), erroring with the list of valid options if there's no unique match. date and number goal fields get the right JSON shape; text fields take the raw string; an empty value is an error. Works on create, subtask, and set, and needs a workspace. It resolves workspace/org-shared custom fields only โ€” a project-local goal field won't be found by name; use aslan fields <project> to get its gid and set it with --field <gid>:<value>.
  • Attaching vs. updating a custom field: Asana lets you update a custom field a task already carries, but the API won't attach a workspace field to a task that lacks it. So --goal/--field work when the task already has the field (e.g. mirroring status on tasks the sheet already tagged), but setting a goal on a brand-new bare task returns HTTP 400 ("is not on given object"); aslan turns that into a hint. Attach the field once in the Asana UI (or via a project that includes it), then aslan can set it. create/subtask succeed regardless โ€” only the custom-field write is subject to this.
  • set edits an existing task in one PUT. It uses presence, not truthiness, so you can explicitly clear: --assignee "" unassigns, --due "" clears the due date, --notes "" empties the description. --name can't be empty.
  • delete refuses to run without --yes (the task moves to Asana's trash).
  • projects, users, and fields exist so an agent can resolve human names to the gids that create/set need for --project, --assignee, and --field.
  • my-tasks surfaces the custom field named by ASANA_GOAL_FIELD inline when the task has one.
  • digest aggregates the activity feed of each task assigned to you into one recent-activity view. It is deliberately not the Asana Inbox โ€” Asana's API exposes no notifications/Inbox endpoint, so digest can't see tasks you only follow, @mentions on tasks you aren't assigned, project messages, or read/unread state. It makes one API call per task, so it's scoped to open tasks and a look-back window (--days, default 7).

Contributors

smythp

Issues