Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,16 @@ clickup changes --workspace 1234 --since 2026-06-09
# Peek without advancing the saved 'last' timestamp
clickup changes --workspace 1234 --no-save

# Agent-friendly / token-light output: keep only IDs, names, status, URL,
# hierarchy IDs/names, and updated timestamps. Limit returned rows while
# preserving full task_count/doc_count totals.
clickup changes --workspace 1234 --since 24h --no-save --compact --limit 25

# Tasks only, scoped to specific lists/spaces/folders
clickup changes --workspace 1234 --skip-docs --list-ids 111,222
clickup changes --workspace 1234 --skip-docs --list-ids 111,222 --compact
```

Output: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Tasks are filtered server-side (`date_updated_gt`, paginated automatically up to 5,000); docs have no server-side updated filter, so they are filtered client-side by `date_updated` — granularity is per-doc (use `doc page-list` to find which page changed).
Output: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. With `--compact`, the response also includes `task_returned`, `doc_returned`, and `truncated` when a `--limit` caps returned rows. Tasks are filtered server-side (`date_updated_gt`, paginated automatically up to 5,000); docs have no server-side updated filter, so they are filtered client-side by `date_updated` — granularity is per-doc (use `doc page-list` to find which page changed). For agent workflows, prefer `--compact --limit <n>` to avoid spending tokens on assignees, watchers, tags, custom fields, and other full task metadata.

### Custom Task IDs

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Added

- **`clickup changes`** — list tasks and docs updated in a workspace since a point in time. `--since` accepts `last` (tracked per workspace in `~/.clickup-cli-state.json`), durations (`30m`, `24h`, `7d`, `2w`), dates, RFC3339 timestamps, or Unix ms. Tasks are filtered server-side (`date_updated_gt`); docs are filtered client-side by `date_updated` since the v3 Docs API has no updated-since parameter. Supports `--skip-docs`, `--no-save`, and `--space-ids`/`--folder-ids`/`--list-ids` scoping.
- **`clickup changes`** — list tasks and docs updated in a workspace since a point in time. `--since` accepts `last` (tracked per workspace in `~/.clickup-cli-state.json`), durations (`30m`, `24h`, `7d`, `2w`), dates, RFC3339 timestamps, or Unix ms. Tasks are filtered server-side (`date_updated_gt`); docs are filtered client-side by `date_updated` since the v3 Docs API has no updated-since parameter. Supports `--compact` for token-light agent summaries, `--limit` to cap returned rows while keeping total counts, `--skip-docs`, `--no-save`, and `--space-ids`/`--folder-ids`/`--list-ids` scoping.
- `date_updated` field on the Doc model (returned by the v3 API but previously dropped during decoding).

## [1.0.0] - 2026-02-16
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ clickup task search --workspace 1234567 --assignee 12345 --include-closed
# 4b. What changed since my last visit? (tasks + docs, state tracked automatically)
clickup changes --workspace 1234567

# Agent-friendly compact activity feed (lower token usage)
clickup changes --workspace 1234567 --since 24h --no-save --compact --limit 25

# 5. Track time
clickup time-entry start --workspace 1234567 --task abc123 --description "Working on feature"
clickup time-entry stop --workspace 1234567
Expand Down
157 changes: 141 additions & 16 deletions cmd/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,51 @@ const defaultChangesWindow = 24 * time.Hour
const maxChangesPages = 50

type changesResponse struct {
WorkspaceID string `json:"workspace_id"`
Since int64 `json:"since"`
Until int64 `json:"until"`
FirstRun bool `json:"first_run,omitempty"`
TaskCount int `json:"task_count"`
DocCount int `json:"doc_count"`
Tasks []api.Task `json:"tasks"`
Docs []api.Doc `json:"docs"`
WorkspaceID string `json:"workspace_id"`
Since int64 `json:"since"`
Until int64 `json:"until"`
FirstRun bool `json:"first_run,omitempty"`
TaskCount int `json:"task_count"`
DocCount int `json:"doc_count"`
TaskReturned int `json:"task_returned,omitempty"`
DocReturned int `json:"doc_returned,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Tasks []api.Task `json:"tasks"`
Docs []api.Doc `json:"docs"`
}

type compactChangesResponse struct {
WorkspaceID string `json:"workspace_id"`
Since int64 `json:"since"`
Until int64 `json:"until"`
FirstRun bool `json:"first_run,omitempty"`
TaskCount int `json:"task_count"`
DocCount int `json:"doc_count"`
TaskReturned int `json:"task_returned"`
DocReturned int `json:"doc_returned"`
Truncated bool `json:"truncated,omitempty"`
Tasks []compactTaskChange `json:"tasks"`
Docs []compactDocChange `json:"docs"`
}

type compactTaskChange struct {
ID string `json:"id"`
CustomID string `json:"custom_id,omitempty"`
Name string `json:"name"`
Status string `json:"status,omitempty"`
URL string `json:"url,omitempty"`
ListID string `json:"list_id,omitempty"`
ListName string `json:"list_name,omitempty"`
FolderID string `json:"folder_id,omitempty"`
FolderName string `json:"folder_name,omitempty"`
SpaceID string `json:"space_id,omitempty"`
DateUpdated string `json:"date_updated,omitempty"`
}

type compactDocChange struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
DateUpdated int64 `json:"date_updated,omitempty"`
}

var changesCmd = &cobra.Command{
Expand Down Expand Up @@ -121,15 +158,42 @@ by their date_updated field.
}
}

limit, _ := cmd.Flags().GetInt("limit")
if limit < 0 {
output.PrintError("VALIDATION_ERROR", "--limit must be 0 or greater")
return &exitError{code: 1}
}

if compact, _ := cmd.Flags().GetBool("compact"); compact {
output.JSON(buildCompactChangesResponse(workspaceID, since, now, firstRun, tasks, docs, limit))
return nil
}

taskCount := len(tasks)
docCount := len(docs)
taskReturned := 0
docReturned := 0
truncated := false
if limit > 0 {
tasks = limitTasks(tasks, limit)
docs = limitDocs(docs, limit)
taskReturned = len(tasks)
docReturned = len(docs)
truncated = len(tasks) < taskCount || len(docs) < docCount
}

output.JSON(changesResponse{
WorkspaceID: workspaceID,
Since: since,
Until: now,
FirstRun: firstRun,
TaskCount: len(tasks),
DocCount: len(docs),
Tasks: tasks,
Docs: docs,
WorkspaceID: workspaceID,
Since: since,
Until: now,
FirstRun: firstRun,
TaskCount: taskCount,
DocCount: docCount,
TaskReturned: taskReturned,
DocReturned: docReturned,
Truncated: truncated,
Tasks: tasks,
Docs: docs,
})
return nil
},
Expand Down Expand Up @@ -185,9 +249,70 @@ func parseDuration(s string) (time.Duration, error) {
return time.ParseDuration(s)
}

func buildCompactChangesResponse(workspaceID string, since, until int64, firstRun bool, tasks []api.Task, docs []api.Doc, limit int) compactChangesResponse {
taskCount := len(tasks)
docCount := len(docs)
tasks = limitTasks(tasks, limit)
docs = limitDocs(docs, limit)

resp := compactChangesResponse{
WorkspaceID: workspaceID,
Since: since,
Until: until,
FirstRun: firstRun,
TaskCount: taskCount,
DocCount: docCount,
TaskReturned: len(tasks),
DocReturned: len(docs),
Truncated: len(tasks) < taskCount || len(docs) < docCount,
Tasks: make([]compactTaskChange, 0, len(tasks)),
Docs: make([]compactDocChange, 0, len(docs)),
}
for i := range tasks {
task := &tasks[i]
resp.Tasks = append(resp.Tasks, compactTaskChange{
ID: task.ID,
CustomID: task.CustomID,
Name: task.Name,
Status: task.Status.Status,
URL: task.URL,
ListID: task.List.ID,
ListName: task.List.Name,
FolderID: task.Folder.ID,
FolderName: task.Folder.Name,
SpaceID: task.Space.ID,
DateUpdated: task.DateUpdated,
})
}
for i := range docs {
resp.Docs = append(resp.Docs, compactDocChange{
ID: docs[i].ID,
Name: docs[i].Name,
DateUpdated: docUpdatedAt(&docs[i]),
})
}
return resp
}

func limitTasks(tasks []api.Task, limit int) []api.Task {
if limit <= 0 || len(tasks) <= limit {
return tasks
}
return tasks[:limit]
}

func limitDocs(docs []api.Doc, limit int) []api.Doc {
if limit <= 0 || len(docs) <= limit {
return docs
}
return docs[:limit]
}

func init() {
changesCmd.Flags().String("workspace", "", "Workspace/Team ID")
changesCmd.Flags().String("since", "last", "Point in time: last, duration (24h, 7d), date, RFC3339, or Unix ms")
changesCmd.Flags().Bool("compact", false, "Return token-light task/doc summaries for agent workflows")
changesCmd.Flags().Int("limit", 0, "Maximum tasks and docs to return after counting all matches (0 = no limit)")
changesCmd.Flags().Bool("skip-docs", false, "Skip checking docs for updates")
changesCmd.Flags().Bool("no-save", false, "Don't record this check as the new 'last' timestamp")
changesCmd.Flags().StringSlice("space-ids", nil, "Limit task changes to space IDs")
Expand Down
92 changes: 92 additions & 0 deletions cmd/changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,98 @@ func TestChangesCommand(t *testing.T) {
}
}

func TestChangesCompactOutput(t *testing.T) {
t.Setenv("HOME", t.TempDir())
docsJSON := `{"docs":[{"id":"doc-new","name":"Fresh","date_updated":9000000000000,"creator":{"id":1},"visibility":"private"}]}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/v2/team/") {
fmt.Fprint(w, `{"tasks":[{"id":"task1","name":"Updated Task","status":{"status":"in progress","color":"#fff","type":"custom"},"date_updated":"9000000000000","url":"https://app.clickup.com/t/task1","list":{"id":"list1","name":"Sprint"},"folder":{"id":"folder1","name":"Product"},"space":{"id":"space1"},"assignees":[{"id":1,"username":"Agent"}],"custom_fields":[{"id":"field1","name":"Huge field"}]}]}`)
return
}
fmt.Fprint(w, docsJSON)
}))
t.Cleanup(server.Close)

out, err := runCommand(t, server.URL, "changes", "--since", "5000", "--compact")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(out, "custom_fields") || strings.Contains(out, "assignees") || strings.Contains(out, "creator") {
t.Fatalf("compact output should omit token-heavy fields, got: %s", out)
}

var resp struct {
TaskCount int `json:"task_count"`
TaskReturned int `json:"task_returned"`
DocCount int `json:"doc_count"`
DocReturned int `json:"doc_returned"`
Tasks []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
URL string `json:"url"`
ListID string `json:"list_id"`
ListName string `json:"list_name"`
FolderName string `json:"folder_name"`
SpaceID string `json:"space_id"`
DateUpdated string `json:"date_updated"`
} `json:"tasks"`
Docs []struct {
ID string `json:"id"`
Name string `json:"name"`
DateUpdated int64 `json:"date_updated"`
} `json:"docs"`
}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out)
}
if resp.TaskCount != 1 || resp.TaskReturned != 1 || resp.DocCount != 1 || resp.DocReturned != 1 {
t.Fatalf("unexpected counts: %+v", resp)
}
task := resp.Tasks[0]
if task.ID != "task1" || task.Status != "in progress" || task.ListName != "Sprint" || task.FolderName != "Product" || task.SpaceID != "space1" || task.URL == "" || task.DateUpdated == "" {
t.Errorf("unexpected compact task: %+v", task)
}
if resp.Docs[0].ID != "doc-new" || resp.Docs[0].Name != "Fresh" || resp.Docs[0].DateUpdated == 0 {
t.Errorf("unexpected compact doc: %+v", resp.Docs[0])
}
}

func TestChangesLimitCompactsReturnedItemsWithoutChangingCounts(t *testing.T) {
t.Setenv("HOME", t.TempDir())
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/v2/team/") {
fmt.Fprint(w, `{"tasks":[{"id":"task1","name":"One"},{"id":"task2","name":"Two"},{"id":"task3","name":"Three"}]}`)
return
}
fmt.Fprint(w, `{"docs":[{"id":"doc1","name":"One","date_updated":9000000000000},{"id":"doc2","name":"Two","date_updated":9000000000000}]}`)
}))
t.Cleanup(server.Close)

out, err := runCommand(t, server.URL, "changes", "--since", "5000", "--compact", "--limit", "1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp struct {
TaskCount int `json:"task_count"`
TaskReturned int `json:"task_returned"`
DocCount int `json:"doc_count"`
DocReturned int `json:"doc_returned"`
Truncated bool `json:"truncated"`
Tasks []struct{ ID string } `json:"tasks"`
Docs []struct{ ID string } `json:"docs"`
}
if err := json.Unmarshal([]byte(out), &resp); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out)
}
if resp.TaskCount != 3 || resp.TaskReturned != 1 || resp.DocCount != 2 || resp.DocReturned != 1 || !resp.Truncated {
t.Fatalf("expected full counts but limited returned items, got %+v", resp)
}
if resp.Tasks[0].ID != "task1" || resp.Docs[0].ID != "doc1" {
t.Errorf("limit should preserve updated ordering, got tasks=%+v docs=%+v", resp.Tasks, resp.Docs)
}
}

func TestChangesTaskQueryParams(t *testing.T) {
t.Setenv("HOME", t.TempDir())
var taskQuery string
Expand Down
11 changes: 8 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,23 +521,28 @@ With `--since last` (the default), the timestamp of each run is recorded per wor
|------|------|---------|-----------|-------------|
| `--workspace` | string | *(global)* | `team_id` / `workspace_id` (path) | Workspace ID |
| `--since` | string | `last` | `date_updated_gt` (query, tasks) | `last`, a duration (`30m`, `24h`, `7d`, `2w`), a date (`2026-06-09`), an RFC3339 timestamp, or Unix ms |
| `--compact` | bool | `false` | — | Return token-light task/doc summaries for agent workflows |
| `--limit` | int | `0` | — | Maximum tasks and docs to return after counting all matches (`0` = no limit) |
| `--skip-docs` | bool | `false` | — | Skip checking docs for updates |
| `--no-save` | bool | `false` | — | Don't record this check as the new `last` timestamp |
| `--space-ids` | string[] | — | `space_ids[]` (query) | Limit task changes to space IDs |
| `--folder-ids` | string[] | — | `folder_ids[]` (query) | Limit task changes to folder IDs |
| `--list-ids` | string[] | — | `list_ids[]` (query) | Limit task changes to list IDs |

Output shape: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Task pagination is handled automatically (up to 5,000 tasks per run).
Output shape: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Task pagination is handled automatically (up to 5,000 tasks per run). With `--compact`, each task is reduced to `id`, `custom_id`, `name`, `status`, `url`, list/folder/space identifiers, and `date_updated`; docs are reduced to `id`, `name`, and `date_updated`. When `--limit` is set, `task_count`/`doc_count` still report total matches, while `task_returned`/`doc_returned` and `truncated` describe the capped payload.

```bash
# What changed since I last checked? (tracks state automatically)
clickup changes --workspace 1234567

# What changed in the last 2 days, without touching saved state
clickup changes --workspace 1234567 --since 2d
clickup changes --workspace 1234567 --since 2d --no-save

# Low-token agent summary for recent changes
clickup changes --workspace 1234567 --since 24h --no-save --compact --limit 25

# Tasks only, scoped to two lists
clickup changes --workspace 1234567 --since 2026-06-09 --skip-docs --list-ids 111,222
clickup changes --workspace 1234567 --since 2026-06-09 --skip-docs --list-ids 111,222 --compact
```

---
Expand Down
Loading