From 23daff7dbdadd5515e0dac86caeabb0ef3e905d5 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 27 Mar 2026 23:17:02 +0800 Subject: [PATCH] enhance(cli): add example subcmd --- cli-e2e/spec/non_sync_cases.edn | 111 +++++++++ cli-e2e/spec/non_sync_inventory.edn | 14 ++ .../070-logseq-cli-example-subcommand.md | 224 ++++++++++++++++++ docs/cli/logseq-cli.md | 6 +- src/main/logseq/cli/command/core.cljs | 26 +- src/main/logseq/cli/command/example.cljs | 132 +++++++++++ src/main/logseq/cli/command/query.cljs | 3 +- src/main/logseq/cli/commands.cljs | 19 +- src/main/logseq/cli/format.cljs | 20 ++ src/test/logseq/cli/command/example_test.cljs | 82 +++++++ src/test/logseq/cli/command/query_test.cljs | 8 + src/test/logseq/cli/commands_test.cljs | 81 ++++++- .../logseq/cli/completion_generator_test.cljs | 18 +- src/test/logseq/cli/format_test.cljs | 30 +++ 14 files changed, 747 insertions(+), 27 deletions(-) create mode 100644 docs/agent-guide/070-logseq-cli-example-subcommand.md create mode 100644 src/main/logseq/cli/command/example.cljs create mode 100644 src/test/logseq/cli/command/example_test.cljs diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn index 8975f21a60..eac1962807 100644 --- a/cli-e2e/spec/non_sync_cases.edn +++ b/cli-e2e/spec/non_sync_cases.edn @@ -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" diff --git a/cli-e2e/spec/non_sync_inventory.edn b/cli-e2e/spec/non_sync_inventory.edn index 5ac1deec10..3d74f1517f 100644 --- a/cli-e2e/spec/non_sync_inventory.edn +++ b/cli-e2e/spec/non_sync_inventory.edn @@ -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" diff --git a/docs/agent-guide/070-logseq-cli-example-subcommand.md b/docs/agent-guide/070-logseq-cli-example-subcommand.md new file mode 100644 index 0000000000..ecd850a8e6 --- /dev/null +++ b/docs/agent-guide/070-logseq-cli-example-subcommand.md @@ -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 ` 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 +``` + +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 --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? diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index 820bdc11e8..fb0c726bde 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -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 ` - generate shell completion script to stdout +- `example ` - 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"`). diff --git a/src/main/logseq/cli/command/core.cljs b/src/main/logseq/cli/command/core.cljs index 8b0a844d6d..595ef131ea 100644 --- a/src/main/logseq/cli/command/core.cljs +++ b/src/main/logseq/cli/command/core.cljs @@ -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 [options]" "" diff --git a/src/main/logseq/cli/command/example.cljs b/src/main/logseq/cli/command/example.cljs new file mode 100644 index 0000000000..4d3f19ceb4 --- /dev/null +++ b/src/main/logseq/cli/command/example.cljs @@ -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])})) diff --git a/src/main/logseq/cli/command/query.cljs b/src/main/logseq/cli/command/query.cljs index 9d6f15f619..d7926af5d8 100644 --- a/src/main/logseq/cli/command/query.cljs +++ b/src/main/logseq/cli/command/query.cljs @@ -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" diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index 05e1ccc563..06b1ae1678 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -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 "))) :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) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index d7b3be06fe..add999b7d3 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -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)) diff --git a/src/test/logseq/cli/command/example_test.cljs b/src/test/logseq/cli/command/example_test.cljs new file mode 100644 index 0000000000..e0d3e5babe --- /dev/null +++ b/src/test/logseq/cli/command/example_test.cljs @@ -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])))))) diff --git a/src/test/logseq/cli/command/query_test.cljs b/src/test/logseq/cli/command/query_test.cljs index 1c28aa6944..e6c1cbf01b 100644 --- a/src/test/logseq/cli/command/query_test.cljs +++ b/src/test/logseq/cli/command/query_test.cljs @@ -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]]" diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index 2cd34ee3d3..f515a952eb 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -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 {}} diff --git a/src/test/logseq/cli/completion_generator_test.cljs b/src/test/logseq/cli/completion_generator_test.cljs index 51c0685db1..28d764908b 100644 --- a/src/test/logseq/cli/completion_generator_test.cljs +++ b/src/test/logseq/cli/completion_generator_test.cljs @@ -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" diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index 5e06d328eb..9b8562e057 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -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"}}