diff --git a/AGENTS.md b/AGENTS.md index dbed9fd..b525468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` to avoid spending tokens on assignees, watchers, tags, custom fields, and other full task metadata. ### Custom Task IDs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb3a09..68d6061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f0eb609..f1e836c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/changes.go b/cmd/changes.go index 9c6e540..f6ede8f 100644 --- a/cmd/changes.go +++ b/cmd/changes.go @@ -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{ @@ -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 }, @@ -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") diff --git a/cmd/changes_test.go b/cmd/changes_test.go index d09c615..0f2a4cd 100644 --- a/cmd/changes_test.go +++ b/cmd/changes_test.go @@ -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 diff --git a/docs/api.md b/docs/api.md index 62d8acd..db83c9f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 ``` ---