From 4dc4152703563dacab448d0070afe80405edbc84 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:20:04 +0100 Subject: [PATCH 1/4] feat(output)!: print a default field set on -j listings, with --fields all for the full record Without --fields, a JSON task listing, search and projects now print the keys an agent needs to pick an item and act on it. Notes, the parent uuids, creationDate and Things' bookkeeping need --fields all or show. An empty --fields gets the unknown field payload with the valid names. BREAKING CHANGE: -j on listings no longer prints every key; pass --fields all for the previous output. --- README.md | 13 ++++--- cmd/things/fields.go | 10 ++++-- cmd/things/fields_test.go | 65 ++++++++++++++++++++++++++++++++++ cmd/things/list.go | 6 ++-- docs/content/agents.md | 15 +++++++- docs/content/commands.md | 26 +++++++++++--- internal/output/docs_test.go | 52 +++++++++++++++++++++++++++ internal/output/fields.go | 44 ++++++++++++++++++++--- internal/output/fields_test.go | 56 +++++++++++++++++++++++++++-- internal/skill/SKILL.md | 9 ++--- 10 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 internal/output/docs_test.go diff --git a/README.md b/README.md index 9281d58..5bfaeb0 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,14 @@ for structured JSON suitable for piping into `jq` or another tool. List commands assign each result a stable index (`1`, `2`, `3`, …) you can use in follow-up commands like `show`, `edit`, `complete`, and `cancel`. -`--fields` limits a JSON listing to the keys you name, in that order, which -keeps output small enough for an agent to read whole: -`things anytime -j --fields uuid,title,projectTitle,deadline`. It works on -the task listings, `search` and `projects`, and needs `--json`. An unknown -name fails with the list of valid ones. See the +A JSON listing prints a default set of keys per row, enough to pick an +item and act on its `uuid`; notes, the parent uuids and Things' own +bookkeeping are left out. `--fields` names the keys you want instead, in +that order: `things anytime -j --fields uuid,title,projectTitle,deadline`. +`--fields all` prints every key, as `--json` did by default in v0.10.0 and +earlier, and `things show -j` prints one item's full record. It +works on the task listings, `search` and `projects`, and needs `--json`. An +unknown name fails with the list of valid ones. See the [command reference](https://things.rlew.io/commands/). `--json` also implies non-interactive: the CLI never prompts, so a reference diff --git a/cmd/things/fields.go b/cmd/things/fields.go index e8a5d51..06ac584 100644 --- a/cmd/things/fields.go +++ b/cmd/things/fields.go @@ -7,18 +7,22 @@ import ( ) // fieldsHelp is the --fields help text, shared by every listing that takes it. -const fieldsHelp = "With --json, print only these comma-separated keys of each row, in this order, e.g. uuid,title,deadline. A row leaves out a key it would leave out of the full record. Names are the JSON keys as printed; an unknown one is rejected with the list of valid ones." +const fieldsHelp = "With --json, print only these comma-separated keys of each row, in this order, e.g. uuid,title,deadline, or 'all' for the full record. Without it, --json prints a default set that leaves out notes, the parent uuids and Things' bookkeeping. A row leaves out a key it would leave out of the full record. Names are the JSON keys as printed; an unknown one is rejected with the list of valid ones." // listingFields reads a listing's --fields into the keys to print, or nil -// for the full record. It runs before the database is opened: a flag the +// for the full record or plain output. A JSON listing without --fields +// prints defaults, the default set for its kind. It runs before the database is opened: a flag the // command cannot honour is a mistake in the invocation, not something to // report after the work is done. // // --fields only shapes JSON. Plain output has its own columns, and quietly // ignoring the flag there would leave a script believing it had asked for // something it got. -func listingFields(d *Deps, raw *string, kind string, valid []string) ([]string, error) { +func listingFields(d *Deps, raw *string, kind string, valid, defaults []string) ([]string, error) { if raw == nil { + if d.JSON { + return defaults, nil + } return nil, nil } if !d.JSON { diff --git a/cmd/things/fields_test.go b/cmd/things/fields_test.go index 5af8e8c..9ec4ea3 100644 --- a/cmd/things/fields_test.go +++ b/cmd/things/fields_test.go @@ -157,6 +157,71 @@ func TestRunFieldsCheckedBeforeDatabase(t *testing.T) { } } +// Without --fields a JSON listing prints its kind's default set, and +// --fields all the full record. +func TestRunListingDefaultFields(t *testing.T) { + cases := map[string]struct { + args []string + defaults []string + full []string + }{ + "bare default view": {[]string{"--json"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}}, + "named view": {[]string{"--json", "anytime"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}}, + "list with filter": {[]string{"-j", "list", "--project", "Chores"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}}, + "search": {[]string{"-j", "search", "milk"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}}, + "projects": {[]string{"-j", "projects"}, output.ProjectDefaultFields, []string{"startBucket"}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + stdout, _, err := runStreams(t, seedFullDB(t), c.args...) + if err != nil { + t.Fatalf("run: %v", err) + } + rows := fieldKeys(t, stdout) + if len(rows) == 0 { + t.Fatalf("no rows listed:\n%s", stdout) + } + for i, keys := range rows { + if !isSubsequence(keys, c.defaults) { + t.Errorf("row %d keys = %v, want a cut of %v", i, keys, c.defaults) + } + } + + stdout, _, err = runStreams(t, seedFullDB(t), append(c.args, "--fields", "all")...) + if err != nil { + t.Fatalf("run --fields all: %v", err) + } + for i, keys := range fieldKeys(t, stdout) { + for _, k := range c.full { + if !slices.Contains(keys, k) { + t.Errorf("--fields all row %d lacks %q: %v", i, k, keys) + } + } + } + }) + } +} + +// An empty list gets the unknown-field payload, with the valid names and none +// unknown, so an agent can read the names off it either way. +func TestRunFieldsEmptyListsValidNames(t *testing.T) { + _, _, err := runStreams(t, seedFullDB(t), "-j", "anytime", "--fields", " , ") + p := errorPayload(err) + if p.Error != "unknown field" || p.Kind != "task" || len(p.Unknown) != 0 || !slices.Equal(p.Valid, output.TaskFields) { + t.Errorf("payload = %+v", p) + } +} + +func TestRunFieldsAllTakesNoOtherNames(t *testing.T) { + stdout, _, err := runStreams(t, seedFullDB(t), "-j", "projects", "--fields", "all,uuid") + if err == nil || !strings.Contains(err.Error(), "full record") { + t.Errorf("err = %v, want all-with-names refusal", err) + } + if stdout != "" { + t.Errorf("stdout = %q, want nothing", stdout) + } +} + // show prints the full record; --fields is a listing flag only. func TestShowTakesNoFields(t *testing.T) { var cli CLI diff --git a/cmd/things/list.go b/cmd/things/list.go index 34b9553..d00d0fb 100644 --- a/cmd/things/list.go +++ b/cmd/things/list.go @@ -28,7 +28,7 @@ type ListCmd struct { } func (c *ListCmd) Run(kctx *kong.Context, d *Deps) error { - fields, err := listingFields(d, c.Fields, "task", output.TaskFields) + fields, err := listingFields(d, c.Fields, "task", output.TaskFields, output.TaskDefaultFields) if err != nil { return err } @@ -253,7 +253,7 @@ type ProjectsCmd struct { } func (c *ProjectsCmd) Run(d *Deps) error { - fields, err := listingFields(d, c.Fields, "project", output.ProjectFields) + fields, err := listingFields(d, c.Fields, "project", output.ProjectFields, output.ProjectDefaultFields) if err != nil { return err } @@ -347,7 +347,7 @@ type SearchCmd struct { } func (c *SearchCmd) Run(d *Deps) error { - fields, err := listingFields(d, c.Fields, "task", output.TaskFields) + fields, err := listingFields(d, c.Fields, "task", output.TaskFields, output.TaskDefaultFields) if err != nil { return err } diff --git a/docs/content/agents.md b/docs/content/agents.md index 273daa7..7d93a7f 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -284,6 +284,18 @@ nothing on success. Their errors are still JSON. `--when 2026-10-09@09:00` sets one, reports `reminderTime` as a local `"HH:MM"` clock time on its `startDate`. A task without one leaves the field out. +- **A listing prints a default set of keys.** Without `--fields`, a task + listing, `search` and `things projects` print the keys needed to pick an + item and act on it, and leave out `notes`, the parent uuids + (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate` and Things' + bookkeeping (`index`, `todayIndex`, `startBucket`, `trashed`, + `projectTrashed`). Read an item's notes with `things show --json`, + or pass `--fields all` for every key of every row. `--fields` also names + the keys you want, in order. **This changed:** in v0.10.0 and earlier + `--json` printed every key, so a script reading one of those keys off a + listing needs `--fields all`. The + [command reference](/commands/#choosing-fields-with---fields) lists the + default keys. ```console $ things show milk --json; echo "exit=$?" @@ -313,7 +325,8 @@ and `error` for everything else. `empty reference` is an empty or all-space `` argument, refused before any lookup. `unknown field` is a `--fields` list naming a key the listing's rows do not have, refused before anything is read: `unknown` lists the names it did not know and `valid` every name it -takes, so read `valid` and retry. `blank-title` is an `add`, `project +takes, so read `valid` and retry. An empty `--fields` gets the same token, +with `valid` and no `unknown`. `blank-title` is an `add`, `project add`, or an `edit` or `project edit` `--title`, whose title is empty or only whitespace: nothing is sent, as `import` refuses the same title with reason `blank-title`. diff --git a/docs/content/commands.md b/docs/content/commands.md index c178290..7b9681e 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -51,19 +51,31 @@ from a listing into an import item. ### Choosing fields with `--fields` -A JSON listing prints every field of every row, and notes alone can be -half of it. `--fields` cuts each row down to the keys you name, in the -order you name them: +A JSON listing prints a default set of keys for each row: the ones needed +to pick an item and act on it. Notes, the parent uuids and Things' own +bookkeeping are left out, since notes alone can be half of a full listing. +`--fields` names the keys you want instead, in the order you want them, and +`--fields all` prints every key: ```sh things anytime -a "Personal Projects" -j --fields uuid,title,projectTitle,deadline,tags things projects -j --fields uuid,title,openCount +things anytime -j --fields all +things show -j # one item's full record, notes and checklist included ``` It works on every task listing (the views, `list` with filters, and `search`) and on `things projects`. `things show` always prints the full record, and `areas` and `tags` rows are short enough not to need it. +- Without `--fields`, a task row carries these keys: `uuid`, `title`, + `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, + `stopDate`, `projectTitle`, `areaTitle`, `headingTitle`, `tags`, + `repeating`, `checklistProgress`. A project row from `things projects` + carries these keys: `uuid`, `title`, `status`, `start`, `startDate`, + `deadline`, `areaTitle`, `tags`, `taskCount`, `openCount`. +- `--fields all` prints the full record. `all` takes no other names: + `all,notes` fails. - The names are the JSON keys exactly as the full output prints them, case included: `projectTitle`, not `project`. There are no aliases. - A task listing takes the task keys: `uuid`, `title`, `notes`, `type`, @@ -80,9 +92,15 @@ record, and `areas` and `tags` rows are short enough not to need it. where it first appears. - An unknown name fails the whole command before anything is read, with the `unknown field` error: `unknown` lists the names it did not know and - `valid` lists every name it takes. + `valid` lists every name it takes. An empty `--fields ""` fails the same + way, with `valid` and no `unknown`. - `--fields` needs `--json`; without it the command fails rather than ignore the flag. Plain output is unchanged. +- In v0.10.0 and earlier, `--json` printed the full record, as + `--fields all` does now. A script that reads `notes`, + a parent uuid, `index`, `todayIndex`, `startBucket`, `trashed`, + `projectTrashed` or `creationDate` off a listing needs `--fields all`, or + `--fields` naming those keys. ## Listing diff --git a/internal/output/docs_test.go b/internal/output/docs_test.go new file mode 100644 index 0000000..2632cba --- /dev/null +++ b/internal/output/docs_test.go @@ -0,0 +1,52 @@ +package output + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// readPage reads a docs page with its whitespace runs folded to one space, so +// a list wrapped across lines matches as one line. +func readPage(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(filepath.FromSlash(path)) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.Join(strings.Fields(string(body)), " ") +} + +// codeList renders names as the pages write them: `a`, `b`, `c`, with last +// joining the final two ("" for a plain comma). +func codeList(names []string, last string) string { + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = "`" + n + "`" + } + if last == "" || len(quoted) < 2 { + return strings.Join(quoted, ", ") + } + return strings.Join(quoted[:len(quoted)-1], ", ") + " " + last + " " + quoted[len(quoted)-1] +} + +// The command reference and the skill spell out the keys --fields takes and +// the default sets, which drift silently from the struct tags and the +// variables above unless something checks them. +func TestDocsListFieldNames(t *testing.T) { + for _, c := range []struct { + path, want string + }{ + {"../../docs/content/commands.md", "A task listing takes the task keys: " + codeList(TaskFields, "") + "."}, + {"../../docs/content/commands.md", "`things projects` takes the project keys: " + codeList(ProjectFields, "") + "."}, + {"../../docs/content/commands.md", "a task row carries these keys: " + codeList(TaskDefaultFields, "") + "."}, + {"../../docs/content/commands.md", "A project row from `things projects` carries these keys: " + codeList(ProjectDefaultFields, "") + "."}, + {"../../internal/skill/SKILL.md", "Task rows carry " + codeList(TaskDefaultFields, "and") + ";"}, + {"../../internal/skill/SKILL.md", "`things projects` rows carry " + codeList(ProjectDefaultFields, "and") + " "}, + } { + if !strings.Contains(readPage(t, c.path), c.want) { + t.Errorf("%s does not say %q", c.path, c.want) + } + } +} diff --git a/internal/output/fields.go b/internal/output/fields.go index d1431d0..3dae064 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -22,6 +22,25 @@ var ( ProjectFields = jsonKeys(reflect.TypeFor[model.Project]()) ) +// TaskDefaultFields and ProjectDefaultFields are what a JSON listing prints +// when --fields is not given: the keys an agent picks an item by and acts on, +// in record order. Notes, the parent uuids and the app's own bookkeeping are +// left out; `--fields all` or `things show -j` has them. +var ( + TaskDefaultFields = []string{ + "uuid", "title", "type", "status", "start", "startDate", "reminderTime", + "deadline", "stopDate", "projectTitle", "areaTitle", "headingTitle", + "tags", "repeating", "checklistProgress", + } + ProjectDefaultFields = []string{ + "uuid", "title", "status", "start", "startDate", "deadline", "areaTitle", + "tags", "taskCount", "openCount", + } +) + +// AllFields is the --fields value that asks for the full record. +const AllFields = "all" + // jsonKeys lists the keys encoding/json writes for struct type t. The model // types have no embedded or untagged exported fields, so the tag name is // the whole rule. @@ -43,8 +62,9 @@ func jsonKeys(t reflect.Type) []string { return keys } -// UnknownFieldError is a --fields list naming a key the rows do not have. -// Kind is "task" or "project", the kind of row the listing prints. +// UnknownFieldError is a --fields list naming a key the rows do not have, +// or naming no key at all, when Unknown is empty. Kind is "task" or +// "project", the kind of row the listing prints. type UnknownFieldError struct { Kind string Unknown []string @@ -52,6 +72,9 @@ type UnknownFieldError struct { } func (e *UnknownFieldError) Error() string { + if len(e.Unknown) == 0 { + return fmt.Sprintf("--fields names no fields; valid fields: %s", strings.Join(e.Valid, ", ")) + } quoted := make([]string, len(e.Unknown)) for i, u := range e.Unknown { quoted[i] = fmt.Sprintf("%q", u) @@ -68,11 +91,18 @@ func (e *UnknownFieldError) Error() string { // valid. Each name is trimmed and a repeat is dropped, keeping the first, so // the result is the order the caller asked for. A name not in valid is an // *UnknownFieldError naming every such name; a list with no names at all is -// refused too, since printing empty objects is never what was meant. +// one too, with none unknown, since printing empty objects is never what was +// meant. AllFields on its own is nil, the full record; with other names it is +// refused, since it already names them. func ParseFields(raw, kind string, valid []string) ([]string, error) { var fields, unknown []string + all := false for name := range strings.SplitSeq(raw, ",") { name = strings.TrimSpace(name) + if name == AllFields { + all = true + continue + } if name == "" || slices.Contains(fields, name) || slices.Contains(unknown, name) { continue } @@ -87,8 +117,14 @@ func ParseFields(raw, kind string, valid []string) ([]string, error) { // TaskFields or ProjectFields it was given. return nil, &UnknownFieldError{Kind: kind, Unknown: unknown, Valid: slices.Clone(valid)} } + if all { + if len(fields) > 0 { + return nil, fmt.Errorf("--fields %s prints the full record, so it takes no other names", AllFields) + } + return nil, nil + } if len(fields) == 0 { - return nil, fmt.Errorf("--fields names no fields; valid fields: %s", strings.Join(valid, ", ")) + return nil, &UnknownFieldError{Kind: kind, Valid: slices.Clone(valid)} } return fields, nil } diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go index 9e0b962..c230400 100644 --- a/internal/output/fields_test.go +++ b/internal/output/fields_test.go @@ -124,10 +124,62 @@ func TestParseFieldsIsCaseSensitive(t *testing.T) { } } +// An empty list is refused with the valid names, as an unknown one is, so a +// caller can read them off the error either way. func TestParseFieldsRejectsEmpty(t *testing.T) { for _, raw := range []string{"", " ", ",", " , "} { - if _, err := ParseFields(raw, "task", TaskFields); err == nil { - t.Errorf("ParseFields(%q) accepted a list naming no fields", raw) + _, err := ParseFields(raw, "task", TaskFields) + var ufe *UnknownFieldError + if !errors.As(err, &ufe) { + t.Errorf("ParseFields(%q) err = %v, want *UnknownFieldError", raw, err) + continue + } + if len(ufe.Unknown) != 0 || !slices.Equal(ufe.Valid, TaskFields) { + t.Errorf("ParseFields(%q) Unknown/Valid = %v/%v", raw, ufe.Unknown, ufe.Valid) + } + if msg := err.Error(); !strings.Contains(msg, "names no fields") || !strings.Contains(msg, "projectTitle") { + t.Errorf("ParseFields(%q) message = %q", raw, msg) + } + } +} + +// all on its own is the full record, which a nil list means. +func TestParseFieldsAll(t *testing.T) { + for _, raw := range []string{"all", " all ", "all,all", "all,"} { + got, err := ParseFields(raw, "task", TaskFields) + if err != nil || got != nil { + t.Errorf("ParseFields(%q) = %v, %v; want nil, nil", raw, got, err) + } + } + for _, raw := range []string{"all,uuid", "title,all"} { + if _, err := ParseFields(raw, "task", TaskFields); err == nil || !strings.Contains(err.Error(), "full record") { + t.Errorf("ParseFields(%q) err = %v, want all-with-names refusal", raw, err) + } + } +} + +// Each default is a key the full record prints, given once and in record +// order, so the default output reads as a cut of the full one. all must not +// be a key, or --fields all would be ambiguous. +func TestDefaultFieldsAreValidKeys(t *testing.T) { + for _, c := range []struct { + kind string + defaults, valid []string + }{{"task", TaskDefaultFields, TaskFields}, {"project", ProjectDefaultFields, ProjectFields}} { + last := -1 + for _, name := range c.defaults { + i := slices.Index(c.valid, name) + if i < 0 { + t.Errorf("%s default %q is not a valid key", c.kind, name) + continue + } + if i <= last { + t.Errorf("%s default %q is repeated or out of record order", c.kind, name) + } + last = i + } + if slices.Contains(c.valid, AllFields) { + t.Errorf("%s rows have a key named %q", c.kind, AllFields) } } } diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 8a0a177..c8a7c20 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -37,17 +37,18 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `status` is a string enum — `"open"`, `"cancelled"`, `"completed"` — on tasks, projects and checklist items, not the raw Things integer. Filter with `jq '.[] | select(.status=="open")'`. - `type` is a string enum the same way — `"task"` or `"project"` — not the raw Things integer. It is on task rows only: `things projects` rows and checklist items carry no `type`. Headings are never returned by any command, so `"heading"` never appears. Filter with `jq '.[] | select(.type=="project")'`. **This changed:** in v0.7.0 and earlier `type` was the integer `0`, `1` or `2`, so a filter matching on `.type==1` needs updating. Do not copy this value into an `import` payload — that format is Things' own and spells it `"to-do"`, and neither the CLI nor Things will tell you the item was dropped. -- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. +- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it (in `show`, and in a listing under `--fields all`) is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. - **The CLI's word is `task`** — `type` on a row, `kind` in an error payload, and the prose throughout this skill. Things' own JSON URL scheme calls the same thing a `"to-do"`, and an `import` payload is the one place that word appears in anything the CLI emits, because the payload is passed to Things untouched. The `description` in this file's frontmatter is the exception that proves it: it lists the phrases a *user* might say, "to-do lists" among them, so the skill matches how people talk rather than how the CLI writes. - `"repeating": true` marks an item Things treats as repeating; the field is omitted otherwise. A project appearing as a row in a task listing carries `"type": "project"`. - A task with a reminder carries `reminderTime`, the local `"HH:MM"` clock time on its `startDate`, as `--when 2026-10-09@09:00` sets it. A task without one leaves it out. Plain output prints the time before the title on a listing row and on the `Start:` line of `things show`. - A task row with a checklist carries `checklistProgress` — `{"total": 5, "open": 3}`, every item and the ones still open, so the rest are completed or cancelled. A row without a checklist leaves it out. `things show ` lists the items themselves. -- `things projects` reports `start`, `startBucket`, `startDate` and `deadline` under the same names and encodings a task uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. +- `things projects` reports `start`, `startBucket` (under `--fields all`), `startDate` and `deadline` under the same names and encodings a task uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. - Project rows arrive in the same listings as tasks. Split them on `"type"` — `jq '.[] | select(.type=="project")'` for the projects, `.[] | select(.type=="task")` for the tasks. Plain output tags a project row `(project)`. Which views carry them, and why, is under `things list` below — that is the one statement of it. - `someday` is the deferred things not filed under a project: Someday projects and unparented Someday tasks. A task inside a project stays inside it however it is deferred, so it is not a `someday` row even when its project is in Someday too. Reach those with `things --project "Name"` — `things someday --project "Name"` is rejected, because no `someday` row has a parent project for it to match. - `logbook` is everything closed, not just everything finished: cancelled items sit beside completed ones, as they do in the app's Logbook. Split them on `"status"` — `"completed"` or `"cancelled"`; plain output prints `[x]` and `[~]`. Filter with `jq '.[] | select(.status=="completed")'` when you mean finished rather than closed. - `things projects` also reports `taskCount` and `openCount`. `taskCount` is every untrashed task in the project; `openCount` is the ones still open. The difference is the ones that are no longer open, which means completed or cancelled. Tasks filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed tasks or checklist items. Both numbers are Things' own bookkeeping, read straight from the database rather than recounted by the CLI. -- **Cut big listings with `--fields`.** `things anytime -a "Area" -j --fields uuid,title,projectTitle,deadline,tags` prints only those keys of each row, in that order; notes alone can be half a full listing. It works on the task listings, `search` and `projects` (not `show`, which is always the full record). Names are the JSON keys exactly as printed (`projectTitle`, not `project`); a row leaves out a key the full record would leave out. An unknown name fails before anything is read with `{"error": "unknown field", "kind": "task", "unknown": [...], "valid": [...]}`, so read `valid` and retry. It needs `--json`. +- **A `-j` listing prints a default set of keys, not the full record.** Task rows carry `uuid`, `title`, `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, `stopDate`, `projectTitle`, `areaTitle`, `headingTitle`, `tags`, `repeating` and `checklistProgress`; `things projects` rows carry `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount` and `openCount` (each left out when the row has no value, as before). `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate`, `startBucket`, `index`, `todayIndex`, `trashed` and `projectTrashed` are not in a listing unless you ask: read one item's notes with `things show -j`, or pass `--fields all` for every key of every row. **This changed:** before, `-j` printed every key. This applies to the task listings, `search` and `projects`; `show` is always the full record. +- **Pick keys with `--fields`.** `things anytime -a "Area" -j --fields uuid,title,projectTitle,deadline,tags` prints only those keys of each row, in that order; `--fields all` prints the full record and takes no other names. Names are the JSON keys exactly as printed (`projectTitle`, not `project`); a row leaves out a key the full record would leave out. An unknown name, or an empty list, fails before anything is read with `{"error": "unknown field", "kind": "task", "unknown": [...], "valid": [...]}` (no `unknown` for an empty list), so read `valid` and retry. It needs `--json`. - Human output is styled and column-aligned; colour auto-disables when piping or under `NO_COLOR`. `--color=always|never` overrides. JSON is unaffected. **A failure under `--json` prints one JSON object to stdout and exits non-zero.** Branch on the exit status and read the failure off stdout — not stderr. On success the read commands print their result there. Of the write commands, `add`, `project add`, `edit`, `project edit`, `complete` and `cancel` print the item (rule 3), `tag add` reports what it created and what it skipped, and the rest print nothing. `error` is a stable token; `message` is the human text. @@ -298,7 +299,7 @@ things projects [-a|--area A] [--completed] [--open-only] [--fields KEYS] # open projects plus those closed and not yet logged (as the app's list # still shows them; "status" tells them apart), unless --open-only; # --completed lists every closed project too. - # carries start/startBucket/startDate/deadline like a task, + # carries start/startDate/deadline like a task (startBucket under --fields all), # plus taskCount/openCount in JSON things areas things tags From 6ef597c8f822d3330054f18e802648f04371d94d Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:22:33 +0100 Subject: [PATCH 2/4] docs: say startBucket is in a listing only under --fields all --- docs/content/agents.md | 6 +++--- docs/content/commands.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/content/agents.md b/docs/content/agents.md index 7d93a7f..4df633c 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -262,12 +262,12 @@ nothing on success. Their errors are still JSON. for that deadline. In v0.7.0 and earlier this field was the integer `0`, `1` or `2`, so a filter matching on `.start==2` has to become `.start=="someday"`. `startBucket` beside it - is still an integer — `1` is the app's This Evening section, `0` is + (in `show`, and in a listing under `--fields all`) is still an integer — `1` is the app's This Evening section, `0` is everything else. `today` lists the evening items after all the others, as the app does. - **Projects carry scheduling too.** `things projects` reports `start`, - `startBucket`, `startDate` and `deadline` under the same names and - encodings a task uses, so a scheduled project reads the same way + `startDate` and `deadline` (and `startBucket` under `--fields all`) + under the same names and encodings a task uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. - **Projects carry their progress too.** `things projects` reports diff --git a/docs/content/commands.md b/docs/content/commands.md index 7b9681e..18b9da9 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -39,7 +39,7 @@ with. In v0.7.0 and earlier `type` and `start` were both integers, so a caller matching on `.type==1` has to become `.type=="project"`, and one matching on `.start==2` has to become `.start=="someday"`. `startBucket` alongside -them is still an integer: `1` is the app's This Evening section and `0` is +them (in `show`, and in a listing under `--fields all`) is still an integer: `1` is the app's This Evening section and `0` is everything else. Only the first of those has a name in Things' own vocabulary, so naming the pair would have meant inventing a word for `0`. @@ -144,7 +144,7 @@ was last placed in Today, most recent first, so items placed today come above those carried over from an earlier day, and then by the position Things keeps for the day. An item closed today stays where it was rather than moving to the end. Items scheduled for this evening (`--when evening`, `"startBucket": 1` in -JSON) come after every other item, arranged the same way among themselves, +`show --json` or a `--fields all` listing) come after every other item, arranged the same way among themselves, and plain output prints a `This Evening` header above them, as the app does. `upcoming` reads by date instead, the way the app's own Upcoming does. Like the app, it also lists an undated Anytime or Someday task or project @@ -373,7 +373,7 @@ cut down to 10. Piped output keeps every column whole. Projects are scheduled the same way tasks are, and `things projects -j` reports that with the same field names and encodings: `start`, -`startBucket`, `startDate` and `deadline`. A caller can tell a scheduled +`startDate`, `deadline`, and `startBucket` under `--fields all`. A caller can tell a scheduled project from an anytime one without a `things show` per project. `things projects -j` also reports two counts per project. `taskCount` is From fe7344d257262c7e8910592e7bc6287fdc75c045 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:27:44 +0100 Subject: [PATCH 3/4] fix(output): keep projectTrashed in default listings and name all in --fields errors --- cmd/things/fields.go | 8 ++++---- cmd/things/fields_test.go | 4 ++++ docs/content/agents.md | 21 ++++++++++----------- docs/content/commands.md | 21 ++++++++++++--------- internal/output/fields.go | 10 ++++++---- internal/output/fields_test.go | 27 +++++++++++++++++++++++++++ internal/skill/SKILL.md | 8 ++++---- 7 files changed, 67 insertions(+), 32 deletions(-) diff --git a/cmd/things/fields.go b/cmd/things/fields.go index 06ac584..7b5016c 100644 --- a/cmd/things/fields.go +++ b/cmd/things/fields.go @@ -10,10 +10,10 @@ import ( const fieldsHelp = "With --json, print only these comma-separated keys of each row, in this order, e.g. uuid,title,deadline, or 'all' for the full record. Without it, --json prints a default set that leaves out notes, the parent uuids and Things' bookkeeping. A row leaves out a key it would leave out of the full record. Names are the JSON keys as printed; an unknown one is rejected with the list of valid ones." // listingFields reads a listing's --fields into the keys to print, or nil -// for the full record or plain output. A JSON listing without --fields -// prints defaults, the default set for its kind. It runs before the database is opened: a flag the -// command cannot honour is a mistake in the invocation, not something to -// report after the work is done. +// for the full record or plain output. A JSON listing without --fields gets +// defaults, the default set for its kind. It runs before the database is +// opened: a flag the command cannot honour is a mistake in the invocation, +// not something to report after the work is done. // // --fields only shapes JSON. Plain output has its own columns, and quietly // ignoring the flag there would leave a script believing it had asked for diff --git a/cmd/things/fields_test.go b/cmd/things/fields_test.go index 9ec4ea3..d848164 100644 --- a/cmd/things/fields_test.go +++ b/cmd/things/fields_test.go @@ -185,6 +185,10 @@ func TestRunListingDefaultFields(t *testing.T) { if !isSubsequence(keys, c.defaults) { t.Errorf("row %d keys = %v, want a cut of %v", i, keys, c.defaults) } + // Every row has these, so a default set cut to nothing fails. + if !slices.Contains(keys, "uuid") || !slices.Contains(keys, "title") || !slices.Contains(keys, "status") { + t.Errorf("row %d keys = %v, want uuid, title and status", i, keys) + } } stdout, _, err = runStreams(t, seedFullDB(t), append(c.args, "--fields", "all")...) diff --git a/docs/content/agents.md b/docs/content/agents.md index 4df633c..b73017e 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -261,15 +261,15 @@ nothing on success. Their errors are still JSON. shape is in Today (and out of Someday), unless it was taken out of Today for that deadline. In v0.7.0 and earlier this field was the integer `0`, `1` or `2`, so a filter matching on `.start==2` has to - become `.start=="someday"`. `startBucket` beside it - (in `show`, and in a listing under `--fields all`) is still an integer — `1` is the app's This Evening section, `0` is - everything else. `today` lists the evening items after all the others, - as the app does. + become `.start=="someday"`. `startBucket` beside it (in `show`, and in + a listing whose `--fields` is `all` or names it) is still an integer — + `1` is the app's This Evening section, `0` is everything else. `today` + lists the evening items after all the others, as the app does. - **Projects carry scheduling too.** `things projects` reports `start`, - `startDate` and `deadline` (and `startBucket` under `--fields all`) - under the same names and encodings a task uses, so a scheduled project reads the same way - without a per-project `show`. `startDate` and `deadline` are omitted - when unset. + `startDate` and `deadline` (and `startBucket` when `--fields` is `all` + or names it) under the same names and encodings a task uses, so a + scheduled project reads the same way without a per-project `show`. + `startDate` and `deadline` are omitted when unset. - **Projects carry their progress too.** `things projects` reports `taskCount`, every untrashed task in the project, and `openCount`, the ones still open. The difference is the ones no longer open, which means @@ -288,9 +288,8 @@ nothing on success. Their errors are still JSON. listing, `search` and `things projects` print the keys needed to pick an item and act on it, and leave out `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate` and Things' - bookkeeping (`index`, `todayIndex`, `startBucket`, `trashed`, - `projectTrashed`). Read an item's notes with `things show --json`, - or pass `--fields all` for every key of every row. `--fields` also names + bookkeeping (`index`, `todayIndex`, `startBucket`, `trashed`). Read an + item's notes with `things show --json`, or pass `--fields all` for every key of every row. `--fields` also names the keys you want, in order. **This changed:** in v0.10.0 and earlier `--json` printed every key, so a script reading one of those keys off a listing needs `--fields all`. The diff --git a/docs/content/commands.md b/docs/content/commands.md index 18b9da9..ac87de4 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -39,7 +39,8 @@ with. In v0.7.0 and earlier `type` and `start` were both integers, so a caller matching on `.type==1` has to become `.type=="project"`, and one matching on `.start==2` has to become `.start=="someday"`. `startBucket` alongside -them (in `show`, and in a listing under `--fields all`) is still an integer: `1` is the app's This Evening section and `0` is +them (in `show`, and in a listing whose `--fields` is `all` or names it) is +still an integer: `1` is the app's This Evening section and `0` is everything else. Only the first of those has a name in Things' own vocabulary, so naming the pair would have meant inventing a word for `0`. @@ -70,8 +71,8 @@ record, and `areas` and `tags` rows are short enough not to need it. - Without `--fields`, a task row carries these keys: `uuid`, `title`, `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, - `stopDate`, `projectTitle`, `areaTitle`, `headingTitle`, `tags`, - `repeating`, `checklistProgress`. A project row from `things projects` + `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, + `headingTitle`, `tags`, `repeating`, `checklistProgress`. A project row from `things projects` carries these keys: `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount`, `openCount`. - `--fields all` prints the full record. `all` takes no other names: @@ -98,8 +99,8 @@ record, and `areas` and `tags` rows are short enough not to need it. ignore the flag. Plain output is unchanged. - In v0.10.0 and earlier, `--json` printed the full record, as `--fields all` does now. A script that reads `notes`, - a parent uuid, `index`, `todayIndex`, `startBucket`, `trashed`, - `projectTrashed` or `creationDate` off a listing needs `--fields all`, or + a parent uuid, `index`, `todayIndex`, `startBucket`, `trashed` + or `creationDate` off a listing needs `--fields all`, or `--fields` naming those keys. ## Listing @@ -144,8 +145,9 @@ was last placed in Today, most recent first, so items placed today come above those carried over from an earlier day, and then by the position Things keeps for the day. An item closed today stays where it was rather than moving to the end. Items scheduled for this evening (`--when evening`, `"startBucket": 1` in -`show --json` or a `--fields all` listing) come after every other item, arranged the same way among themselves, -and plain output prints a `This Evening` header above them, as the app does. +`show --json` or a `--fields all` listing) come after every other item, +arranged the same way among themselves, and plain output prints a +`This Evening` header above them, as the app does. `upcoming` reads by date instead, the way the app's own Upcoming does. Like the app, it also lists an undated Anytime or Someday task or project whose deadline is still to come, under the deadline's day, and @@ -373,8 +375,9 @@ cut down to 10. Piped output keeps every column whole. Projects are scheduled the same way tasks are, and `things projects -j` reports that with the same field names and encodings: `start`, -`startDate`, `deadline`, and `startBucket` under `--fields all`. A caller can tell a scheduled -project from an anytime one without a `things show` per project. +`startDate`, `deadline`, and `startBucket` when `--fields` is `all` or +names it. A caller can tell a scheduled project from an anytime one without +a `things show` per project. `things projects -j` also reports two counts per project. `taskCount` is every untrashed task in the project; `openCount` is the ones still open. diff --git a/internal/output/fields.go b/internal/output/fields.go index 3dae064..d1f1700 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -29,7 +29,8 @@ var ( var ( TaskDefaultFields = []string{ "uuid", "title", "type", "status", "start", "startDate", "reminderTime", - "deadline", "stopDate", "projectTitle", "areaTitle", "headingTitle", + "deadline", "stopDate", "projectTitle", "projectTrashed", "areaTitle", + "headingTitle", "tags", "repeating", "checklistProgress", } ProjectDefaultFields = []string{ @@ -73,7 +74,8 @@ type UnknownFieldError struct { func (e *UnknownFieldError) Error() string { if len(e.Unknown) == 0 { - return fmt.Sprintf("--fields names no fields; valid fields: %s", strings.Join(e.Valid, ", ")) + return fmt.Sprintf("--fields names no fields; valid fields: %s, or %s for the full record", + strings.Join(e.Valid, ", "), AllFields) } quoted := make([]string, len(e.Unknown)) for i, u := range e.Unknown { @@ -83,8 +85,8 @@ func (e *UnknownFieldError) Error() string { if len(e.Unknown) > 1 { noun = "fields" } - return fmt.Sprintf("--fields: unknown %s %s for a %s listing; valid fields: %s", - noun, strings.Join(quoted, ", "), e.Kind, strings.Join(e.Valid, ", ")) + return fmt.Sprintf("--fields: unknown %s %s for a %s listing; valid fields: %s, or %s for the full record", + noun, strings.Join(quoted, ", "), e.Kind, strings.Join(e.Valid, ", "), AllFields) } // ParseFields reads a --fields value: a comma-separated list of keys from diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go index c230400..70bdf9c 100644 --- a/internal/output/fields_test.go +++ b/internal/output/fields_test.go @@ -296,3 +296,30 @@ func mustTime(t *testing.T, s string) time.Time { } return v } + +// Every key a row can carry is either a default or one of the keys the docs +// list as left out, so a field added to the model is a decision about the +// default listing rather than a key that silently never appears in one. +func TestDefaultFieldsAccountForEveryKey(t *testing.T) { + for _, c := range []struct { + kind string + defaults, omitted, valid []string + }{ + {"task", TaskDefaultFields, []string{ + "notes", "startBucket", "creationDate", "trashed", "projectUUID", + "areaUUID", "headingUUID", "index", "todayIndex", + }, TaskFields}, + {"project", ProjectDefaultFields, []string{"startBucket", "areaUUID"}, ProjectFields}, + } { + for _, k := range c.valid { + if slices.Contains(c.defaults, k) == slices.Contains(c.omitted, k) { + t.Errorf("%s key %q must be in exactly one of the defaults and the left-out keys", c.kind, k) + } + } + for _, k := range c.omitted { + if !slices.Contains(c.valid, k) { + t.Errorf("%s left-out key %q is not a valid key", c.kind, k) + } + } + } +} diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index c8a7c20..74a8406 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -37,17 +37,17 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `status` is a string enum — `"open"`, `"cancelled"`, `"completed"` — on tasks, projects and checklist items, not the raw Things integer. Filter with `jq '.[] | select(.status=="open")'`. - `type` is a string enum the same way — `"task"` or `"project"` — not the raw Things integer. It is on task rows only: `things projects` rows and checklist items carry no `type`. Headings are never returned by any command, so `"heading"` never appears. Filter with `jq '.[] | select(.type=="project")'`. **This changed:** in v0.7.0 and earlier `type` was the integer `0`, `1` or `2`, so a filter matching on `.type==1` needs updating. Do not copy this value into an `import` payload — that format is Things' own and spells it `"to-do"`, and neither the CLI nor Things will tell you the item was dropped. -- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it (in `show`, and in a listing under `--fields all`) is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. +- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it (in `show`, and in a listing whose `--fields` is `all` or names it) is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. - **The CLI's word is `task`** — `type` on a row, `kind` in an error payload, and the prose throughout this skill. Things' own JSON URL scheme calls the same thing a `"to-do"`, and an `import` payload is the one place that word appears in anything the CLI emits, because the payload is passed to Things untouched. The `description` in this file's frontmatter is the exception that proves it: it lists the phrases a *user* might say, "to-do lists" among them, so the skill matches how people talk rather than how the CLI writes. - `"repeating": true` marks an item Things treats as repeating; the field is omitted otherwise. A project appearing as a row in a task listing carries `"type": "project"`. - A task with a reminder carries `reminderTime`, the local `"HH:MM"` clock time on its `startDate`, as `--when 2026-10-09@09:00` sets it. A task without one leaves it out. Plain output prints the time before the title on a listing row and on the `Start:` line of `things show`. - A task row with a checklist carries `checklistProgress` — `{"total": 5, "open": 3}`, every item and the ones still open, so the rest are completed or cancelled. A row without a checklist leaves it out. `things show ` lists the items themselves. -- `things projects` reports `start`, `startBucket` (under `--fields all`), `startDate` and `deadline` under the same names and encodings a task uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. +- `things projects` reports `start`, `startBucket` (when `--fields` is `all` or names it), `startDate` and `deadline` under the same names and encodings a task uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. - Project rows arrive in the same listings as tasks. Split them on `"type"` — `jq '.[] | select(.type=="project")'` for the projects, `.[] | select(.type=="task")` for the tasks. Plain output tags a project row `(project)`. Which views carry them, and why, is under `things list` below — that is the one statement of it. - `someday` is the deferred things not filed under a project: Someday projects and unparented Someday tasks. A task inside a project stays inside it however it is deferred, so it is not a `someday` row even when its project is in Someday too. Reach those with `things --project "Name"` — `things someday --project "Name"` is rejected, because no `someday` row has a parent project for it to match. - `logbook` is everything closed, not just everything finished: cancelled items sit beside completed ones, as they do in the app's Logbook. Split them on `"status"` — `"completed"` or `"cancelled"`; plain output prints `[x]` and `[~]`. Filter with `jq '.[] | select(.status=="completed")'` when you mean finished rather than closed. - `things projects` also reports `taskCount` and `openCount`. `taskCount` is every untrashed task in the project; `openCount` is the ones still open. The difference is the ones that are no longer open, which means completed or cancelled. Tasks filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed tasks or checklist items. Both numbers are Things' own bookkeeping, read straight from the database rather than recounted by the CLI. -- **A `-j` listing prints a default set of keys, not the full record.** Task rows carry `uuid`, `title`, `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, `stopDate`, `projectTitle`, `areaTitle`, `headingTitle`, `tags`, `repeating` and `checklistProgress`; `things projects` rows carry `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount` and `openCount` (each left out when the row has no value, as before). `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate`, `startBucket`, `index`, `todayIndex`, `trashed` and `projectTrashed` are not in a listing unless you ask: read one item's notes with `things show -j`, or pass `--fields all` for every key of every row. **This changed:** before, `-j` printed every key. This applies to the task listings, `search` and `projects`; `show` is always the full record. +- **A `-j` listing prints a default set of keys, not the full record.** Task rows carry `uuid`, `title`, `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, `headingTitle`, `tags`, `repeating` and `checklistProgress`; `things projects` rows carry `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount` and `openCount` (each left out when the row has no value, as before). `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate`, `startBucket`, `index`, `todayIndex` and `trashed` are not in a listing unless you ask: read one item's notes with `things show -j`, or pass `--fields all` for every key of every row. **This changed:** before, `-j` printed every key. This applies to the task listings, `search` and `projects`; `show` is always the full record. - **Pick keys with `--fields`.** `things anytime -a "Area" -j --fields uuid,title,projectTitle,deadline,tags` prints only those keys of each row, in that order; `--fields all` prints the full record and takes no other names. Names are the JSON keys exactly as printed (`projectTitle`, not `project`); a row leaves out a key the full record would leave out. An unknown name, or an empty list, fails before anything is read with `{"error": "unknown field", "kind": "task", "unknown": [...], "valid": [...]}` (no `unknown` for an empty list), so read `valid` and retry. It needs `--json`. - Human output is styled and column-aligned; colour auto-disables when piping or under `NO_COLOR`. `--color=always|never` overrides. JSON is unaffected. @@ -299,7 +299,7 @@ things projects [-a|--area A] [--completed] [--open-only] [--fields KEYS] # open projects plus those closed and not yet logged (as the app's list # still shows them; "status" tells them apart), unless --open-only; # --completed lists every closed project too. - # carries start/startDate/deadline like a task (startBucket under --fields all), + # carries start/startDate/deadline like a task (startBucket via --fields), # plus taskCount/openCount in JSON things areas things tags From 3ae0d4ba7e0d8ce66a1bf98ac1e4c7dbbc079f0f Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:32:02 +0100 Subject: [PATCH 4/4] feat(output): keep startBucket in default task listings so today -j shows This Evening --- cmd/things/fields_test.go | 34 ++++++++++++++++++++++++++++++++++ docs/content/agents.md | 15 ++++++++------- docs/content/commands.md | 20 +++++++++----------- internal/output/fields.go | 4 ++-- internal/output/fields_test.go | 2 +- internal/skill/SKILL.md | 4 ++-- 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/cmd/things/fields_test.go b/cmd/things/fields_test.go index d848164..83d1a82 100644 --- a/cmd/things/fields_test.go +++ b/cmd/things/fields_test.go @@ -9,6 +9,9 @@ import ( "github.com/alecthomas/kong" + "github.com/ryanlewis/things-cli/internal/db" + "github.com/ryanlewis/things-cli/internal/db/dbtest" + "github.com/ryanlewis/things-cli/internal/model" "github.com/ryanlewis/things-cli/internal/output" ) @@ -206,6 +209,37 @@ func TestRunListingDefaultFields(t *testing.T) { } } +// today -j tells the app's This Evening rows apart without --fields, as the +// app's own Today does. +func TestRunTodayDefaultCarriesStartBucket(t *testing.T) { + sqlDB := dbtest.NewSQL(t) + fx := dbtest.NewFixture(t, sqlDB) + today := int64(model.ThingsDateFromTime(testNow)) + fx.Todo("day", "Day task", 0, dbtest.AnytimeOn(today)) + fx.Todo("eve", "Evening task", 1, dbtest.Evening(today)) + stdout, _, err := runStreams(t, db.NewFromSQL(sqlDB), "-j", "today") + if err != nil { + t.Fatalf("run: %v", err) + } + var rows []struct { + UUID string `json:"uuid"` + StartBucket *int `json:"startBucket"` + } + if err := json.Unmarshal([]byte(stdout), &rows); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout) + } + got := map[string]int{} + for _, r := range rows { + if r.StartBucket == nil { + t.Fatalf("row %s has no startBucket:\n%s", r.UUID, stdout) + } + got[r.UUID] = *r.StartBucket + } + if len(got) != 2 || got["day"] != 0 || got["eve"] != 1 { + t.Errorf("startBucket by uuid = %v, want day 0 and eve 1", got) + } +} + // An empty list gets the unknown-field payload, with the valid names and none // unknown, so an agent can read the names off it either way. func TestRunFieldsEmptyListsValidNames(t *testing.T) { diff --git a/docs/content/agents.md b/docs/content/agents.md index b73017e..7eabfb1 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -261,10 +261,10 @@ nothing on success. Their errors are still JSON. shape is in Today (and out of Someday), unless it was taken out of Today for that deadline. In v0.7.0 and earlier this field was the integer `0`, `1` or `2`, so a filter matching on `.start==2` has to - become `.start=="someday"`. `startBucket` beside it (in `show`, and in - a listing whose `--fields` is `all` or names it) is still an integer — - `1` is the app's This Evening section, `0` is everything else. `today` - lists the evening items after all the others, as the app does. + become `.start=="someday"`. `startBucket` beside it + is still an integer — `1` is the app's This Evening section, `0` is + everything else. `today` lists the evening items after all the others, + as the app does. - **Projects carry scheduling too.** `things projects` reports `start`, `startDate` and `deadline` (and `startBucket` when `--fields` is `all` or names it) under the same names and encodings a task uses, so a @@ -288,9 +288,10 @@ nothing on success. Their errors are still JSON. listing, `search` and `things projects` print the keys needed to pick an item and act on it, and leave out `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate` and Things' - bookkeeping (`index`, `todayIndex`, `startBucket`, `trashed`). Read an - item's notes with `things show --json`, or pass `--fields all` for every key of every row. `--fields` also names - the keys you want, in order. **This changed:** in v0.10.0 and earlier + bookkeeping (`index`, `todayIndex`, `trashed`, and `startBucket` on a + `things projects` row). Read an item's notes with + `things show --json`, or pass `--fields all` for every key of + every row. `--fields` also names the keys you want, in order. **This changed:** in v0.10.0 and earlier `--json` printed every key, so a script reading one of those keys off a listing needs `--fields all`. The [command reference](/commands/#choosing-fields-with---fields) lists the diff --git a/docs/content/commands.md b/docs/content/commands.md index ac87de4..8f7bb42 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -39,8 +39,7 @@ with. In v0.7.0 and earlier `type` and `start` were both integers, so a caller matching on `.type==1` has to become `.type=="project"`, and one matching on `.start==2` has to become `.start=="someday"`. `startBucket` alongside -them (in `show`, and in a listing whose `--fields` is `all` or names it) is -still an integer: `1` is the app's This Evening section and `0` is +them is still an integer: `1` is the app's This Evening section and `0` is everything else. Only the first of those has a name in Things' own vocabulary, so naming the pair would have meant inventing a word for `0`. @@ -70,10 +69,10 @@ It works on every task listing (the views, `list` with filters, and record, and `areas` and `tags` rows are short enough not to need it. - Without `--fields`, a task row carries these keys: `uuid`, `title`, - `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, - `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, - `headingTitle`, `tags`, `repeating`, `checklistProgress`. A project row from `things projects` - carries these keys: `uuid`, `title`, `status`, `start`, `startDate`, + `type`, `status`, `start`, `startBucket`, `startDate`, `reminderTime`, + `deadline`, `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, + `headingTitle`, `tags`, `repeating`, `checklistProgress`. A project row + from `things projects` carries these keys: `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount`, `openCount`. - `--fields all` prints the full record. `all` takes no other names: `all,notes` fails. @@ -99,8 +98,8 @@ record, and `areas` and `tags` rows are short enough not to need it. ignore the flag. Plain output is unchanged. - In v0.10.0 and earlier, `--json` printed the full record, as `--fields all` does now. A script that reads `notes`, - a parent uuid, `index`, `todayIndex`, `startBucket`, `trashed` - or `creationDate` off a listing needs `--fields all`, or + a parent uuid, `index`, `todayIndex`, `trashed`, `creationDate`, or + `startBucket` on a `things projects` row, needs `--fields all`, or `--fields` naming those keys. ## Listing @@ -145,9 +144,8 @@ was last placed in Today, most recent first, so items placed today come above those carried over from an earlier day, and then by the position Things keeps for the day. An item closed today stays where it was rather than moving to the end. Items scheduled for this evening (`--when evening`, `"startBucket": 1` in -`show --json` or a `--fields all` listing) come after every other item, -arranged the same way among themselves, and plain output prints a -`This Evening` header above them, as the app does. +JSON) come after every other item, arranged the same way among themselves, +and plain output prints a `This Evening` header above them, as the app does. `upcoming` reads by date instead, the way the app's own Upcoming does. Like the app, it also lists an undated Anytime or Someday task or project whose deadline is still to come, under the deadline's day, and diff --git a/internal/output/fields.go b/internal/output/fields.go index d1f1700..993c164 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -28,8 +28,8 @@ var ( // left out; `--fields all` or `things show -j` has them. var ( TaskDefaultFields = []string{ - "uuid", "title", "type", "status", "start", "startDate", "reminderTime", - "deadline", "stopDate", "projectTitle", "projectTrashed", "areaTitle", + "uuid", "title", "type", "status", "start", "startBucket", "startDate", + "reminderTime", "deadline", "stopDate", "projectTitle", "projectTrashed", "areaTitle", "headingTitle", "tags", "repeating", "checklistProgress", } diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go index 70bdf9c..2ccffac 100644 --- a/internal/output/fields_test.go +++ b/internal/output/fields_test.go @@ -306,7 +306,7 @@ func TestDefaultFieldsAccountForEveryKey(t *testing.T) { defaults, omitted, valid []string }{ {"task", TaskDefaultFields, []string{ - "notes", "startBucket", "creationDate", "trashed", "projectUUID", + "notes", "creationDate", "trashed", "projectUUID", "areaUUID", "headingUUID", "index", "todayIndex", }, TaskFields}, {"project", ProjectDefaultFields, []string{"startBucket", "areaUUID"}, ProjectFields}, diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 74a8406..f93d141 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -37,7 +37,7 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `status` is a string enum — `"open"`, `"cancelled"`, `"completed"` — on tasks, projects and checklist items, not the raw Things integer. Filter with `jq '.[] | select(.status=="open")'`. - `type` is a string enum the same way — `"task"` or `"project"` — not the raw Things integer. It is on task rows only: `things projects` rows and checklist items carry no `type`. Headings are never returned by any command, so `"heading"` never appears. Filter with `jq '.[] | select(.type=="project")'`. **This changed:** in v0.7.0 and earlier `type` was the integer `0`, `1` or `2`, so a filter matching on `.type==1` needs updating. Do not copy this value into an `import` payload — that format is Things' own and spells it `"to-do"`, and neither the CLI nor Things will tell you the item was dropped. -- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it (in `show`, and in a listing whose `--fields` is `all` or names it) is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. +- `start` is a string enum the same way — `"inbox"`, `"anytime"` or `"someday"` — not the raw Things integer. It is the list an item falls back to when it carries no date, so it does not on its own say which list the app shows the item in: a dated `"anytime"` row is in Today, a `"someday"` row dated after today is in Upcoming, and only an undated one is in Someday. Just after midnight, Things can leave an open task scheduled for the new day in Someday until it next tidies up; the app already shows it as today's, so it reports `"anytime"`, as it will once Things moves it. An undated `"anytime"` task whose deadline is today or past is in Today too, and an undated `"inbox"` one is in Today and Anytime instead of the Inbox. It is on task and project rows. Filter with `jq '.[] | select(.start=="someday")'`. **This changed:** in v0.7.0 and earlier `start` was the integer `0`, `1` or `2`, so a filter matching on `.start==2` needs updating. `startBucket` beside it is still an integer — `1` is the app's This Evening section, `0` is everything else; `today` lists the evening rows after all the others, as the app does. - **The CLI's word is `task`** — `type` on a row, `kind` in an error payload, and the prose throughout this skill. Things' own JSON URL scheme calls the same thing a `"to-do"`, and an `import` payload is the one place that word appears in anything the CLI emits, because the payload is passed to Things untouched. The `description` in this file's frontmatter is the exception that proves it: it lists the phrases a *user* might say, "to-do lists" among them, so the skill matches how people talk rather than how the CLI writes. - `"repeating": true` marks an item Things treats as repeating; the field is omitted otherwise. A project appearing as a row in a task listing carries `"type": "project"`. - A task with a reminder carries `reminderTime`, the local `"HH:MM"` clock time on its `startDate`, as `--when 2026-10-09@09:00` sets it. A task without one leaves it out. Plain output prints the time before the title on a listing row and on the `Start:` line of `things show`. @@ -47,7 +47,7 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `someday` is the deferred things not filed under a project: Someday projects and unparented Someday tasks. A task inside a project stays inside it however it is deferred, so it is not a `someday` row even when its project is in Someday too. Reach those with `things --project "Name"` — `things someday --project "Name"` is rejected, because no `someday` row has a parent project for it to match. - `logbook` is everything closed, not just everything finished: cancelled items sit beside completed ones, as they do in the app's Logbook. Split them on `"status"` — `"completed"` or `"cancelled"`; plain output prints `[x]` and `[~]`. Filter with `jq '.[] | select(.status=="completed")'` when you mean finished rather than closed. - `things projects` also reports `taskCount` and `openCount`. `taskCount` is every untrashed task in the project; `openCount` is the ones still open. The difference is the ones that are no longer open, which means completed or cancelled. Tasks filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed tasks or checklist items. Both numbers are Things' own bookkeeping, read straight from the database rather than recounted by the CLI. -- **A `-j` listing prints a default set of keys, not the full record.** Task rows carry `uuid`, `title`, `type`, `status`, `start`, `startDate`, `reminderTime`, `deadline`, `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, `headingTitle`, `tags`, `repeating` and `checklistProgress`; `things projects` rows carry `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount` and `openCount` (each left out when the row has no value, as before). `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate`, `startBucket`, `index`, `todayIndex` and `trashed` are not in a listing unless you ask: read one item's notes with `things show -j`, or pass `--fields all` for every key of every row. **This changed:** before, `-j` printed every key. This applies to the task listings, `search` and `projects`; `show` is always the full record. +- **A `-j` listing prints a default set of keys, not the full record.** Task rows carry `uuid`, `title`, `type`, `status`, `start`, `startBucket`, `startDate`, `reminderTime`, `deadline`, `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`, `headingTitle`, `tags`, `repeating` and `checklistProgress`; `things projects` rows carry `uuid`, `title`, `status`, `start`, `startDate`, `deadline`, `areaTitle`, `tags`, `taskCount` and `openCount` (each left out when the row has no value, as before). `notes`, the parent uuids (`projectUUID`, `areaUUID`, `headingUUID`), `creationDate`, `index`, `todayIndex`, `trashed`, and `startBucket` on a `things projects` row, are not in a listing unless you ask: read one item's notes with `things show -j`, or pass `--fields all` for every key of every row. **This changed:** before, `-j` printed every key. This applies to the task listings, `search` and `projects`; `show` is always the full record. - **Pick keys with `--fields`.** `things anytime -a "Area" -j --fields uuid,title,projectTitle,deadline,tags` prints only those keys of each row, in that order; `--fields all` prints the full record and takes no other names. Names are the JSON keys exactly as printed (`projectTitle`, not `project`); a row leaves out a key the full record would leave out. An unknown name, or an empty list, fails before anything is read with `{"error": "unknown field", "kind": "task", "unknown": [...], "valid": [...]}` (no `unknown` for an empty list), so read `valid` and retry. It needs `--json`. - Human output is styled and column-aligned; colour auto-disables when piping or under `NO_COLOR`. `--color=always|never` overrides. JSON is unaffected.