From e54b10a092c016352f784f723f856cc7befb6aff Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:12 -0400 Subject: [PATCH 01/16] feat(create): add --project, --section, --followers, and --non-interactive flags Enable fully non-interactive task creation by adding flags for project, section, and followers. Non-interactive mode auto-detects when name, assignee, and project are all provided, or can be set explicitly. Followers are resolved by name (exact, partial, or ID match). Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/create/create.go | 245 +++++++++++++++++++++++++++------ 1 file changed, 200 insertions(+), 45 deletions(-) diff --git a/pkg/cmd/tasks/create/create.go b/pkg/cmd/tasks/create/create.go index 1c8e622..254a609 100644 --- a/pkg/cmd/tasks/create/create.go +++ b/pkg/cmd/tasks/create/create.go @@ -21,10 +21,24 @@ type CreateOptions struct { Config func() (*config.Config, error) Client func() (*asana.Client, error) - Name string - Assignee string - Due string - Description string + Name string + Assignee string + Due string + Description string + Project string + Section string + Followers []string + NonInteractive bool +} + +// isNonInteractive returns true when prompts should be suppressed. +// Explicit --non-interactive flag takes priority, but we also infer it +// when the required flags (name, assignee, project) are all provided. +func (o *CreateOptions) isNonInteractive() bool { + if o.NonInteractive { + return true + } + return o.Name != "" && o.Assignee != "" && o.Project != "" } func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -51,12 +65,17 @@ func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Com cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Assignee name or 'me'") cmd.Flags().StringVarP(&opts.Due, "due", "d", "", "Due date (YYYY-MM-DD, 'today', 'tomorrow')") cmd.Flags().StringVarP(&opts.Description, "description", "m", "", "Task description") + cmd.Flags().StringVarP(&opts.Project, "project", "p", "", "Project name or ID") + cmd.Flags().StringVarP(&opts.Section, "section", "s", "", "Section name or ID") + cmd.Flags().StringSliceVarP(&opts.Followers, "followers", "f", nil, "Comma-separated follower names or IDs") + cmd.Flags().BoolVar(&opts.NonInteractive, "non-interactive", false, "Never prompt; error if required flags are missing") return cmd } func runCreate(opts *CreateOptions) error { cs := opts.IO.ColorScheme() + ni := opts.isNonInteractive() cfg, err := opts.Config() if err != nil { @@ -67,9 +86,12 @@ func runCreate(opts *CreateOptions) error { return fmt.Errorf("failed to initialize Asana client: %w", err) } - // Get or prompt for task name + // --- Name --- name := opts.Name if name == "" { + if ni { + return fmt.Errorf("--name is required in non-interactive mode") + } name, err = opts.Prompter.Input("Enter task name: ", "") if err != nil { return fmt.Errorf("failed to read task name: %w", err) @@ -79,20 +101,29 @@ func runCreate(opts *CreateOptions) error { return fmt.Errorf("task name cannot be empty") } - // Get or prompt for assignee - assignee, err := getOrSelectAssignee(opts, cfg, client) + // --- Assignee --- + assignee, err := getOrSelectAssignee(opts, ni, cfg, client) if err != nil { return err } - // Get or prompt for due date - dueDate, err := getOrPromptDueDate(opts) - if err != nil { - return err + // --- Due date (optional) --- + var dueDate *asana.Date + if opts.Due != "" { + dueDate, err = parseDueDate(opts.Due) + if err != nil { + return err + } + } else if !ni { + dueDate, err = promptDueDate(opts) + if err != nil { + return err + } } + // --- Description (optional) --- description := opts.Description - if description == "" { + if description == "" && !ni { shouldPromptForDescription, err := opts.Prompter.Confirm("Add description?", "No") if err == nil && shouldPromptForDescription { description, err = addDescription(opts) @@ -102,14 +133,20 @@ func runCreate(opts *CreateOptions) error { } } - // Prompt for project - project, err := getProject(opts, cfg.Workspace.ID, client) + // --- Project --- + project, err := getProject(opts, ni, cfg.Workspace.ID, client) + if err != nil { + return err + } + + // --- Section (defaults to first section when not specified in non-interactive mode) --- + section, err := getSection(opts, ni, project.ID, client) if err != nil { return err } - // Prompt for section - section, err := getSection(opts, project.ID, client) + // --- Followers (optional) --- + followerIDs, followerNames, err := resolveFollowers(opts, cfg, client) if err != nil { return err } @@ -122,11 +159,8 @@ func runCreate(opts *CreateOptions) error { }, Workspace: cfg.Workspace.ID, Assignee: assignee.ID, - - // Currently only one project is supported - Projects: []string{project.ID}, - - // Both project and section ID are expected + Followers: followerIDs, + Projects: []string{project.ID}, Memberships: []*asana.CreateMembership{ { Project: project.ID, @@ -145,6 +179,9 @@ func runCreate(opts *CreateOptions) error { opts.IO.Printf("%s Created task %s\n", cs.SuccessIcon, cs.Bold(task.Name)) opts.IO.Printf(" %s %s\n", cs.Gray("Assignee:"), assignee.Name) + if len(followerNames) > 0 { + opts.IO.Printf(" %s %s\n", cs.Gray("Followers:"), strings.Join(followerNames, ", ")) + } if task.DueOn != nil { opts.IO.Printf(" %s %s\n", cs.Gray("Due:"), format.Date(task.DueOn)) } @@ -155,19 +192,15 @@ func runCreate(opts *CreateOptions) error { return nil } -func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana.Client) (*asana.User, error) { +func getOrSelectAssignee(opts *CreateOptions, ni bool, cfg *config.Config, client *asana.Client) (*asana.User, error) { ws := &asana.Workspace{ID: cfg.Workspace.ID} users, _, err := ws.Users(client) if err != nil { return nil, fmt.Errorf("cannot fetch users: %w", err) } - // If flag provided if opts.Assignee != "" { - // Handle 'me' shorthand if strings.ToLower(opts.Assignee) == "me" { - // If no user ID in config, fetch current user - // This is needed because the user id may not be stored in config yet if cfg.UserID == "" { currentUser, err := client.CurrentUser() if err != nil { @@ -189,7 +222,7 @@ func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana. } } - // Try to match by name + // Try exact name match assigneeLower := strings.ToLower(opts.Assignee) for _, user := range users { if strings.ToLower(user.Name) == assigneeLower { @@ -197,7 +230,14 @@ func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana. } } - // Try to match by ID + // Try partial/contains match + for _, user := range users { + if strings.Contains(strings.ToLower(user.Name), assigneeLower) { + return user, nil + } + } + + // Try ID match for _, user := range users { if user.ID == opts.Assignee { return user, nil @@ -207,6 +247,10 @@ func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana. return nil, fmt.Errorf("assignee %q not found in workspace", opts.Assignee) } + if ni { + return nil, fmt.Errorf("--assignee is required in non-interactive mode") + } + names := format.MapToStrings(users, func(u *asana.User) string { return u.Name }) @@ -218,19 +262,7 @@ func getOrSelectAssignee(opts *CreateOptions, cfg *config.Config, client *asana. return users[selected], nil } -func getOrPromptDueDate(opts *CreateOptions) (*asana.Date, error) { - input := opts.Due - if input == "" { - var err error - input, err = opts.Prompter.Input("Enter due date (YYYY-MM-DD), leave blank for none: ", "") - if err != nil { - return nil, fmt.Errorf("failed to read due date: %w", err) - } - } - if input == "" { - return nil, nil - } - +func parseDueDate(input string) (*asana.Date, error) { now := time.Now() switch strings.ToLower(input) { case "today": @@ -238,7 +270,6 @@ func getOrPromptDueDate(opts *CreateOptions) (*asana.Date, error) { case "tomorrow": return convert.ToDate(now.AddDate(0, 0, 1).Format(time.DateOnly), time.DateOnly) } - due, err := convert.ToDate(input, time.DateOnly) if err != nil { return nil, fmt.Errorf("invalid due date %q: %w", input, err) @@ -246,22 +277,53 @@ func getOrPromptDueDate(opts *CreateOptions) (*asana.Date, error) { return due, nil } +func promptDueDate(opts *CreateOptions) (*asana.Date, error) { + input, err := opts.Prompter.Input("Enter due date (YYYY-MM-DD), leave blank for none: ", "") + if err != nil { + return nil, fmt.Errorf("failed to read due date: %w", err) + } + if input == "" { + return nil, nil + } + return parseDueDate(input) +} + func addDescription(opts *CreateOptions) (string, error) { description, err := opts.Prompter.Editor("Enter task description: ", "") if err != nil { return "", fmt.Errorf("failed to read task description: %w", err) } - return strings.TrimSpace(description), nil } -func getProject(opts *CreateOptions, workspaceID string, client *asana.Client) (*asana.Project, error) { +func getProject(opts *CreateOptions, ni bool, workspaceID string, client *asana.Client) (*asana.Project, error) { ws := &asana.Workspace{ID: workspaceID} projects, err := ws.AllProjects(client) if err != nil { return nil, fmt.Errorf("cannot fetch projects: %w", err) } + if opts.Project != "" { + projectLower := strings.ToLower(opts.Project) + // Exact match first + for _, p := range projects { + if strings.ToLower(p.Name) == projectLower || p.ID == opts.Project { + return p, nil + } + } + // Partial/contains match + for _, p := range projects { + if strings.Contains(strings.ToLower(p.Name), projectLower) { + return p, nil + } + } + return nil, fmt.Errorf("project %q not found in workspace", opts.Project) + } + + if ni { + return nil, fmt.Errorf("--project is required in non-interactive mode") + } + names := format.MapToStrings(projects, func(p *asana.Project) string { return p.Name }) @@ -273,13 +335,38 @@ func getProject(opts *CreateOptions, workspaceID string, client *asana.Client) ( return projects[selected], nil } -func getSection(opts *CreateOptions, projectID string, client *asana.Client) (*asana.Section, error) { +func getSection(opts *CreateOptions, ni bool, projectID string, client *asana.Client) (*asana.Section, error) { project := &asana.Project{ID: projectID} sections, _, err := project.Sections(client) if err != nil { return nil, fmt.Errorf("cannot fetch sections: %w", err) } + if opts.Section != "" { + sectionLower := strings.ToLower(opts.Section) + // Exact match + for _, s := range sections { + if strings.ToLower(s.Name) == sectionLower || s.ID == opts.Section { + return s, nil + } + } + // Partial/contains match + for _, s := range sections { + if strings.Contains(strings.ToLower(s.Name), sectionLower) { + return s, nil + } + } + return nil, fmt.Errorf("section %q not found in project", opts.Section) + } + + // In non-interactive mode, default to the first section + if ni { + if len(sections) == 0 { + return nil, fmt.Errorf("project has no sections") + } + return sections[0], nil + } + names := format.MapToStrings(sections, func(p *asana.Section) string { return p.Name }) @@ -290,3 +377,71 @@ func getSection(opts *CreateOptions, projectID string, client *asana.Client) (*a } return sections[selected], nil } + +// resolveFollowers resolves follower names/IDs to user IDs. +// Returns (followerIDs, followerNames, error). +func resolveFollowers(opts *CreateOptions, cfg *config.Config, client *asana.Client) ([]string, []string, error) { + if len(opts.Followers) == 0 { + return nil, nil, nil + } + + ws := &asana.Workspace{ID: cfg.Workspace.ID} + users, _, err := ws.Users(client) + if err != nil { + return nil, nil, fmt.Errorf("cannot fetch users for follower resolution: %w", err) + } + + var ids []string + var names []string + + for _, f := range opts.Followers { + f = strings.TrimSpace(f) + if f == "" { + continue + } + + found := false + fLower := strings.ToLower(f) + + // Exact name match + for _, u := range users { + if strings.ToLower(u.Name) == fLower { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if found { + continue + } + + // Partial/contains match + for _, u := range users { + if strings.Contains(strings.ToLower(u.Name), fLower) { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if found { + continue + } + + // ID match + for _, u := range users { + if u.ID == f { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if !found { + return nil, nil, fmt.Errorf("follower %q not found in workspace", f) + } + } + + return ids, names, nil +} From 7124fb2805cae41958056c52c5055aff47d937b8 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:18 -0400 Subject: [PATCH 02/16] feat(api): add AddFollowers method to Task Add Task.AddFollowers for the /tasks/{id}/addFollowers endpoint, since followers cannot be set via the standard update endpoint. Co-Authored-By: Claude Opus 4.6 --- internal/api/asana/tasks.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index e9dc46b..ae7b488 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -304,6 +304,15 @@ func (t *Task) Update(client *Client, update *UpdateTaskRequest) error { return err } +type AddFollowersRequest struct { + Followers []string `json:"followers"` +} + +func (t *Task) AddFollowers(client *Client, followerIDs []string) error { + client.trace("Adding followers to task %q", t.Name) + return client.post(fmt.Sprintf("/tasks/%s/addFollowers", t.ID), &AddFollowersRequest{Followers: followerIDs}, t) +} + func (t *Task) Delete(client *Client) error { client.info("Deleting task %q", t.Name) From fe72915900614c73fb3dab93a1e0e81863390e1f Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:24 -0400 Subject: [PATCH 03/16] feat(update): add non-interactive flag support Accept task ID as argument with flags: --name, --description, --due, --assignee, --followers, --complete, --non-interactive. Falls back to interactive mode when no task ID is given. Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/update/update.go | 250 ++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/tasks/update/update.go b/pkg/cmd/tasks/update/update.go index 2dd0a69..ecf9b37 100644 --- a/pkg/cmd/tasks/update/update.go +++ b/pkg/cmd/tasks/update/update.go @@ -46,6 +46,20 @@ type UpdateOptions struct { Config func() (*config.Config, error) Client func() (*asana.Client, error) + + // Non-interactive flags + TaskID string + Name string + Description string + Due string + Assignee string + Followers []string + Complete bool + NonInteractive bool +} + +func (o *UpdateOptions) isNonInteractive() bool { + return o.NonInteractive || o.TaskID != "" } func NewCmdUpdate(f factory.Factory, runF func(*UpdateOptions) error) *cobra.Command { @@ -57,26 +71,141 @@ func NewCmdUpdate(f factory.Factory, runF func(*UpdateOptions) error) *cobra.Com } cmd := &cobra.Command{ - Use: "update", + Use: "update [task-id]", Short: "Update details of a specific task", - Long: "Retrieve task details and select one for updating it.", - Args: cobra.NoArgs, + Long: "Update a task interactively or via flags with a task ID.", + Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` + # Interactive mode $ asana tasks update - $ asana ts update`), + + # Non-interactive: update by task ID + $ asana tasks update 1234567890 --name "New name" --due 2026-04-01 + $ asana tasks update 1234567890 --complete + $ asana tasks update 1234567890 --assignee "Chris Christoff" --followers "Tom McFarlin" + `), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.TaskID = args[0] + } if runF != nil { return runF(opts) } - return runUpdate(opts) }, } + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "New task name") + cmd.Flags().StringVarP(&opts.Description, "description", "m", "", "New task description") + cmd.Flags().StringVarP(&opts.Due, "due", "d", "", "New due date (YYYY-MM-DD, 'today', 'tomorrow')") + cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "New assignee name or 'me'") + cmd.Flags().StringSliceVarP(&opts.Followers, "followers", "f", nil, "Comma-separated follower names or IDs to add") + cmd.Flags().BoolVar(&opts.Complete, "complete", false, "Mark task as completed") + cmd.Flags().BoolVar(&opts.NonInteractive, "non-interactive", false, "Never prompt; error if required flags are missing") + return cmd } func runUpdate(opts *UpdateOptions) error { + if opts.isNonInteractive() { + return runNonInteractiveUpdate(opts) + } + return runInteractiveUpdate(opts) +} + +func runNonInteractiveUpdate(opts *UpdateOptions) error { + cs := opts.IO.ColorScheme() + + cfg, err := opts.Config() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + client, err := opts.Client() + if err != nil { + return fmt.Errorf("failed to create Asana client: %w", err) + } + + task := &asana.Task{ID: opts.TaskID} + if err := task.Fetch(client); err != nil { + return fmt.Errorf("task %q not found: %w", opts.TaskID, err) + } + + req := &asana.UpdateTaskRequest{} + changes := []string{} + + if opts.Name != "" { + req.TaskBase.Name = opts.Name + changes = append(changes, "name") + } + + if opts.Description != "" { + req.TaskBase.Notes = opts.Description + changes = append(changes, "description") + } + + if opts.Due != "" { + dueDate, err := parseDueDate(opts.Due) + if err != nil { + return err + } + req.TaskBase.DueOn = dueDate + changes = append(changes, "due date") + } + + if opts.Complete { + completed := true + req.TaskBase.Completed = &completed + changes = append(changes, "completed") + } + + if opts.Assignee != "" { + userID, err := resolveUserID(opts.Assignee, cfg, client) + if err != nil { + return err + } + req.Assignee = userID + changes = append(changes, "assignee") + } + + var followerIDs []string + if len(opts.Followers) > 0 { + var err error + followerIDs, _, err = resolveFollowerIDs(opts.Followers, cfg, client) + if err != nil { + return err + } + changes = append(changes, "followers") + } + + if len(changes) == 0 { + return fmt.Errorf("no updates specified; use flags like --name, --due, --complete, --assignee, --followers") + } + + // Update task fields (everything except followers) + hasFieldUpdates := opts.Name != "" || opts.Description != "" || opts.Due != "" || opts.Complete || opts.Assignee != "" + if hasFieldUpdates { + if err := task.Update(client, req); err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + } + + // Add followers via separate endpoint + if len(followerIDs) > 0 { + if err := task.AddFollowers(client, followerIDs); err != nil { + return fmt.Errorf("failed to add followers: %w", err) + } + } + + opts.IO.Printf("%s Updated task %s (%s)\n", cs.SuccessIcon, cs.Bold(task.Name), strings.Join(changes, ", ")) + if task.PermalinkURL != "" { + opts.IO.Printf(" %s %s\n", cs.Gray("URL:"), task.PermalinkURL) + } + + return nil +} + +func runInteractiveUpdate(opts *UpdateOptions) error { task, err := selectTask(opts) if err != nil { return err @@ -289,3 +418,114 @@ func setTaskDueDate( fmt.Fprintf(opts.IO.Out, "%s Due date updated\n", cs.SuccessIcon) return nil } + +func parseDueDate(input string) (*asana.Date, error) { + now := time.Now() + switch strings.ToLower(input) { + case "today": + return convert.ToDate(now.Format(time.DateOnly), time.DateOnly) + case "tomorrow": + return convert.ToDate(now.AddDate(0, 0, 1).Format(time.DateOnly), time.DateOnly) + } + due, err := convert.ToDate(input, time.DateOnly) + if err != nil { + return nil, fmt.Errorf("invalid due date %q: %w", input, err) + } + return due, nil +} + +func resolveUserID(name string, cfg *config.Config, client *asana.Client) (string, error) { + ws := &asana.Workspace{ID: cfg.Workspace.ID} + users, _, err := ws.Users(client) + if err != nil { + return "", fmt.Errorf("cannot fetch users: %w", err) + } + + if strings.ToLower(name) == "me" { + if cfg.UserID != "" { + return cfg.UserID, nil + } + currentUser, err := client.CurrentUser() + if err != nil { + return "", fmt.Errorf("failed to fetch current user: %w", err) + } + return currentUser.ID, nil + } + + nameLower := strings.ToLower(name) + for _, u := range users { + if strings.ToLower(u.Name) == nameLower { + return u.ID, nil + } + } + for _, u := range users { + if strings.Contains(strings.ToLower(u.Name), nameLower) { + return u.ID, nil + } + } + for _, u := range users { + if u.ID == name { + return u.ID, nil + } + } + + return "", fmt.Errorf("user %q not found in workspace", name) +} + +func resolveFollowerIDs(followers []string, cfg *config.Config, client *asana.Client) ([]string, []string, error) { + if len(followers) == 0 { + return nil, nil, nil + } + + ws := &asana.Workspace{ID: cfg.Workspace.ID} + users, _, err := ws.Users(client) + if err != nil { + return nil, nil, fmt.Errorf("cannot fetch users: %w", err) + } + + var ids, names []string + for _, f := range followers { + f = strings.TrimSpace(f) + if f == "" { + continue + } + fLower := strings.ToLower(f) + found := false + + for _, u := range users { + if strings.ToLower(u.Name) == fLower { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if found { + continue + } + for _, u := range users { + if strings.Contains(strings.ToLower(u.Name), fLower) { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if found { + continue + } + for _, u := range users { + if u.ID == f { + ids = append(ids, u.ID) + names = append(names, u.Name) + found = true + break + } + } + if !found { + return nil, nil, fmt.Errorf("follower %q not found in workspace", f) + } + } + + return ids, names, nil +} From 05a117cd068f4a79deba50e81af41f208457f3ec Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:29 -0400 Subject: [PATCH 04/16] feat(delete): add tasks delete command Delete an Asana task by ID: asana tasks delete Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/delete/delete.go | 65 ++++++++++++++++++++++++++++++++++ pkg/cmd/tasks/tasks.go | 2 ++ 2 files changed, 67 insertions(+) create mode 100644 pkg/cmd/tasks/delete/delete.go diff --git a/pkg/cmd/tasks/delete/delete.go b/pkg/cmd/tasks/delete/delete.go new file mode 100644 index 0000000..4bce8b1 --- /dev/null +++ b/pkg/cmd/tasks/delete/delete.go @@ -0,0 +1,65 @@ +package delete + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + "github.com/timwehrle/asana/internal/api/asana" + "github.com/timwehrle/asana/pkg/factory" + "github.com/timwehrle/asana/pkg/iostreams" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + Client func() (*asana.Client, error) + + TaskID string +} + +func NewCmdDelete(f factory.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Client: f.Client, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a task", + Long: "Permanently delete a task by its ID.", + Args: cobra.ExactArgs(1), + Example: heredoc.Doc(` + $ asana tasks delete 1234567890 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.TaskID = args[0] + if runF != nil { + return runF(opts) + } + return runDelete(opts) + }, + } + + return cmd +} + +func runDelete(opts *DeleteOptions) error { + cs := opts.IO.ColorScheme() + + client, err := opts.Client() + if err != nil { + return fmt.Errorf("failed to initialize Asana client: %w", err) + } + + task := &asana.Task{ID: opts.TaskID} + if err := task.Fetch(client); err != nil { + return fmt.Errorf("task %q not found: %w", opts.TaskID, err) + } + + if err := task.Delete(client); err != nil { + return fmt.Errorf("failed to delete task: %w", err) + } + + opts.IO.Printf("%s Deleted task %s\n", cs.SuccessIcon, cs.Bold(task.Name)) + return nil +} diff --git a/pkg/cmd/tasks/tasks.go b/pkg/cmd/tasks/tasks.go index df89ade..97cd3f2 100644 --- a/pkg/cmd/tasks/tasks.go +++ b/pkg/cmd/tasks/tasks.go @@ -3,6 +3,7 @@ package tasks import ( "github.com/spf13/cobra" "github.com/timwehrle/asana/pkg/cmd/tasks/create" + "github.com/timwehrle/asana/pkg/cmd/tasks/delete" "github.com/timwehrle/asana/pkg/cmd/tasks/list" "github.com/timwehrle/asana/pkg/cmd/tasks/search" "github.com/timwehrle/asana/pkg/cmd/tasks/update" @@ -23,6 +24,7 @@ func NewCmdTasks(f factory.Factory) *cobra.Command { cmd.AddCommand(update.NewCmdUpdate(f, nil)) cmd.AddCommand(search.NewCmdSearch(f, nil)) cmd.AddCommand(create.NewCmdCreate(f, nil)) + cmd.AddCommand(delete.NewCmdDelete(f, nil)) return cmd } From 8f935e1e1714e3020e4792ab04ce945f0db48d07 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:38 -0400 Subject: [PATCH 05/16] feat(projects): add projects sections command List sections in a project by name: asana projects sections "Project Name" Supports exact and partial name matching. Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/projects/projects.go | 2 + pkg/cmd/projects/sections/sections.go | 114 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 pkg/cmd/projects/sections/sections.go diff --git a/pkg/cmd/projects/projects.go b/pkg/cmd/projects/projects.go index 6a1a222..3a0c8e9 100644 --- a/pkg/cmd/projects/projects.go +++ b/pkg/cmd/projects/projects.go @@ -3,6 +3,7 @@ package projects import ( "github.com/spf13/cobra" "github.com/timwehrle/asana/pkg/cmd/projects/list" + "github.com/timwehrle/asana/pkg/cmd/projects/sections" "github.com/timwehrle/asana/pkg/cmd/projects/tasks" "github.com/timwehrle/asana/pkg/factory" ) @@ -15,6 +16,7 @@ func NewCmdProjects(f factory.Factory) *cobra.Command { } cmd.AddCommand(list.NewCmdList(f, nil)) + cmd.AddCommand(sections.NewCmdSections(f, nil)) cmd.AddCommand(tasks.NewCmdTasks(f, nil)) return cmd diff --git a/pkg/cmd/projects/sections/sections.go b/pkg/cmd/projects/sections/sections.go new file mode 100644 index 0000000..8f84e9a --- /dev/null +++ b/pkg/cmd/projects/sections/sections.go @@ -0,0 +1,114 @@ +package sections + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + "github.com/timwehrle/asana/internal/api/asana" + "github.com/timwehrle/asana/internal/config" + "github.com/timwehrle/asana/pkg/factory" + "github.com/timwehrle/asana/pkg/iostreams" +) + +type SectionsOptions struct { + IO *iostreams.IOStreams + Config func() (*config.Config, error) + Client func() (*asana.Client, error) + + ProjectName string +} + +func NewCmdSections(f factory.Factory, runF func(*SectionsOptions) error) *cobra.Command { + opts := &SectionsOptions{ + IO: f.IOStreams, + Config: f.Config, + Client: f.Client, + } + + cmd := &cobra.Command{ + Use: "sections ", + Short: "List sections of a project", + Long: "Retrieve and display all sections under a project.", + Args: cobra.ExactArgs(1), + Example: heredoc.Doc(` + $ asana projects sections Lindris + $ asana projects sections "Outgoing Tasks" + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ProjectName = args[0] + if runF != nil { + return runF(opts) + } + return runSections(opts) + }, + } + + return cmd +} + +func runSections(opts *SectionsOptions) error { + cs := opts.IO.ColorScheme() + + cfg, err := opts.Config() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client, err := opts.Client() + if err != nil { + return fmt.Errorf("failed to initialize Asana client: %w", err) + } + + ws := &asana.Workspace{ID: cfg.Workspace.ID} + projects, err := ws.AllProjects(client) + if err != nil { + return fmt.Errorf("failed to fetch projects: %w", err) + } + + var project *asana.Project + nameLower := strings.ToLower(opts.ProjectName) + for _, p := range projects { + if strings.ToLower(p.Name) == nameLower || p.ID == opts.ProjectName { + project = p + break + } + } + if project == nil { + for _, p := range projects { + if strings.Contains(strings.ToLower(p.Name), nameLower) { + project = p + break + } + } + } + if project == nil { + return fmt.Errorf("project %q not found in workspace", opts.ProjectName) + } + + sections := make([]*asana.Section, 0, 20) + options := &asana.Options{} + for { + batch, nextPage, err := project.Sections(client, options) + if err != nil { + return fmt.Errorf("failed to fetch sections: %w", err) + } + sections = append(sections, batch...) + if nextPage == nil || nextPage.Offset == "" { + break + } + options.Offset = nextPage.Offset + } + + fmt.Fprintf(opts.IO.Out, "\nSections in %s:\n\n", cs.Bold(project.Name)) + if len(sections) == 0 { + fmt.Fprintln(opts.IO.Out, "No sections found") + return nil + } + for i, s := range sections { + fmt.Fprintf(opts.IO.Out, " %d. %s\n", i+1, s.Name) + } + + return nil +} From 7012e46f49d20e19cb1b8121ec9654fce770260c Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:19:46 -0400 Subject: [PATCH 06/16] feat: add Claude Code plugin with skills, commands, and agent Add plugin structure for Claude Code integration: - Skills: using-asana-cli, troubleshooting-asana - Commands: create-task, update-task, delete-task - Agent: task-manager (autonomous Asana task management) - Settings with permission allowlist for asana commands Co-Authored-By: Claude Opus 4.6 --- .claude-plugin/marketplace.json | 13 ++ claude-plugin/.claude-plugin/README.md | 23 +++ claude-plugin/.claude-plugin/plugin.json | 11 ++ claude-plugin/agents/task-manager.md | 49 +++++ claude-plugin/commands/create-task.md | 38 ++++ claude-plugin/commands/delete-task.md | 22 +++ claude-plugin/commands/update-task.md | 35 ++++ claude-plugin/hooks/hooks.json | 3 + claude-plugin/settings.json | 15 ++ .../skills/troubleshooting-asana/SKILL.md | 66 +++++++ claude-plugin/skills/using-asana-cli/SKILL.md | 172 ++++++++++++++++++ claude-plugin/testing/skill-evaluations.json | 51 ++++++ 12 files changed, 498 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 claude-plugin/.claude-plugin/README.md create mode 100644 claude-plugin/.claude-plugin/plugin.json create mode 100644 claude-plugin/agents/task-manager.md create mode 100644 claude-plugin/commands/create-task.md create mode 100644 claude-plugin/commands/delete-task.md create mode 100644 claude-plugin/commands/update-task.md create mode 100644 claude-plugin/hooks/hooks.json create mode 100644 claude-plugin/settings.json create mode 100644 claude-plugin/skills/troubleshooting-asana/SKILL.md create mode 100644 claude-plugin/skills/using-asana-cli/SKILL.md create mode 100644 claude-plugin/testing/skill-evaluations.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..a24e7fc --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "asana-cli", + "description": "Manage Asana tasks, Asana projects, and Asana workspace users via the asana CLI", + "owner": { "name": "jtsternberg" }, + "plugins": [ + { + "name": "asana-cli", + "source": "./claude-plugin", + "description": "Non-interactive Asana CLI for task management, project organization, and team collaboration", + "version": "1.0.0" + } + ] +} diff --git a/claude-plugin/.claude-plugin/README.md b/claude-plugin/.claude-plugin/README.md new file mode 100644 index 0000000..dfd9fc4 --- /dev/null +++ b/claude-plugin/.claude-plugin/README.md @@ -0,0 +1,23 @@ +# Asana CLI Plugin for Claude Code + +Manage Asana tasks, projects, and users directly from Claude Code conversations. + +## Installation + +Add to your Claude Code plugins: + +```bash +claude plugins add /path/to/asana-cli +``` + +## Features + +- Create, update, and delete tasks non-interactively +- List projects, sections, and users +- Add followers/collaborators to tasks +- Search tasks across workspaces + +## Prerequisites + +- `asana` CLI installed and authenticated (`asana auth login`) +- Run `asana auth status` to verify your setup diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..8391e9f --- /dev/null +++ b/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "asana-cli", + "description": "Manage Asana tasks, projects, and users via the asana CLI", + "version": "1.0.0", + "author": { "name": "jtsternberg", "url": "https://github.com/jtsternberg" }, + "repository": "https://github.com/jtsternberg/asana", + "license": "MIT", + "keywords": ["asana", "tasks", "projects", "project-management"], + "skills": "./skills/", + "commands": "./commands/" +} diff --git a/claude-plugin/agents/task-manager.md b/claude-plugin/agents/task-manager.md new file mode 100644 index 0000000..928627b --- /dev/null +++ b/claude-plugin/agents/task-manager.md @@ -0,0 +1,49 @@ +--- +description: Autonomous agent for managing Asana tasks end-to-end via the `asana` CLI +allowed_tools: + - Bash + - Read + - Grep + - Glob +--- + +# Asana Task Manager Agent + +You are a specialist agent for managing Asana tasks using the `asana` CLI. + +## Capabilities + +- Create tasks with full metadata (name, assignee, project, section, due date, followers) +- Update existing tasks (rename, reassign, change due dates, add followers, complete) +- Delete tasks +- List and search tasks +- Discover projects, sections, and users + +## Key Commands + +| Operation | Command | +|-----------|---------| +| Create task | `asana tasks create -n "..." -a "..." -p "..." [-s "..."] [-d "..."] [-m "..."] [-f "..."]` | +| Update task | `asana tasks update [--name "..."] [--due "..."] [--complete] [--followers "..."]` | +| Delete task | `asana tasks delete ` | +| List tasks | `asana tasks list` | +| Search tasks | `asana tasks search` | +| List projects | `asana projects list -l 20` | +| List sections | `asana projects sections "Project Name"` | +| List users | `asana users list` | + +## Guidelines + +1. Always verify auth first: `asana auth status` +2. Use non-interactive flags for all operations — never rely on interactive prompts +3. When creating tasks, always provide `-n`, `-a`, and `-p` at minimum +4. Use `asana projects sections "Project"` to discover section names before creating tasks +5. Use `asana users list` to verify user names if assignment fails +6. Name matching is case-insensitive and supports partial matches +7. Task IDs can be found in Asana URLs or from `asana tasks list` + +## Error Handling + +- If a command fails with "unknown flag", the user may be running the upstream version — rebuild from `~/Code/asana-cli` +- If a command fails with "not found", use list/search commands to discover correct names or IDs +- If authentication fails, prompt the user to run `asana auth login` diff --git a/claude-plugin/commands/create-task.md b/claude-plugin/commands/create-task.md new file mode 100644 index 0000000..d7d4c64 --- /dev/null +++ b/claude-plugin/commands/create-task.md @@ -0,0 +1,38 @@ +# Create Asana Task + +Create a new Asana task with full metadata. + +## Usage + +/asana-cli:create-task [task description] + +## Arguments + +- `task description` (optional) - Natural language description of the task to create + +## Instructions + +1. Parse the user's request for: task name, assignee, project, section, due date, description, and followers +2. If project is unknown, list available projects: `asana projects list -l 20` +3. If section is unknown, list sections: `asana projects sections "Project Name"` +4. If assignee is unclear, list users: `asana users list` +5. Create the task with all available flags: + +```bash +asana tasks create \ + -n "Task name" \ + -a "Assignee" \ + -p "Project" \ + -s "Section" \ + -d "YYYY-MM-DD" \ + -m "Description" \ + -f "Follower1,Follower2" +``` + +6. Report the created task URL back to the user + +## Error Handling + +- If creation fails, check `asana auth status` first +- If a name doesn't match, use list commands to discover the correct value +- If section is not found, run `asana projects sections "Project"` and suggest alternatives diff --git a/claude-plugin/commands/delete-task.md b/claude-plugin/commands/delete-task.md new file mode 100644 index 0000000..369923b --- /dev/null +++ b/claude-plugin/commands/delete-task.md @@ -0,0 +1,22 @@ +# Delete Asana Task + +Permanently delete an Asana task. + +## Usage + +/asana-cli:delete-task + +## Arguments + +- `task-id` (required) - The Asana task ID + +## Instructions + +1. Confirm the task ID with the user before deleting +2. Run: `asana tasks delete ` +3. Report success or failure + +## Error Handling + +- If task not found, inform the user the ID may be wrong +- This action is permanent and cannot be undone diff --git a/claude-plugin/commands/update-task.md b/claude-plugin/commands/update-task.md new file mode 100644 index 0000000..e49cb56 --- /dev/null +++ b/claude-plugin/commands/update-task.md @@ -0,0 +1,35 @@ +# Update Asana Task + +Update an existing Asana task by its Asana task ID. + +## Usage + +/asana-cli:update-task [updates] + +## Arguments + +- `task-id` (required) - The Asana task ID or URL +- `updates` (optional) - Natural language description of changes + +## Instructions + +1. Extract the task ID from the argument (if a URL, parse the ID from it) +2. Parse requested changes: name, due date, assignee, followers, completion, description +3. Run the update: + +```bash +asana tasks update \ + [-n "New name"] \ + [-d "YYYY-MM-DD"] \ + [-a "Assignee"] \ + [-f "Follower1,Follower2"] \ + [-m "Description"] \ + [--complete] +``` + +4. Report the result to the user + +## Error Handling + +- If task not found, ask the user to verify the ID +- If user/assignee not found, run `asana users list` and suggest closest match diff --git a/claude-plugin/hooks/hooks.json b/claude-plugin/hooks/hooks.json new file mode 100644 index 0000000..b9820d5 --- /dev/null +++ b/claude-plugin/hooks/hooks.json @@ -0,0 +1,3 @@ +{ + "hooks": [] +} diff --git a/claude-plugin/settings.json b/claude-plugin/settings.json new file mode 100644 index 0000000..3a02965 --- /dev/null +++ b/claude-plugin/settings.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(asana auth *)", + "Bash(asana tasks *)", + "Bash(asana projects *)", + "Bash(asana users *)", + "Bash(asana workspaces *)", + "Bash(asana teams *)", + "Bash(asana tags *)", + "Bash(asana config *)", + "Bash(asana search *)" + ] + } +} diff --git a/claude-plugin/skills/troubleshooting-asana/SKILL.md b/claude-plugin/skills/troubleshooting-asana/SKILL.md new file mode 100644 index 0000000..464c3c5 --- /dev/null +++ b/claude-plugin/skills/troubleshooting-asana/SKILL.md @@ -0,0 +1,66 @@ +--- +name: troubleshooting-asana +description: Diagnoses and resolves Asana CLI issues. Use when `asana` commands fail or Asana authentication errors occur. +--- + +# Troubleshooting the Asana CLI + +## Diagnostic Steps + +When an `asana` command fails, follow this order: + +### 1. Check authentication + +```bash +asana auth status +``` + +If this fails, re-authenticate: + +```bash +asana auth login +``` + +### 2. Check the binary + +```bash +which asana +asana --version +``` + +Ensure you're running the fork with non-interactive support (version should show `dev` or include `--project` flag in `asana tasks create --help`). + +### 3. Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `unknown flag: --name` | Running upstream v1.2.0 (no flag support) | Install the fork from `~/Code/asana-cli`: `cd ~/Code/asana-cli && go build -o /usr/local/bin/asana ./cmd/asana` | +| `could not prompt: EOF` | Interactive prompt in non-TTY context | Use flags to skip prompts (`-n`, `-a`, `-p`) | +| `The result is too large` | API pagination issue | Use commands that paginate properly (e.g., `projects sections` instead of raw API) | +| `section "X" not found in project` | Section name doesn't exist in that project | Run `asana projects sections "Project Name"` to see available sections | +| `assignee "X" not found` | Name doesn't match any workspace user | Run `asana users list` to see available users; try partial name match | +| `followers: Cannot write this property` | Using followers in update request body | Followers must be added via `AddFollowers` endpoint (handled in fork) | +| `task "X" not found` | Wrong task ID | Get the task ID from the Asana URL or from `asana tasks list` | + +### 4. Rebuild from source + +If the binary is outdated or broken: + +```bash +cd ~/Code/asana-cli +go build -o /usr/local/bin/asana ./cmd/asana +asana --version +``` + +Requires Go installed (`brew install go`). + +### 5. Keychain issues + +The CLI stores tokens in the system keychain. If authentication fails after a successful login: + +```bash +# Check if the token is stored +security find-generic-password -s "asana" -w 2>&1 +``` + +If the token is missing, re-run `asana auth login`. diff --git a/claude-plugin/skills/using-asana-cli/SKILL.md b/claude-plugin/skills/using-asana-cli/SKILL.md new file mode 100644 index 0000000..b11f09d --- /dev/null +++ b/claude-plugin/skills/using-asana-cli/SKILL.md @@ -0,0 +1,172 @@ +--- +name: using-asana-cli +description: Manages Asana tasks, Asana projects, and Asana workspace users via the `asana` CLI. Use when the user explicitly mentions Asana or uses `asana` commands. +--- + +# Asana CLI + +Manage Asana tasks, Asana projects, and Asana workspace members from the command line using the `asana` CLI. All commands support non-interactive mode for scripting and agent use. + +This skill only applies when the user is working with Asana specifically. + +## Prerequisites + +Verify authentication before running commands: + +```bash +asana auth status +``` + +If not authenticated, run `asana auth login` and follow the prompts. + +## Task Management + +### Create a task (non-interactive) + +Provide `--name`, `--assignee`, and `--project` to skip all prompts: + +```bash +asana tasks create \ + -n "Task name" \ + -a "Assignee Name" \ + -p "Project Name" \ + -s "Section Name" \ + -d "2026-04-01" \ + -m "Task description" \ + -f "Follower One,Follower Two" +``` + +**Flags:** +| Flag | Short | Required | Description | +|------|-------|----------|-------------| +| `--name` | `-n` | Yes* | Task name | +| `--assignee` | `-a` | Yes* | Assignee name, ID, or `me` | +| `--project` | `-p` | Yes* | Project name or ID | +| `--section` | `-s` | No | Section name or ID (defaults to first section) | +| `--due` | `-d` | No | Due date: `YYYY-MM-DD`, `today`, `tomorrow` | +| `--description` | `-m` | No | Task description | +| `--followers` | `-f` | No | Comma-separated follower names or IDs | +| `--non-interactive` | | No | Explicitly prevent prompts; errors on missing required fields | + +*Required in non-interactive mode. When all three are provided, non-interactive mode is auto-detected. + +Without flags, the command falls back to interactive prompts. + +### Update a task (non-interactive) + +Pass a task ID as the first argument to use flags: + +```bash +asana tasks update \ + -n "New name" \ + -d "2026-04-01" \ + -a "New Assignee" \ + -f "Follower Name" \ + --complete +``` + +**Flags:** +| Flag | Short | Description | +|------|-------|-------------| +| `--name` | `-n` | New task name | +| `--description` | `-m` | New description | +| `--due` | `-d` | New due date | +| `--assignee` | `-a` | New assignee name or `me` | +| `--followers` | `-f` | Comma-separated follower names to add | +| `--complete` | | Mark task as completed | +| `--non-interactive` | | Explicitly prevent prompts | + +Without a task ID, falls back to interactive mode. + +### Delete a task + +```bash +asana tasks delete +``` + +### View a task + +```bash +asana tasks view +``` + +### List your tasks + +```bash +asana tasks list +``` + +### Search tasks + +```bash +asana tasks search +``` + +## Project Management + +### List projects + +```bash +asana projects list -l 20 # Limit to 20 +asana projects list -f # Favorites only +asana projects list -s asc # Sort ascending +``` + +### List sections in a project + +```bash +asana projects sections "Project Name" +``` + +### List tasks in a project + +```bash +asana projects tasks # Interactive project selection +asana projects tasks --sections # Group by section +``` + +## Users + +### List workspace users + +```bash +asana users list +``` + +## Name Matching + +All name-based flags (assignee, project, section, followers) support: +1. **Exact match** (case-insensitive) +2. **Partial/contains match** (case-insensitive) +3. **ID match** (Asana GID) + +For example, `-a "Chris"` will match "Chris Christoff" if no exact "Chris" exists. + +## Common Patterns + +### Create a task and add collaborators + +```bash +asana tasks create \ + -n "Review PR #42" \ + -a me \ + -p "Engineering" \ + -f "Alice,Bob" \ + -d tomorrow +``` + +### Batch update: complete multiple tasks + +```bash +for id in 123 456 789; do + asana tasks update "$id" --complete +done +``` + +### Discover sections before creating a task + +```bash +asana projects sections "My Project" +# Then use the section name in create: +asana tasks create -n "..." -a me -p "My Project" -s "Sprint 5" +``` diff --git a/claude-plugin/testing/skill-evaluations.json b/claude-plugin/testing/skill-evaluations.json new file mode 100644 index 0000000..9a4df6a --- /dev/null +++ b/claude-plugin/testing/skill-evaluations.json @@ -0,0 +1,51 @@ +[ + { + "id": "eval-01", + "skills": ["using-asana-cli"], + "query": "Create a task called 'Fix login bug' assigned to Chris Christoff in the Outgoing Tasks project", + "expected_behavior": [ + "Runs asana tasks create with -n, -a, and -p flags", + "Does not trigger interactive prompts", + "Reports the created task URL" + ] + }, + { + "id": "eval-02", + "skills": ["using-asana-cli"], + "query": "Add Tom McFarlin as a follower to task 1234567890 and set the due date to next Friday", + "expected_behavior": [ + "Runs asana tasks update with task ID, -f and -d flags", + "Resolves 'next Friday' to a YYYY-MM-DD date", + "Reports the updated task" + ] + }, + { + "id": "eval-03", + "skills": ["using-asana-cli"], + "query": "What sections are in the Lindris project?", + "expected_behavior": [ + "Runs asana projects sections Lindris", + "Displays the list of sections" + ] + }, + { + "id": "eval-04", + "skills": ["troubleshooting-asana"], + "query": "asana tasks create is failing with 'unknown flag: --name'", + "expected_behavior": [ + "Identifies the user is running upstream v1.2.0", + "Suggests rebuilding from ~/Code/asana-cli", + "Provides the go build command" + ] + }, + { + "id": "eval-05", + "skills": ["using-asana-cli"], + "query": "Delete task 9876543210", + "expected_behavior": [ + "Confirms with the user before deleting", + "Runs asana tasks delete 9876543210", + "Reports success" + ] + } +] From 7a5b2f49addf857ca63556ab0db4bdb73c1d97d9 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:29:51 -0400 Subject: [PATCH 07/16] feat(view): add non-interactive task ID argument to tasks view Accepts optional task-id argument for direct task viewing without interactive prompts. Shows permalink URL in output. Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/view/view.go | 43 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/tasks/view/view.go b/pkg/cmd/tasks/view/view.go index fc416eb..8255e40 100644 --- a/pkg/cmd/tasks/view/view.go +++ b/pkg/cmd/tasks/view/view.go @@ -22,6 +22,8 @@ type ViewOptions struct { Config func() (*config.Config, error) Client func() (*asana.Client, error) + + TaskID string } func NewCmdView(f factory.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -33,15 +35,23 @@ func NewCmdView(f factory.Factory, runF func(*ViewOptions) error) *cobra.Command } cmd := &cobra.Command{ - Use: "view", + Use: "view [task-id]", Short: "View details of a specific task", Example: heredoc.Doc(` + # Interactive: select from your tasks $ asana tasks view - $ asana ts view`), + + # Non-interactive: view by task ID + $ asana tasks view 1234567890 + $ asana ts view 1234567890`), Long: heredoc.Doc(` - Display detailed information about a specific task, allowing you to - analyze and manage it effectively.`), + Display detailed information about a specific task. + Pass a task ID to view it directly, or omit for interactive selection.`), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.TaskID = args[0] + } if runF != nil { return runF(opts) } @@ -54,12 +64,22 @@ func NewCmdView(f factory.Factory, runF func(*ViewOptions) error) *cobra.Command } func viewRun(opts *ViewOptions) error { - cfg, err := opts.Config() + client, err := opts.Client() if err != nil { return err } - client, err := opts.Client() + // Non-interactive: view by task ID + if opts.TaskID != "" { + task := &asana.Task{ID: opts.TaskID} + if err := task.Fetch(client); err != nil { + return fmt.Errorf("task %q not found: %w", opts.TaskID, err) + } + return displayDetails(client, task, opts.IO) + } + + // Interactive: select from your tasks + cfg, err := opts.Config() if err != nil { return err } @@ -80,12 +100,7 @@ func viewRun(opts *ViewOptions) error { return err } - err = displayDetails(client, selectedTask, opts.IO) - if err != nil { - return err - } - - return nil + return displayDetails(client, selectedTask, opts.IO) } func prompt(allTasks []*asana.Task, prompter prompter.Prompter) (*asana.Task, error) { @@ -123,5 +138,9 @@ func displayDetails(client *asana.Client, task *asana.Task, io *iostreams.IOStre fmt.Fprintf(io.Out, "%s\n", format.Tags(task.Tags)) fmt.Fprintln(io.Out, format.Notes(task.Notes)) + if task.PermalinkURL != "" { + fmt.Fprintf(io.Out, "\n%s %s\n", cs.Gray("URL:"), task.PermalinkURL) + } + return nil } From 8c89ccde2fbfefcdb522a41112a6c1853f995ecd Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 17:29:57 -0400 Subject: [PATCH 08/16] refactor(plugin): apply skill review fixes - Update SKILL.md: document view/list/search non-interactive flags, condense name matching, replace common patterns with verification step - Deduplicate agent: reference skill instead of copying command table - Add check-auth.sh utility script Co-Authored-By: Claude Opus 4.6 --- claude-plugin/agents/task-manager.md | 20 ++------- claude-plugin/scripts/check-auth.sh | 7 ++++ claude-plugin/skills/using-asana-cli/SKILL.md | 42 ++++--------------- 3 files changed, 20 insertions(+), 49 deletions(-) create mode 100755 claude-plugin/scripts/check-auth.sh diff --git a/claude-plugin/agents/task-manager.md b/claude-plugin/agents/task-manager.md index 928627b..3e76a28 100644 --- a/claude-plugin/agents/task-manager.md +++ b/claude-plugin/agents/task-manager.md @@ -19,28 +19,16 @@ You are a specialist agent for managing Asana tasks using the `asana` CLI. - List and search tasks - Discover projects, sections, and users -## Key Commands - -| Operation | Command | -|-----------|---------| -| Create task | `asana tasks create -n "..." -a "..." -p "..." [-s "..."] [-d "..."] [-m "..."] [-f "..."]` | -| Update task | `asana tasks update [--name "..."] [--due "..."] [--complete] [--followers "..."]` | -| Delete task | `asana tasks delete ` | -| List tasks | `asana tasks list` | -| Search tasks | `asana tasks search` | -| List projects | `asana projects list -l 20` | -| List sections | `asana projects sections "Project Name"` | -| List users | `asana users list` | +## Reference + +See the `using-asana-cli` skill for full command reference, flag details, and name matching behavior. ## Guidelines 1. Always verify auth first: `asana auth status` 2. Use non-interactive flags for all operations — never rely on interactive prompts 3. When creating tasks, always provide `-n`, `-a`, and `-p` at minimum -4. Use `asana projects sections "Project"` to discover section names before creating tasks -5. Use `asana users list` to verify user names if assignment fails -6. Name matching is case-insensitive and supports partial matches -7. Task IDs can be found in Asana URLs or from `asana tasks list` +4. Verify results after create/update with `asana tasks view ` ## Error Handling diff --git a/claude-plugin/scripts/check-auth.sh b/claude-plugin/scripts/check-auth.sh new file mode 100755 index 0000000..03e7322 --- /dev/null +++ b/claude-plugin/scripts/check-auth.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Verify asana CLI is authenticated and ready +if ! command -v asana &> /dev/null; then + echo "ERROR: asana CLI not found. Build from ~/Code/asana-cli: go build -o /usr/local/bin/asana ./cmd/asana" + exit 1 +fi +asana auth status 2>&1 diff --git a/claude-plugin/skills/using-asana-cli/SKILL.md b/claude-plugin/skills/using-asana-cli/SKILL.md index b11f09d..3c7b69c 100644 --- a/claude-plugin/skills/using-asana-cli/SKILL.md +++ b/claude-plugin/skills/using-asana-cli/SKILL.md @@ -87,19 +87,21 @@ asana tasks delete ### View a task ```bash -asana tasks view +asana tasks view ``` +Without a task ID, falls back to interactive selection. + ### List your tasks ```bash -asana tasks list +asana tasks list [--sort due_on|created_at] [--limit 20] [--user me] ``` ### Search tasks ```bash -asana tasks search +asana tasks search --query "search term" [--assignee me] [--sort-by due_date] [--due-on 2026-04-01] ``` ## Project Management @@ -135,38 +137,12 @@ asana users list ## Name Matching -All name-based flags (assignee, project, section, followers) support: -1. **Exact match** (case-insensitive) -2. **Partial/contains match** (case-insensitive) -3. **ID match** (Asana GID) - -For example, `-a "Chris"` will match "Chris Christoff" if no exact "Chris" exists. - -## Common Patterns +Name flags support exact, partial, and ID matching (case-insensitive). -### Create a task and add collaborators - -```bash -asana tasks create \ - -n "Review PR #42" \ - -a me \ - -p "Engineering" \ - -f "Alice,Bob" \ - -d tomorrow -``` - -### Batch update: complete multiple tasks - -```bash -for id in 123 456 789; do - asana tasks update "$id" --complete -done -``` +## Verification -### Discover sections before creating a task +After creating or updating a task, verify by checking the returned output or running: ```bash -asana projects sections "My Project" -# Then use the section name in create: -asana tasks create -n "..." -a me -p "My Project" -s "Sprint 5" +asana tasks view ``` From f3ae1214c88db6e36700e0ba890ee29ee95cda9e Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:24:43 -0400 Subject: [PATCH 09/16] docs: update README with non-interactive flags and new commands Document create/update flag tables, task view/delete commands, projects sections command, and name matching behavior. Co-Authored-By: Claude Opus 4.6 --- README.md | 97 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0fcc62e..c9fd4f9 100644 --- a/README.md +++ b/README.md @@ -93,61 +93,100 @@ asana config get default-workspace asana config get dw ``` -## Basic Commands +## Task Management -View your tasks: +### Create a task + +All commands support both interactive and non-interactive modes. Provide flags to skip prompts: ```shell -asana tasks list # List all your tasks -asana tasks list --sort due-desc # Sort tasks by descending due date -asana tasks view # Interactive task viewer with details -asana tasks update # Interactive task updater +asana tasks create \ + -n "Task name" \ + -a "Assignee Name" \ + -p "Project Name" \ + -s "Section Name" \ + -d "2025-04-01" \ + -m "Task description" \ + -f "Follower One,Follower Two" ``` -View tasks with filters: +| Flag | Short | Required | Description | +|------|-------|----------|-------------| +| `--name` | `-n` | Yes* | Task name | +| `--assignee` | `-a` | Yes* | Assignee name, ID, or `me` | +| `--project` | `-p` | Yes* | Project name or ID | +| `--section` | `-s` | No | Section name or ID (defaults to first section) | +| `--due` | `-d` | No | Due date: `YYYY-MM-DD`, `today`, `tomorrow` | +| `--description` | `-m` | No | Task description | +| `--followers` | `-f` | No | Comma-separated follower names or IDs | +| `--non-interactive` | | No | Explicitly prevent prompts; errors on missing fields | -```shell -asana tasks search --assignee me,12345678 # Search tasks by assignee and more filters -``` +\*Required in non-interactive mode. When all three are provided, non-interactive mode is auto-detected. -Log, check and delete time entries on your tasks: +Without flags, the command falls back to interactive prompts. -```shell -# Create a new time entry -asana time create -m 23 --date 2025-01-06 +### Update a task -# Check time entries -asana time status +Pass a task ID to use flags, or omit for interactive mode: -# Delete a time entry -asana time delete +```shell +asana tasks update \ + -n "New name" \ + -d "2025-04-01" \ + -a "New Assignee" \ + -f "Follower Name" \ + --complete ``` -View the projects in your workspace: +| Flag | Short | Description | +|------|-------|-------------| +| `--name` | `-n` | New task name | +| `--description` | `-m` | New description | +| `--due` | `-d` | New due date | +| `--assignee` | `-a` | New assignee name or `me` | +| `--followers` | `-f` | Comma-separated follower names to add | +| `--complete` | | Mark task as completed | +| `--non-interactive` | | Explicitly prevent prompts | + +### View, list, search, and delete tasks ```shell -asana projects list # List all the projects -asana projects list -l 25 --sort desc # List with options +asana tasks view # View task details (or omit ID for interactive) +asana tasks list # List all your tasks +asana tasks list --sort due-desc # Sort tasks by descending due date +asana tasks search --assignee me # Search tasks with filters +asana tasks delete # Delete a task by ID ``` -View the teams in your workspace: +### Name matching + +All name-based flags (assignee, project, section, followers) support case-insensitive exact matching, partial/contains matching, and Asana GID matching. For example, `-a "Chris"` will match "Chris Christoff" if no exact "Chris" exists. + +## Time Tracking ```shell -asana teams list # List all teams +asana time create -m 23 --date 2025-01-06 # Log time +asana time status # Check time entries +asana time delete # Delete a time entry ``` -View the users in your workspace: +## Projects ```shell -asana users list # List all the users -asana users list -l 25 --sort desc # List with options +asana projects list # List all projects +asana projects list -l 25 --sort desc # List with options +asana projects sections "Project Name" # List sections in a project +asana projects tasks # List tasks in a project +asana projects tasks --sections # Group by section ``` -View tags of your workspace: +## Teams, Users, and Tags ```shell -asana tags list # List all tags -asana tags list --favorite # List tags that you marked as favorite +asana teams list # List all teams +asana users list # List all users +asana tags list # List all tags +asana tags list --favorite # List favorite tags ``` For more usage: From 95f357a479d00eba2110876cc6b1490f6772e966 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:27:23 -0400 Subject: [PATCH 10/16] rename plugin agent and commands with asana- prefix Prevents name collisions with other plugins that may have generic task-manager, create-task, etc. names. Co-Authored-By: Claude Opus 4.6 --- claude-plugin/agents/{task-manager.md => asana-task-manager.md} | 0 claude-plugin/commands/{create-task.md => asana-create-task.md} | 2 +- claude-plugin/commands/{delete-task.md => asana-delete-task.md} | 2 +- claude-plugin/commands/{update-task.md => asana-update-task.md} | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename claude-plugin/agents/{task-manager.md => asana-task-manager.md} (100%) rename claude-plugin/commands/{create-task.md => asana-create-task.md} (95%) rename claude-plugin/commands/{delete-task.md => asana-delete-task.md} (90%) rename claude-plugin/commands/{update-task.md => asana-update-task.md} (94%) diff --git a/claude-plugin/agents/task-manager.md b/claude-plugin/agents/asana-task-manager.md similarity index 100% rename from claude-plugin/agents/task-manager.md rename to claude-plugin/agents/asana-task-manager.md diff --git a/claude-plugin/commands/create-task.md b/claude-plugin/commands/asana-create-task.md similarity index 95% rename from claude-plugin/commands/create-task.md rename to claude-plugin/commands/asana-create-task.md index d7d4c64..2082a7c 100644 --- a/claude-plugin/commands/create-task.md +++ b/claude-plugin/commands/asana-create-task.md @@ -4,7 +4,7 @@ Create a new Asana task with full metadata. ## Usage -/asana-cli:create-task [task description] +/asana-cli:asana-create-task [task description] ## Arguments diff --git a/claude-plugin/commands/delete-task.md b/claude-plugin/commands/asana-delete-task.md similarity index 90% rename from claude-plugin/commands/delete-task.md rename to claude-plugin/commands/asana-delete-task.md index 369923b..b0faf65 100644 --- a/claude-plugin/commands/delete-task.md +++ b/claude-plugin/commands/asana-delete-task.md @@ -4,7 +4,7 @@ Permanently delete an Asana task. ## Usage -/asana-cli:delete-task +/asana-cli:asana-delete-task ## Arguments diff --git a/claude-plugin/commands/update-task.md b/claude-plugin/commands/asana-update-task.md similarity index 94% rename from claude-plugin/commands/update-task.md rename to claude-plugin/commands/asana-update-task.md index e49cb56..4030b44 100644 --- a/claude-plugin/commands/update-task.md +++ b/claude-plugin/commands/asana-update-task.md @@ -4,7 +4,7 @@ Update an existing Asana task by its Asana task ID. ## Usage -/asana-cli:update-task [updates] +/asana-cli:asana-update-task [updates] ## Arguments From 7f824208f1aeafd0ba597abc1c692de1834b10ef Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:36:33 -0400 Subject: [PATCH 11/16] feat: add --json output, task IDs in list/search, and --limit to search - Show task IDs in list and search text output (ID: 123456) - Add --json flag to list, search, and view for structured output - Add --limit flag to search, consistent with list These changes enable non-interactive scripting workflows where task IDs from list/search can be piped into view/update/delete commands. Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/list/list.go | 26 +++++++++++++++++++++- pkg/cmd/tasks/search/search.go | 34 +++++++++++++++++++++++++++-- pkg/cmd/tasks/view/view.go | 40 +++++++++++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/tasks/list/list.go b/pkg/cmd/tasks/list/list.go index 249216d..5cc0024 100644 --- a/pkg/cmd/tasks/list/list.go +++ b/pkg/cmd/tasks/list/list.go @@ -1,7 +1,9 @@ package list import ( + "encoding/json" "fmt" + "time" "github.com/timwehrle/asana/internal/config" @@ -41,6 +43,7 @@ type ListOptions struct { Sort SortOption Limit int User string + JSON bool } func (o *ListOptions) ResolveUser() string { @@ -90,6 +93,7 @@ func NewCmdList(f factory.Factory, runF func(*ListOptions) error) *cobra.Command StringVarP((*string)(&opts.Sort), "sort", "s", "", "Sort tasks by name, due date, creation date (options: asc, desc, due, due-desc, created-at)") cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 0, "Limit the tasks to display") cmd.Flags().StringVarP(&opts.User, "user", "u", "", "Show the task list of the provided user") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") return cmd } @@ -125,6 +129,25 @@ func listRun(opts *ListOptions) error { sortTasks(tasks, opts.Sort) + if opts.JSON { + type jsonTask struct { + ID string `json:"id"` + Name string `json:"name"` + DueOn string `json:"due_on,omitempty"` + } + out := make([]jsonTask, len(tasks)) + for i, t := range tasks { + jt := jsonTask{ID: t.ID, Name: t.Name} + if t.DueOn != nil { + jt.DueOn = time.Time(*t.DueOn).Format("2006-01-02") + } + out[i] = jt + } + enc := json.NewEncoder(opts.IO.Out) + enc.SetIndent("", " ") + return enc.Encode(out) + } + return printTasks(opts.IO, cfg.Username, tasks) } @@ -202,10 +225,11 @@ func printTasks(io *iostreams.IOStreams, username string, tasks []*asana.Task) e fmt.Fprintf(io.Out, "\nTasks for %s:\n\n", cs.Bold(username)) for i, task := range tasks { - fmt.Fprintf(io.Out, "%d. [%s] %s\n", + fmt.Fprintf(io.Out, "%d. [%s] %s (ID: %s)\n", i+1, format.Date(task.DueOn), cs.Bold(task.Name), + task.ID, ) } diff --git a/pkg/cmd/tasks/search/search.go b/pkg/cmd/tasks/search/search.go index 24249cf..9b8fdd1 100644 --- a/pkg/cmd/tasks/search/search.go +++ b/pkg/cmd/tasks/search/search.go @@ -1,7 +1,11 @@ package search import ( + "encoding/json" "fmt" + "strings" + "time" + "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" "github.com/timwehrle/asana/internal/api/asana" @@ -10,7 +14,6 @@ import ( "github.com/timwehrle/asana/pkg/factory" "github.com/timwehrle/asana/pkg/format" "github.com/timwehrle/asana/pkg/iostreams" - "strings" ) type SearchOptions struct { @@ -27,6 +30,8 @@ type SearchOptions struct { CreatorAny []string ExcludeCreator []string Blocked bool + Limit int + JSON bool SortBy string DueOnBefore string DueOnAfter string @@ -120,6 +125,8 @@ func NewCmdSearch(f factory.Factory, runF func(*SearchOptions) error) *cobra.Com cmd.Flags().StringVar(&opts.DueOn, "due-on", "", "Filter to tasks due on a specific date (YYYY-MM-DD)") cmd.Flags().StringVar(&opts.DueAtBefore, "due-at-before", "", "Filter to tasks due at or before a specific date (YYYY-MM-DD)") cmd.Flags().StringVar(&opts.DueAtAfter, "due-at-after", "", "Filter to tasks due at or after a specific date (YYYY-MM-DD)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 0, "Limit the number of results") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") return cmd } @@ -167,6 +174,10 @@ func runSearch(opts *SearchOptions) error { return fmt.Errorf("failed searching tasks: %w", err) } + if opts.Limit > 0 && len(tasks) > opts.Limit { + tasks = tasks[:opts.Limit] + } + if len(tasks) == 0 { io.Println("No tasks found matching your criteria.") io.Println("- Try broadening your search by removing some filters") @@ -178,10 +189,29 @@ func runSearch(opts *SearchOptions) error { return nil } + if opts.JSON { + type jsonTask struct { + ID string `json:"id"` + Name string `json:"name"` + DueOn string `json:"due_on,omitempty"` + } + out := make([]jsonTask, len(tasks)) + for i, t := range tasks { + jt := jsonTask{ID: t.ID, Name: t.Name} + if t.DueOn != nil { + jt.DueOn = time.Time(*t.DueOn).Format("2006-01-02") + } + out[i] = jt + } + enc := json.NewEncoder(io.Out) + enc.SetIndent("", " ") + return enc.Encode(out) + } + io.Printf("\nTasks assigned to %s:\n\n", cs.Bold(strings.Join(opts.Assignee, ", "))) for i, task := range tasks { - io.Printf("%2d. [%s] %s\n", i+1, format.Date(task.DueOn), task.Name) + io.Printf("%2d. [%s] %s (ID: %s)\n", i+1, format.Date(task.DueOn), task.Name, task.ID) } return nil diff --git a/pkg/cmd/tasks/view/view.go b/pkg/cmd/tasks/view/view.go index 8255e40..0ab0d95 100644 --- a/pkg/cmd/tasks/view/view.go +++ b/pkg/cmd/tasks/view/view.go @@ -1,6 +1,7 @@ package view import ( + "encoding/json" "fmt" "time" @@ -24,6 +25,7 @@ type ViewOptions struct { Client func() (*asana.Client, error) TaskID string + JSON bool } func NewCmdView(f factory.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -60,6 +62,8 @@ func NewCmdView(f factory.Factory, runF func(*ViewOptions) error) *cobra.Command }, } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output in JSON format") + return cmd } @@ -75,7 +79,7 @@ func viewRun(opts *ViewOptions) error { if err := task.Fetch(client); err != nil { return fmt.Errorf("task %q not found: %w", opts.TaskID, err) } - return displayDetails(client, task, opts.IO) + return displayDetails(client, task, opts.IO, opts.JSON) } // Interactive: select from your tasks @@ -100,7 +104,7 @@ func viewRun(opts *ViewOptions) error { return err } - return displayDetails(client, selectedTask, opts.IO) + return displayDetails(client, selectedTask, opts.IO, opts.JSON) } func prompt(allTasks []*asana.Task, prompter prompter.Prompter) (*asana.Task, error) { @@ -120,7 +124,7 @@ func prompt(allTasks []*asana.Task, prompter prompter.Prompter) (*asana.Task, er return allTasks[index], nil } -func displayDetails(client *asana.Client, task *asana.Task, io *iostreams.IOStreams) error { +func displayDetails(client *asana.Client, task *asana.Task, io *iostreams.IOStreams, jsonOutput bool) error { cs := io.ColorScheme() err := task.Fetch(client) @@ -128,6 +132,36 @@ func displayDetails(client *asana.Client, task *asana.Task, io *iostreams.IOStre return err } + if jsonOutput { + type jsonTask struct { + ID string `json:"id"` + Name string `json:"name"` + DueOn string `json:"due_on,omitempty"` + Notes string `json:"notes,omitempty"` + Projects []string `json:"projects,omitempty"` + Tags []string `json:"tags,omitempty"` + PermalinkURL string `json:"permalink_url,omitempty"` + } + jt := jsonTask{ + ID: task.ID, + Name: task.Name, + Notes: task.Notes, + PermalinkURL: task.PermalinkURL, + } + if task.DueOn != nil { + jt.DueOn = time.Time(*task.DueOn).Format("2006-01-02") + } + for _, p := range task.Projects { + jt.Projects = append(jt.Projects, p.Name) + } + for _, t := range task.Tags { + jt.Tags = append(jt.Tags, t.Name) + } + enc := json.NewEncoder(io.Out) + enc.SetIndent("", " ") + return enc.Encode(jt) + } + fmt.Fprintf( io.Out, "%s | Due: %s | %s\n", From f5c68f0200667e08b917491c488bdeb8ac7efb09 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:36:38 -0400 Subject: [PATCH 12/16] docs: document --json flag, --limit on search, and task ID visibility Co-Authored-By: Claude Opus 4.6 --- README.md | 23 +++++++++++++++---- claude-plugin/skills/using-asana-cli/SKILL.md | 18 +++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c9fd4f9..1570ccb 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,24 @@ asana tasks update \ ### View, list, search, and delete tasks ```shell -asana tasks view # View task details (or omit ID for interactive) -asana tasks list # List all your tasks -asana tasks list --sort due-desc # Sort tasks by descending due date -asana tasks search --assignee me # Search tasks with filters -asana tasks delete # Delete a task by ID +asana tasks view # View task details (or omit ID for interactive) +asana tasks list # List all your tasks +asana tasks list --sort due-desc # Sort tasks by descending due date +asana tasks search --assignee me # Search tasks with filters +asana tasks search --query "deploy" -l 5 # Search with limit +asana tasks delete # Delete a task by ID +``` + +Task IDs are shown in `list` and `search` output for easy use with other commands. + +### Structured output (JSON) + +All task commands (`list`, `search`, `view`) support `--json` for machine-readable output: + +```shell +asana tasks list --json # JSON array of {id, name, due_on} +asana tasks search --query "bug" --json # Search results as JSON +asana tasks view --json # Full task details as JSON ``` ### Name matching diff --git a/claude-plugin/skills/using-asana-cli/SKILL.md b/claude-plugin/skills/using-asana-cli/SKILL.md index 3c7b69c..3d6350d 100644 --- a/claude-plugin/skills/using-asana-cli/SKILL.md +++ b/claude-plugin/skills/using-asana-cli/SKILL.md @@ -95,15 +95,29 @@ Without a task ID, falls back to interactive selection. ### List your tasks ```bash -asana tasks list [--sort due_on|created_at] [--limit 20] [--user me] +asana tasks list [--sort due_on|created_at] [--limit 20] [--user me] [--json] ``` ### Search tasks ```bash -asana tasks search --query "search term" [--assignee me] [--sort-by due_date] [--due-on 2026-04-01] +asana tasks search --query "search term" [--assignee me] [--sort-by due_date] [--due-on 2026-04-01] [--limit 10] [--json] ``` +## Structured Output + +All task commands (`list`, `search`, `view`) support `--json` for machine-readable output. Use this for scripting and piping results between commands: + +```bash +# Get task IDs from search results +asana tasks search --query "deploy" --json + +# View a specific task as JSON +asana tasks view --json +``` + +Task IDs are also shown in the default text output of `list` and `search` (e.g., `(ID: 1234567890)`). + ## Project Management ### List projects From 5b3f8d5995b1c54a0ff51e60277005cb34287523 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:42:55 -0400 Subject: [PATCH 13/16] fix(search): remove default --assignee filter and rename --creator-any - Remove silent default of --assignee me that hid tasks assigned to others, making search miss results unexpectedly - Rename --creator-any to --creator for natural flag naming - Update examples to show creator and unfiltered search patterns Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/search/search.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/tasks/search/search.go b/pkg/cmd/tasks/search/search.go index 9b8fdd1..fc207d9 100644 --- a/pkg/cmd/tasks/search/search.go +++ b/pkg/cmd/tasks/search/search.go @@ -72,11 +72,17 @@ func NewCmdSearch(f factory.Factory, runF func(*SearchOptions) error) *cobra.Com Results can be sorted according to your preference. `), Example: heredoc.Doc(` - # Search for milestone tasks assigned to you - $ asana tasks search --type milestone --assignee me --sort-asc - - # Search for tasks containing "UI refresh" not assigned to specific users - $ asana tasks search --query "UI refresh" --exclude-assignee 1234,5678 --tags-all 1234,4567 + # Search for tasks assigned to you + $ asana tasks search --assignee me + + # Search all tasks by keyword (no assignee filter) + $ asana tasks search --query "UI refresh" + + # Search for tasks you created + $ asana tasks search --creator me + + # Search with multiple filters + $ asana tasks search --query "deploy" --assignee me --sort-asc --limit 10 `), PreRunE: func(cmd *cobra.Command, args []string) error { if err := cmdutils.ValidateStringEnum("sort-by", opts.SortBy, validSortBy); err != nil { @@ -112,12 +118,12 @@ func NewCmdSearch(f factory.Factory, runF func(*SearchOptions) error) *cobra.Com cmd.Flags().StringVarP(&opts.Query, "query", "q", "", "Perform full-text search on task names and descriptions") cmd.Flags().StringVar(&opts.Type, "type", "default_task", "Resource subtype to filter tasks (e.g., default_task, milestone)") - cmd.Flags().StringSliceVarP(&opts.Assignee, "assignee", "a", []string{"me"}, "Comma-separated list of assignee user IDs (e.g., 1234,me)") + cmd.Flags().StringSliceVarP(&opts.Assignee, "assignee", "a", nil, "Comma-separated list of assignee user IDs (e.g., me,1234). Omit to search all assignees") cmd.Flags().StringSliceVar(&opts.ExcludeAssignee, "exclude-assignee", nil, "Comma separated list of user IDs to exclude from the search (e.g., 1234,5678)") cmd.Flags().StringSliceVar(&opts.TagsAll, "tags-all", nil, "Comma-separated list of tags to include in the search") cmd.Flags().BoolVar(&opts.SortAscending, "sort-asc", false, "Sort results in ascending order") cmd.Flags().StringVar(&opts.SortBy, "sort-by", "modified_at", "Sort results by one of: due_date, created_at, completed_at, likes or modified_at") - cmd.Flags().StringSliceVar(&opts.CreatorAny, "creator-any", nil, "Comma-separated list of user IDs to include in the search") + cmd.Flags().StringSliceVar(&opts.CreatorAny, "creator", nil, "Comma-separated list of creator user IDs or 'me' (e.g., me,1234)") cmd.Flags().StringSliceVar(&opts.ExcludeCreator, "exclude-creator", nil, "Comma-separated list of user IDs to exclude from the search") cmd.Flags().BoolVar(&opts.Blocked, "is-blocked", false, "Filter to tasks with incomplete dependencies") cmd.Flags().StringVar(&opts.DueOnBefore, "due-on-before", "", "Filter to tasks due before a specific date (YYYY-MM-DD)") @@ -208,7 +214,11 @@ func runSearch(opts *SearchOptions) error { return enc.Encode(out) } - io.Printf("\nTasks assigned to %s:\n\n", cs.Bold(strings.Join(opts.Assignee, ", "))) + if len(opts.Assignee) > 0 { + io.Printf("\nTasks assigned to %s:\n\n", cs.Bold(strings.Join(opts.Assignee, ", "))) + } else { + io.Printf("\nSearch results:\n\n") + } for i, task := range tasks { io.Printf("%2d. [%s] %s (ID: %s)\n", i+1, format.Date(task.DueOn), task.Name, task.ID) From 1f9b0a05ae9b696c5eae495897ee0bb9098244d5 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 18:42:59 -0400 Subject: [PATCH 14/16] docs: document --creator flag and assignee filter behavior Co-Authored-By: Claude Opus 4.6 --- README.md | 3 ++- claude-plugin/skills/using-asana-cli/SKILL.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1570ccb..84f36b1 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,9 @@ asana tasks update \ asana tasks view # View task details (or omit ID for interactive) asana tasks list # List all your tasks asana tasks list --sort due-desc # Sort tasks by descending due date -asana tasks search --assignee me # Search tasks with filters +asana tasks search --assignee me # Search your assigned tasks asana tasks search --query "deploy" -l 5 # Search with limit +asana tasks search --creator me # Search tasks you created asana tasks delete # Delete a task by ID ``` diff --git a/claude-plugin/skills/using-asana-cli/SKILL.md b/claude-plugin/skills/using-asana-cli/SKILL.md index 3d6350d..6cdcd26 100644 --- a/claude-plugin/skills/using-asana-cli/SKILL.md +++ b/claude-plugin/skills/using-asana-cli/SKILL.md @@ -101,9 +101,11 @@ asana tasks list [--sort due_on|created_at] [--limit 20] [--user me] [--json] ### Search tasks ```bash -asana tasks search --query "search term" [--assignee me] [--sort-by due_date] [--due-on 2026-04-01] [--limit 10] [--json] +asana tasks search --query "search term" [--assignee me] [--creator me] [--sort-by due_date] [--due-on 2026-04-01] [--limit 10] [--json] ``` +**Note:** `--assignee` has no default — omit it to search across all assignees. Use `--creator me` to find tasks you created regardless of assignee. + ## Structured Output All task commands (`list`, `search`, `view`) support `--json` for machine-readable output. Use this for scripting and piping results between commands: From 41221a09dff206188efaa9fe1f0ed159d66fd0fd Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Wed, 11 Mar 2026 19:06:21 -0400 Subject: [PATCH 15/16] docs: add Claude Code Plugin section to README and expand search skill docs - Add Claude Code Plugin section to README with installation, features, and prerequisites - Expand search flags table in using-asana-cli skill with all supported flags - Add list vs search guidance to clarify when to use each command - Add explicit "assigned to me" vs "created by me" examples Co-Authored-By: Claude Opus 4.6 --- README.md | 18 ++++++ claude-plugin/skills/using-asana-cli/SKILL.md | 56 +++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 84f36b1..9a5359a 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,24 @@ For more usage: asana help # Show all available commands ``` +# Claude Code Plugin + +This repo includes a [Claude Code](https://claude.com/claude-code) plugin for AI-assisted Asana task management. + +## Installation + +```shell +claude plugins add /path/to/asana-cli +``` + +## What's included + +- **Skills**: `using-asana-cli` (command reference), `troubleshooting-asana` (error diagnosis) +- **Commands**: `/asana-create-task`, `/asana-update-task`, `/asana-delete-task` +- **Agent**: `asana-task-manager` for autonomous task management + +The `asana` CLI must be installed and authenticated (`asana auth login`) before using the plugin. + # Security To keep your Asana credentials safe, this CLI uses your system's keyring for secure token storage. diff --git a/claude-plugin/skills/using-asana-cli/SKILL.md b/claude-plugin/skills/using-asana-cli/SKILL.md index 6cdcd26..2359b97 100644 --- a/claude-plugin/skills/using-asana-cli/SKILL.md +++ b/claude-plugin/skills/using-asana-cli/SKILL.md @@ -92,19 +92,67 @@ asana tasks view Without a task ID, falls back to interactive selection. -### List your tasks +### List vs Search + +Use **`tasks list`** for a quick view of tasks assigned to a user. Use **`tasks search`** for anything more flexible — filtering by creator, tags, blocked status, date ranges, or keyword. + +```bash +# "My tasks" (assigned to me) — use list +asana tasks list + +# "Tasks I created" — use search +asana tasks search --creator me + +# "Tasks assigned to me about X" — use search +asana tasks search --assignee me --query "X" +``` + +### List tasks + +Lists tasks assigned to a user (defaults to `me`). Cannot filter by creator — use `search` for that. ```bash -asana tasks list [--sort due_on|created_at] [--limit 20] [--user me] [--json] +asana tasks list [--sort due|due-desc|asc|desc|created-at] [--limit 20] [--user me] [--json] ``` ### Search tasks +Flexible search across all tasks in the workspace. + ```bash -asana tasks search --query "search term" [--assignee me] [--creator me] [--sort-by due_date] [--due-on 2026-04-01] [--limit 10] [--json] +# Tasks assigned to me +asana tasks search --assignee me + +# Tasks I created (regardless of assignee) +asana tasks search --creator me + +# Keyword search with limit +asana tasks search --query "deploy" --limit 5 + +# Blocked tasks due this week +asana tasks search --is-blocked --due-on-after 2026-03-09 --due-on-before 2026-03-13 ``` -**Note:** `--assignee` has no default — omit it to search across all assignees. Use `--creator me` to find tasks you created regardless of assignee. +**Search flags:** +| Flag | Short | Description | +|------|-------|-------------| +| `--query` | `-q` | Full-text search on task names and descriptions | +| `--assignee` | `-a` | Comma-separated assignee IDs or `me`. Omit to search all | +| `--creator` | | Comma-separated creator IDs or `me` | +| `--limit` | `-l` | Limit number of results | +| `--sort-by` | | Sort by: `due_date`, `created_at`, `completed_at`, `likes`, `modified_at` (default: `modified_at`) | +| `--sort-asc` | | Sort ascending (default is descending) | +| `--due-on` | | Tasks due on exact date (`YYYY-MM-DD`) | +| `--due-on-before` | | Tasks due before date | +| `--due-on-after` | | Tasks due after date | +| `--is-blocked` | | Only tasks with incomplete dependencies | +| `--tags-all` | | Comma-separated tag IDs to filter by | +| `--type` | | Resource subtype: `default_task`, `milestone` (default: `default_task`) | +| `--exclude-assignee` | | Comma-separated user IDs to exclude | +| `--exclude-creator` | | Comma-separated creator IDs to exclude | +| `--json` | | Output as JSON | + +**Note:** `--assignee` has no default — omit it to search across all assignees. ## Structured Output From 402fe780e7e36f4f2ba4fb46065f4fa360027558 Mon Sep 17 00:00:00 2001 From: Justin Sternberg Date: Thu, 12 Mar 2026 13:00:50 -0400 Subject: [PATCH 16/16] fix: extract getOrPromptDueDate helper to fix undefined reference in tests The test file referenced getOrPromptDueDate which didn't exist as a standalone function. Extract the inline due-date logic from runCreate into a proper helper function that the tests can call. Co-Authored-By: Claude Opus 4.6 --- pkg/cmd/tasks/create/create.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/tasks/create/create.go b/pkg/cmd/tasks/create/create.go index 254a609..e89b7bf 100644 --- a/pkg/cmd/tasks/create/create.go +++ b/pkg/cmd/tasks/create/create.go @@ -108,17 +108,9 @@ func runCreate(opts *CreateOptions) error { } // --- Due date (optional) --- - var dueDate *asana.Date - if opts.Due != "" { - dueDate, err = parseDueDate(opts.Due) - if err != nil { - return err - } - } else if !ni { - dueDate, err = promptDueDate(opts) - if err != nil { - return err - } + dueDate, err := getOrPromptDueDate(opts) + if err != nil { + return err } // --- Description (optional) --- @@ -262,6 +254,16 @@ func getOrSelectAssignee(opts *CreateOptions, ni bool, cfg *config.Config, clien return users[selected], nil } +func getOrPromptDueDate(opts *CreateOptions) (*asana.Date, error) { + if opts.Due != "" { + return parseDueDate(opts.Due) + } + if opts.NonInteractive { + return nil, nil + } + return promptDueDate(opts) +} + func parseDueDate(input string) (*asana.Date, error) { now := time.Now() switch strings.ToLower(input) {