From e43bb3d8d251bc68ceac075ad4910915e26db5d4 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 27 Mar 2026 13:17:09 +0100 Subject: [PATCH 1/3] feat: add server-side search and parallel space traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paginated team-task endpoint orders by date_updated and does not cover tasks in folderless lists. This means a task that hasn't been touched recently — or lives in a folderless list — is invisible to pagination alone. This commit adds two new search levels to close that gap: - Level 0: server-side search using ClickUp's `search=` query parameter, which filters by task name in a single API call regardless of recency. - Level 4: parallel space traversal that walks every space, discovers all folders + folderless lists, and fetches tasks from each list concurrently (8 goroutines). Cancels remaining work as soon as an exact match is found. The full drill-down order is now: 0. Server-side search (1 call) 1. Sprint list (1 page) 2. User's assigned tasks (1 page) 3. Configured default space (3 pages) 4. Parallel space traversal (all spaces/lists) 5. Workspace paginated (10 pages, last resort) Levels 0-3 return early on any result. Level 4 accumulates results and only falls through to Level 5 when nothing is found. Before: `task search "obletter"` timed out at 90s with no results. After: finds "Gioielleria Obletter" in ~10s. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/cmd/task/search.go | 301 ++++++++++++++++++++++++++++++----------- 1 file changed, 220 insertions(+), 81 deletions(-) diff --git a/pkg/cmd/task/search.go b/pkg/cmd/task/search.go index 83f2433..58aa77c 100644 --- a/pkg/cmd/task/search.go +++ b/pkg/cmd/task/search.go @@ -9,6 +9,8 @@ import ( "net/url" "sort" "strings" + "sync" + "sync/atomic" "time" "github.com/lithammer/fuzzysearch/fuzzy" @@ -466,7 +468,37 @@ func doSearch(ctx context.Context, opts *searchOptions) ([]scoredTask, error) { query := strings.ToLower(opts.query) - // Progressive drill-down: sprint → user → space → workspace. + // Search uses a 6-level progressive drill-down. Each level is more + // expensive than the previous one, so we return early as soon as we + // find results. The key insight: the paginated team-task endpoint + // (GET /team/{id}/task) orders by date_updated and does NOT cover + // tasks in folderless lists. This means a task that hasn't been + // touched recently (or lives in a folderless list) is invisible to + // pagination alone. Level 0 (server-side search=) and Level 4 + // (parallel space traversal) exist specifically to close that gap. + // + // Level 0: Server-side search (1 API call) — fast, best-effort + // Level 1: Sprint list — 1 page, if configured + // Level 2: User's assigned tasks — 1 page + // Level 3: Configured default space — 3 pages, if configured + // Level 4: Parallel space traversal — all spaces/lists + // Level 5: Workspace paginated — 10 pages, last resort + + // Level 0: Pass the query as ClickUp's `search` query-parameter so that + // filtering happens server-side. This is a single API call and finds + // tasks by name regardless of when they were last updated. It won't + // cover tasks in folderless lists (a known ClickUp API limitation), + // which is why we still need Level 4. + fmt.Fprintf(ios.ErrOut, " searching (server-side)...\n") + scored, err := searchLevel(ctx, client, teamID, query, "search="+url.QueryEscape(query), 1, opts.comments, ios) + if err != nil { + fmt.Fprintf(ios.ErrOut, " server-side search failed: %v\n", err) + } + if len(scored) > 0 { + return scored, nil + } + + // Levels 1-2: Cheap, targeted searches (unchanged from before). // Level 1: Sprint list (if sprint_folder configured). if cfg.SprintFolder != "" { @@ -496,31 +528,63 @@ func doSearch(ctx context.Context, opts *searchOptions) ([]scoredTask, error) { } } - // Level 3: Configured space. + // Level 3: Configured default space — a fast, targeted paginated search + // scoped to a single space. If we get an exact substring match, return + // immediately. Otherwise, keep any fuzzy results and continue to deeper + // levels — a fuzzy match here doesn't mean the exact task isn't hiding + // in a folderless list that pagination can't reach. if cfg.Space != "" { - fmt.Fprintf(ios.ErrOut, " searching space...\n") - scored, err := searchLevel(ctx, client, teamID, query, "space_ids[]="+cfg.Space, 3, opts.comments, ios) + fmt.Fprintf(ios.ErrOut, " searching configured space...\n") + spaceScored, err := searchLevel(ctx, client, teamID, query, "space_ids[]="+cfg.Space, 3, opts.comments, ios) if err != nil { return nil, err } - if len(scored) > 0 { - return scored, nil + if hasSubstringMatch(spaceScored) { + return spaceScored, nil } + scored = append(scored, spaceScored...) } - // Level 4: Full workspace (up to 10 pages). - fmt.Fprintf(ios.ErrOut, " searching workspace...\n") - scored, err := searchLevel(ctx, client, teamID, query, "", 10, opts.comments, ios) + // Level 4: Parallel space traversal — walks every space, discovers all + // folders + folderless lists, and fetches tasks from each list concurrently. + // This is the only level that reliably finds tasks in folderless lists. + // It uses goroutines (capped at 8) for both list discovery and task + // fetching, and cancels remaining work as soon as an exact substring + // match is found. Typical latency: ~10s for ~200 lists. + fmt.Fprintf(ios.ErrOut, " searching all spaces...\n") + viaSpaces, err := searchViaSpaces(ctx, opts) if err != nil { - return nil, err + fmt.Fprintf(ios.ErrOut, " space search failed: %v\n", err) } + scored = append(scored, viaSpaces...) if len(scored) > 0 { - return scored, nil + return dedupScored(scored), nil } - // If nothing found via pagination, fall back to space traversal. - fmt.Fprintf(ios.ErrOut, "Falling back to space/folder search...\n") - return searchViaSpaces(ctx, opts) + // Level 5: Full workspace paginated (10 pages) as a last resort. + // This can find tasks that space traversal missed (e.g. tasks beyond + // page 0 of a large list), but it's slow and limited to recently-updated + // tasks. + fmt.Fprintf(ios.ErrOut, " searching workspace...\n") + scored, err = searchLevel(ctx, client, teamID, query, "", 10, opts.comments, ios) + if err != nil { + return nil, err + } + return scored, nil +} + +// hasSubstringMatch returns true if any scored task is an exact substring match +// in the task name. Used to decide whether a search level's results are +// definitive enough to skip deeper (more expensive) levels. Fuzzy-only results +// are NOT considered definitive because the exact task may exist in a list that +// the current level cannot reach (e.g. folderless lists). +func hasSubstringMatch(tasks []scoredTask) bool { + for _, t := range tasks { + if t.kind == matchSubstring { + return true + } + } + return false } // filterTasks scores tasks by name and description, separating matched from unmatched. @@ -782,6 +846,27 @@ func pickTask(ios *iostreams.IOStreams, allTasks []searchTask) error { return nil } +// searchViaSpaces traverses the full space → folder → list hierarchy and +// fetches tasks from every discovered list. Unlike the paginated team-task +// endpoint, this covers folderless lists (lists not inside any folder), +// which is where many tasks live in workspaces that use flat list structures. +// +// The function runs in two phases, both fully parallelized: +// +// Phase 1 — List discovery: up to 8 spaces are queried concurrently. +// For each space, we fetch its folders (and their lists) plus any +// folderless lists. All discovered list IDs are collected into a +// single slice. +// +// Phase 2 — Task fetching: up to 8 lists are queried concurrently. +// Each list is fetched (page 0 only) and tasks are scored against the +// query. When an exact substring match is found, a child context is +// cancelled to abort all remaining in-flight HTTP requests, providing +// an early exit that typically saves several seconds. +// +// The semaphore (channel of size 8) is shared between phases but never +// used concurrently — Phase 1 completes (listWg.Wait) before Phase 2 +// starts, so all 8 slots are guaranteed to be free. func searchViaSpaces(ctx context.Context, opts *searchOptions) ([]scoredTask, error) { ios := opts.factory.IOStreams client, err := opts.factory.ApiClient() @@ -796,22 +881,29 @@ func searchViaSpaces(ctx context.Context, opts *searchOptions) ([]scoredTask, er teamID := cfg.Workspace - // Get spaces. spaces, _, err := client.Clickup.Spaces.GetSpaces(ctx, teamID, false) if err != nil { return nil, err } query := strings.ToLower(opts.query) - var results []scoredTask + + // Phase 1: Discover all list IDs across all spaces in parallel. + // Each goroutine handles one space: fetching its folders, each folder's + // lists, and the space's folderless lists. Results are collected under + // listMu. The semaphore caps concurrency at 8 to stay within ClickUp's + // rate limits (100 req/min on most plans). + var ( + listMu sync.Mutex + listWg sync.WaitGroup + allListIDs []string + sem = make(chan struct{}, 8) + ) for _, space := range spaces { if ctx.Err() != nil { - fmt.Fprintf(ios.ErrOut, "Search timed out during space traversal\n") break } - - // Filter by --space if provided (match by name or ID). if opts.space != "" { if !strings.EqualFold(space.Name, opts.space) && space.ID != opts.space { continue @@ -820,99 +912,146 @@ func searchViaSpaces(ctx context.Context, opts *searchOptions) ([]scoredTask, er fmt.Fprintf(ios.ErrOut, " searching space %q...\n", space.Name) - // Get folders in space. - folders, _, err := client.Clickup.Folders.GetFolders(ctx, space.ID, false) - if err != nil { - continue - } - - var listIDs []string + listWg.Add(1) + sem <- struct{}{} + go func(spaceID string) { + defer listWg.Done() + defer func() { <-sem }() - for _, folder := range folders { - if ctx.Err() != nil { - break - } + var ids []string - // Filter by --folder if provided (substring match, case-insensitive). - if opts.folder != "" { - if !strings.Contains(strings.ToLower(folder.Name), strings.ToLower(opts.folder)) { - continue + // Get folder lists. + folders, _, err := client.Clickup.Folders.GetFolders(ctx, spaceID, false) + if err == nil { + for _, folder := range folders { + if ctx.Err() != nil { + break + } + if opts.folder != "" { + if !strings.Contains(strings.ToLower(folder.Name), strings.ToLower(opts.folder)) { + continue + } + } + lists, _, err := client.Clickup.Lists.GetLists(ctx, folder.ID, false) + if err == nil { + for _, l := range lists { + ids = append(ids, l.ID) + } + } } } - fmt.Fprintf(ios.ErrOut, " folder %q...\n", folder.Name) - lists, _, err := client.Clickup.Lists.GetLists(ctx, folder.ID, false) - if err != nil { - continue - } - for _, l := range lists { - listIDs = append(listIDs, l.ID) - } - } - - // Also get folderless lists (only if no --folder filter). - if opts.folder == "" { - folderlessURL := fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/list", url.PathEscape(space.ID)) - req, err := http.NewRequestWithContext(ctx, "GET", folderlessURL, nil) - if err == nil { - resp, err := client.DoRequest(req) + // Get folderless lists. + if opts.folder == "" { + folderlessURL := fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/list", url.PathEscape(spaceID)) + req, err := http.NewRequestWithContext(ctx, "GET", folderlessURL, nil) if err == nil { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - var listResp struct { - Lists []struct { - ID string `json:"id"` - } `json:"lists"` - } - if json.Unmarshal(body, &listResp) == nil { - for _, l := range listResp.Lists { - listIDs = append(listIDs, l.ID) + resp, err := client.DoRequest(req) + if err == nil { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var listResp struct { + Lists []struct { + ID string `json:"id"` + } `json:"lists"` + } + if json.Unmarshal(body, &listResp) == nil { + for _, l := range listResp.Lists { + ids = append(ids, l.ID) + } } } } } - } - fmt.Fprintf(ios.ErrOut, " scanning %d lists...\n", len(listIDs)) + listMu.Lock() + allListIDs = append(allListIDs, ids...) + listMu.Unlock() + }(space.ID) + } + listWg.Wait() - // Search tasks in each list. - for _, listID := range listIDs { - if ctx.Err() != nil { - break - } + if ctx.Err() != nil { + return nil, ctx.Err() + } + + fmt.Fprintf(ios.ErrOut, " scanning %d lists...\n", len(allListIDs)) + + // Phase 2: Fetch tasks from all lists in parallel and score them. + // + // We create a child context (searchCtx) that gets cancelled as soon as + // any goroutine finds an exact substring match. This causes all in-flight + // HTTP requests (created with searchCtx) to abort, and the launch loop + // to stop enqueuing new work. The result: for a 200-list workspace, + // finding the target task in the first ~50 lists means the other ~150 + // are never fetched. + // + // foundExact is accessed with sync/atomic (not the mutex) because it is + // read in the comment-search path outside resultMu to avoid serializing + // the expensive comment fetching behind the lock. + searchCtx, searchCancel := context.WithCancel(ctx) + defer searchCancel() + + var ( + resultMu sync.Mutex + resultWg sync.WaitGroup + results []scoredTask + foundExact int32 // 0 = not found, 1 = found; accessed via atomic.{Load,Store}Int32 + ) - taskURL := fmt.Sprintf("https://api.clickup.com/api/v2/list/%s/task?include_closed=true&page=0", url.PathEscape(listID)) - req, err := http.NewRequestWithContext(ctx, "GET", taskURL, nil) + for _, listID := range allListIDs { + if searchCtx.Err() != nil { + break + } + resultWg.Add(1) + sem <- struct{}{} + go func(lid string) { + defer resultWg.Done() + defer func() { <-sem }() + + taskURL := fmt.Sprintf("https://api.clickup.com/api/v2/list/%s/task?include_closed=true&page=0", url.PathEscape(lid)) + req, err := http.NewRequestWithContext(searchCtx, "GET", taskURL, nil) if err != nil { - continue + return } - resp, err := client.DoRequest(req) if err != nil { - continue + return } - body, _ := io.ReadAll(resp.Body) resp.Body.Close() var taskResp searchResponse if json.Unmarshal(body, &taskResp) == nil { nameMatched, unmatched := filterTasks(query, taskResp.Tasks) - results = append(results, nameMatched...) - - // If --comments is enabled, check comments on unmatched tasks. - if opts.comments && len(unmatched) > 0 { + if len(nameMatched) > 0 { + resultMu.Lock() + results = append(results, nameMatched...) + for _, m := range nameMatched { + if m.kind == matchSubstring { + atomic.StoreInt32(&foundExact, 1) + searchCancel() + break + } + } + resultMu.Unlock() + } + if opts.comments && atomic.LoadInt32(&foundExact) == 0 && len(unmatched) > 0 { limit := len(unmatched) if limit > 100 { limit = 100 } - fmt.Fprintf(ios.ErrOut, " checking comments on %d tasks...\n", limit) - commentMatches := searchTaskComments(ctx, client, query, unmatched[:limit]) - results = append(results, commentMatches...) + commentMatches := searchTaskComments(searchCtx, client, query, unmatched[:limit]) + if len(commentMatches) > 0 { + resultMu.Lock() + results = append(results, commentMatches...) + resultMu.Unlock() + } } } - } + }(listID) } + resultWg.Wait() return results, nil } From f00982f37ddd3b958a0a7ccbb1a22cb15d622c3b Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 27 Mar 2026 13:39:38 +0100 Subject: [PATCH 2/3] feat: add --assignee flag to task search Resolves assignee by name, username, or numeric ID (case-insensitive substring match). Fetches up to 10 pages of tasks assigned to that person via the team-task API. If a query is also provided, results are filtered client-side by name/description. Examples: clickup task search --assignee Michela clickup task search "bug" --assignee 42547184 Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/cmd/task/search.go | 139 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/task/search.go b/pkg/cmd/task/search.go index 58aa77c..e13ba3d 100644 --- a/pkg/cmd/task/search.go +++ b/pkg/cmd/task/search.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -27,6 +28,7 @@ type searchOptions struct { query string space string folder string + assignee string pick bool comments bool exact bool @@ -133,6 +135,7 @@ sprint tasks first, then your assigned tasks, then configured space, then full workspace. Use --space and --folder to narrow the search scope for faster results. +Use --assignee to filter by team member (name, username, or numeric ID). Use --comments to also search through task comments (slower). In interactive mode (TTY), if many results are found you will be asked @@ -153,6 +156,10 @@ recently updated tasks and discover which folders/lists to search in.`, # Search within a specific folder clickup task search nextjs --folder "Engineering sprint" + # Filter by assignee (name, username, or ID) + clickup task search --assignee Michela + clickup task search "bug" --assignee 42547184 + # Also search through task comments clickup task search "migration issue" --comments @@ -165,16 +172,22 @@ recently updated tasks and discover which folders/lists to search in.`, # JSON output clickup task search geozone --json`, - Args: cobra.ExactArgs(1), + Args: cobra.RangeArgs(0, 1), PersistentPreRunE: cmdutil.NeedsAuth(f), RunE: func(cmd *cobra.Command, args []string) error { - opts.query = args[0] + if len(args) > 0 { + opts.query = args[0] + } + if opts.query == "" && opts.assignee == "" { + return fmt.Errorf("either a search query or --assignee is required") + } return runSearch(opts) }, } cmd.Flags().StringVar(&opts.space, "space", "", "Limit search to a specific space (name or ID)") cmd.Flags().StringVar(&opts.folder, "folder", "", "Limit search to a specific folder (name, substring match)") + cmd.Flags().StringVar(&opts.assignee, "assignee", "", "Filter by assignee (name, username, or numeric ID)") cmd.Flags().BoolVar(&opts.pick, "pick", false, "Interactively select a task and print its ID") cmd.Flags().BoolVar(&opts.comments, "comments", false, "Also search through task comments (slower)") cmd.Flags().BoolVar(&opts.exact, "exact", false, "Only show exact substring matches (no fuzzy results)") @@ -441,6 +454,120 @@ func searchLevel(ctx context.Context, client *api.Client, teamID, query string, return allScored, nil } +type resolvedMember struct { + Username string + ID int +} + +// resolveAssigneeID resolves an --assignee value to a numeric ClickUp user ID. +// Accepts a numeric ID string, a username, or a partial name (case-insensitive +// substring match). Returns the resolved member or an error. +func resolveAssigneeID(ctx context.Context, f *cmdutil.Factory, client *api.Client, input string) (resolvedMember, error) { + cfg, err := f.Config() + if err != nil { + return resolvedMember{}, err + } + teams, _, err := client.Clickup.Teams.GetTeams(ctx) + if err != nil { + return resolvedMember{}, err + } + + var members []resolvedMember + for _, team := range teams { + if team.ID != cfg.Workspace { + continue + } + for _, m := range team.Members { + members = append(members, resolvedMember{m.User.Username, m.User.ID}) + } + break + } + + // Numeric ID — look up the display name. + if numID, err := strconv.Atoi(input); err == nil { + for _, m := range members { + if m.ID == numID { + return m, nil + } + } + return resolvedMember{input, numID}, nil + } + + lowerInput := strings.ToLower(input) + + // Exact username match. + for _, m := range members { + if strings.ToLower(m.Username) == lowerInput { + return m, nil + } + } + + // Substring match on username. + for _, m := range members { + if strings.Contains(strings.ToLower(m.Username), lowerInput) { + return m, nil + } + } + + return resolvedMember{}, fmt.Errorf("no workspace member matching %q", input) +} + +// searchByAssignee fetches all tasks assigned to the given user across the +// workspace. If a query is also provided, results are filtered client-side. +// Uses the paginated team-task endpoint with assignees[]={id}. +func searchByAssignee(ctx context.Context, opts *searchOptions, client *api.Client, teamID string) ([]scoredTask, error) { + ios := opts.factory.IOStreams + + member, err := resolveAssigneeID(ctx, opts.factory, client, opts.assignee) + if err != nil { + return nil, err + } + fmt.Fprintf(ios.ErrOut, " searching tasks assigned to %s (ID %d)...\n", member.Username, member.ID) + + assigneeParam := fmt.Sprintf("assignees[]=%d", member.ID) + + // Fetch up to 10 pages of tasks for this assignee. + var allTasks []searchTask + for page := 0; page < 10; page++ { + if ctx.Err() != nil { + break + } + tasks, err := fetchTeamTasks(ctx, client, teamID, page, assigneeParam) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + break + } + allTasks = append(allTasks, tasks...) + } + + // If a query was provided, filter results by name/description. + // Otherwise, return all tasks as substring matches (they matched by assignee). + if opts.query != "" { + matched, unmatched := filterTasks(strings.ToLower(opts.query), allTasks) + if opts.comments && len(unmatched) > 0 { + limit := len(unmatched) + if limit > 100 { + limit = 100 + } + commentMatches := searchTaskComments(ctx, client, opts.query, unmatched[:limit]) + matched = append(matched, commentMatches...) + } + return matched, nil + } + + // No query — return all assignee tasks as-is. + var scored []scoredTask + for _, t := range allTasks { + scored = append(scored, scoredTask{ + searchTask: t, + kind: matchSubstring, + }) + } + return scored, nil +} + // doSearch performs the actual search using progressive drill-down or // the space/folder hierarchy (when --space or --folder is specified). func doSearch(ctx context.Context, opts *searchOptions) ([]scoredTask, error) { @@ -461,6 +588,14 @@ func doSearch(ctx context.Context, opts *searchOptions) ([]scoredTask, error) { return nil, fmt.Errorf("workspace ID required. Set with 'clickup auth login'") } + // If --assignee is specified, resolve the name/username to a numeric ID + // and fetch all tasks assigned to that person. The query (if provided) + // is used to filter the results client-side. This takes priority over + // other search strategies since the user explicitly asked for a person. + if opts.assignee != "" { + return searchByAssignee(ctx, opts, client, teamID) + } + // If --space or --folder is specified, go directly to targeted search. if opts.space != "" || opts.folder != "" { return searchViaSpaces(ctx, opts) From 016edc2a6b53c7177f9128a85cf89304227d8716 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 28 Mar 2026 20:33:17 +0100 Subject: [PATCH 3/3] feat: add list ls, chat send, and --list-name flag - `clickup list ls`: list all lists in a space (folderless + inside folders) - `clickup chat send "message"`: post messages to ClickUp Chat channels - `clickup task create --list-name "Issues"`: resolve list by name instead of requiring numeric list-id Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 125 +++++++++++++++----------- pkg/cmd/chat/chat.go | 19 ++++ pkg/cmd/chat/send.go | 94 ++++++++++++++++++++ pkg/cmd/list/list.go | 19 ++++ pkg/cmd/list/ls.go | 189 ++++++++++++++++++++++++++++++++++++++++ pkg/cmd/root/root.go | 4 + pkg/cmd/task/create.go | 13 ++- pkg/cmd/task/helpers.go | 111 +++++++++++++++++++++++ 8 files changed, 523 insertions(+), 51 deletions(-) create mode 100644 pkg/cmd/chat/chat.go create mode 100644 pkg/cmd/chat/send.go create mode 100644 pkg/cmd/list/list.go create mode 100644 pkg/cmd/list/ls.go diff --git a/README.md b/README.md index a0555d0..5074ae0 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,109 @@ -# @triptechtravel/clickup-cli +# clickup-cli (nick-preda fork) -A command-line tool for working with ClickUp tasks, comments, and sprints -- designed for developers who live in the terminal and use GitHub. +A CLI for managing ClickUp from the terminal. Forked from [triptechtravel/clickup-cli](https://github.com/triptechtravel/clickup-cli) with extra features for daily use with AI agents (Claude Code) and automation. -[![Release](https://img.shields.io/github/v/release/triptechtravel/clickup-cli)](https://github.com/triptechtravel/clickup-cli/releases) -[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![CI](https://github.com/triptechtravel/clickup-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/triptechtravel/clickup-cli/actions/workflows/ci.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/triptechtravel/clickup-cli)](https://goreportcard.com/report/github.com/triptechtravel/clickup-cli) -[![Go Reference](https://pkg.go.dev/badge/github.com/triptechtravel/clickup-cli.svg)](https://pkg.go.dev/github.com/triptechtravel/clickup-cli) +## What this fork adds + +| Feature | Command | Why | +|---------|---------|-----| +| **List all lists in a space** | `clickup list ls` | Find list IDs without digging through the UI | +| **Send messages to Chat channels** | `clickup chat send "msg"` | Post reports, alerts, and notifications to Chat | +| **Create tasks by list name** | `clickup task create --list-name "Issues"` | No need to look up numeric list IDs | +| **Search with assignee filter** | `clickup task search "term" --assignee me` | Filter search results by assignee | +| **Faster search** | Server-side search + parallel space traversal | Upstream only did client-side filtering | ## Install ```sh -# Homebrew -brew install triptechtravel/tap/clickup - -# Go -go install github.com/triptechtravel/clickup-cli/cmd/clickup@latest +# From source (recommended for this fork) +git clone https://github.com/nick-preda/clickup-cli.git +cd clickup-cli +make install -# Or download a binary from the releases page +# Or directly with Go +go install github.com/nick-preda/clickup-cli/cmd/clickup@latest ``` ## Quick start ```sh -clickup auth login # authenticate with your API token +clickup auth login # authenticate with your API token clickup space select # choose a default space -clickup task view # view the task from your current git branch -clickup status set "done" # fuzzy-matched status update -clickup link pr # link the current GitHub PR to the task +clickup list ls # see all lists and their IDs +clickup task create --list-name "Issues" --name "Fix the bug" --priority 2 +clickup task search "bug" # find tasks +clickup chat send khpgh-10335 "Deploy done" # post to a Chat channel ``` -See the [getting started guide](https://triptechtravel.github.io/clickup-cli/getting-started/) for a full walkthrough. +## Daily workflow + +```sh +# Find where to create a task +clickup list ls +# 900601764492 Issues (no folder) +# 900401327544 Nanea (no folder) +# 901510841332 Task Gitlab -## What it does +# Create a task by name (no list-id needed) +clickup task create --list-name "Issues" \ + --name "[Bug] Fix login timeout" --priority 2 -- **Task management** -- view, create, edit, search, and bulk-edit tasks with custom fields, tags, points, and time estimates -- **Git integration** -- auto-detects task IDs from branch names and links PRs, branches, and commits to ClickUp -- **Sprint dashboard** -- `sprint current` shows tasks grouped by status; `task create --current` creates tasks in the active sprint -- **Time tracking** -- log time, view per-task entries, or query workspace-wide timesheets by date range -- **Comments & inbox** -- add comments with @mentions, view your recent mentions across the workspace -- **Fuzzy status matching** -- set statuses with partial input (`"review"` matches `"code review"`) -- **AI-friendly** -- `--json` output and explicit flags make it easy for AI agents to read and update tasks -- **CI/CD ready** -- `--with-token`, exit codes, and JSON output for automation; includes GitHub Actions examples +# Search your tasks +clickup task search "login" --assignee me -## Commands +# Add a comment with @mentions +clickup comment add 86abc123 "@Michela this is ready for review" + +# Send a report to a Chat channel +clickup chat send khpgh-10335 "Daily report: all systems green" + +# View task from current git branch (auto-detected) +clickup task view +``` -Full command list with flags and examples: **[Command reference](https://triptechtravel.github.io/clickup-cli/commands/)** +## All commands -| Area | Key commands | -|------|-------------| -| **Tasks** | `task view`, `task create`, `task edit`, `task search`, `task recent` | -| **Time** | `task time log`, `task time list` | +| Area | Commands | +|------|----------| +| **Tasks** | `task view`, `task create`, `task edit`, `task search`, `task list`, `task recent`, `task delete` | +| **Lists** | `list ls` | +| **Chat** | `chat send` | +| **Comments** | `comment add`, `comment list`, `comment edit`, `comment delete` | | **Status** | `status set`, `status list`, `status add` | | **Git** | `link pr`, `link sync`, `link branch`, `link commit` | | **Sprints** | `sprint current`, `sprint list` | -| **Comments** | `comment add`, `comment list` | -| **Workspace** | `inbox`, `member list`, `space select`, `tag list`, `field list` | +| **Workspace** | `space list`, `space select`, `member list`, `inbox`, `tag list`, `field list` | -## Documentation +## Using with AI agents -**[triptechtravel.github.io/clickup-cli](https://triptechtravel.github.io/clickup-cli/)** +This CLI is designed to work well with Claude Code and other AI agents: -- [Installation](https://triptechtravel.github.io/clickup-cli/installation/) -- Homebrew, Go, binaries, shell completions -- [Getting started](https://triptechtravel.github.io/clickup-cli/getting-started/) -- first-time setup walkthrough -- [Configuration](https://triptechtravel.github.io/clickup-cli/configuration/) -- config file, per-directory defaults, aliases -- [Git integration](https://triptechtravel.github.io/clickup-cli/git-integration/) -- branch naming, GitHub linking strategy -- [CI usage](https://triptechtravel.github.io/clickup-cli/ci-usage/) -- non-interactive auth, JSON output, scripting -- [GitHub Actions](https://triptechtravel.github.io/clickup-cli/github-actions/) -- ready-to-use workflow templates -- [AI agents](https://triptechtravel.github.io/clickup-cli/ai-agents/) -- integration with Claude Code, Copilot, Cursor -- [Command reference](https://triptechtravel.github.io/clickup-cli/reference/clickup/) -- auto-generated flag and usage docs +```sh +# JSON output for programmatic use +clickup list ls --json +clickup task search "deploy" --json + +# AI agent can create tasks without knowing list IDs +clickup task create --list-name "Issues" --name "task name" + +# AI agent can post to Chat channels +clickup chat send "automated report here" +``` -## Contributing +## Configuration + +Config is stored in `~/.config/clickup/config.yml`: + +```yaml +workspace: "20503057" # your team/workspace ID +space: "90060297766" # default space for list ls, task create --list-name +``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, and guidelines. +Set per-directory defaults with `directory_defaults` in the config file. -## Author +## Upstream docs -Created by [Isaac Rowntree](https://github.com/isaacrowntree). +For features inherited from upstream, see the [original documentation](https://triptechtravel.github.io/clickup-cli/). ## License diff --git a/pkg/cmd/chat/chat.go b/pkg/cmd/chat/chat.go new file mode 100644 index 0000000..50bb4c7 --- /dev/null +++ b/pkg/cmd/chat/chat.go @@ -0,0 +1,19 @@ +package chat + +import ( + "github.com/spf13/cobra" + "github.com/triptechtravel/clickup-cli/pkg/cmdutil" +) + +// NewCmdChat returns the "chat" parent command. +func NewCmdChat(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "chat", + Short: "Manage chat channels", + Long: "Send messages to ClickUp Chat channels.", + } + + cmd.AddCommand(NewCmdChatSend(f)) + + return cmd +} diff --git a/pkg/cmd/chat/send.go b/pkg/cmd/chat/send.go new file mode 100644 index 0000000..da49a2c --- /dev/null +++ b/pkg/cmd/chat/send.go @@ -0,0 +1,94 @@ +package chat + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/spf13/cobra" + "github.com/triptechtravel/clickup-cli/pkg/cmdutil" +) + +type sendOptions struct { + factory *cmdutil.Factory + channelID string + body string + notifyAll bool +} + +// NewCmdChatSend returns the "chat send" command. +func NewCmdChatSend(f *cmdutil.Factory) *cobra.Command { + opts := &sendOptions{factory: f} + + cmd := &cobra.Command{ + Use: "send ", + Short: "Send a message to a ClickUp Chat channel", + Long: `Post a message to a ClickUp Chat channel (view). + +The channel ID can be found in the Chat URL: + https://app.clickup.com//chat/r/ + +For example, if the URL is: + https://app.clickup.com/20503057/chat/r/khpgh-10335 +then the channel ID is "khpgh-10335".`, + Example: ` # Send a message to a chat channel + clickup chat send khpgh-10335 "Deploy completed successfully" + + # Send with notifications + clickup chat send khpgh-10335 "Urgent: server down" --notify`, + Args: cobra.ExactArgs(2), + PersistentPreRunE: cmdutil.NeedsAuth(f), + RunE: func(cmd *cobra.Command, args []string) error { + opts.channelID = args[0] + opts.body = args[1] + return sendRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.notifyAll, "notify", false, "Notify all channel members") + + return cmd +} + +func sendRun(opts *sendOptions) error { + ios := opts.factory.IOStreams + cs := ios.ColorScheme() + + client, err := opts.factory.ApiClient() + if err != nil { + return err + } + + url := fmt.Sprintf("https://api.clickup.com/api/v2/view/%s/comment", opts.channelID) + + payload, err := json.Marshal(map[string]interface{}{ + "comment_text": opts.body, + "notify_all": opts.notifyAll, + }) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.DoRequest(req) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + fmt.Fprintf(ios.Out, "%s Message sent to channel %s\n", cs.Green("!"), cs.Bold(opts.channelID)) + + return nil +} diff --git a/pkg/cmd/list/list.go b/pkg/cmd/list/list.go new file mode 100644 index 0000000..822c369 --- /dev/null +++ b/pkg/cmd/list/list.go @@ -0,0 +1,19 @@ +package list + +import ( + "github.com/spf13/cobra" + "github.com/triptechtravel/clickup-cli/pkg/cmdutil" +) + +// NewCmdList returns the "list" parent command. +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Manage lists", + Long: "List and browse ClickUp lists within spaces and folders.", + } + + cmd.AddCommand(NewCmdListLs(f)) + + return cmd +} diff --git a/pkg/cmd/list/ls.go b/pkg/cmd/list/ls.go new file mode 100644 index 0000000..5d43d6d --- /dev/null +++ b/pkg/cmd/list/ls.go @@ -0,0 +1,189 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/spf13/cobra" + "github.com/triptechtravel/clickup-cli/internal/api" + "github.com/triptechtravel/clickup-cli/internal/tableprinter" + "github.com/triptechtravel/clickup-cli/pkg/cmdutil" +) + +type lsEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Folder string `json:"folder"` +} + +// NewCmdListLs returns the "list ls" command. +func NewCmdListLs(f *cmdutil.Factory) *cobra.Command { + var jsonFlags cmdutil.JSONFlags + var spaceID string + + cmd := &cobra.Command{ + Use: "ls", + Short: "List all lists in a space", + Long: `Show all lists in the configured (or specified) space, +grouped by folder. Folderless lists are shown first.`, + Example: ` # List all lists in the current space + clickup list ls + + # List all lists in a specific space + clickup list ls --space 12345`, + PreRunE: cmdutil.NeedsAuth(f), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := f.ApiClient() + if err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + sid := spaceID + if sid == "" { + sid = cfg.Space + } + if sid == "" { + return fmt.Errorf("no space configured. Run 'clickup space select' or use --space") + } + + ctx := context.Background() + _ = ctx // reserved for future use + + var entries []lsEntry + + // 1. Folderless lists + folderlessLists, err := getFolderlessLists(client, sid) + if err != nil { + return fmt.Errorf("failed to fetch folderless lists: %w", err) + } + for _, l := range folderlessLists { + entries = append(entries, lsEntry{ID: l.ID, Name: l.Name, Folder: ""}) + } + + // 2. Folders and their lists + folders, err := getFolders(client, sid) + if err != nil { + return fmt.Errorf("failed to fetch folders: %w", err) + } + for _, folder := range folders { + for _, l := range folder.Lists { + entries = append(entries, lsEntry{ID: l.ID, Name: l.Name, Folder: folder.Name}) + } + } + + if jsonFlags.WantsJSON() { + return jsonFlags.OutputJSON(f.IOStreams.Out, entries) + } + + if len(entries) == 0 { + fmt.Fprintln(f.IOStreams.Out, "No lists found in this space.") + return nil + } + + cs := f.IOStreams.ColorScheme() + tp := tableprinter.New(f.IOStreams) + + for _, e := range entries { + folder := cs.Gray("(no folder)") + if e.Folder != "" { + folder = e.Folder + } + tp.AddField(e.ID) + tp.AddField(e.Name) + tp.AddField(folder) + tp.EndRow() + } + + if err := tp.Render(); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out) + fmt.Fprintln(f.IOStreams.Out, cs.Gray("---")) + fmt.Fprintln(f.IOStreams.Out, cs.Gray("Quick actions:")) + fmt.Fprintf(f.IOStreams.Out, " %s clickup task create --list-id \n", cs.Gray("Create:")) + fmt.Fprintf(f.IOStreams.Out, " %s clickup task list --list-id \n", cs.Gray("Tasks:")) + + return nil + }, + } + + cmd.Flags().StringVar(&spaceID, "space", "", "Space ID (defaults to configured space)") + cmdutil.AddJSONFlags(cmd, &jsonFlags) + return cmd +} + +// API response types + +type apiList struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type apiFolder struct { + ID string `json:"id"` + Name string `json:"name"` + Lists []apiList `json:"lists"` +} + +func getFolderlessLists(client *api.Client, spaceID string) ([]apiList, error) { + url := fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/list", spaceID) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := client.DoRequest(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Lists []apiList `json:"lists"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Lists, nil +} + +func getFolders(client *api.Client, spaceID string) ([]apiFolder, error) { + url := fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/folder", spaceID) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := client.DoRequest(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Folders []apiFolder `json:"folders"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Folders, nil +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 10993ee..f1201c3 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -3,11 +3,13 @@ package root import ( "github.com/spf13/cobra" "github.com/triptechtravel/clickup-cli/pkg/cmd/auth" + "github.com/triptechtravel/clickup-cli/pkg/cmd/chat" "github.com/triptechtravel/clickup-cli/pkg/cmd/comment" "github.com/triptechtravel/clickup-cli/pkg/cmd/completion" "github.com/triptechtravel/clickup-cli/pkg/cmd/field" "github.com/triptechtravel/clickup-cli/pkg/cmd/inbox" "github.com/triptechtravel/clickup-cli/pkg/cmd/link" + listcmd "github.com/triptechtravel/clickup-cli/pkg/cmd/list" "github.com/triptechtravel/clickup-cli/pkg/cmd/member" "github.com/triptechtravel/clickup-cli/pkg/cmd/space" "github.com/triptechtravel/clickup-cli/pkg/cmd/sprint" @@ -41,6 +43,8 @@ Links GitHub PRs, branches, and commits to ClickUp tasks.`, cmd.AddCommand(link.NewCmdLink(f)) cmd.AddCommand(sprint.NewCmdSprint(f)) cmd.AddCommand(space.NewCmdSpace(f)) + cmd.AddCommand(listcmd.NewCmdList(f)) + cmd.AddCommand(chat.NewCmdChat(f)) cmd.AddCommand(field.NewCmdField(f)) cmd.AddCommand(tag.NewCmdTag(f)) diff --git a/pkg/cmd/task/create.go b/pkg/cmd/task/create.go index 5a460c0..8627e22 100644 --- a/pkg/cmd/task/create.go +++ b/pkg/cmd/task/create.go @@ -17,6 +17,7 @@ const pointsNotSet = -999.0 type createOptions struct { listID string + listName string currentSprint bool name string description string @@ -121,8 +122,15 @@ Additional properties can be set with flags: } opts.listID = listID } + if opts.listName != "" && opts.listID == "" { + listID, err := resolveListByName(f, opts.listName) + if err != nil { + return err + } + opts.listID = listID + } if opts.listID == "" { - return fmt.Errorf("either --list-id or --current is required") + return fmt.Errorf("either --list-id, --list-name, or --current is required") } if opts.fromFile != "" { return runBulkCreate(f, opts) @@ -132,6 +140,7 @@ Additional properties can be set with flags: } cmd.Flags().StringVar(&opts.listID, "list-id", "", "ClickUp list ID") + cmd.Flags().StringVar(&opts.listName, "list-name", "", "Resolve list by name within the configured space") cmd.Flags().BoolVar(&opts.currentSprint, "current", false, "Create in the current sprint (auto-resolves list ID from sprint folder)") cmd.Flags().StringVar(&opts.name, "name", "", "Task name (convention: [Type] Context — Action (Platform))") cmd.Flags().StringVar(&opts.description, "description", "", "Task description") @@ -153,7 +162,7 @@ Additional properties can be set with flags: cmd.Flags().StringArrayVar(&opts.fields, "field", nil, `Set a custom field value ("Name=value", repeatable)`) cmd.Flags().StringVar(&opts.fromFile, "from-file", "", "Create tasks from a JSON file (array of task objects)") - cmd.MarkFlagsMutuallyExclusive("list-id", "current") + cmd.MarkFlagsMutuallyExclusive("list-id", "list-name", "current") cmdutil.AddJSONFlags(cmd, &opts.jsonFlags) diff --git a/pkg/cmd/task/helpers.go b/pkg/cmd/task/helpers.go index 7d24d01..72bd184 100644 --- a/pkg/cmd/task/helpers.go +++ b/pkg/cmd/task/helpers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -243,3 +244,113 @@ func resolveCurrentSprintList(f *cmdutil.Factory) (string, error) { return listID, nil } + +// resolveListByName searches all lists (folderless + inside folders) in the +// configured space and returns the list ID matching the given name. +// Matching is case-insensitive. +func resolveListByName(f *cmdutil.Factory, name string) (string, error) { + cfg, err := f.Config() + if err != nil { + return "", err + } + + spaceID := cfg.Space + if spaceID == "" { + return "", fmt.Errorf("no space configured. Run 'clickup space select' first") + } + + client, err := f.ApiClient() + if err != nil { + return "", err + } + + nameLower := strings.ToLower(name) + + type apiList struct { + ID string `json:"id"` + Name string `json:"name"` + } + + // Check folderless lists first. + if listID, err := findListInURL(client, fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/list", spaceID), nameLower); err != nil { + return "", err + } else if listID != "" { + return listID, nil + } + + // Check folders. + type apiFolder struct { + Lists []apiList `json:"lists"` + } + + foldersURL := fmt.Sprintf("https://api.clickup.com/api/v2/space/%s/folder", spaceID) + req, err := http.NewRequest(http.MethodGet, foldersURL, nil) + if err != nil { + return "", err + } + resp, err := client.DoRequest(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to fetch folders: %s", string(body)) + } + + var foldersResult struct { + Folders []apiFolder `json:"folders"` + } + if err := json.NewDecoder(resp.Body).Decode(&foldersResult); err != nil { + return "", err + } + + for _, folder := range foldersResult.Folders { + for _, l := range folder.Lists { + if strings.ToLower(l.Name) == nameLower { + return l.ID, nil + } + } + } + + return "", fmt.Errorf("list %q not found in space %s. Run 'clickup list ls' to see available lists", name, spaceID) +} + +// findListInURL fetches lists from a ClickUp API URL and returns the ID of +// the list matching nameLower, or empty string if not found. +func findListInURL(client *api.Client, url, nameLower string) (string, error) { + type apiList struct { + ID string `json:"id"` + Name string `json:"name"` + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := client.DoRequest(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to fetch lists: %s", string(body)) + } + + var result struct { + Lists []apiList `json:"lists"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + for _, l := range result.Lists { + if strings.ToLower(l.Name) == nameLower { + return l.ID, nil + } + } + return "", nil +}