enhance(cli): add example subcmd

This commit is contained in:
rcmerci
2026-03-27 23:17:02 +08:00
parent 88d5873176
commit 23daff7dbd
14 changed files with 747 additions and 27 deletions

View File

@@ -381,6 +381,117 @@
:cleanup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"]
:tags [:query]}
{:id "example-show-human"
:cmds ["{{cli}} --output human example show"]
:expect {:exit 0
:stdout-contains ["Selector: show"
"Matched commands:"
"show"
"Examples:"]}
:covers {:commands ["example show"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-upsert-page-json"
:cmds ["{{cli}} --output json example upsert page"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "upsert page"
[:data :matched-commands 0] "upsert page"}}
:covers {:commands ["example upsert page"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-upsert-prefix-json"
:cmds ["{{cli}} --output json example upsert"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "upsert"
[:data :matched-commands 0] "upsert block"}}
:covers {:commands ["example upsert"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-list-prefix-json"
:cmds ["{{cli}} --output json example list"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "list"
[:data :matched-commands 0] "list page"}}
:covers {:commands ["example list"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-list-page-json"
:cmds ["{{cli}} --output json example list page"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "list page"
[:data :matched-commands] ["list page"]}}
:covers {:commands ["example list page"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-query-prefix-json"
:cmds ["{{cli}} --output json example query"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "query"
[:data :matched-commands 0] "query"}}
:covers {:commands ["example query"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-query-list-json"
:cmds ["{{cli}} --output json example query list"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "query list"
[:data :matched-commands] ["query list"]}}
:covers {:commands ["example query list"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-remove-prefix-json"
:cmds ["{{cli}} --output json example remove"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "remove"
[:data :matched-commands 0] "remove block"}}
:covers {:commands ["example remove"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-remove-page-json"
:cmds ["{{cli}} --output json example remove page"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "remove page"
[:data :matched-commands] ["remove page"]}}
:covers {:commands ["example remove page"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-search-prefix-json"
:cmds ["{{cli}} --output json example search"]
:expect {:exit 0
:stdout-json-paths {[:status] "ok"
[:data :selector] "search"
[:data :matched-commands 0] "search block"}}
:covers {:commands ["example search"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "example-search-block-edn"
:cmds ["{{cli}} --output edn example search block"]
:expect {:exit 0
:stdout-edn-paths {[:status] :ok
[:data :selector] "search block"
[:data :matched-commands] ["search block"]}}
:covers {:commands ["example search block"]
:options {:global ["--output"]}}
:tags [:example]}
{:id "show-id-human"
:setup ["{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"
"{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page Home >/dev/null"

View File

@@ -96,6 +96,20 @@
"--level"
"stdin:--id"]}
:example
{:commands ["example list"
"example list page"
"example upsert"
"example upsert page"
"example remove"
"example remove page"
"example query"
"example query list"
"example search"
"example search block"
"example show"]
:options []}
:server
{:commands ["server list"
"server status"

View File

@@ -0,0 +1,224 @@
# Logseq CLI `example` Subcommand Implementation Plan
Goal: Add a new `example` command group so users can ask for runnable command examples by command path or command prefix, for example:
- `logseq example upsert page`
- `logseq example upsert`
- `logseq example show`
- `logseq example search block`
Phase 1 scope is limited to **all commands in the current `Graph Inspect and Edit` group**.
Architecture: Keep this feature CLI-only in phase 1. Reuse command metadata already defined in `logseq-cli` command entries (`:examples`) and do not add any new `db-worker-node` invoke methods.
Tech Stack: ClojureScript, `babashka.cli` dispatch table, existing `logseq.cli.command.*` entry model, existing formatter/completion/test stack.
Related:
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/core.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/list.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/upsert.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/remove.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/query.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/search.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/show.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/completion_generator.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/main/frontend/worker/db_worker_node.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs`
- `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/completion_generator_test.cljs`
- `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_inventory.edn`
- `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_cases.edn`
- `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md`
## Problem statement
Today users can see examples only via `--help` on each specific command entry.
That creates two UX gaps:
1. There is no dedicated way to request examples directly by command path.
2. There is no single command namespace for future automation/docs workflows that want examples on demand.
We need `example` as a first-class command group that resolves an existing command path and prints curated example lines.
## Current baseline
- Command registration is centralized in `src/main/logseq/cli/commands.cljs` with a single dispatch table built from `command/*` namespaces.
- Command help rendering already supports per-entry `:examples` metadata through `logseq.cli.command.core/command-summary`.
- `Graph Inspect and Edit` currently includes top-level commands:
- `list`
- `upsert`
- `remove`
- `query`
- `search`
- `show`
- `db-worker-node` is an HTTP invoke daemon with no concept of CLI examples; this feature does not require worker protocol changes.
## Phase 1 target coverage (Graph Inspect and Edit)
Phase 1 must support `logseq example <target...>` for all of these command paths:
| Group | Target command path |
| --- | --- |
| list | `list page`, `list tag`, `list property` |
| upsert | `upsert block`, `upsert page`, `upsert tag`, `upsert property` |
| remove | `remove block`, `remove page`, `remove tag`, `remove property` |
| query | `query`, `query list` |
| search | `search block`, `search page`, `search property`, `search tag` |
| show | `show` |
Additionally, phase 1 supports group-level prefix selectors for covered groups (`example list`, `example upsert`, `example remove`, `example query`, `example search`, `example show`).
## Target UX
### Main usage
```text
logseq example <command-or-prefix...>
```
Examples:
```text
logseq example upsert page
logseq example upsert
logseq example show
logseq example search block
```
Selector semantics:
- Exact command path: `example upsert page` returns examples only for `upsert page`.
- Prefix command path: `example upsert` returns merged examples for all covered `upsert *` subcommands in a stable order.
### Help behavior
- `logseq example` shows available phase-1 example selectors.
- `logseq example <target...> --help` shows command help for that example selector.
- `logseq example upsert --help` and `logseq example upsert page --help` are both valid.
### Output behavior
- Human output: clear text block with selected target and example lines.
- JSON/EDN output (**required**): include machine-readable fields, at minimum:
- `selector` (requested selector, e.g. `"upsert"` or `"upsert page"`)
- `matched-commands` (resolved command paths)
- `examples` (flattened example lines)
- `message` (human-readable summary string)
## Design
### 1) Create an `example` command namespace
Add a new namespace, e.g. `src/main/logseq/cli/command/example.cljs`, responsible for:
- declaring phase-1 target selector (Graph Inspect and Edit only)
- generating mirrored `example` entries from existing command paths
- validating requested target path
- building non-worker action payloads
- executing as pure local logic
### 2) Mirror both exact paths and prefix selectors
Use generated entries like:
- exact: `example upsert page`, `example show`, `example search block`
- prefix: `example upsert`, `example list`, `example remove`, `example query`, `example search`
This keeps behavior aligned with existing dispatch/help/completion generation while also supporting grouped output (`example upsert`) without introducing ad-hoc free-form parsing.
### 3) Reuse `:examples` metadata as source of truth
For each supported target command entry:
- pull `:examples` from the original entry metadata
- for exact selectors, return only that entry's examples
- for prefix selectors, aggregate examples from all matched covered subcommands
If any matched target has missing/empty examples, return a clear CLI error (new code such as `:missing-examples`) so metadata coverage remains enforceable.
### 4) Ensure metadata completeness for phase-1 targets
Add/normalize `:examples` metadata on phase-1 target entries where missing (notably `query list`).
### 5) Integrate into central command table
In `commands.cljs`:
- build a `base-table` from existing command namespaces
- generate exact-selector and prefix-selector `example` entries from that base table
- create final `table = base-table + example entries`
Then wire command handling for example commands in:
- parse/finalize branch (if needed)
- `build-action`
- `execute`
No server ensure/invoke is needed for `example` actions.
### 6) Update top-level help groups
In `command/core.cljs` top-level summary groups, include `example` under `Utilities` so the command is visible in `logseq --help`.
### 7) Completion support
Because example commands are mirrored table entries, existing completion generation should include them automatically once table wiring is updated.
## Testing plan (TDD)
1. Add failing parser/help tests in `src/test/logseq/cli/commands_test.cljs`:
- `example` group help appears
- exact selectors: `example upsert page`, `example show`, `example search block`
- prefix selectors: `example upsert`, `example list`, `example query`
- unknown/uncovered selector returns expected error
2. Add focused tests for new namespace (new test file):
- entry generation from base table
- phase-1 filtering only allows Graph Inspect and Edit targets
- missing examples metadata handling
3. Extend completion tests in `src/test/logseq/cli/completion_generator_test.cljs`:
- example group appears
- mirrored example command functions are generated
4. Add format assertions for required structured output:
- human output includes selector, matched commands, and example lines
- json/edn include `selector`, `matched-commands`, `examples`, and `message`
5. Extend CLI e2e inventory/spec:
- add `example` command scope and selectors
- add non-sync cases for representative exact and prefix selectors (`example show`, `example upsert page`, `example upsert`, `example search block`)
6. Run focused and full checks:
- `bb dev:test -v logseq.cli.commands-test`
- `bb dev:test -v logseq.cli.completion-generator-test`
- `bb -f cli-e2e/bb.edn test --skip-build`
- `bb dev:lint-and-test`
## File-by-file change map
| File | Change |
| --- | --- |
| `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/example.cljs` | New namespace: target filtering, mirrored entries, build/execute helpers. |
| `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/commands.cljs` | Build base table, append generated example entries, wire build/execute for example actions. |
| `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/core.cljs` | Add `example` to top-level help groups (Utilities). |
| `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/command/query.cljs` | Add missing `:examples` metadata for `query list` (if absent). |
| `/Users/rcmerci/gh-repos/logseq/src/main/logseq/cli/format.cljs` | Add explicit example formatting branch for human and structured (`json`/`edn`) output contract. |
| `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/commands_test.cljs` | Add parse/help/build/execute coverage for `example` commands. |
| `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/command/example_test.cljs` | New unit tests for entry generation and target validation. |
| `/Users/rcmerci/gh-repos/logseq/src/test/logseq/cli/completion_generator_test.cljs` | Assert completion output includes example command tree. |
| `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_inventory.edn` | Add `example` command inventory coverage. |
| `/Users/rcmerci/gh-repos/logseq/cli-e2e/spec/non_sync_cases.edn` | Add non-sync runtime cases for representative example commands. |
| `/Users/rcmerci/gh-repos/logseq/docs/cli/logseq-cli.md` | Document `example` command usage and phase-1 coverage. |
## db-worker-node impact
No `db-worker-node` API, transport, or thread-api changes are required in phase 1.
`example` is resolved and rendered entirely in CLI command metadata/action flow.
## Rollout / extension plan
- Phase 1 (this plan): only Graph Inspect and Edit coverage.
- Phase 2: extend target selector to include Graph Management, Authentication, and Utilities commands.
- Keep coverage policy explicit in tests so newly added CLI commands either:
- automatically receive `example` coverage, or
- are intentionally excluded with a documented reason.
## Open questions
1. For commands with sensitive placeholders, should we define a metadata convention to mark examples as redacted/non-runnable?

View File

@@ -110,8 +110,11 @@ Auth commands:
- `login` - authenticate this machine and create/update `~/logseq/auth.json`
- `logout` - remove persisted CLI auth from `~/logseq/auth.json`
Shell completion:
Shell completion and examples:
- `completion <zsh|bash>` - generate shell completion script to stdout
- `example <command-or-prefix...>` - show runnable command examples for a command path or command prefix (phase 1 covers Graph Inspect and Edit commands)
- exact selector example: `logseq example upsert page`
- prefix selector example: `logseq example upsert`
Setup for zsh (add to `~/.zshrc`):
```bash
@@ -240,6 +243,7 @@ Output formats:
- Output formatting is controlled via global `--output`, `:output-format` in config, or `LOGSEQ_CLI_OUTPUT`.
- Global `--profile` enables stage timing output to **stderr**. This is for debugging latency and does not change command stdout payloads.
- Human output is plain text. List/search commands render tables with a final `Count: N` line. For list and search subcommands, the ID column uses `:db/id` (not UUID). If `:db/ident` exists, an `IDENT` column is included. `list property` includes dedicated `TYPE` and `CARDINALITY` columns. Search table columns are `ID` and `TITLE`. Block titles can include multiple lines; multi-line rows align additional lines under the `TITLE` column. Times such as list `UPDATED-AT`/`CREATED-AT` and `graph info` `Created at` are shown in human-friendly relative form. Errors include error codes and may include a `Hint:` line. Use `--output json|edn` for structured output.
- `example` human output includes `Selector`, `Matched commands`, and `Examples` sections. Structured output (`json`/`edn`) includes `selector`, `matched-commands`, `examples`, and `message` fields under `data`.
- `sync download` progress lines are streamed to stdout only when progress is enabled. In `json`/`edn` mode, progress is disabled by default unless `--progress true` is provided.
- JSON machine output preserves namespaced keyword semantics:
- Namespaced keyword keys are emitted as canonical string keys in `namespace/name` form (for example `:block/title` -> `"block/title"`).

View File

@@ -115,10 +115,28 @@
{:title "Authentication"
:commands #{"login" "logout"}}
{:title "Utilities"
:commands #{"completion"}}]
render-group (fn [{:keys [title commands]}]
(let [entries (filter #(contains? commands (first (:cmds %))) table)]
(string/join "\n" [title (format-commands entries)])))]
:commands #{"completion" "example"}
:top-level-only? true
:desc-overrides {"example" "Show command examples"}}]
to-top-level-entries (fn [entries commands desc-overrides]
(->> commands
sort
(keep (fn [command]
(let [command-entries (filter #(= command (first (:cmds %))) entries)
leaf-entry (first (filter #(= 1 (count (:cmds %)))
command-entries))
desc (or (get desc-overrides command)
(:desc leaf-entry)
(:desc (first command-entries)))]
(when (seq command-entries)
{:cmds [command]
:desc desc}))))))
render-group (fn [{:keys [title commands top-level-only? desc-overrides]}]
(let [entries (filter #(contains? commands (first (:cmds %))) table)
entries* (if top-level-only?
(to-top-level-entries entries commands (or desc-overrides {}))
entries)]
(string/join "\n" [title (format-commands entries*)])))]
(string/join "\n"
["Usage: logseq <command> [options]"
""

View File

@@ -0,0 +1,132 @@
(ns logseq.cli.command.example
"Example command generation and execution."
(:require [clojure.string :as string]
[logseq.cli.command.core :as core]
[promesa.core :as p]))
(def ^:private phase1-groups
["list" "upsert" "remove" "query" "search" "show"])
(defn- command-path->label
[cmds]
(string/join " " cmds))
(defn- normalize-example-lines
[examples]
(->> (or examples [])
(keep (fn [example]
(let [line (some-> example str string/trim)]
(when (seq line)
line))))
vec))
(defn phase1-target-entries
[base-table]
(->> base-table
(filter (fn [entry]
(contains? (set phase1-groups)
(first (:cmds entry)))))
vec))
(defn- selector-definitions
[base-table]
(let [targets (phase1-target-entries base-table)
by-group (group-by (comp first :cmds) targets)
prefix-defs (->> phase1-groups
(keep (fn [group]
(when-let [matches (seq (get by-group group))]
{:selector [group]
:matches (vec matches)}))))
prefix-selector-set (set (map :selector prefix-defs))
exact-defs (->> targets
(mapv (fn [entry]
{:selector (:cmds entry)
:matches [entry]}))
(remove (fn [{:keys [selector]}]
(contains? prefix-selector-set selector))))]
(vec (concat prefix-defs exact-defs))))
(defn- selector-entry
[{:keys [selector matches]}]
(let [selector-label (command-path->label selector)
matched-labels (mapv (comp command-path->label :cmds) matches)
matched-count (count matched-labels)
command-cmds (into ["example"] selector)
examples (->> matches
(mapcat (comp normalize-example-lines :examples))
vec)
desc (if (> matched-count 1)
(str "Show examples for " selector-label " subcommands")
(str "Show examples for " selector-label))]
(core/command-entry command-cmds :example desc {}
{:examples examples
:long-desc (str "Show runnable command examples for selector `"
selector-label
"`." )})))
(defn build-example-entries
[base-table]
(->> (selector-definitions base-table)
(mapv selector-entry)))
(defn resolve-selector
[base-table selector-cmds]
(let [targets (phase1-target-entries base-table)
selector-cmds (vec selector-cmds)
prefix? (= 1 (count selector-cmds))
matches (if prefix?
(filterv #(= (first selector-cmds)
(first (:cmds %)))
targets)
(filterv #(= selector-cmds (:cmds %))
targets))
missing-example-commands (->> matches
(filter #(empty? (normalize-example-lines (:examples %))))
(mapv (comp command-path->label :cmds)))
matched-commands (mapv (comp command-path->label :cmds) matches)
examples (->> matches
(mapcat (comp normalize-example-lines :examples))
vec)
selector (command-path->label selector-cmds)]
{:selector selector
:matched-commands matched-commands
:examples examples
:missing-example-commands missing-example-commands}))
(defn build-action
[base-table cmds]
(let [selector-cmds (vec (rest (or cmds [])))
{:keys [selector matched-commands examples missing-example-commands]}
(resolve-selector base-table selector-cmds)]
(cond
(empty? selector-cmds)
{:ok? false
:error {:code :missing-example-selector
:message "example selector is required"}}
(empty? matched-commands)
{:ok? false
:error {:code :unknown-command
:message (str "unknown example selector: " selector)}}
(seq missing-example-commands)
{:ok? false
:error {:code :missing-examples
:message (str "missing examples metadata for: "
(string/join ", " missing-example-commands))}}
:else
{:ok? true
:action {:type :example
:selector selector
:matched-commands matched-commands
:examples examples
:message (str "Found "
(count examples)
" examples for selector "
selector)}})))
(defn execute-example
[action _config]
(p/resolved {:status :ok
:data (select-keys action [:selector :matched-commands :examples :message])}))

View File

@@ -20,7 +20,8 @@
[(core/command-entry ["query"] :query "Run a Datascript query" query-spec
{:examples ["logseq query --graph my-graph --name block-search --inputs '[\"daily\"]'"
"logseq query --graph my-graph --query '[:find [?e ...] :where [?e :block/name]]'"]})
(core/command-entry ["query" "list"] :query-list "List available queries" query-list-spec)])
(core/command-entry ["query" "list"] :query-list "List available queries" query-list-spec
{:examples ["logseq query list --graph my-graph"]})])
(def ^:private built-in-query-specs
{"block-search"

View File

@@ -6,6 +6,7 @@
[logseq.cli.command.completion :as completion-command]
[logseq.cli.command.core :as command-core]
[logseq.cli.command.doctor :as doctor-command]
[logseq.cli.command.example :as example-command]
[logseq.cli.command.graph :as graph-command]
[logseq.cli.command.list :as list-command]
[logseq.cli.command.query :as query-command]
@@ -105,7 +106,7 @@
;; Command-specific validation and entries are in subcommand namespaces.
(def ^:private table
(def ^:private base-table
(vec (concat graph-command/entries
server-command/entries
list-command/entries
@@ -119,6 +120,10 @@
auth-command/entries
completion-command/entries)))
(def ^:private table
(vec (concat base-table
(example-command/build-example-entries base-table))))
;; Global option parsing lives in logseq.cli.command.core.
(defn- index-of
@@ -178,7 +183,8 @@
cmd-summary (command-core/command-summary {:cmds cmds
:spec spec
:long-desc long-desc
:examples examples})
:examples (when (= command :example)
examples)})
graph (:graph opts)
has-args? (seq args)
has-content? (or (seq (:content opts))
@@ -293,7 +299,8 @@
"missing shell argument; usage: logseq completion <zsh|bash>")))
:else
(command-core/ok-result command opts args summary))))
(cond-> (command-core/ok-result command opts args summary)
(= command :example) (assoc :cmds cmds)))))
;; CLI error handling is in logseq.cli.command.core.
@@ -404,7 +411,7 @@
[parsed config]
(if-not (:ok? parsed)
parsed
(let [{:keys [command options args]} parsed
(let [{:keys [command options args cmds]} parsed
graph (command-core/pick-graph options args config)
repo (command-core/resolve-repo graph)
server-repo (command-core/resolve-repo (:graph options))]
@@ -470,6 +477,9 @@
:action {:type :completion
:shell (or (:shell options) (first args))}}
:example
(example-command/build-action base-table cmds)
{:ok? false
:error {:code :unknown-command
:message (str "unknown command: " command)}}))))
@@ -519,6 +529,7 @@
{:status :ok
:data {:message (completion-gen/generate-completions
(:shell action) table)}})
:example (example-command/execute-example action config)
:server-list (server-command/execute-list action config)
:server-status (server-command/execute-status action config)
:server-start (server-command/execute-start action config)

View File

@@ -408,6 +408,25 @@
(or doc "-")])
(or queries []))))
(defn- format-example
[{:keys [selector matched-commands examples message]}]
(let [selector (or selector "-")
matched-commands (vec (or matched-commands []))
examples (vec (or examples []))
matched-lines (if (seq matched-commands)
(mapv #(str " - " %) matched-commands)
[" - (none)"])
example-lines (if (seq examples)
(mapv #(str " - " %) examples)
[" - (none)"])]
(string/join "\n"
(concat (when (seq message) [message ""])
[(str "Selector: " selector)
"Matched commands:"]
matched-lines
["Examples:"]
example-lines))))
(declare kv-key->string
graph-info-human-max-string-length
graph-info-truncated-suffix)
@@ -724,6 +743,7 @@
:graph-import (format-graph-import context data)
:query (format-query-results (:result data))
:query-list (format-query-list (:queries data))
:example (format-example data)
:show (or (:message data) (pr-str data))
:doctor (format-doctor (:status data) (:checks data))
(if (and (map? data) (contains? data :message))

View File

@@ -0,0 +1,82 @@
(ns logseq.cli.command.example-test
(:require [cljs.test :refer [deftest is testing]]
[logseq.cli.command.example :as example-command]
[logseq.cli.command.graph :as graph-command]
[logseq.cli.command.list :as list-command]
[logseq.cli.command.query :as query-command]
[logseq.cli.command.remove :as remove-command]
[logseq.cli.command.search :as search-command]
[logseq.cli.command.show :as show-command]
[logseq.cli.command.upsert :as upsert-command]))
(def ^:private phase1-base-table
(vec (concat graph-command/entries
list-command/entries
upsert-command/entries
remove-command/entries
query-command/entries
search-command/entries
show-command/entries)))
(deftest test-phase1-target-filter
(let [targets (example-command/phase1-target-entries phase1-base-table)
groups (set (map (comp first :cmds) targets))]
(testing "phase1 includes inspect/edit groups"
(is (contains? groups "list"))
(is (contains? groups "upsert"))
(is (contains? groups "remove"))
(is (contains? groups "query"))
(is (contains? groups "search"))
(is (contains? groups "show")))
(testing "phase1 excludes graph management commands"
(is (not (contains? groups "graph"))))))
(deftest test-build-example-entries
(let [entries (example-command/build-example-entries phase1-base-table)
cmds-set (set (map :cmds entries))]
(testing "builds prefix selectors"
(is (contains? cmds-set ["example" "upsert"]))
(is (contains? cmds-set ["example" "query"]))
(is (contains? cmds-set ["example" "show"])))
(testing "builds exact selectors"
(is (contains? cmds-set ["example" "upsert" "page"]))
(is (contains? cmds-set ["example" "search" "block"]))
(is (contains? cmds-set ["example" "query" "list"])))
(testing "does not build uncovered selectors"
(is (not (contains? cmds-set ["example" "graph"])))))
(testing "all generated entries use :example command keyword"
(is (every? #(= :example (:command %))
(example-command/build-example-entries phase1-base-table)))))
(deftest test-build-action
(testing "builds exact selector action"
(let [result (example-command/build-action phase1-base-table ["example" "upsert" "page"])]
(is (true? (:ok? result)))
(is (= :example (get-in result [:action :type])))
(is (= "upsert page" (get-in result [:action :selector])))
(is (= ["upsert page"] (get-in result [:action :matched-commands])))
(is (seq (get-in result [:action :examples])))))
(testing "builds prefix selector action"
(let [result (example-command/build-action phase1-base-table ["example" "upsert"])]
(is (true? (:ok? result)))
(is (= "upsert" (get-in result [:action :selector])))
(is (<= 2 (count (get-in result [:action :matched-commands]))))))
(testing "rejects unknown selector"
(let [result (example-command/build-action phase1-base-table ["example" "graph"])]
(is (false? (:ok? result)))
(is (= :unknown-command (get-in result [:error :code])))))
(testing "rejects matched commands with missing examples metadata"
(let [mock-base-table [{:cmds ["upsert" "page"]
:examples ["logseq upsert page --graph my-graph --page Home"]}
{:cmds ["upsert" "tag"]
:examples []}]
result (example-command/build-action mock-base-table ["example" "upsert"])]
(is (false? (:ok? result)))
(is (= :missing-examples (get-in result [:error :code]))))))

View File

@@ -3,6 +3,14 @@
[clojure.string :as string]
[logseq.cli.command.query :as query-command]))
(deftest test-query-entries-examples
(let [query-entry (first (filter #(= :query (:command %)) query-command/entries))
query-list-entry (first (filter #(= :query-list (:command %)) query-command/entries))]
(testing "query command has examples metadata"
(is (seq (:examples query-entry))))
(testing "query list command has examples metadata"
(is (seq (:examples query-list-entry))))))
(deftest test-build-action-parses-query
(testing "query parses query and inputs"
(let [result (query-command/build-action {:query "[:find ?e :in $ ?title :where [?e :block/title ?title]]"

View File

@@ -78,6 +78,8 @@
(is (string/includes? plain-summary "sync"))
(is (string/includes? plain-summary "login"))
(is (string/includes? plain-summary "logout"))
(is (string/includes? plain-summary "example"))
(is (not (string/includes? plain-summary "example upsert")))
(is (string/includes? plain-summary "Path to db-worker data dir (default ~/logseq/graphs)"))
(is (contains-bold? summary "list page"))
(is (contains-bold? summary "list tag"))
@@ -106,6 +108,8 @@
(is (contains-bold? summary "sync start"))
(is (contains-bold? summary "login"))
(is (contains-bold? summary "logout"))
(is (contains-bold? summary "example"))
(is (not (contains-bold? summary "example upsert")))
(is (contains-bold? summary "--help"))
(is (contains-bold? summary "--graph"))
(is (re-find #"\u001b\[[0-9;]*mCommands\u001b\[[0-9;]*m:" summary))
@@ -204,6 +208,18 @@
(is (contains-bold? summary "search property"))
(is (contains-bold? summary "search tag"))))
(testing "example group shows selectors"
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["example"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? plain-summary "example upsert"))
(is (string/includes? plain-summary "example upsert page"))
(is (string/includes? plain-summary "example show"))
(is (contains-bold? summary "example upsert"))
(is (contains-bold? summary "example show"))))
(testing "group help command list omits [options]"
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["list"])))
@@ -212,7 +228,7 @@
(is (every? #(not (string/includes? % "[options]")) lines)))))
(deftest test-parse-args-help-command-examples
(testing "remove block command shows help"
(testing "remove block command help no longer shows examples"
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["remove" "block" "--help"]))
summary (:summary result)
@@ -220,22 +236,20 @@
(is (true? (:help? result)))
(is (string/includes? plain-summary "Usage: logseq remove block"))
(is (string/includes? plain-summary "Command options:"))
(is (string/includes? plain-summary "Examples:"))
(is (string/includes? plain-summary "logseq remove block --graph my-graph --id 123"))
(is (not (string/includes? plain-summary "Examples:")))
(is (contains-bold? summary "--id"))
(is (contains-bold? summary "--uuid"))))
(testing "sync config set help limits examples to five lines"
(testing "sync config set command help no longer shows examples"
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["sync" "config" "set" "--help"]))
plain-summary (strip-ansi (:summary result))]
(is (true? (:help? result)))
(is (string/includes? plain-summary "Examples:"))
(is (string/includes? plain-summary "logseq sync config set ws-url wss://sync.logseq.com"))
(is (string/includes? plain-summary "logseq sync config set http-base http://localhost:8080"))
(is (not (string/includes? plain-summary "logseq sync config set ws-url wss://example.com/socket")))))
(is (not (string/includes? plain-summary "Examples:")))
(is (not (string/includes? plain-summary "logseq sync config set ws-url wss://sync.logseq.com")))
(is (not (string/includes? plain-summary "logseq sync config set http-base http://localhost:8080")))))
(testing "upsert block command shows help"
(testing "upsert block command help no longer shows examples"
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["upsert" "block" "--help"]))
summary (:summary result)
@@ -243,7 +257,7 @@
(is (true? (:help? result)))
(is (string/includes? plain-summary "Usage: logseq upsert block"))
(is (string/includes? plain-summary "Command options:"))
(is (string/includes? plain-summary "Examples:"))
(is (not (string/includes? plain-summary "Examples:")))
(is (contains-bold? summary "--id"))
(is (contains-bold? summary "--uuid"))
(is (contains-bold? summary "--content"))
@@ -252,11 +266,20 @@
(is (contains-bold? summary "--update-tags"))
(is (contains-bold? summary "--update-properties"))
(is (contains-bold? summary "--remove-tags"))
(is (contains-bold? summary "--remove-properties")))))
(is (contains-bold? summary "--remove-properties"))))
(testing "example command help is the place that shows examples"
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["example" "upsert" "--help"]))
plain-summary (strip-ansi (:summary result))]
(is (true? (:help? result)))
(is (string/includes? plain-summary "Usage: logseq example upsert"))
(is (string/includes? plain-summary "Examples:"))
(is (string/includes? plain-summary "logseq upsert block --graph my-graph --target-page Home --content \"New block\"")))))
(deftest test-parse-args-group-help-flags
(testing "all groups show group help with -h and --help"
(doseq [group ["graph" "server" "list" "upsert" "remove" "query" "search" "sync"]
(doseq [group ["graph" "server" "list" "upsert" "remove" "query" "search" "sync" "example"]
help-flag ["-h" "--help"]]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args [group help-flag]))
@@ -274,6 +297,28 @@
)
(deftest test-parse-args-example-selectors
(testing "example supports exact selectors"
(doseq [args [["example" "upsert" "page"]
["example" "show"]
["example" "search" "block"]]]
(let [result (commands/parse-args args)]
(is (true? (:ok? result)))
(is (= :example (:command result))))))
(testing "example supports prefix selectors"
(doseq [args [["example" "upsert"]
["example" "list"]
["example" "query"]]]
(let [result (commands/parse-args args)]
(is (true? (:ok? result)))
(is (= :example (:command result))))))
(testing "example rejects uncovered selectors"
(let [result (commands/parse-args ["example" "graph"])]
(is (false? (:ok? result)))
(is (= :unknown-command (get-in result [:error :code]))))))
(deftest test-parse-args-help-auth-commands
(testing "login command shows help"
(let [result (binding [style/*color-enabled?* true]
@@ -1602,6 +1647,18 @@
(is (= (cli-server/db-worker-dev-script-path)
(get-in result [:action :script-path]))))))
(deftest test-build-action-example
(testing "example builds local action"
(let [parsed {:ok? true
:command :example
:cmds ["example" "upsert" "page"]
:options {}
:args []}
result (commands/build-action parsed {})]
(is (true? (:ok? result)))
(is (= :example (get-in result [:action :type])))
(is (= "upsert page" (get-in result [:action :selector]))))))
(deftest test-build-action-inspect-edit-add-upsert
(testing "list page requires repo"
(let [parsed {:ok? true :command :list-page :options {}}

View File

@@ -4,6 +4,7 @@
[logseq.cli.command.completion :as completion-command]
[logseq.cli.command.core :as core]
[logseq.cli.command.doctor :as doctor-command]
[logseq.cli.command.example :as example-command]
[logseq.cli.command.graph :as graph-command]
[logseq.cli.command.list :as list-command]
[logseq.cli.command.query :as query-command]
@@ -14,7 +15,7 @@
[logseq.cli.command.upsert :as upsert-command]
[logseq.cli.completion-generator :as gen]))
(def ^:private full-table
(def ^:private base-table
(vec (concat graph-command/entries
server-command/entries
list-command/entries
@@ -26,6 +27,10 @@
doctor-command/entries
completion-command/entries)))
(def ^:private full-table
(vec (concat base-table
(example-command/build-example-entries base-table))))
;; ---------------------------------------------------------------------------
;; Phase 1 — Spec enrichment tests
;; ---------------------------------------------------------------------------
@@ -149,13 +154,14 @@
(testing "show and doctor are leaves"
(is (contains? leaf-names "show"))
(is (contains? leaf-names "doctor")))
(testing "graph, server, list, upsert, remove, search are groups"
(testing "graph, server, list, upsert, remove, search, example are groups"
(is (contains? group-names "graph"))
(is (contains? group-names "server"))
(is (contains? group-names "list"))
(is (contains? group-names "upsert"))
(is (contains? group-names "remove"))
(is (contains? group-names "search")))))
(is (contains? group-names "search"))
(is (contains? group-names "example")))))
(deftest test-spec->token
(testing "boolean spec → :flag type"
@@ -204,12 +210,14 @@
(is (string/includes? output "_logseq_json_names data items block/title")))
(testing "output contains per-command functions"
(is (string/includes? output "_logseq_graph_export()"))
(is (string/includes? output "_logseq_show()")))
(is (string/includes? output "_logseq_show()"))
(is (string/includes? output "_logseq_example_upsert_page()")))
(testing "output contains group dispatchers"
(is (string/includes? output "_logseq_graph()"))
(is (string/includes? output "_logseq_list()"))
(is (string/includes? output "_logseq_search()"))
(is (string/includes? output "_logseq_upsert()")))
(is (string/includes? output "_logseq_upsert()"))
(is (string/includes? output "_logseq_example()")))
(testing "output contains top-level dispatcher"
(is (string/includes? output "_logseq()")))
(testing "output ends with compdef _logseq logseq"

View File

@@ -29,6 +29,36 @@
:json? true})]
(is (= "ok" result)))))
(deftest test-format-example-output
(let [base-result {:status :ok
:command :example
:data {:selector "upsert"
:matched-commands ["upsert block" "upsert page"]
:examples ["logseq upsert block --graph my-graph --content \"hello\""
"logseq upsert page --graph my-graph --page Home"]
:message "Found 2 examples for selector upsert"}}
human-result (format/format-result base-result {:output-format nil})
json-result (format/format-result base-result {:output-format :json})
edn-result (format/format-result base-result {:output-format :edn})
parsed-json (js->clj (js/JSON.parse json-result) :keywordize-keys true)
parsed-edn (reader/read-string edn-result)]
(testing "human output includes selector, matched commands and examples"
(is (string/includes? human-result "Selector: upsert"))
(is (string/includes? human-result "Matched commands:"))
(is (string/includes? human-result "upsert block"))
(is (string/includes? human-result "Examples:"))
(is (string/includes? human-result "logseq upsert page --graph my-graph --page Home")))
(testing "json output keeps required structured fields"
(is (= "upsert" (get-in parsed-json [:data :selector])))
(is (= ["upsert block" "upsert page"] (get-in parsed-json [:data :matched-commands])))
(is (= "Found 2 examples for selector upsert" (get-in parsed-json [:data :message]))))
(testing "edn output keeps required structured fields"
(is (= "upsert" (get-in parsed-edn [:data :selector])))
(is (= ["upsert block" "upsert page"] (get-in parsed-edn [:data :matched-commands])))
(is (= "Found 2 examples for selector upsert" (get-in parsed-edn [:data :message]))))))
(deftest test-format-error
(testing "json error via output-format"
(let [result (format/format-result {:status :error :error {:code :boom :message "nope"}}