diff --git a/README.md b/README.md index 0fefe8d..c027f97 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A GitHub CLI-inspired command-line interface for ClickUp. - **GitHub CLI-like Interface**: Familiar command structure for developers who use `gh` - **Task Management**: Create, view, update, and manage tasks from the command line - **Comment Management**: Add, list, and delete comments on tasks with user assignment +- **Custom Fields**: Read and write ClickUp custom fields, resolving names to option ids - **Cache Management**: Optimize performance with intelligent caching and cache control commands - **Project Configuration**: Set project-specific defaults with `.cu.yml` configuration files - **API Passthrough**: Direct access to ClickUp API endpoints for advanced operations @@ -93,6 +94,23 @@ cu comment list cu comment -m "Please review" --assignee user@example.com ``` +### Custom Fields +```bash +# List the custom fields available on a list +cu field list --list abc123 + +# Show the values set on a task +cu field get + +# Set values — names are resolved to option ids, dates to epoch millis +cu field set Repo https://github.com/owner/repo +cu field set "Last synced" today +cu field set Machine "Chilastra,Sunrunner" + +# Clear a value +cu field clear "Last synced" +``` + ### Cache Management ```bash # View cache statistics diff --git a/docs/site/commands/cu.md b/docs/site/commands/cu.md index b5ea0c3..f619108 100644 --- a/docs/site/commands/cu.md +++ b/docs/site/commands/cu.md @@ -29,6 +29,7 @@ enabling efficient task management and seamless integration with development wor * [cu completion](cu_completion.md) - Generate shell completion script * [cu config](cu_config.md) - Manage cu configuration * [cu export](cu_export.md) - Export data to various formats +* [cu field](cu_field.md) - Manage custom field values * [cu interactive](cu_interactive.md) - Interactive mode for task management * [cu list](cu_list.md) - Manage lists * [cu me](cu_me.md) - Show current user information @@ -37,4 +38,4 @@ enabling efficient task management and seamless integration with development wor * [cu user](cu_user.md) - Manage users * [cu version](cu_version.md) - Show cu version information -###### Auto generated by spf13/cobra on 29-Jun-2025 +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/docs/site/commands/cu_field.md b/docs/site/commands/cu_field.md new file mode 100644 index 0000000..b0da9cd --- /dev/null +++ b/docs/site/commands/cu_field.md @@ -0,0 +1,35 @@ +## cu field + +Manage custom field values + +### Synopsis + +Read and write ClickUp custom fields. + +Fields are resolved by name (case-insensitive) or by id. Values are coerced to +the shape each field type expects — epoch milliseconds for dates, option ids +for dropdowns and labels — so names can be used on the command line. + +### Options + +``` + -h, --help help for field +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.config/cu/config.yml) + --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") +``` + +### SEE ALSO + +* [cu](cu.md) - A GitHub CLI-inspired command-line interface for ClickUp +* [cu field clear](cu_field_clear.md) - Clear a custom field value on a task +* [cu field get](cu_field_get.md) - Show custom field values on a task +* [cu field list](cu_field_list.md) - List custom fields available on a list +* [cu field set](cu_field_set.md) - Set a custom field value on a task + +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/docs/site/commands/cu_field_clear.md b/docs/site/commands/cu_field_clear.md new file mode 100644 index 0000000..cad3f52 --- /dev/null +++ b/docs/site/commands/cu_field_clear.md @@ -0,0 +1,27 @@ +## cu field clear + +Clear a custom field value on a task + +``` +cu field clear [flags] +``` + +### Options + +``` + -h, --help help for clear +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.config/cu/config.yml) + --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") +``` + +### SEE ALSO + +* [cu field](cu_field.md) - Manage custom field values + +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/docs/site/commands/cu_field_get.md b/docs/site/commands/cu_field_get.md new file mode 100644 index 0000000..7942f8a --- /dev/null +++ b/docs/site/commands/cu_field_get.md @@ -0,0 +1,27 @@ +## cu field get + +Show custom field values on a task + +``` +cu field get [field] [flags] +``` + +### Options + +``` + -h, --help help for get +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.config/cu/config.yml) + --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") +``` + +### SEE ALSO + +* [cu field](cu_field.md) - Manage custom field values + +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/docs/site/commands/cu_field_list.md b/docs/site/commands/cu_field_list.md new file mode 100644 index 0000000..d6e685d --- /dev/null +++ b/docs/site/commands/cu_field_list.md @@ -0,0 +1,28 @@ +## cu field list + +List custom fields available on a list + +``` +cu field list [flags] +``` + +### Options + +``` + -h, --help help for list + -l, --list string List ID (defaults to the configured default list) +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.config/cu/config.yml) + --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") +``` + +### SEE ALSO + +* [cu field](cu_field.md) - Manage custom field values + +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/docs/site/commands/cu_field_set.md b/docs/site/commands/cu_field_set.md new file mode 100644 index 0000000..57c7278 --- /dev/null +++ b/docs/site/commands/cu_field_set.md @@ -0,0 +1,39 @@ +## cu field set + +Set a custom field value on a task + +### Synopsis + +Set a custom field value on a task. + +The field is resolved against the fields accessible from the task's own list, +so space-level and folder-level fields work the same as list-level ones. + +Examples: + cu field set 86dxbeqyt Repo git@github.com:owner/repo.git + cu field set 86dxbeqyt "Last synced" today + cu field set 86dxbeqyt Machine "Chilastra,Sunrunner" + +``` +cu field set [flags] +``` + +### Options + +``` + -h, --help help for set +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.config/cu/config.yml) + --debug enable debug mode + -o, --output string output format (table|json|yaml|csv) (default "table") +``` + +### SEE ALSO + +* [cu field](cu_field.md) - Manage custom field values + +###### Auto generated by spf13/cobra on 22-Aug-2026 diff --git a/go.mod b/go.mod index 88aa89a..ac2f6fc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/manifoldco/promptui v0.9.0 github.com/raksul/go-clickup v0.0.0-20241002105938-60c057c125ff github.com/spf13/cobra v1.9.1 + github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/zalando/go-keyring v0.2.6 @@ -33,7 +34,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect diff --git a/internal/api/customfields.go b/internal/api/customfields.go new file mode 100644 index 0000000..f001556 --- /dev/null +++ b/internal/api/customfields.go @@ -0,0 +1,265 @@ +package api + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/raksul/go-clickup/clickup" +) + +// GetCustomFields returns the custom fields accessible from a list. Fields +// created at space or folder level are included, which is why the same field +// id comes back for every list beneath that location. +func (c *Client) GetCustomFields(ctx context.Context, listID string) ([]clickup.CustomField, error) { + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, err + } + + fields, _, err := c.client.CustomFields.GetAccessibleCustomFields(ctx, listID) + if err != nil { + return nil, c.handleError(err) + } + return fields, nil +} + +// SetCustomFieldValue writes a single custom field value on a task. The value +// map is the request body, e.g. {"value": "https://github.com/owner/repo"} — +// build it with CoerceCustomFieldValue rather than by hand. +func (c *Client) SetCustomFieldValue(ctx context.Context, taskID string, fieldID string, value map[string]interface{}) error { + if err := c.rateLimiter.Wait(ctx); err != nil { + return err + } + + if _, err := c.client.CustomFields.SetCustomFieldValue(ctx, taskID, fieldID, value, &clickup.CustomFieldOptions{}); err != nil { + return c.handleError(err) + } + return nil +} + +// RemoveCustomFieldValue clears a custom field value on a task. +func (c *Client) RemoveCustomFieldValue(ctx context.Context, taskID string, fieldID string) error { + if err := c.rateLimiter.Wait(ctx); err != nil { + return err + } + + if _, err := c.client.CustomFields.RemoveCustomFieldValue(ctx, taskID, fieldID, &clickup.CustomFieldOptions{}); err != nil { + return c.handleError(err) + } + return nil +} + +// FindCustomField resolves a field by id, or by name case-insensitively. +// An ambiguous name is an error rather than a silent first-match. +func FindCustomField(fields []clickup.CustomField, nameOrID string) (*clickup.CustomField, error) { + var matches []clickup.CustomField + for _, f := range fields { + if f.ID == nameOrID { + return &f, nil + } + if strings.EqualFold(f.Name, nameOrID) { + matches = append(matches, f) + } + } + + switch len(matches) { + case 0: + names := make([]string, 0, len(fields)) + for _, f := range fields { + names = append(names, f.Name) + } + return nil, fmt.Errorf("no custom field %q (available: %s)", nameOrID, strings.Join(names, ", ")) + case 1: + return &matches[0], nil + default: + return nil, fmt.Errorf("custom field name %q is ambiguous; use the field id", nameOrID) + } +} + +// fieldOptions extracts the {id, name|label} option list from a field's +// type_config, which the SDK models as an untyped interface{}. +func fieldOptions(f *clickup.CustomField) []map[string]interface{} { + cfg, ok := f.TypeConfig.(map[string]interface{}) + if !ok { + return nil + } + raw, ok := cfg["options"].([]interface{}) + if !ok { + return nil + } + + out := make([]map[string]interface{}, 0, len(raw)) + for _, o := range raw { + if m, ok := o.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +func optionLabel(o map[string]interface{}) string { + // Dropdowns use "name", labels use "label". + for _, k := range []string{"name", "label"} { + if s, ok := o[k].(string); ok && s != "" { + return s + } + } + return "" +} + +// resolveOption maps an option name (or id) to its id. +func resolveOption(f *clickup.CustomField, input string) (string, error) { + opts := fieldOptions(f) + labels := make([]string, 0, len(opts)) + + for _, o := range opts { + id, _ := o["id"].(string) + label := optionLabel(o) + labels = append(labels, label) + if id == input || strings.EqualFold(label, input) { + return id, nil + } + } + return "", fmt.Errorf("no option %q on field %q (available: %s)", input, f.Name, strings.Join(labels, ", ")) +} + +// CoerceCustomFieldValue turns a command-line string into the request body +// ClickUp expects for that field's type. Types differ enough — epoch +// milliseconds for dates, option uuids for labels — that passing a raw string +// through would silently write the wrong thing. +func CoerceCustomFieldValue(f *clickup.CustomField, raw string) (map[string]interface{}, error) { + switch f.Type { + case "url", "text", "short_text", "email", "phone", "location": + return map[string]interface{}{"value": raw}, nil + + case "number": + n, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, fmt.Errorf("field %q expects a number, got %q", f.Name, raw) + } + return map[string]interface{}{"value": n}, nil + + case "checkbox": + b, err := strconv.ParseBool(raw) + if err != nil { + return nil, fmt.Errorf("field %q expects true or false, got %q", f.Name, raw) + } + return map[string]interface{}{"value": b}, nil + + case "date": + ms, err := parseDateMillis(raw) + if err != nil { + return nil, fmt.Errorf("field %q: %w", f.Name, err) + } + return map[string]interface{}{"value": ms}, nil + + case "drop_down": + id, err := resolveOption(f, raw) + if err != nil { + return nil, err + } + return map[string]interface{}{"value": id}, nil + + case "labels": + // Labels are multi-select: accept a comma-separated list of names. + parts := strings.Split(raw, ",") + ids := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + id, err := resolveOption(f, p) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return map[string]interface{}{"value": ids}, nil + + default: + // Unknown or unsupported types (formula, rollup, progress…) are not + // guessed at — a wrong write is worse than a clear refusal. + return nil, fmt.Errorf("setting fields of type %q is not supported", f.Type) + } +} + +// parseDateMillis accepts epoch milliseconds, an ISO date, an RFC3339 +// timestamp, or "today"/"now". +func parseDateMillis(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + + switch strings.ToLower(raw) { + case "now": + return time.Now().UnixMilli(), nil + case "today": + y, m, d := time.Now().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.Local).UnixMilli(), nil + } + + if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { + return ms, nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t.UnixMilli(), nil + } + } + return 0, fmt.Errorf("cannot parse %q as a date (try 2006-01-02, an RFC3339 timestamp, epoch milliseconds, today, or now)", raw) +} + +// FormatCustomFieldValue renders a field's current value for display. +func FormatCustomFieldValue(f *clickup.CustomField) string { + if f.Value == nil { + return "" + } + + switch f.Type { + case "labels": + vals, ok := f.Value.([]interface{}) + if !ok { + return fmt.Sprintf("%v", f.Value) + } + out := make([]string, 0, len(vals)) + for _, v := range vals { + id, _ := v.(string) + label := id + for _, o := range fieldOptions(f) { + if oid, _ := o["id"].(string); oid == id { + if l := optionLabel(o); l != "" { + label = l + } + break + } + } + out = append(out, label) + } + return strings.Join(out, ", ") + + case "drop_down": + id := fmt.Sprintf("%v", f.Value) + for _, o := range fieldOptions(f) { + if oid, _ := o["id"].(string); oid == id { + if l := optionLabel(o); l != "" { + return l + } + } + } + return id + + case "date": + switch v := f.Value.(type) { + case string: + if ms, err := strconv.ParseInt(v, 10, 64); err == nil { + return time.UnixMilli(ms).Format("2006-01-02 15:04") + } + return v + case float64: + return time.UnixMilli(int64(v)).Format("2006-01-02 15:04") + } + } + + return fmt.Sprintf("%v", f.Value) +} diff --git a/internal/api/customfields_test.go b/internal/api/customfields_test.go new file mode 100644 index 0000000..8a34b81 --- /dev/null +++ b/internal/api/customfields_test.go @@ -0,0 +1,187 @@ +package api + +import ( + "testing" + "time" + + "github.com/raksul/go-clickup/clickup" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// labelsField mirrors the shape ClickUp returns: type_config is untyped, with +// options carrying "label" for labels fields and "name" for dropdowns. +func labelsField() *clickup.CustomField { + return &clickup.CustomField{ + ID: "a258ab77", + Name: "Machine", + Type: "labels", + TypeConfig: map[string]interface{}{ + "options": []interface{}{ + map[string]interface{}{"id": "opt-chi", "label": "Chilastra"}, + map[string]interface{}{"id": "opt-sun", "label": "Sunrunner"}, + map[string]interface{}{"id": "opt-abe", "label": "Aberama Gold"}, + }, + }, + } +} + +func dropdownField() *clickup.CustomField { + return &clickup.CustomField{ + ID: "dd-1", + Name: "Stage", + Type: "drop_down", + TypeConfig: map[string]interface{}{ + "options": []interface{}{ + map[string]interface{}{"id": "o1", "name": "Scoping"}, + map[string]interface{}{"id": "o2", "name": "Shipped"}, + }, + }, + } +} + +func TestFindCustomField(t *testing.T) { + fields := []clickup.CustomField{ + {ID: "f1", Name: "Repo", Type: "url"}, + {ID: "f2", Name: "Last synced", Type: "date"}, + } + + t.Run("by id", func(t *testing.T) { + f, err := FindCustomField(fields, "f2") + require.NoError(t, err) + assert.Equal(t, "Last synced", f.Name) + }) + + t.Run("by name, case-insensitive", func(t *testing.T) { + f, err := FindCustomField(fields, "rEpO") + require.NoError(t, err) + assert.Equal(t, "f1", f.ID) + }) + + t.Run("unknown name lists what is available", func(t *testing.T) { + _, err := FindCustomField(fields, "Nope") + require.Error(t, err) + assert.Contains(t, err.Error(), "Repo") + assert.Contains(t, err.Error(), "Last synced") + }) + + t.Run("ambiguous name is an error, not a first match", func(t *testing.T) { + dupes := []clickup.CustomField{ + {ID: "a", Name: "Owner", Type: "text"}, + {ID: "b", Name: "owner", Type: "text"}, + } + _, err := FindCustomField(dupes, "Owner") + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") + }) +} + +func TestCoerceCustomFieldValue(t *testing.T) { + t.Run("string types pass through", func(t *testing.T) { + for _, typ := range []string{"url", "text", "short_text", "email", "phone"} { + v, err := CoerceCustomFieldValue(&clickup.CustomField{Name: "F", Type: typ}, "hello") + require.NoError(t, err, typ) + assert.Equal(t, map[string]interface{}{"value": "hello"}, v) + } + }) + + t.Run("number", func(t *testing.T) { + v, err := CoerceCustomFieldValue(&clickup.CustomField{Name: "N", Type: "number"}, "42.5") + require.NoError(t, err) + assert.Equal(t, 42.5, v["value"]) + + _, err = CoerceCustomFieldValue(&clickup.CustomField{Name: "N", Type: "number"}, "abc") + assert.Error(t, err) + }) + + t.Run("checkbox", func(t *testing.T) { + v, err := CoerceCustomFieldValue(&clickup.CustomField{Name: "C", Type: "checkbox"}, "true") + require.NoError(t, err) + assert.Equal(t, true, v["value"]) + + _, err = CoerceCustomFieldValue(&clickup.CustomField{Name: "C", Type: "checkbox"}, "maybe") + assert.Error(t, err) + }) + + t.Run("date accepts several forms", func(t *testing.T) { + f := &clickup.CustomField{Name: "D", Type: "date"} + + v, err := CoerceCustomFieldValue(f, "1755835200000") + require.NoError(t, err) + assert.Equal(t, int64(1755835200000), v["value"]) + + v, err = CoerceCustomFieldValue(f, "2026-08-22") + require.NoError(t, err) + want := time.Date(2026, 8, 22, 0, 0, 0, 0, time.Local).UnixMilli() + assert.Equal(t, want, v["value"]) + + v, err = CoerceCustomFieldValue(f, "today") + require.NoError(t, err) + y, m, d := time.Now().Date() + assert.Equal(t, time.Date(y, m, d, 0, 0, 0, 0, time.Local).UnixMilli(), v["value"]) + + _, err = CoerceCustomFieldValue(f, "not-a-date") + assert.Error(t, err) + }) + + t.Run("dropdown resolves option name to id", func(t *testing.T) { + v, err := CoerceCustomFieldValue(dropdownField(), "shipped") + require.NoError(t, err) + assert.Equal(t, "o2", v["value"]) + + v, err = CoerceCustomFieldValue(dropdownField(), "o1") + require.NoError(t, err) + assert.Equal(t, "o1", v["value"]) + }) + + t.Run("labels resolve a comma-separated list", func(t *testing.T) { + v, err := CoerceCustomFieldValue(labelsField(), "Chilastra, Aberama Gold") + require.NoError(t, err) + assert.Equal(t, []string{"opt-chi", "opt-abe"}, v["value"]) + }) + + t.Run("unknown label option is rejected with the valid set", func(t *testing.T) { + _, err := CoerceCustomFieldValue(labelsField(), "Chilastra,Nope") + require.Error(t, err) + assert.Contains(t, err.Error(), "Sunrunner") + }) + + t.Run("unsupported types refuse rather than guess", func(t *testing.T) { + _, err := CoerceCustomFieldValue(&clickup.CustomField{Name: "P", Type: "manual_progress"}, "50") + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + }) +} + +func TestFormatCustomFieldValue(t *testing.T) { + t.Run("nil value renders empty", func(t *testing.T) { + assert.Equal(t, "", FormatCustomFieldValue(&clickup.CustomField{Type: "url"})) + }) + + t.Run("labels render as names", func(t *testing.T) { + f := labelsField() + f.Value = []interface{}{"opt-chi", "opt-sun"} + assert.Equal(t, "Chilastra, Sunrunner", FormatCustomFieldValue(f)) + }) + + t.Run("unknown label id falls back to the id", func(t *testing.T) { + f := labelsField() + f.Value = []interface{}{"opt-gone"} + assert.Equal(t, "opt-gone", FormatCustomFieldValue(f)) + }) + + t.Run("dropdown renders as name", func(t *testing.T) { + f := dropdownField() + f.Value = "o2" + assert.Equal(t, "Shipped", FormatCustomFieldValue(f)) + }) + + t.Run("date renders from epoch milliseconds", func(t *testing.T) { + ms := time.Date(2026, 8, 22, 9, 30, 0, 0, time.Local).UnixMilli() + f := &clickup.CustomField{Type: "date", Value: float64(ms)} + assert.Equal(t, "2026-08-22 09:30", FormatCustomFieldValue(f)) + + f = &clickup.CustomField{Type: "date", Value: "1755835200000"} + assert.Equal(t, time.UnixMilli(1755835200000).Format("2006-01-02 15:04"), FormatCustomFieldValue(f)) + }) +} diff --git a/internal/cmd/field.go b/internal/cmd/field.go new file mode 100644 index 0000000..d8c7ddd --- /dev/null +++ b/internal/cmd/field.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/raksul/go-clickup/clickup" + "github.com/spf13/cobra" + "github.com/timimsms/cu/internal/api" + "github.com/timimsms/cu/internal/config" + "github.com/timimsms/cu/internal/output" +) + +// fieldRow is the rendered shape of a custom field, shared by every output +// format so table, json, yaml and csv agree on the columns. +type fieldRow struct { + Name string `json:"name"` + Type string `json:"type"` + ID string `json:"id"` + Value string `json:"value"` +} + +func newFieldRow(f *clickup.CustomField) fieldRow { + return fieldRow{ + Name: f.Name, + Type: f.Type, + ID: f.ID, + Value: api.FormatCustomFieldValue(f), + } +} + +var fieldCmd = &cobra.Command{ + Use: "field", + Short: "Manage custom field values", + Long: `Read and write ClickUp custom fields. + +Fields are resolved by name (case-insensitive) or by id. Values are coerced to +the shape each field type expects — epoch milliseconds for dates, option ids +for dropdowns and labels — so names can be used on the command line.`, +} + +var fieldListCmd = &cobra.Command{ + Use: "list", + Short: "List custom fields available on a list", + RunE: func(cmd *cobra.Command, args []string) error { + listID, _ := cmd.Flags().GetString("list") + if listID == "" { + listID = config.GetString("default_list") + } + if listID == "" { + return fmt.Errorf("no list specified: pass --list or set a default with 'cu list default '") + } + + client, err := api.NewClient() + if err != nil { + return err + } + + fields, err := client.GetCustomFields(context.Background(), listID) + if err != nil { + return err + } + + rows := make([]fieldRow, 0, len(fields)) + for i := range fields { + rows = append(rows, newFieldRow(&fields[i])) + } + return output.Format(outputFormat, rows) + }, +} + +var fieldGetCmd = &cobra.Command{ + Use: "get [field]", + Short: "Show custom field values on a task", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := api.NewClient() + if err != nil { + return err + } + + task, err := client.GetTask(context.Background(), args[0]) + if err != nil { + return err + } + + fields := task.CustomFields + if len(args) == 2 { + f, err := api.FindCustomField(fields, args[1]) + if err != nil { + return err + } + fields = []clickup.CustomField{*f} + } + + rows := make([]fieldRow, 0, len(fields)) + for i := range fields { + rows = append(rows, newFieldRow(&fields[i])) + } + return output.Format(outputFormat, rows) + }, +} + +var fieldSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a custom field value on a task", + Long: `Set a custom field value on a task. + +The field is resolved against the fields accessible from the task's own list, +so space-level and folder-level fields work the same as list-level ones. + +Examples: + cu field set 86dxbeqyt Repo git@github.com:owner/repo.git + cu field set 86dxbeqyt "Last synced" today + cu field set 86dxbeqyt Machine "Chilastra,Sunrunner"`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + taskID, name, raw := args[0], args[1], args[2] + + client, err := api.NewClient() + if err != nil { + return err + } + ctx := context.Background() + + field, err := resolveTaskField(ctx, client, taskID, name) + if err != nil { + return err + } + + value, err := api.CoerceCustomFieldValue(field, raw) + if err != nil { + return err + } + + if err := client.SetCustomFieldValue(ctx, taskID, field.ID, value); err != nil { + return err + } + + fmt.Printf("Set %s on task %s\n", field.Name, taskID) + return nil + }, +} + +var fieldClearCmd = &cobra.Command{ + Use: "clear ", + Short: "Clear a custom field value on a task", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + taskID, name := args[0], args[1] + + client, err := api.NewClient() + if err != nil { + return err + } + ctx := context.Background() + + field, err := resolveTaskField(ctx, client, taskID, name) + if err != nil { + return err + } + + if err := client.RemoveCustomFieldValue(ctx, taskID, field.ID); err != nil { + return err + } + + fmt.Printf("Cleared %s on task %s\n", field.Name, taskID) + return nil + }, +} + +// resolveTaskField finds a field by name or id using the fields accessible +// from the task's own list, which is what makes space-level fields resolvable +// without the caller knowing where the field was defined. +func resolveTaskField(ctx context.Context, client *api.Client, taskID, nameOrID string) (*clickup.CustomField, error) { + task, err := client.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task.List.ID == "" { + return nil, fmt.Errorf("could not determine the list for task %s", taskID) + } + + fields, err := client.GetCustomFields(ctx, task.List.ID) + if err != nil { + return nil, err + } + return api.FindCustomField(fields, nameOrID) +} + +func init() { + fieldListCmd.Flags().StringP("list", "l", "", "List ID (defaults to the configured default list)") + + fieldCmd.AddCommand(fieldListCmd) + fieldCmd.AddCommand(fieldGetCmd) + fieldCmd.AddCommand(fieldSetCmd) + fieldCmd.AddCommand(fieldClearCmd) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3594f0e..0b07e47 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -75,6 +75,7 @@ func init() { rootCmd.AddCommand(interactiveCmd) rootCmd.AddCommand(bulkCmd) rootCmd.AddCommand(exportCmd) + rootCmd.AddCommand(fieldCmd) } func initConfig() { diff --git a/mkdocs.yml b/mkdocs.yml index 54a26b8..43b4b31 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,12 @@ nav: - Spaces: - cu space: commands/cu_space.md - cu space list: commands/cu_space_list.md + - Custom fields: + - cu field: commands/cu_field.md + - cu field list: commands/cu_field_list.md + - cu field get: commands/cu_field_get.md + - cu field set: commands/cu_field_set.md + - cu field clear: commands/cu_field_clear.md - Comments: - cu comment: commands/cu_comment.md - cu comment list: commands/cu_comment_list.md