From c030619b2c3de20473813464b46a4ec3e0abb531 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:45:13 +0000 Subject: [PATCH 1/3] feat: add 'changes' command for tasks and docs updated since a point in time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'clickup changes' — a what-changed-since-my-last-visit log for a workspace. Tasks are filtered server-side via date_updated_gt (with automatic pagination); docs have no server-side updated filter, so they are fetched and filtered client-side by date_updated. --since accepts 'last' (run timestamp tracked per workspace in ~/.clickup-cli-state.json, separate from config so the token file is never rewritten), durations (30m/24h/7d/2w), dates, RFC3339, or Unix ms. Also supports --skip-docs, --no-save, and space/folder/list scoping. Also adds the date_updated field to the Doc model (returned by the v3 API but previously dropped during decoding), and fixes a test-harness deadlock in runCommand when command output exceeds the 64KB pipe buffer. https://claude.ai/code/session_019Aexh4uFP9TfNcm8nD42fN --- CHANGELOG.md | 7 + README.md | 4 + cmd/changes.go | 197 +++++++++++++++++++++++++++ cmd/changes_test.go | 282 +++++++++++++++++++++++++++++++++++++++ cmd/integration_test.go | 14 +- docs/api.md | 35 +++++ internal/api/docs.go | 1 + internal/config/state.go | 71 ++++++++++ 8 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 cmd/changes.go create mode 100644 cmd/changes_test.go create mode 100644 internal/config/state.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a32b23c..0cb3a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### 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. +- `date_updated` field on the Doc model (returned by the v3 API but previously dropped during decoding). + ## [1.0.0] - 2026-02-16 First release of `clickup-cli` — a production-quality CLI covering **99.3% of the ClickUp API** (134/135 endpoints), optimized for AI agents. diff --git a/README.md b/README.md index a750acf..f0eb609 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ clickup task create --list 900100200300 \ # 4. Search across workspace 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 + # 5. Track time clickup time-entry start --workspace 1234567 --task abc123 --description "Working on feature" clickup time-entry stop --workspace 1234567 @@ -96,6 +99,7 @@ clickup task list --list 900100200300 --format text | `task` | `merge`, `time-in-status` | Merge tasks, get status timing | | `task dependency` | `add`, `remove` | Task dependency management | | `task link` | `add`, `remove` | Task link management | +| `changes` | — | Tasks & docs updated since a point in time (`--since last\|24h\|7d\|date`) | ### Content & Collaboration diff --git a/cmd/changes.go b/cmd/changes.go new file mode 100644 index 0000000..8927525 --- /dev/null +++ b/cmd/changes.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/blockful/clickup-cli/internal/api" + "github.com/blockful/clickup-cli/internal/config" + "github.com/blockful/clickup-cli/internal/output" + "github.com/spf13/cobra" +) + +// defaultChangesWindow is used for --since last when no previous check is +// recorded for the workspace. +const defaultChangesWindow = 24 * time.Hour + +// maxChangesPages caps task pagination as a safety limit (100 tasks/page). +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"` +} + +var changesCmd = &cobra.Command{ + Use: "changes", + Short: "List tasks and docs updated since a point in time", + Long: `List everything updated in a workspace since a point in time. + +Tasks are filtered server-side (date_updated_gt). Docs do not support a +server-side updated filter, so they are fetched and filtered client-side +by their date_updated field. + +--since accepts: + last changes since the previous 'changes --since last' run + (tracked per workspace in ~/` + config.StateFileName + `; + defaults to 24h on first run) + a duration e.g. 30m, 24h, 7d, 2w + a date or timestamp e.g. 2026-06-09 or RFC3339 + Unix milliseconds e.g. 1765432100000`, + RunE: func(cmd *cobra.Command, args []string) error { + client := getClient() + ctx := context.Background() + workspaceID := getWorkspaceID(cmd) + + now := time.Now().UnixMilli() + sinceFlag, _ := cmd.Flags().GetString("since") + trackLast := sinceFlag == "last" + + var since int64 + firstRun := false + if trackLast { + last, err := config.GetLastChangesCheck(workspaceID) + if err != nil { + output.PrintError("STATE_ERROR", err.Error()) + return &exitError{code: 1} + } + since = last + if since == 0 { + since = now - defaultChangesWindow.Milliseconds() + firstRun = true + } + } else { + var err error + since, err = parseSince(sinceFlag, now) + if err != nil { + output.PrintError("VALIDATION_ERROR", err.Error()) + return &exitError{code: 1} + } + } + + opts := &api.SearchTasksOptions{ + DateUpdatedGt: since, + IncludeClosed: true, + Subtasks: true, + OrderBy: "updated", + } + opts.SpaceIDs, _ = cmd.Flags().GetStringSlice("space-ids") + opts.ListIDs, _ = cmd.Flags().GetStringSlice("list-ids") + opts.FolderIDs, _ = cmd.Flags().GetStringSlice("folder-ids") + + tasks := []api.Task{} + for page := 0; page < maxChangesPages; page++ { + opts.Page = page + resp, err := client.SearchTasks(ctx, workspaceID, opts) + if err != nil { + return handleError(err) + } + tasks = append(tasks, resp.Tasks...) + if len(resp.Tasks) < 100 { + break + } + } + + docs := []api.Doc{} + if skipDocs, _ := cmd.Flags().GetBool("skip-docs"); !skipDocs { + resp, err := client.SearchDocs(ctx, workspaceID) + if err != nil { + return handleError(err) + } + for _, doc := range resp.Docs { + if docUpdatedAt(doc) > since { + docs = append(docs, doc) + } + } + } + + if noSave, _ := cmd.Flags().GetBool("no-save"); trackLast && !noSave { + if err := config.SetLastChangesCheck(workspaceID, now); err != nil { + output.PrintError("STATE_ERROR", err.Error()) + return &exitError{code: 1} + } + } + + output.JSON(changesResponse{ + WorkspaceID: workspaceID, + Since: since, + Until: now, + FirstRun: firstRun, + TaskCount: len(tasks), + DocCount: len(docs), + Tasks: tasks, + Docs: docs, + }) + return nil + }, +} + +// docUpdatedAt returns the doc's date_updated in Unix ms, falling back to +// date_created when the API omits date_updated. +func docUpdatedAt(doc api.Doc) int64 { + if v, err := doc.DateUpdated.Int64(); err == nil && v > 0 { + return v + } + if v, err := doc.DateCreated.Int64(); err == nil { + return v + } + return 0 +} + +// parseSince converts a --since value into a Unix ms timestamp. It accepts +// raw Unix ms, durations (30m, 24h, 7d, 2w), dates (2006-01-02), and +// RFC3339 timestamps. +func parseSince(s string, now int64) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("--since requires a value (e.g. last, 24h, 7d, 2026-06-09, or Unix ms)") + } + + if ms, err := strconv.ParseInt(s, 10, 64); err == nil { + return ms, nil + } + + if d, err := parseDuration(s); err == nil { + return now - d.Milliseconds(), nil + } + + if t, err := time.Parse("2006-01-02", s); err == nil { + return t.UnixMilli(), nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UnixMilli(), nil + } + + return 0, fmt.Errorf("invalid --since value %q: use last, a duration (24h, 7d), a date (2026-06-09), an RFC3339 timestamp, or Unix ms", s) +} + +// parseDuration extends time.ParseDuration with d (days) and w (weeks) units. +func parseDuration(s string) (time.Duration, error) { + if n, err := strconv.ParseFloat(strings.TrimSuffix(s, "d"), 64); err == nil && strings.HasSuffix(s, "d") { + return time.Duration(n * 24 * float64(time.Hour)), nil + } + if n, err := strconv.ParseFloat(strings.TrimSuffix(s, "w"), 64); err == nil && strings.HasSuffix(s, "w") { + return time.Duration(n * 7 * 24 * float64(time.Hour)), nil + } + return time.ParseDuration(s) +} + +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("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") + changesCmd.Flags().StringSlice("folder-ids", nil, "Limit task changes to folder IDs") + changesCmd.Flags().StringSlice("list-ids", nil, "Limit task changes to list IDs") + rootCmd.AddCommand(changesCmd) +} diff --git a/cmd/changes_test.go b/cmd/changes_test.go new file mode 100644 index 0000000..d09c615 --- /dev/null +++ b/cmd/changes_test.go @@ -0,0 +1,282 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/blockful/clickup-cli/internal/config" +) + +// newChangesServer mocks the task search and docs endpoints. +func newChangesServer(t *testing.T, docsJSON string) (*httptest.Server, *requestLog) { + t.Helper() + log := &requestLog{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.mu.Lock() + log.Method = r.Method + log.Path = r.URL.Path + log.Query = r.URL.RawQuery + log.mu.Unlock() + switch { + case strings.Contains(r.URL.Path, "/v2/team/"): + fmt.Fprint(w, `{"tasks":[{"id":"task1","name":"Updated Task"}]}`) + case strings.Contains(r.URL.Path, "/v3/workspaces/") && strings.HasSuffix(r.URL.Path, "/docs"): + fmt.Fprint(w, docsJSON) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server, log +} + +func TestChangesCommand(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + docsJSON := `{"docs":[ + {"id":"doc-new","name":"Fresh","date_updated":9000000000000}, + {"id":"doc-old","name":"Stale","date_updated":1000}, + {"id":"doc-created-only","name":"NoUpdatedField","date_created":9000000000000} + ]}` + server, _ := newChangesServer(t, docsJSON) + + out, err := runCommand(t, server.URL, "changes", "--since", "5000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp struct { + Since int64 `json:"since"` + TaskCount int `json:"task_count"` + DocCount int `json:"doc_count"` + Tasks []struct { + ID string `json:"id"` + } `json:"tasks"` + Docs []struct { + ID string `json:"id"` + } `json:"docs"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + if resp.Since != 5000 { + t.Errorf("expected since=5000, got %d", resp.Since) + } + if resp.TaskCount != 1 || resp.Tasks[0].ID != "task1" { + t.Errorf("expected 1 task task1, got %+v", resp.Tasks) + } + if resp.DocCount != 2 { + t.Fatalf("expected 2 docs after filtering, got %d: %+v", resp.DocCount, resp.Docs) + } + if resp.Docs[0].ID != "doc-new" || resp.Docs[1].ID != "doc-created-only" { + t.Errorf("unexpected docs after filtering: %+v", resp.Docs) + } +} + +func TestChangesTaskQueryParams(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var taskQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/v2/team/") { + taskQuery = r.URL.RawQuery + fmt.Fprint(w, `{"tasks":[]}`) + return + } + fmt.Fprint(w, `{"docs":[]}`) + })) + t.Cleanup(server.Close) + + _, err := runCommand(t, server.URL, "changes", "--since", "5000", "--space-ids", "sp1", "--list-ids", "l1,l2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, want := range []string{ + "date_updated_gt=5000", + "include_closed=true", + "subtasks=true", + "order_by=updated", + "space_ids%5B%5D=sp1", + "list_ids%5B%5D=l1", + "list_ids%5B%5D=l2", + } { + if !strings.Contains(taskQuery, want) { + t.Errorf("expected task query to contain %q, got %q", want, taskQuery) + } + } +} + +func TestChangesSkipDocs(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, "/docs") { + t.Errorf("docs endpoint should not be called with --skip-docs") + } + fmt.Fprint(w, `{"tasks":[]}`) + })) + t.Cleanup(server.Close) + + out, err := runCommand(t, server.URL, "changes", "--since", "5000", "--skip-docs") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `"doc_count": 0`) { + t.Errorf("expected doc_count 0, got: %s", out) + } +} + +func TestChangesSinceLast(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + server, _ := newChangesServer(t, `{"docs":[]}`) + + // First run: no saved state → defaults to a 24h window and records now. + before := time.Now().UnixMilli() + out, err := runCommand(t, server.URL, "changes", "--since", "last") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var resp struct { + Since int64 `json:"since"` + FirstRun bool `json:"first_run"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + if !resp.FirstRun { + t.Errorf("expected first_run=true on first run") + } + if got, want := resp.Since, before-24*time.Hour.Milliseconds(); got < want-5000 || got > want+5000 { + t.Errorf("expected since≈now-24h (%d), got %d", want, got) + } + + saved, err := config.GetLastChangesCheck("12345678") + if err != nil { + t.Fatalf("failed reading state: %v", err) + } + if saved < before { + t.Errorf("expected saved timestamp >= %d, got %d", before, saved) + } + + // Second run: uses the saved timestamp. + out, err = runCommand(t, server.URL, "changes", "--since", "last") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resp.FirstRun = false // Unmarshal leaves absent (omitempty) fields untouched + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + if resp.FirstRun { + t.Errorf("expected first_run=false on second run") + } + if resp.Since != saved { + t.Errorf("expected since=%d (saved), got %d", saved, resp.Since) + } +} + +func TestChangesNoSave(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + server, _ := newChangesServer(t, `{"docs":[]}`) + + _, err := runCommand(t, server.URL, "changes", "--since", "last", "--no-save") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(home, config.StateFileName)); !os.IsNotExist(err) { + t.Errorf("expected no state file with --no-save, stat err: %v", err) + } +} + +func TestChangesPagination(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/docs") { + fmt.Fprint(w, `{"docs":[]}`) + return + } + pages++ + if pages == 1 { + tasks := make([]string, 100) + for i := range tasks { + tasks[i] = fmt.Sprintf(`{"id":"t%d"}`, i) + } + fmt.Fprintf(w, `{"tasks":[%s]}`, strings.Join(tasks, ",")) + return + } + fmt.Fprint(w, `{"tasks":[{"id":"t100"}]}`) + })) + t.Cleanup(server.Close) + + out, err := runCommand(t, server.URL, "changes", "--since", "5000") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pages != 2 { + t.Errorf("expected 2 task pages fetched, got %d", pages) + } + if !strings.Contains(out, `"task_count": 101`) { + t.Errorf("expected task_count 101, got: %s", out) + } +} + +func TestChangesInvalidSince(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server, _ := newChangesServer(t, `{"docs":[]}`) + + _, err := runCommand(t, server.URL, "changes", "--since", "yesterday-ish") + if err == nil { + t.Fatal("expected error for invalid --since value") + } +} + +func TestParseSince(t *testing.T) { + now := int64(10_000_000_000_000) + cases := []struct { + in string + want int64 + wantErr bool + }{ + {"1765432100000", 1765432100000, false}, + {"24h", now - 24*time.Hour.Milliseconds(), false}, + {"30m", now - 30*time.Minute.Milliseconds(), false}, + {"7d", now - 7*24*time.Hour.Milliseconds(), false}, + {"2w", now - 14*24*time.Hour.Milliseconds(), false}, + {"2026-06-09T12:00:00Z", time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC).UnixMilli(), false}, + {"", 0, true}, + {"banana", 0, true}, + } + for _, c := range cases { + got, err := parseSince(c.in, now) + if c.wantErr { + if err == nil { + t.Errorf("parseSince(%q): expected error, got %d", c.in, got) + } + continue + } + if err != nil { + t.Errorf("parseSince(%q): unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("parseSince(%q) = %d, want %d", c.in, got, c.want) + } + } + // Date-only values parse in local time; just check it round-trips to the right day. + got, err := parseSince("2026-06-09", now) + if err != nil { + t.Fatalf("parseSince(2026-06-09): %v", err) + } + if d := time.UnixMilli(got).Format("2006-01-02"); d != "2026-06-09" { + t.Errorf("parseSince(2026-06-09) = %d (%s), want same day", got, d) + } +} diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 3d1d1e2..e50d1db 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -33,21 +33,27 @@ func runCommand(t *testing.T, serverURL string, args ...string) (string, error) viper.Set("workspace", "12345678") defer viper.Reset() - // Capture stdout + // Capture stdout, draining concurrently so output larger than the + // pipe buffer (64KB) doesn't deadlock the command. oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + // Reset and execute root command rootCmd.SetArgs(args) err := rootCmd.Execute() w.Close() + <-done os.Stdout = oldStdout - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - return buf.String(), err } diff --git a/docs/api.md b/docs/api.md index 6869686..62d8acd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,6 +507,41 @@ Remove a link between tasks. --- +## Changes + +### `clickup changes` + +List tasks and docs updated in a workspace since a point in time — a "what changed since my last visit" log. Tasks are filtered server-side via `date_updated_gt`; docs have no server-side updated filter, so they are fetched and filtered client-side by `date_updated` (falling back to `date_created`). + +With `--since last` (the default), the timestamp of each run is recorded per workspace in `~/.clickup-cli-state.json`, so the next run reports only what changed in between. The first run defaults to a 24-hour window and sets `"first_run": true` in the output. + +**API:** `GET /v2/team/{team_id}/task` + `GET /v3/workspaces/{workspace_id}/docs` + +| Flag | Type | Default | API Param | Description | +|------|------|---------|-----------|-------------| +| `--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 | +| `--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). + +```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 + +# Tasks only, scoped to two lists +clickup changes --workspace 1234567 --since 2026-06-09 --skip-docs --list-ids 111,222 +``` + +--- + ## Comments ### `clickup comment list` diff --git a/internal/api/docs.go b/internal/api/docs.go index eebd2bd..b08fc8f 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -14,6 +14,7 @@ type Doc struct { Parent interface{} `json:"parent,omitempty"` Creator interface{} `json:"creator,omitempty"` DateCreated json.Number `json:"date_created,omitempty"` + DateUpdated json.Number `json:"date_updated,omitempty"` Deleted bool `json:"deleted,omitempty"` Visibility string `json:"visibility,omitempty"` } diff --git a/internal/config/state.go b/internal/config/state.go new file mode 100644 index 0000000..54544ad --- /dev/null +++ b/internal/config/state.go @@ -0,0 +1,71 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const StateFileName = ".clickup-cli-state.json" + +// state holds CLI-managed bookkeeping that is not user configuration. +// It lives in a separate file from the config so writing it never +// touches the token. +type state struct { + // LastChangesCheck maps workspace ID to the Unix ms timestamp of the + // last successful `clickup changes --since last` run. + LastChangesCheck map[string]int64 `json:"last_changes_check,omitempty"` +} + +func StateFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, StateFileName) +} + +func readState() (*state, error) { + s := &state{} + data, err := os.ReadFile(StateFilePath()) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, fmt.Errorf("failed to read state file: %w", err) + } + if err := json.Unmarshal(data, s); err != nil { + return nil, fmt.Errorf("failed to parse state file %s: %w", StateFilePath(), err) + } + return s, nil +} + +// GetLastChangesCheck returns the timestamp (Unix ms) of the last recorded +// changes check for a workspace, or 0 if none is recorded. +func GetLastChangesCheck(workspaceID string) (int64, error) { + s, err := readState() + if err != nil { + return 0, err + } + return s.LastChangesCheck[workspaceID], nil +} + +// SetLastChangesCheck records the timestamp (Unix ms) of a changes check +// for a workspace. +func SetLastChangesCheck(workspaceID string, ts int64) error { + s, err := readState() + if err != nil { + return err + } + if s.LastChangesCheck == nil { + s.LastChangesCheck = map[string]int64{} + } + s.LastChangesCheck[workspaceID] = ts + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + if err := os.WriteFile(StateFilePath(), data, 0o600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + return nil +} From 5ce1b26b4d23ff517a71841192d05b847a727d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:51:45 +0000 Subject: [PATCH 2/3] docs: document 'changes' command in AGENTS.md https://claude.ai/code/session_019Aexh4uFP9TfNcm8nD42fN --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6f69022..dbed9fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,26 @@ clickup task link add --task abc123 --links-to def456 clickup task link remove --task abc123 --links-to def456 ``` +### Change Tracking + +```bash +# Everything updated since the previous run (timestamp tracked per workspace +# in ~/.clickup-cli-state.json; first run defaults to a 24h window) +clickup changes --workspace 1234 + +# Explicit windows: durations (30m, 24h, 7d, 2w), dates, RFC3339, or Unix ms +clickup changes --workspace 1234 --since 7d +clickup changes --workspace 1234 --since 2026-06-09 + +# Peek without advancing the saved 'last' timestamp +clickup changes --workspace 1234 --no-save + +# Tasks only, scoped to specific lists/spaces/folders +clickup changes --workspace 1234 --skip-docs --list-ids 111,222 +``` + +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). + ### Custom Task IDs When your workspace uses custom task IDs (e.g., `PROJ-123`), add `--custom-task-ids` and `--team-id`: @@ -418,6 +438,19 @@ Or override per-command: `--workspace 9999` ## Common Agent Workflows +### Catch up on what changed since the last visit + +```bash +# First call returns the last 24h and records the timestamp; +# every later call returns only what changed in between. +clickup changes --workspace 1234 + +# Then drill into anything interesting +clickup task get --id TASKID --include-markdown +clickup comment list --task TASKID +clickup doc page-list --workspace 1234 --doc DOCID # pages carry date_updated +``` + ### Create a task with full metadata ```bash From 9c7f593a126592b6ac056207dca7b3e54eb8420b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:08:32 +0000 Subject: [PATCH 3/3] fix: pass Doc by pointer in docUpdatedAt (gocritic hugeParam) https://claude.ai/code/session_019Aexh4uFP9TfNcm8nD42fN --- cmd/changes.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/changes.go b/cmd/changes.go index 8927525..9c6e540 100644 --- a/cmd/changes.go +++ b/cmd/changes.go @@ -107,9 +107,9 @@ by their date_updated field. if err != nil { return handleError(err) } - for _, doc := range resp.Docs { - if docUpdatedAt(doc) > since { - docs = append(docs, doc) + for i := range resp.Docs { + if docUpdatedAt(&resp.Docs[i]) > since { + docs = append(docs, resp.Docs[i]) } } } @@ -137,7 +137,7 @@ by their date_updated field. // docUpdatedAt returns the doc's date_updated in Unix ms, falling back to // date_created when the API omits date_updated. -func docUpdatedAt(doc api.Doc) int64 { +func docUpdatedAt(doc *api.Doc) int64 { if v, err := doc.DateUpdated.Int64(); err == nil && v > 0 { return v }