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..7b5016c 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 -// 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 // 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..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" ) @@ -157,6 +160,106 @@ 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) + } + // 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")...) + 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) + } + } + } + }) + } +} + +// 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) { + _, _, 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..7eabfb1 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -266,10 +266,10 @@ nothing on success. Their errors are still JSON. 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 - 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 @@ -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`, `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 + 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..8f7bb42 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`, `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. - 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`, `trashed`, `creationDate`, or + `startBucket` on a `things projects` row, needs `--fields all`, or + `--fields` naming those keys. ## Listing @@ -355,8 +373,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`, -`startBucket`, `startDate` and `deadline`. 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/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..993c164 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -22,6 +22,26 @@ 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", "startBucket", "startDate", + "reminderTime", "deadline", "stopDate", "projectTitle", "projectTrashed", "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 +63,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 +73,10 @@ type UnknownFieldError struct { } func (e *UnknownFieldError) Error() string { + if len(e.Unknown) == 0 { + 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 { quoted[i] = fmt.Sprintf("%q", u) @@ -60,19 +85,26 @@ 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 // 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 +119,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..2ccffac 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) } } } @@ -244,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", "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 8a0a177..f93d141 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -42,12 +42,13 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `"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` (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. -- **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`, `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. **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 via --fields), # plus taskCount/openCount in JSON things areas things tags