diff --git a/deps.edn b/deps.edn index 23c11b6f17..1338a113f9 100644 --- a/deps.edn +++ b/deps.edn @@ -22,6 +22,7 @@ cljs-drag-n-drop/cljs-drag-n-drop {:mvn/version "0.1.0"} cljs-http/cljs-http {:mvn/version "0.1.48"} org.babashka/sci {:mvn/version "0.3.2"} + org.babashka/cli {:mvn/version "0.8.67"} org.clj-commons/hickory {:mvn/version "0.7.3"} hiccups/hiccups {:mvn/version "0.3.0"} tongue/tongue {:mvn/version "0.4.4"} diff --git a/docs/agent-guide/002-logseq-cli-subcommands.md b/docs/agent-guide/002-logseq-cli-subcommands.md new file mode 100644 index 0000000000..ca58e2009f --- /dev/null +++ b/docs/agent-guide/002-logseq-cli-subcommands.md @@ -0,0 +1,170 @@ +# Logseq CLI Subcommands Implementation Plan + +Goal: Replace the CLI argument parser with babashka/cli and expose every command as a subcommand with consistent help and output formats. + +Architecture: The CLI remains a Node-targeted ClojureScript program built via shadow-cljs, but command parsing moves to babashka/cli with an explicit subcommand map. The CLI entrypoint will delegate to per-subcommand parsers and handlers that return a consistent result envelope that is rendered to human, JSON, or EDN output. + +Tech Stack: ClojureScript, babashka/cli, shadow-cljs, Node.js runtime, db-worker-node HTTP API. + +Related: Builds on docs/agent-guide/001-logseq-cli.md. + +## Problem statement + +The current CLI uses clojure.tools.cli with a flat flag set and manual command detection. +This limits help text, makes subcommand-specific options awkward, and complicates output formatting consistency. +We need to migrate to babashka/cli so that each command is a first-class subcommand with its own help, and so output formats are consistent across all commands. + +## Testing Plan + +I will add unit tests that validate babashka/cli subcommand parsing for every command and its flags. +I will add unit tests that assert each subcommand renders help and that top-level help includes all subcommands. +I will add unit tests that verify output formatting for human, JSON, and EDN across success and error paths for each subcommand. +I will add integration tests that invoke the Node CLI with subcommands and verify consistent output formats for graph and content commands. +NOTE: I will write all tests before I add any implementation behavior. + +## Architecture sketch + ++--------------+ HTTP +---------------------+ +| logseq-cli | -----------------> | db-worker-node | +| node script | <----------------- | server on port 9101 | ++--------------+ +---------------------+ + +## Command and output surface + +The CLI will expose these subcommands and shared output controls. + +| Subcommand | Purpose | Output formats | Notes | +| --- | --- | --- | --- | +| graph list | List graphs | human, json, edn | Replaces graph-list | +| graph create | Create graph | human, json, edn | Replaces graph-create | +| graph switch | Switch current graph | human, json, edn | Replaces graph-switch | +| graph remove | Remove graph | human, json, edn | Replaces graph-remove | +| graph validate | Validate graph | human, json, edn | Replaces graph-validate | +| graph info | Graph metadata | human, json, edn | Replaces graph-info | +| block add | Add blocks | human, json, edn | Replaces add | +| block remove | Remove block or page | human, json, edn | Replaces remove | +| block search | Search blocks | human, json, edn | Replaces search | +| block tree | Show tree | human, json, edn | Replaces tree | + +The plan assumes a single global output flag that defaults to human, and each subcommand may also accept it. + +## Subcommand map design + +Global options apply to all subcommands and are parsed before subcommand options. + +| Option | Purpose | Notes | +| --- | --- | --- | +| --help | Show help | Available at top level and per subcommand. | +| --config PATH | Config file path | Defaults to ~/.logseq/cli.edn. | +| --base-url URL | Server URL | Overrides host/port. | +| --host HOST | Server host | Combined with --port. | +| --port PORT | Server port | Combined with --host. | +| --auth-token TOKEN | Auth token | Sent as header. | +| --repo REPO | Graph name | Used as current repo. | +| --timeout-ms MS | Request timeout | Integer milliseconds. | +| --retries N | Retry count | Integer count. | +| --output FORMAT | Output format | One of human, json, edn. | + +Each subcommand uses a nested path and its own options. + +| Subcommand path | Required args | Options | Notes | +| --- | --- | --- | --- | +| graph list | none | --output | Lists all graphs. | +| graph create | none | --graph GRAPH, --output | Creates and switches graph. | +| graph switch | none | --graph GRAPH, --output | Switches current graph. | +| graph remove | none | --graph GRAPH, --output | Removes graph. | +| graph validate | none | --graph GRAPH, --output | Validates graph. | +| graph info | none | --graph GRAPH, --output | Shows metadata, defaults to config repo if omitted. | +| block add | none | --content TEXT, --blocks EDN, --blocks-file PATH, --page PAGE, --parent UUID, --output | Content source is required, with file and text variants. | +| block remove | none | --block UUID, --page PAGE, --output | One of block or page is required. | +| block search | none | --text TEXT, --limit N, --output | Search text is required. | +| block tree | none | --block UUID, --page PAGE, --format FORMAT, --output | One of block or page is required, and format controls tree rendering. | + +## Plan + +1. Consult the clojure-expert agent about babashka/cli idioms for nested subcommands and help generation. +2. Consult the research-agent for a reference implementation of babashka/cli subcommand parsing in ClojureScript, including Node usage. +3. Review current CLI documentation in docs/cli/logseq-cli.md and list all existing flags and examples that must be preserved. +4. Review the current parser and action mapping in src/main/logseq/cli/commands.cljs and list which options are command-specific versus global. +5. Create a babashka/cli command map design and capture it in this document as a table of subcommands, arguments, and defaults. +6. Write new unit tests for top-level help output in src/test/logseq/cli/commands_test.cljs that assert subcommand listing and usage text. +7. Write new unit tests for each subcommand parse path in src/test/logseq/cli/commands_test.cljs covering required args, missing args, and unknown flags. +8. Write new unit tests in src/test/logseq/cli/format_test.cljs that assert human, json, and edn output for success and error results. +9. Write new unit tests in src/test/logseq/cli/config_test.cljs for output format precedence between flags, env, and config file. +10. Write new integration tests in src/test/logseq/cli/integration_test.cljs that invoke the built CLI with subcommands and verify outputs for at least one graph and one block command in each format. +11. Run the new tests to confirm they fail for the current parser and output handling. +12. Replace the parser in src/main/logseq/cli/commands.cljs with babashka/cli, using a subcommand map and per-command option specs. +13. Update src/main/logseq/cli/main.cljs to route to babashka/cli and return subcommand-specific help when requested. +14. Update src/main/logseq/cli/config.cljs to add a unified output format option and ensure json and edn are both supported. +15. Update src/main/logseq/cli/format.cljs so that all commands emit consistent human, json, or edn output using a single option path. +16. Update docs/cli/logseq-cli.md to document subcommands, shared output flags, and per-subcommand help examples. +17. Run the unit test suite with bb dev:test -v logseq.cli.* and confirm 0 failures and 0 errors. +18. Run lint and tests with bb dev:lint-and-test and confirm a zero exit code. +19. Refactor for naming clarity, shared helpers, and reduced duplication while keeping tests green. + +## Status + +- Completed: Plan tasks 1-6. +- In progress: Plan task 7. +- Pending: Plan tasks 8-19. + +## Edge cases + +Missing subcommand should show top-level help with a non-zero exit code. +Unknown subcommands should show a helpful error that includes the available subcommands. +Subcommand-specific help should not require a working db-worker-node server. +Output format flags should be accepted both at the top level and at subcommand level without conflict. +Existing config keys such as :output-format and the legacy --json flag should either be preserved or mapped with a clear deprecation path. +Windows quoting should be covered for block add subcommand with multi-word content arguments. + +## Testing commands and expected output + +Run a single failing unit test in red phase. + +```bash +bb dev:test -v logseq.cli.commands-test/test-help-output +``` + +Expected output includes a failing assertion about subcommand help text and ends with a non-zero exit code. + +Run the full unit test suite in green phase. + +```bash +bb dev:test -v logseq.cli.* +``` + +Expected output includes 0 failures and 0 errors. + +Run lint and unit tests when all work is complete. + +```bash +bb dev:lint-and-test +``` + +Expected output includes successful linting and tests with exit code 0. + +## Testing Details + +The unit tests will exercise parsing and output formatting behavior without mocking internal parser details. +The integration tests will start db-worker-node on a test port and invoke the CLI entrypoint with subcommands to verify end-to-end behavior. + +## Implementation Details + +- Replace clojure.tools.cli usage with babashka/cli and define a nested subcommand map for graph and block groups. +- Keep global options for server connection and output format and merge them with per-subcommand options. +- Normalize output format selection to :human, :json, or :edn and route it through a single formatting function. +- Preserve config precedence across flags, env vars, and config file while adding the output format option. +- Ensure each subcommand has a help string and usage text generated by babashka/cli. +- Keep error envelopes consistent with current :status and :error keys to avoid breaking existing scripts. +- Update CLI docs to show subcommand usage and output format examples. +- Add a transition note for legacy command names if backward compatibility is required. + +## Question + +Should we keep backwards compatibility for legacy command names like graph-list and add, or require the new subcommand forms only. +- Answer: No need to keep backwards compatibility +Should we retain the --json flag as an alias for --output json or remove it after a deprecation period. +- Answer: remove --json, only keep --output +Do we want --output edn and --output json to be accepted at both the top level and per-subcommand level. +- Answer: yes, accept at both levels +--- diff --git a/docs/cli/logseq-cli.md b/docs/cli/logseq-cli.md index 7e9cc8cb68..6b1f1ee56b 100644 --- a/docs/cli/logseq-cli.md +++ b/docs/cli/logseq-cli.md @@ -18,7 +18,7 @@ node ./static/db-worker-node.js ## Run the CLI ```bash -node ./static/logseq-cli.js graph-list --base-url http://127.0.0.1:9101 +node ./static/logseq-cli.js graph list --base-url http://127.0.0.1:9101 ``` ## Configuration @@ -38,28 +38,41 @@ CLI flags take precedence over environment variables, which take precedence over ## Commands Graph commands: -- `graph-list` - list all db graphs -- `graph-create --graph ` - create a new db graph and switch to it -- `graph-switch --graph ` - switch current graph -- `graph-remove --graph ` - remove a graph -- `graph-validate --graph ` - validate graph data -- `graph-info [--graph ]` - show graph metadata (defaults to current graph) +- `graph list` - list all db graphs +- `graph create --graph ` - create a new db graph and switch to it +- `graph switch --graph ` - switch current graph +- `graph remove --graph ` - remove a graph +- `graph validate --graph ` - validate graph data +- `graph info [--graph ]` - show graph metadata (defaults to current graph) -Graph content commands: -- `add --content [--page ] [--parent ]` - add blocks; defaults to today’s journal page if no page is given -- `add --blocks [--page ] [--parent ]` - insert blocks via EDN vector -- `add --blocks-file [--page ] [--parent ]` - insert blocks from an EDN file -- `remove --block ` - remove a block and its children -- `remove --page ` - remove a page and its children -- `search --text [--limit ]` - search block titles (Datalog includes?) -- `tree --page [--format text|json|edn]` - show page tree -- `tree --block [--format text|json|edn]` - show block tree +Block commands: +- `block add --content [--page ] [--parent ]` - add blocks; defaults to today’s journal page if no page is given +- `block add --blocks [--page ] [--parent ]` - insert blocks via EDN vector +- `block add --blocks-file [--page ] [--parent ]` - insert blocks from an EDN file +- `block remove --block ` - remove a block and its children +- `block remove --page ` - remove a page and its children +- `block search --text [--limit ]` - search block titles (Datalog includes?) +- `block tree --page [--format text|json|edn]` - show page tree +- `block tree --block [--format text|json|edn]` - show block tree + +Help output: + +``` +Subcommands: + block add [options] Add blocks + block remove [options] Remove block or page + block search [options] Search blocks + block tree [options] Show tree +``` + +Output formats: +- Global `--output ` (also accepted per subcommand) Examples: ```bash -node ./static/logseq-cli.js graph-create --graph demo --base-url http://127.0.0.1:9101 -node ./static/logseq-cli.js add --page TestPage --content "hello world" -node ./static/logseq-cli.js search --text "hello" -node ./static/logseq-cli.js tree --page TestPage --format json +node ./static/logseq-cli.js graph create --graph demo --base-url http://127.0.0.1:9101 +node ./static/logseq-cli.js block add --page TestPage --content "hello world" +node ./static/logseq-cli.js block search --text "hello" +node ./static/logseq-cli.js block tree --page TestPage --format json --output json ``` diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index bdf8a568d3..6c103a518c 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -1,10 +1,10 @@ (ns logseq.cli.commands "Command parsing and action building for the Logseq CLI." (:require ["fs" :as fs] + [babashka.cli :as cli] [cljs-time.coerce :as tc] [cljs.reader :as reader] [clojure.string :as string] - [clojure.tools.cli :as cli] [logseq.cli.config :as cli-config] [logseq.cli.transport :as transport] [logseq.common.config :as common-config] @@ -12,83 +12,307 @@ [logseq.common.util.date-time :as date-time-util] [promesa.core :as p])) -(def ^:private command->keyword - {"graph-list" :graph-list - "graph-create" :graph-create - "graph-switch" :graph-switch - "graph-remove" :graph-remove - "graph-validate" :graph-validate - "graph-info" :graph-info - "add" :add - "remove" :remove - "search" :search - "tree" :tree}) +(def ^:private global-spec + {:help {:alias :h + :desc "Show help" + :coerce :boolean} + :config {:desc "Path to cli.edn"} + :base-url {:desc "Base URL for db-worker-node"} + :host {:desc "Host for db-worker-node"} + :port {:desc "Port for db-worker-node" + :coerce :long} + :auth-token {:desc "Auth token for db-worker-node"} + :repo {:desc "Graph name"} + :timeout-ms {:desc "Request timeout in ms" + :coerce :long} + :retries {:desc "Retry count for requests" + :coerce :long} + :output {:desc "Output format (human, json, edn)"}}) -(def ^:private cli-options - [["-h" "--help" "Show help"] - [nil "--config PATH" "Path to cli.edn" - :id :config-path] - [nil "--base-url URL" "Base URL for db-worker-node"] - [nil "--host HOST" "Host for db-worker-node"] - [nil "--port PORT" "Port for db-worker-node" - :parse-fn #(js/parseInt % 10)] - [nil "--auth-token TOKEN" "Auth token for db-worker-node"] - [nil "--repo REPO" "Graph name"] - [nil "--graph GRAPH" "Graph name (alias for --repo in graph commands)"] - [nil "--timeout-ms MS" "Request timeout in ms" - :parse-fn #(js/parseInt % 10)] - [nil "--retries N" "Retry count for requests" - :parse-fn #(js/parseInt % 10)] - [nil "--json" "Output JSON" - :id :json? - :default false] - [nil "--format FORMAT" "Output format (tree)"] - [nil "--limit N" "Limit results" - :parse-fn #(js/parseInt % 10)] - [nil "--page PAGE" "Page name"] - [nil "--block UUID" "Block UUID"] - [nil "--parent UUID" "Parent block UUID for add"] - [nil "--content TEXT" "Block content for add"] - [nil "--blocks EDN" "EDN vector of blocks for add"] - [nil "--blocks-file PATH" "EDN file of blocks for add"] - [nil "--text TEXT" "Search text"]]) +(def ^:private graph-spec + {:graph {:desc "Graph name"}}) -(defn parse-args +(def ^:private content-add-spec + {:content {:desc "Block content for add"} + :blocks {:desc "EDN vector of blocks for add"} + :blocks-file {:desc "EDN file of blocks for add"} + :page {:desc "Page name"} + :parent {:desc "Parent block UUID for add"}}) + +(def ^:private content-remove-spec + {:block {:desc "Block UUID"} + :page {:desc "Page name"}}) + +(def ^:private content-search-spec + {:text {:desc "Search text"} + :limit {:desc "Limit results" + :coerce :long}}) + +(def ^:private content-tree-spec + {:block {:desc "Block UUID"} + :page {:desc "Page name"} + :format {:desc "Output format (tree)"}}) + +(defn- format-commands + [table] + (let [rows (->> table + (filter (comp seq :cmds)) + (map (fn [{:keys [cmds desc spec]}] + (let [command (str (string/join " " cmds) + (when (seq spec) " [options]"))] + {:command command + :desc desc})))) + width (apply max 0 (map (comp count :command) rows))] + (->> rows + (map (fn [{:keys [command desc]}] + (let [padding (apply str (repeat (- width (count command)) " "))] + (cond-> (str " " command padding) + (seq desc) (str " " desc))))) + (string/join "\n")))) + +(defn- group-summary + [group table] + (let [group-table (filter #(= group (first (:cmds %))) table)] + (string/join "\n" + [(str "Usage: logseq-cli " group " [options]") + "" + "Subcommands:" + (format-commands group-table) + "" + "Options:" + (cli/format-opts {:spec global-spec})]))) + +(defn- top-level-summary + [table] + (string/join "\n" + ["Usage: logseq-cli [options]" + "" + "Commands:" + (format-commands table) + "" + "Options:" + (cli/format-opts {:spec global-spec})])) + +(defn- command-summary + [{:keys [cmds spec]}] + (string/join "\n" + [(str "Usage: logseq-cli " (string/join " " cmds) " [options]") + "" + "Options:" + (cli/format-opts {:spec spec})])) + +(defn- merge-spec + [spec] + (merge global-spec (or spec {}))) + +(defn- normalize-opts + [opts] + (cond-> opts + (:config opts) (-> (assoc :config-path (:config opts)) + (dissoc :config)))) + +(defn- ok-result + [command opts args summary] + {:ok? true + :command command + :options (normalize-opts opts) + :args (vec args) + :summary summary}) + +(defn- missing-graph-result + [summary] + {:ok? false + :error {:code :missing-graph + :message "graph name is required"} + :summary summary}) + +(defn- missing-content-result + [summary] + {:ok? false + :error {:code :missing-content + :message "content is required"} + :summary summary}) + +(defn- missing-target-result + [summary] + {:ok? false + :error {:code :missing-target + :message "block or page is required"} + :summary summary}) + +(defn- missing-search-result + [summary] + {:ok? false + :error {:code :missing-search-text + :message "search text is required"} + :summary summary}) + +(defn- help-result + [summary] + {:ok? false + :help? true + :summary summary}) + +(defn- invalid-options-result + [summary message] + {:ok? false + :error {:code :invalid-options + :message message} + :summary summary}) + +(defn- unknown-command-result + [summary message] + {:ok? false + :error {:code :unknown-command + :message message} + :summary summary}) + +(defn- command-entry + [cmds command desc spec] + (let [spec* (merge-spec spec)] + {:cmds cmds + :desc desc + :spec spec* + :restrict true + :fn (fn [{:keys [opts args]}] + {:command command + :cmds cmds + :spec spec* + :opts opts + :args args})})) + +(def ^:private table + [(command-entry ["graph" "list"] :graph-list "List graphs" {}) + (command-entry ["graph" "create"] :graph-create "Create graph" graph-spec) + (command-entry ["graph" "switch"] :graph-switch "Switch current graph" graph-spec) + (command-entry ["graph" "remove"] :graph-remove "Remove graph" graph-spec) + (command-entry ["graph" "validate"] :graph-validate "Validate graph" graph-spec) + (command-entry ["graph" "info"] :graph-info "Graph metadata" graph-spec) + (command-entry ["block" "add"] :add "Add blocks" content-add-spec) + (command-entry ["block" "remove"] :remove "Remove block or page" content-remove-spec) + (command-entry ["block" "search"] :search "Search blocks" content-search-spec) + (command-entry ["block" "tree"] :tree "Show tree" content-tree-spec)]) + +(def ^:private global-aliases + (->> global-spec + (keep (fn [[k {:keys [alias]}]] + (when alias + [alias k]))) + (into {}))) + +(def ^:private global-flag-options + (->> global-spec + (keep (fn [[k {:keys [coerce]}]] + (when (= coerce :boolean) k))) + (set))) + +(defn- global-opt-key + [token] + (cond + (string/starts-with? token "--") + (keyword (subs token 2)) + + (and (string/starts-with? token "-") + (= 2 (count token))) + (get global-aliases (keyword (subs token 1))) + + :else nil)) + +(defn- parse-leading-global-opts [args] - (let [{:keys [options arguments errors summary]} (cli/parse-opts args cli-options) - command-str (first arguments) - command-args (vec (rest arguments)) - command (get command->keyword command-str)] + (loop [remaining args + opts {}] + (if (empty? remaining) + {:opts opts :args []} + (let [token (first remaining)] + (if-let [opt-key (global-opt-key token)] + (if (contains? global-flag-options opt-key) + (recur (rest remaining) (assoc opts opt-key true)) + (if-let [value (second remaining)] + (recur (drop 2 remaining) (assoc opts opt-key value)) + {:opts opts :args (rest remaining)})) + {:opts opts :args remaining}))))) + +(defn- unknown-command-message + [{:keys [dispatch wrong-input]}] + (string/join " " (cond-> (vec dispatch) + wrong-input (conj wrong-input)))) + +(defn- finalize-command + [summary {:keys [command opts args cmds spec]}] + (let [opts (normalize-opts opts) + args (vec args) + cmd-summary (command-summary {:cmds cmds :spec spec}) + graph (or (:graph opts) (:repo opts)) + has-args? (seq args) + has-content? (or (seq (:content opts)) + (seq (:blocks opts)) + (seq (:blocks-file opts)) + has-args?)] (cond - (seq errors) - {:ok? false - :error {:code :invalid-options - :message (string/join "\n" errors)} - :summary summary} + (:help opts) + (help-result cmd-summary) - (:help options) - {:ok? false - :help? true - :summary summary} + (and (#{:graph-create :graph-switch :graph-remove :graph-validate} command) + (not (seq graph))) + (missing-graph-result summary) - (nil? command-str) - {:ok? false - :error {:code :missing-command - :message "missing command"} - :summary summary} + (and (= command :add) (not has-content?)) + (missing-content-result summary) - (nil? command) - {:ok? false - :error {:code :unknown-command - :message (str "unknown command: " command-str)} - :summary summary} + (and (= command :remove) (not (or (seq (:block opts)) (seq (:page opts))))) + (missing-target-result summary) + + (and (= command :tree) (not (or (seq (:block opts)) (seq (:page opts))))) + (missing-target-result summary) + + (and (= command :search) (not (or (seq (:text opts)) has-args?))) + (missing-search-result summary) :else - {:ok? true - :command command - :options options - :args command-args - :summary summary}))) + (ok-result command opts args summary)))) + +(defn- cli-error->result + [summary {:keys [msg]}] + (invalid-options-result summary (or msg "invalid options"))) + +(defn parse-args + [raw-args] + (let [summary (top-level-summary table) + {:keys [opts args]} (parse-leading-global-opts raw-args)] + (if (empty? args) + (if (:help opts) + (help-result summary) + {:ok? false + :error {:code :missing-command + :message "missing command"} + :summary summary}) + (if (and (= 1 (count args)) (#{"graph" "block"} (first args))) + (help-result (group-summary (first args) table)) + (try + (let [result (cli/dispatch table args {:spec global-spec})] + (if (nil? result) + (unknown-command-result summary (str "unknown command: " (string/join " " args))) + (finalize-command summary (update result :opts #(merge opts (or % {})))))) + (catch :default e + (let [{:keys [cause] :as data} (ex-data e)] + (cond + (= cause :input-exhausted) + (if (:help opts) + (help-result summary) + {:ok? false + :error {:code :missing-command + :message "missing command"} + :summary summary}) + + (= cause :no-match) + (unknown-command-result summary (str "unknown command: " (unknown-command-message data))) + + (some? data) + (cli-error->result summary data) + + :else + (unknown-command-result summary (str "unknown command: " (string/join " " args))))))))))) (defn- graph->repo [graph] diff --git a/src/main/logseq/cli/config.cljs b/src/main/logseq/cli/config.cljs index 7aaf2239fa..78ca0edef9 100644 --- a/src/main/logseq/cli/config.cljs +++ b/src/main/logseq/cli/config.cljs @@ -12,6 +12,18 @@ (when (and (some? value) (not (string/blank? value))) (js/parseInt value 10))) +(def ^:private output-formats + #{:human :json :edn}) + +(defn- parse-output-format + [value] + (let [kw (cond + (keyword? value) value + (string? value) (-> value string/trim string/lower-case keyword) + :else nil)] + (when (output-formats kw) + kw))) + (defn- default-config-path [] (node-path/join (.homedir os) ".logseq" "cli.edn")) @@ -57,6 +69,9 @@ (seq (gobj/get env "LOGSEQ_CLI_RETRIES")) (assoc :retries (parse-int (gobj/get env "LOGSEQ_CLI_RETRIES"))) + (seq (gobj/get env "LOGSEQ_CLI_OUTPUT")) + (assoc :output-format (parse-output-format (gobj/get env "LOGSEQ_CLI_OUTPUT"))) + (seq (gobj/get env "LOGSEQ_CLI_CONFIG")) (assoc :config-path (gobj/get env "LOGSEQ_CLI_CONFIG"))))) @@ -78,7 +93,14 @@ (:config-path env) (:config-path defaults)) file-config (or (read-config-file config-path) {}) + output-format (or (parse-output-format (:output-format opts)) + (parse-output-format (:output opts)) + (parse-output-format (:output-format env)) + (parse-output-format (:output env)) + (parse-output-format (:output-format file-config)) + (parse-output-format (:output file-config))) merged (merge defaults file-config env opts {:config-path config-path}) derived (build-base-url merged)] (cond-> merged + output-format (assoc :output-format output-format) (seq derived) (assoc :base-url derived)))) diff --git a/src/main/logseq/cli/main.cljs b/src/main/logseq/cli/main.cljs index af5d4869fe..168debdafe 100644 --- a/src/main/logseq/cli/main.cljs +++ b/src/main/logseq/cli/main.cljs @@ -12,7 +12,7 @@ (string/join "\n" ["logseq-cli [options]" "" - "Commands: graph-list, graph-create, graph-switch, graph-remove, graph-validate, graph-info, add, remove, search, tree" + "Commands: graph list, graph create, graph switch, graph remove, graph validate, graph info, block add, block remove, block search, block tree" "" "Options:" summary])) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index ae27b07842..c8276a8171 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -1,14 +1,79 @@ (ns logseq.cli.commands-test - (:require [cljs.test :refer [deftest is testing]] + (:require [clojure.string :as string] + [cljs.test :refer [deftest is testing]] [logseq.cli.commands :as commands])) +(deftest test-help-output + (testing "top-level help lists subcommand groups" + (let [result (commands/parse-args ["--help"]) + summary (:summary result)] + (is (true? (:help? result))) + (is (string/includes? summary "graph")) + (is (string/includes? summary "block"))))) + (deftest test-parse-args - (testing "rejects removed commands" - (doseq [command ["ping" "status" "query" "export"]] + (testing "graph group shows subcommands" + (let [result (commands/parse-args ["graph"]) + summary (:summary result)] + (is (true? (:help? result))) + (is (string/includes? summary "graph list")) + (is (string/includes? summary "graph create")))) + + (testing "block group shows subcommands" + (let [result (commands/parse-args ["block"]) + summary (:summary result)] + (is (true? (:help? result))) + (is (string/includes? summary "block add")) + (is (string/includes? summary "block search")))) + + (testing "graph group aligns subcommand columns" + (let [result (commands/parse-args ["graph"]) + summary (:summary result) + subcommand-lines (let [lines (string/split-lines summary) + start (inc (.indexOf lines "Subcommands:"))] + (->> lines + (drop start) + (take-while (complement string/blank?)))) + desc-starts (->> subcommand-lines + (keep (fn [line] + (when-let [[_ desc] (re-matches #"^\s+.*?\s{2,}(.+)$" line)] + (.indexOf line desc)))))] + (is (seq subcommand-lines)) + (is (apply = desc-starts)))) + + (testing "block group aligns subcommand columns" + (let [result (commands/parse-args ["block"]) + summary (:summary result) + subcommand-lines (let [lines (string/split-lines summary) + start (inc (.indexOf lines "Subcommands:"))] + (->> lines + (drop start) + (take-while (complement string/blank?)))) + desc-starts (->> subcommand-lines + (keep (fn [line] + (when-let [[_ desc] (re-matches #"^\s+.*?\s{2,}(.+)$" line)] + (.indexOf line desc)))))] + (is (seq subcommand-lines)) + (is (apply = desc-starts)))) + + (testing "rejects legacy commands" + (doseq [command ["graph-list" "graph-create" "graph-switch" "graph-remove" + "graph-validate" "graph-info" "add" "remove" "search" "tree" + "ping" "status" "query" "export"]] (let [result (commands/parse-args [command])] (is (false? (:ok? result))) (is (= :unknown-command (get-in result [:error :code])))))) + (testing "rejects removed commands" + (let [result (commands/parse-args ["graph" "wat"])] + (is (false? (:ok? result))) + (is (= :unknown-command (get-in result [:error :code]))))) + + (testing "rejects removed group commands" + (let [result (commands/parse-args ["content" "add"])] + (is (false? (:ok? result))) + (is (= :unknown-command (get-in result [:error :code]))))) + (testing "errors on missing command" (let [result (commands/parse-args [])] (is (false? (:ok? result))) @@ -17,9 +82,142 @@ (testing "errors on unknown command" (let [result (commands/parse-args ["wat"])] (is (false? (:ok? result))) - (is (= :unknown-command (get-in result [:error :code])))))) + (is (= :unknown-command (get-in result [:error :code]))))) -(deftest test-graph-commands + (testing "global output option is accepted" + (let [result (commands/parse-args ["--output" "json" "graph" "list"])] + (is (true? (:ok? result))) + (is (= "json" (get-in result [:options :output])))))) + +(deftest test-graph-subcommand-parse + (testing "graph list parses" + (let [result (commands/parse-args ["graph" "list"])] + (is (true? (:ok? result))) + (is (= :graph-list (:command result))))) + + (testing "graph create requires graph option" + (let [result (commands/parse-args ["graph" "create"])] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + + (testing "graph create parses with graph option" + (let [result (commands/parse-args ["graph" "create" "--graph" "demo"])] + (is (true? (:ok? result))) + (is (= :graph-create (:command result))) + (is (= "demo" (get-in result [:options :graph]))))) + + (testing "graph switch requires graph option" + (let [result (commands/parse-args ["graph" "switch"])] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + + (testing "graph switch parses with graph option" + (let [result (commands/parse-args ["graph" "switch" "--graph" "demo"])] + (is (true? (:ok? result))) + (is (= :graph-switch (:command result))) + (is (= "demo" (get-in result [:options :graph]))))) + + (testing "graph remove requires graph option" + (let [result (commands/parse-args ["graph" "remove"])] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + + (testing "graph remove parses with graph option" + (let [result (commands/parse-args ["graph" "remove" "--graph" "demo"])] + (is (true? (:ok? result))) + (is (= :graph-remove (:command result))) + (is (= "demo" (get-in result [:options :graph]))))) + + (testing "graph validate requires graph option" + (let [result (commands/parse-args ["graph" "validate"])] + (is (false? (:ok? result))) + (is (= :missing-graph (get-in result [:error :code]))))) + + (testing "graph validate parses with graph option" + (let [result (commands/parse-args ["graph" "validate" "--graph" "demo"])] + (is (true? (:ok? result))) + (is (= :graph-validate (:command result))) + (is (= "demo" (get-in result [:options :graph]))))) + + (testing "graph info parses without graph option" + (let [result (commands/parse-args ["graph" "info"])] + (is (true? (:ok? result))) + (is (= :graph-info (:command result))))) + + (testing "graph info parses with graph option" + (let [result (commands/parse-args ["graph" "info" "--graph" "demo"])] + (is (true? (:ok? result))) + (is (= :graph-info (:command result))) + (is (= "demo" (get-in result [:options :graph]))))) + + (testing "graph subcommands reject unknown flags" + (doseq [subcommand ["list" "create" "switch" "remove" "validate" "info"]] + (let [result (commands/parse-args ["graph" subcommand "--wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))))) + + (testing "graph subcommands accept output option" + (let [result (commands/parse-args ["graph" "list" "--output" "edn"])] + (is (true? (:ok? result))) + (is (= "edn" (get-in result [:options :output]))))) + +(deftest test-block-subcommand-parse + (testing "block add requires content source" + (let [result (commands/parse-args ["block" "add"])] + (is (false? (:ok? result))) + (is (= :missing-content (get-in result [:error :code]))))) + + (testing "block add parses with content" + (let [result (commands/parse-args ["block" "add" "--content" "hello"])] + (is (true? (:ok? result))) + (is (= :add (:command result))) + (is (= "hello" (get-in result [:options :content]))))) + + (testing "block remove requires target" + (let [result (commands/parse-args ["block" "remove"])] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "block remove parses with block" + (let [result (commands/parse-args ["block" "remove" "--block" "demo"])] + (is (true? (:ok? result))) + (is (= :remove (:command result))) + (is (= "demo" (get-in result [:options :block]))))) + + (testing "block search requires text" + (let [result (commands/parse-args ["block" "search"])] + (is (false? (:ok? result))) + (is (= :missing-search-text (get-in result [:error :code]))))) + + (testing "block search parses with text" + (let [result (commands/parse-args ["block" "search" "--text" "hello"])] + (is (true? (:ok? result))) + (is (= :search (:command result))) + (is (= "hello" (get-in result [:options :text]))))) + + (testing "block tree requires target" + (let [result (commands/parse-args ["block" "tree"])] + (is (false? (:ok? result))) + (is (= :missing-target (get-in result [:error :code]))))) + + (testing "block tree parses with page" + (let [result (commands/parse-args ["block" "tree" "--page" "Home"])] + (is (true? (:ok? result))) + (is (= :tree (:command result))) + (is (= "Home" (get-in result [:options :page]))))) + + (testing "block subcommands reject unknown flags" + (doseq [subcommand ["add" "remove" "search" "tree"]] + (let [result (commands/parse-args ["block" subcommand "--wat"])] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code])))))) + + (testing "block subcommands accept output option" + (let [result (commands/parse-args ["block" "search" "--text" "hello" "--output" "json"])] + (is (true? (:ok? result))) + (is (= "json" (get-in result [:options :output])))))) + +(deftest test-build-action (testing "graph-list uses list-db" (let [parsed {:ok? true :command :graph-list :options {}} result (commands/build-action parsed {})] @@ -42,9 +240,8 @@ (let [parsed {:ok? true :command :graph-info :options {}} result (commands/build-action parsed {:repo "demo"})] (is (true? (:ok? result))) - (is (= :graph-info (get-in result [:action :type])))))) + (is (= :graph-info (get-in result [:action :type]))))) -(deftest test-content-commands (testing "add requires content" (let [parsed {:ok? true :command :add :options {}} result (commands/build-action parsed {:repo "demo"})] diff --git a/src/test/logseq/cli/config_test.cljs b/src/test/logseq/cli/config_test.cljs index ea974adf3e..6387e102c5 100644 --- a/src/test/logseq/cli/config_test.cljs +++ b/src/test/logseq/cli/config_test.cljs @@ -27,25 +27,29 @@ ":auth-token \"file-token\" " ":repo \"file-repo\" " ":timeout-ms 111 " - ":retries 1}")) + ":retries 1 " + ":output-format :edn}")) env {"LOGSEQ_DB_WORKER_URL" "http://env:9999" "LOGSEQ_DB_WORKER_AUTH_TOKEN" "env-token" "LOGSEQ_CLI_REPO" "env-repo" "LOGSEQ_CLI_TIMEOUT_MS" "222" - "LOGSEQ_CLI_RETRIES" "2"} + "LOGSEQ_CLI_RETRIES" "2" + "LOGSEQ_CLI_OUTPUT" "json"} opts {:config-path cfg-path :base-url "http://cli:1234" :auth-token "cli-token" :repo "cli-repo" :timeout-ms 333 - :retries 3} + :retries 3 + :output-format :human} result (with-env env #(config/resolve-config opts))] (is (= cfg-path (:config-path result))) (is (= "http://cli:1234" (:base-url result))) (is (= "cli-token" (:auth-token result))) (is (= "cli-repo" (:repo result))) (is (= 333 (:timeout-ms result))) - (is (= 3 (:retries result))))) + (is (= 3 (:retries result))) + (is (= :human (:output-format result))))) (deftest test-host-port-derived-base-url (let [result (config/resolve-config {:host "127.0.0.2" :port 9200})] @@ -61,6 +65,14 @@ (is (= "http://env:9999" (:base-url result))) (is (= "env-repo" (:repo result))))) +(deftest test-output-format-env-overrides-file + (let [dir (node-helper/create-tmp-dir) + cfg-path (node-path/join dir "cli.edn") + _ (fs/writeFileSync cfg-path "{:output-format :edn}") + env {"LOGSEQ_CLI_OUTPUT" "json"} + result (with-env env #(config/resolve-config {:config-path cfg-path}))] + (is (= :json (:output-format result))))) + (deftest test-update-config (let [dir (node-helper/create-tmp-dir "cli") cfg-path (node-path/join dir "cli.edn") diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index db06a6cf47..d28f058a63 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -3,23 +3,33 @@ [logseq.cli.format :as format])) (deftest test-format-success - (testing "json output" + (testing "json output via output-format" (let [result (format/format-result {:status :ok :data {:message "ok"}} - {:json? true})] + {:output-format :json})] (is (= "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" result)))) - (testing "human output" + (testing "edn output via output-format" (let [result (format/format-result {:status :ok :data {:message "ok"}} - {:json? false})] + {:output-format :edn})] + (is (= "{:status :ok, :data {:message \"ok\"}}" result)))) + + (testing "human output (default)" + (let [result (format/format-result {:status :ok :data {:message "ok"}} + {:output-format nil})] (is (= "ok" result))))) (deftest test-format-error - (testing "json error" + (testing "json error via output-format" (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} - {:json? true})] + {:output-format :json})] (is (= "{\"status\":\"error\",\"error\":{\"code\":\"boom\",\"message\":\"nope\"}}" result)))) - (testing "human error" + (testing "edn error via output-format" (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} - {:json? false})] + {:output-format :edn})] + (is (= "{:status :error, :error {:code :boom, :message \"nope\"}}" result)))) + + (testing "human error (default)" + (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}} + {:output-format nil})] (is (= "error: nope" result))))) diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index 9a141b7030..9c861c5cae 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -9,7 +9,7 @@ (defn- run-cli [args url cfg-path] - (cli-main/run! (vec (concat args ["--base-url" url "--config" cfg-path "--json"])) + (cli-main/run! (vec (concat args ["--base-url" url "--config" cfg-path "--output" "json"])) {:exit? false})) (defn- parse-json-output @@ -24,7 +24,7 @@ :data-dir data-dir}) url (str "http://127.0.0.1:" (:port daemon)) cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") - result (run-cli ["graph-list"] url cfg-path) + result (run-cli ["graph" "list"] url cfg-path) payload (parse-json-output result)] (is (= 0 (:exit-code result))) (is (= "ok" (:status payload))) @@ -44,9 +44,9 @@ url (str "http://127.0.0.1:" (:port daemon)) cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") _ (fs/writeFileSync cfg-path "{}") - create-result (run-cli ["graph-create" "--graph" "demo-graph"] url cfg-path) + create-result (run-cli ["graph" "create" "--graph" "demo-graph"] url cfg-path) create-payload (parse-json-output create-result) - info-result (run-cli ["graph-info"] url cfg-path) + info-result (run-cli ["graph" "info"] url cfg-path) info-payload (parse-json-output info-result)] (is (= 0 (:exit-code create-result))) (is (= "ok" (:status create-payload))) @@ -68,15 +68,15 @@ url (str "http://127.0.0.1:" (:port daemon)) cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") _ (fs/writeFileSync cfg-path "{}") - _ (run-cli ["graph-create" "--graph" "content-graph"] url cfg-path) - add-result (run-cli ["add" "--page" "TestPage" "--content" "hello world"] url cfg-path) + _ (run-cli ["graph" "create" "--graph" "content-graph"] url cfg-path) + add-result (run-cli ["block" "add" "--page" "TestPage" "--content" "hello world"] url cfg-path) _ (parse-json-output add-result) - search-result (run-cli ["search" "--text" "hello world"] url cfg-path) + search-result (run-cli ["block" "search" "--text" "hello world"] url cfg-path) search-payload (parse-json-output search-result) - tree-result (run-cli ["tree" "--page" "TestPage" "--format" "json"] url cfg-path) + tree-result (run-cli ["block" "tree" "--page" "TestPage" "--format" "json"] url cfg-path) tree-payload (parse-json-output tree-result) block-uuid (get-in tree-payload [:data :root :children 0 :uuid]) - remove-result (run-cli ["remove" "--block" (str block-uuid)] url cfg-path) + remove-result (run-cli ["block" "remove" "--block" (str block-uuid)] url cfg-path) remove-payload (parse-json-output remove-result)] (is (= 0 (:exit-code add-result))) (is (= "ok" (:status search-payload)))