Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,6 @@ example `--plugin tapper-dev`, to add optional plugins. `--scope` defaults to
- [Node Snapshots](node-snapshots.md)
- [Backups And Archives](backups-and-archives.md)
- [Query Expressions](query-expressions.md)
- [Output Formats](output-formats.md)
- [Troubleshooting](configuration/troubleshooting.md)
- [Architecture Overview](architecture/README.md)
152 changes: 152 additions & 0 deletions docs/output-formats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Output Formats

`tap list`, `tap grep`, `tap tags`, `tap links`, and `tap backlinks` all render
node listings through one `--format` template, and the MCP tools of the same
names accept the same string.

## Quick reference

```sh
tap list # id, updated, title (default)
tap list -f '%i %t' # id and title
tap list -f '%i\t%{type}\t%{status}' # metadata columns
tap list -f '%i\t%{tags}' # the tag list
tap list -f '%i\t%{.accessCount}' # a statistics field
tap list -f '100%%' # a literal percent
```

The default format is `"%i\t%d\t%t"`.

## Field selectors

Selectors share the vocabulary of [query expressions](query-expressions.md), so
one set of names covers both filtering and display. A selector appears in two
positions, and a bare word means something different in each:

- **Predicate position** (a query expression) — a bare word is a **tag**, because
a metadata key is always written there as `key=value`.
- **Field position** (a format template) — a bare word is a **metadata key**,
because there is no value to compare against and a tag is not a field.

Statistics fields carry a leading dot in both positions, so the one selector
that appears in both means the same thing in both.

| Selector | Resolves to | Cost |
| --- | --- | --- |
| `%{id}` | the node id | free |
| `%{title}` | the node title | free |
| `%{.updated}`, `%{.created}`, `%{.accessed}` | index timestamps, RFC3339 | free |
| `%{.hash}`, `%{.lead}`, `%{.accessCount}`, `%{.omega}` | statistics fields | one read per node |
| `%{tags}` | the node's tags, comma separated | one read per node |
| `%{anything-else}` | that metadata key | one read per node |

`id`, `title`, and `tags` are reserved. A node carrying metadata under one of
those keys cannot address it in field position; the intrinsic wins.

### Legacy verbs

The single-letter verbs remain supported as aliases. No new letters are added,
because a single letter cannot address an arbitrary metadata key.

| Verb | Equivalent |
| --- | --- |
| `%i` | `%{id}` |
| `%t` | `%{title}` |
| `%d` | `%{.updated}` |
| `%c` | `%{.created}` |
| `%a` | `%{.accessed}` |
| `%%` | a literal `%` |

An unrecognised `%X` passes through as literal text, so a format containing a
bare percent keeps working.

### Escapes

A shell does not expand `\t` inside double quotes, so `tap` interprets backslash
escapes itself. This is what makes the tab-separated default typeable at a
prompt:

```sh
tap list -f "%{id}\t%{type}\t%{title}" # real tabs
```

| Escape | Renders |
| --- | --- |
| `\t` | tab |
| `\n` | newline |
| `\r` | carriage return |
| `\\` | a literal backslash |

An unrecognised `\X` passes through untouched, the same rule as `%X`, so a
Windows-style path in a template survives. A real tab — from `$'...'` quoting or
a script — passes through unchanged.

## Per-keg defaults

A keg can declare the columns its listings should show, so a keg whose nodes are
distinguished by `type` and `subkind` displays them without every caller passing
`--format`:

```yaml
# keg
kegv: 2025-07
listFields: [id, type, subkind, title]
```

The same setting drives the node list in Tapper Hub, so a keg looks the same on
both surfaces. Resolution order is `--format` → `listFields` → the built-in
default. Entries use the selector vocabulary above and are validated when the
config is saved, so a typo is reported at that point rather than rendering a
silently blank column.

## Absent values

An absent value renders as the empty string rather than a placeholder, so a
tabular format keeps a stable column count no matter which nodes carry a key.
A sentinel such as `-` would be indistinguishable from a real value.

```sh
tap list -f '%i\t%{type}' | cut -f2 # stays column 2 for every node
```

These all render empty: a metadata key the node does not have, a metadata value
that is not a scalar (a list or a map), an empty tag list, a zero timestamp, and
an absent `omega`.

Two deliberate exceptions:

- **`%{.accessCount}` always renders an integer, including `0`.** `stats.json`
omits the key when the count is zero, so absent and zero are the same state on
disk and there is no absence to represent.
- **`%{.omega}` distinguishes absent from zero**, because omega genuinely is
tri-state. An unset omega renders empty; an omega of `0` renders `0`.

## Cost

Formats naming only `id`, `title`, the three dates, or the legacy verbs — which
includes the default — read nothing beyond the index that a listing already
loads.

Any other selector reads one file per node, for the nodes in the result window
only. Combine with `--limit` on a large keg. The same is true of the equivalent
query predicates: filtering on `entity=plan` or `.hash=` also reads per node.

## Notes

- **Spelling follows the query language, not the on-disk file.** The selector is
`%{.accessCount}`; the key inside `stats.json` is `access_count`. There is one
canonical vocabulary and it is the query language's.
- **`%{.created}` reads the index, not `stats.json`**, matching how `.created>…`
is evaluated in a query. If the index has drifted from `stats.json`, both show
the index value. Run `tap index rebuild` to reconcile.
- **Control characters in a rendered value collapse to single spaces.** Each
output line is one node, and a value such as `%{.lead}` can contain newlines.
Literal text in the template is untouched, so an explicit `\t` separator
survives.
- **Values are never re-scanned.** A node whose title contains `%c` renders it
literally.

## See also

- [Query Expressions](query-expressions.md) — the same vocabulary, in predicate
position.
31 changes: 27 additions & 4 deletions docs/query-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A query expression is built from **terms** combined with **operators**.

### Terms

A term is either a tag name or a key=value attribute predicate.
A term is a tag name, a metadata predicate, or a statistics-field predicate.

- **Tag**: a plain identifier that matches nodes carrying that tag.

Expand All @@ -26,14 +26,37 @@ A term is either a tag name or a key=value attribute predicate.
```

- **Attribute predicate**: `key=value` matches nodes whose `meta.yaml` contains
the given key with the given value.
the given key with the given value. `!=` negates.

```
entity=plan
status!=draft
```

Tags are resolved from the dex index (fast). Attribute predicates scan each
node's `meta.yaml` (slower on large kegs).
- **Statistics-field predicate**: a dot-prefixed field name, optionally compared
with `=`, `!=`, `<`, `<=`, `>`, or `>=`. With no operator it is a non-zero
existence check.

```
.created>2026-01-01
.accessCount>=5
.hash=abc123
.omega
```

The recognized fields are `updated`, `created`, `accessed`, `hash`,
`accessCount`, `lead`, and `omega`.

Tags are resolved from the dex index (fast), as are the `.updated`, `.created`,
and `.accessed` timestamps. Attribute predicates and the remaining statistics
fields scan each node's `meta.yaml` or `stats.json` (slower on large kegs).

### Bare words

A bare word means a **tag** here, because a metadata key is always written as
`key=value` in predicate position. The same vocabulary also names fields for
*display*, where a bare word instead names a metadata key — see
[Output Formats](output-formats.md).

### Operators

Expand Down
6 changes: 4 additions & 2 deletions pkg/cli/cmd_backlinks.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ func NewBacklinksCmd(deps *Deps) *cobra.Command {
Long: `List nodes that link to the given NODE_IDs. When multiple IDs are
provided, results are merged and deduplicated.

Format placeholders: %i (node id), %d (date), %t (title), %% (literal %).
Default format: "%i %d %t".`,
` + formatHelp + `

Default format: "%i\t%d\t%t".`,
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: nodeIDCompletionFunc(deps, 0),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -40,6 +41,7 @@ Default format: "%i %d %t".`,
cmd.Flags().IntVarP(&opts.Limit, "limit", "n", 0, "maximum number of results (0 for no limit)")
cmd.Flags().IntVar(&opts.Offset, "offset", 0, "skip the first N results")
cmd.Flags().StringVarP(&opts.Format, "format", "f", "", "output format")
registerFormatCompletion(cmd)

return cmd
}
10 changes: 8 additions & 2 deletions pkg/cli/cmd_grep.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ func NewGrepCmd(deps *Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "grep QUERY",
Short: "search node content by query",
Long: "Search node content with a regex and print matching lines grouped by node.",
Args: cobra.ExactArgs(1),
Long: `Search node content with a regex and print matching lines grouped by node.

With --format or --id-only, matching nodes are listed instead of their
matching lines.

` + formatHelp,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Query = args[0]
applyKegTargetProfile(deps, &opts.KegTargetOptions)
Expand All @@ -35,6 +40,7 @@ func NewGrepCmd(deps *Deps) *cobra.Command {
cmd.Flags().IntVarP(&opts.Limit, "limit", "n", 0, "maximum number of results (0 for no limit)")
cmd.Flags().IntVar(&opts.Offset, "offset", 0, "skip the first N results")
cmd.Flags().StringVarP(&opts.Format, "format", "f", "", "output format")
registerFormatCompletion(cmd)
cmd.Flags().BoolVarP(&opts.IgnoreCase, "ignore-case", "i", false, "perform case-insensitive matching")
cmd.Flags().IntVar(&opts.MaxLines, "max-lines", 0, "maximum matched lines per node (0 for unlimited)")

Expand Down
6 changes: 4 additions & 2 deletions pkg/cli/cmd_links.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ func NewLinksCmd(deps *Deps) *cobra.Command {
Long: `List nodes that the given NODE_IDs link to. When multiple IDs are
provided, results are merged and deduplicated.

Format placeholders: %i (node id), %d (date), %t (title), %% (literal %).
Default format: "%i %d %t".`,
` + formatHelp + `

Default format: "%i\t%d\t%t".`,
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: nodeIDCompletionFunc(deps, 0),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -40,6 +41,7 @@ Default format: "%i %d %t".`,
cmd.Flags().IntVarP(&opts.Limit, "limit", "n", 0, "maximum number of results (0 for no limit)")
cmd.Flags().IntVar(&opts.Offset, "offset", 0, "skip the first N results")
cmd.Flags().StringVarP(&opts.Format, "format", "f", "", "output format")
registerFormatCompletion(cmd)

return cmd
}
22 changes: 3 additions & 19 deletions pkg/cli/cmd_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package cli

import (
"fmt"
"strings"

"github.com/jlrickert/tapper/pkg/keg"
"github.com/jlrickert/tapper/pkg/tapper"
"github.com/spf13/cobra"
)
Expand All @@ -17,13 +15,7 @@ func NewListCmd(deps *Deps) *cobra.Command {
Short: "list all indexed nodes",
Long: `List indexed nodes for the resolved keg.

Format placeholders:
%i node id
%d updated date
%c created date
%a accessed date
%t title
%% literal percent
` + formatHelp + `

Default format: "%i\t%d\t%t".

Expand Down Expand Up @@ -67,16 +59,8 @@ Use --sort to order by "id", "updated", "created", or "accessed".`,
mustRegisterFlagCompletion(cmd, "sort", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"id", "updated", "created", "accessed"}, cobra.ShellCompDirectiveNoFileComp
})
mustRegisterFlagCompletion(cmd, "query", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if strings.HasPrefix(toComplete, ".") || toComplete == "" {
suggestions := make([]string, len(keg.StatsFieldNames))
for i, name := range keg.StatsFieldNames {
suggestions[i] = "." + name
}
return suggestions, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
})
registerQueryFieldCompletion(cmd)
registerFormatCompletion(cmd)

return cmd
}
Loading
Loading