Browse Source

add :logseq-cli build

see also docs/cli/logseq-cli.md
rcmerci 5 ngày trước cách đây
mục cha
commit
2986d7582e

+ 1 - 0
.gitignore

@@ -16,6 +16,7 @@ node_modules/
 static/**
 tmp
 cljs-test-runner-out
+.tmp/
 
 .cpcache/
 /src/gen

+ 4 - 0
README.md

@@ -165,6 +165,10 @@ If you want to set up a development environment for the Logseq web or desktop ap
 
 In addition to these guides, you can also find other helpful resources in the [docs/](docs/) folder, such as the [Guide for Contributing to Translations](docs/contributing-to-translations.md), the [Docker Web App Guide](docs/docker-web-app-guide.md) and the [mobile development guide](docs/develop-logseq-on-mobile.md)
 
+### 🧰 Logseq CLI (Node)
+
+Logseq CLI documentation is maintained in `docs/cli/logseq-cli.md`.
+
 ## ✨ Inspiration
 
 Logseq is inspired by several unique tools and projects, including [Roam Research](https://roamresearch.com/), [Org Mode](https://orgmode.org/), [TiddlyWiki](https://tiddlywiki.com/), [Workflowy](https://workflowy.com/), and [Cuekeeper](https://github.com/talex5/cuekeeper).

+ 152 - 0
docs/agent-guide/001-logseq-cli.md

@@ -0,0 +1,152 @@
+# Logseq CLI Implementation Plan
+
+Goal: Build a new Logseq CLI in ClojureScript that runs on Node.js and connects to the db-worker-node server.
+
+Architecture: The CLI is a Node-targeted ClojureScript program built via shadow-cljs and packaged with a small JavaScript launcher.
+The CLI speaks a simple request and response protocol to the existing db-worker-node HTTP or WebSocket API and exposes high-level subcommands for users.
+
+Tech Stack: ClojureScript, shadow-cljs :node-script target, Node.js runtime, existing db-worker-node server.
+
+Related: Relates to docs/agent-guide/task--basic-logseq-cli.md and docs/agent-guide/task--db-worker-nodejs-compatible.md.
+
+## Problem statement
+
+We need a new Logseq CLI that is independent of any existing CLI code in the repo.
+The CLI must run in Node.js, be written in ClojureScript, and connect to the db-worker-node server started from static/db-worker-node.js.
+The CLI should provide a stable interface for scripting and troubleshooting, and it should be easy to extend with new commands.
+
+## Testing Plan
+
+I will add an integration test that starts db-worker-node on a test port and verifies the CLI can connect and run a simple request like ping or status.
+I will add unit tests for command parsing, configuration precedence, and error formatting.
+I will add unit tests for the client transport layer to ensure timeouts and retries behave correctly.
+I will add unit tests for new graph/content commands (parsing, validation, and request mapping).
+I will add integration tests for graph lifecycle commands and content commands against a real db-worker-node.
+I will follow @test-driven-development for all behavior changes.
+NOTE: I will write *all* tests before I add any implementation behavior.
+
+## Architecture sketch
+
+The CLI is a Node program that parses flags, loads config, and sends requests to db-worker-node.
+The db-worker-node server is already built from the :db-worker-node shadow-cljs target and listens on a TCP port.
+
+ASCII diagram:
+
++--------------+        HTTP or WS        +---------------------+
+| logseq-cli   | -----------------------> | db-worker-node       |
+| node script  | <----------------------- | server on port 9101  |
++--------------+                          +---------------------+
+
+## Assumptions
+
+The db-worker-node server exposes a stable API for a small set of requests needed by the CLI.
+The CLI will default to localhost:9101 unless configured otherwise.
+The CLI will use JSON for request and response bodies for ease of scripting.
+
+## Implementation plan
+
+1. Use TodoWrite to track the full task list and include the @test-driven-development red-green-refactor steps.
+2. Read @test-driven-development guidelines and confirm the red phase will include all CLI tests first.
+3. Identify existing db-worker-node request handlers and document their request and response shapes.
+4. Define the initial CLI command surface as a table that includes command, input, output, and errors.
+5. Decide on transport protocol based on db-worker-node capabilities and document the selection.
+6. Add a new shadow-cljs build target named :logseq-cli with :target :node-script and a dedicated output file in static/.
+7. Create a new namespace for the CLI entrypoint in src/main/cli/main.cljs and wire it as the :main for the build.
+8. Create src/main/cli/config.cljs with config resolution order of CLI flags, env vars, then config file.
+9. Create src/main/cli/transport.cljs with a small client that can send requests and parse responses.
+10. Create src/main/cli/commands.cljs with pure functions that map parsed args to transport requests.
+11. Create src/main/cli/format.cljs that formats success and error output for human and machine usage.
+12. Add unit tests in src/test/logseq/cli for config precedence, command parsing, and error formatting behavior.
+13. Add integration tests in src/test/logseq/cli that start db-worker-node and invoke the CLI entrypoint.
+14. Run tests in red phase with bb dev:test -v and confirm failures are behavior-related.
+15. Implement the minimal code to make the tests pass and re-run in green phase.
+16. Refactor for naming and reuse while keeping tests green.
+17. Document how to build and run the CLI in a short section in README.md.
+
+## Command surface definition
+
+| Command | Input | Output | Errors |
+| --- | --- | --- | --- |
+| ping | none | ok message | server unavailable, timeout |
+| status | none | server version, db state | server unavailable, timeout |
+| query | query string or file | query result JSON | invalid query, parse error |
+| export | target path and format | export result | unsupported format, write error |
+| graph-list | none | list of graphs | server unavailable, timeout |
+| graph-create | graph name | created graph + set current graph | invalid name, server unavailable |
+| graph-switch | graph name | switched graph + set current graph | missing graph, server unavailable |
+| graph-remove | graph name | removal confirmation | missing graph, server unavailable |
+| graph-validate | graph name or current graph | validation result | missing graph, server unavailable |
+| graph-info | graph name or current graph | graph metadata/info | missing graph, server unavailable |
+| add | block/page payload | created block IDs | invalid input, server unavailable |
+| remove | block/page id or name | removal confirmation | invalid input, server unavailable |
+| search | query string | matched blocks/pages | invalid input, server unavailable |
+| tree | block/page id or name | hierarchical tree output | invalid input, server unavailable |
+
+## Edge cases
+
+The db-worker-node server is not running or is listening on a different port.
+The response payload is invalid JSON or missing fields.
+The request times out or the server closes the connection early.
+The user passes incompatible flags or unknown commands.
+The CLI is run on Windows where path and quoting rules differ.
+Graph commands are invoked without a current graph configured.
+Content commands are invoked without specifying a graph and no current graph is set.
+Content commands refer to missing pages/blocks.
+Graph removal is attempted while a graph is open.
+
+## Testing commands and expected output
+
+Run a single unit test in red phase.
+
+```bash
+bb dev:test -v logseq.cli.config-test/test-config-precedence
+```
+
+Expected output includes a failing assertion 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
+
+I will add behavior-driven tests that verify the CLI connects to a real db-worker-node process and that each command returns the expected output for valid input.
+I will keep unit tests focused on pure functions like parsing, formatting, and config resolution, and avoid mocking internal implementation details.
+
+## Implementation Details
+
+- Add a new shadow-cljs build target for the CLI with a node-script output in static/.
+- Create a dedicated CLI entrypoint namespace that handles args, logging, and exit codes.
+- Implement config resolution for flags, env vars, and optional config file.
+- Implement a transport client with timeouts and explicit error mapping.
+- Define a small command map with functions that return request objects and output renderers.
+- Add structured JSON output mode for scripting alongside human-readable output.
+- Ensure the CLI exits with non-zero status codes on errors.
+- Document build and run steps, including starting db-worker-node first.
+- Add graph management commands that map to db-worker thread-apis.
+- Add graph content commands (add/remove/search/tree) with clear input formats and output.
+- Persist/resolve a “current graph” for commands that default to current context.
+
+## Question
+
+Which exact db-worker-node endpoints and request schemas should the CLI use for ping, status, query, and export.
+- Answer: all thread-apis are available in http endpoint, check @src/main/frontend/worker/db_worker_node.cljs
+
+Do we want WebSocket or HTTP as the default transport for the CLI.
+- HTTP
+
+Can I consult the clojure-expert and research-agent agents for architecture and reference implementations as required by the planning guidelines.
+- yes
+---

+ 65 - 0
docs/cli/logseq-cli.md

@@ -0,0 +1,65 @@
+# Logseq CLI (Node)
+
+The Logseq CLI is a Node.js program compiled from ClojureScript that connects to the db-worker-node server.
+
+## Build the CLI
+
+```bash
+clojure -M:cljs compile logseq-cli
+```
+
+## Start db-worker-node (in another terminal)
+
+```bash
+clojure -M:cljs compile db-worker-node
+node ./static/db-worker-node.js
+```
+
+## Run the CLI
+
+```bash
+node ./static/logseq-cli.js ping --base-url http://127.0.0.1:9101
+```
+
+## Configuration
+
+Optional configuration file: `~/.logseq/cli.edn`
+
+Supported keys include:
+- `:base-url`
+- `:auth-token`
+- `:repo`
+- `:timeout-ms`
+- `:retries`
+- `:output-format` (use `:json` or `:edn` for scripting)
+
+CLI flags take precedence over environment variables, which take precedence over the config file.
+
+## Commands
+
+Graph commands:
+- `graph-list` - list all db graphs
+- `graph-create --graph <name>` - create a new db graph and switch to it
+- `graph-switch --graph <name>` - switch current graph
+- `graph-remove --graph <name>` - remove a graph
+- `graph-validate --graph <name>` - validate graph data
+- `graph-info [--graph <name>]` - show graph metadata (defaults to current graph)
+
+Graph content commands:
+- `add --content <text> [--page <name>] [--parent <uuid>]` - add blocks; defaults to today’s journal page if no page is given
+- `add --blocks <edn> [--page <name>] [--parent <uuid>]` - insert blocks via EDN vector
+- `add --blocks-file <path> [--page <name>] [--parent <uuid>]` - insert blocks from an EDN file
+- `remove --block <uuid>` - remove a block and its children
+- `remove --page <name>` - remove a page and its children
+- `search --text <query> [--limit <n>]` - search block titles (Datalog includes?)
+- `tree --page <name> [--format text|json|edn]` - show page tree
+- `tree --block <uuid> [--format text|json|edn]` - show block tree
+
+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
+```

+ 10 - 0
shadow-cljs.edn

@@ -95,6 +95,16 @@
                                       :warnings {:fn-deprecated false
                                                  :redef false}}}
 
+  :logseq-cli {:target :node-script
+               :output-to "static/logseq-cli.js"
+               :main logseq.cli.main/main
+               :compiler-options {:infer-externs :auto
+                                  :source-map true
+                                  :externs ["datascript/externs.js"
+                                            "externs.js"]
+                                  :warnings {:fn-deprecated false
+                                             :redef false}}}
+
   :inference-worker {:target        :browser
                      :module-loader true
                      :js-options    {:js-provider    :external

+ 592 - 0
src/main/logseq/cli/commands.cljs

@@ -0,0 +1,592 @@
+(ns logseq.cli.commands
+  (:require ["fs" :as fs]
+            [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]
+            [logseq.common.util :as common-util]
+            [logseq.common.util.date-time :as date-time-util]
+            [promesa.core :as p]))
+
+(def ^:private command->keyword
+  {"ping" :ping
+   "status" :status
+   "query" :query
+   "export" :export
+   "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 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/export)"]
+   [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"]
+   [nil "--query QUERY" "EDN query input"]
+   [nil "--file PATH" "Path to EDN query file"]
+   [nil "--out PATH" "Output path"]])
+
+(defn parse-args
+  [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)]
+    (cond
+      (seq errors)
+      {:ok? false
+       :error {:code :invalid-options
+               :message (string/join "\n" errors)}
+       :summary summary}
+
+      (:help options)
+      {:ok? false
+       :help? true
+       :summary summary}
+
+      (nil? command-str)
+      {:ok? false
+       :error {:code :missing-command
+               :message "missing command"}
+       :summary summary}
+
+      (nil? command)
+      {:ok? false
+       :error {:code :unknown-command
+               :message (str "unknown command: " command-str)}
+       :summary summary}
+
+      :else
+      {:ok? true
+       :command command
+       :options options
+       :args command-args
+       :summary summary})))
+
+(defn- graph->repo
+  [graph]
+  (when (seq graph)
+    (if (string/starts-with? graph common-config/db-version-prefix)
+      graph
+      (str common-config/db-version-prefix graph))))
+
+(defn- repo->graph
+  [repo]
+  (when (seq repo)
+    (string/replace-first repo common-config/db-version-prefix "")))
+
+(defn- pick-graph
+  [options command-args config]
+  (or (:graph options)
+      (:repo options)
+      (first command-args)
+      (:repo config)))
+
+(defn- read-query
+  [{:keys [query file]}]
+  (cond
+    (seq query)
+    {:ok? true :value (reader/read-string query)}
+
+    (seq file)
+    (let [contents (.toString (fs/readFileSync file) "utf8")]
+      {:ok? true :value (reader/read-string contents)})
+
+    :else
+    {:ok? false
+     :error {:code :missing-query
+             :message "query is required"}}))
+
+(defn- read-blocks
+  [options command-args]
+  (cond
+    (seq (:blocks options))
+    {:ok? true :value (reader/read-string (:blocks options))}
+
+    (seq (:blocks-file options))
+    (let [contents (.toString (fs/readFileSync (:blocks-file options)) "utf8")]
+      {:ok? true :value (reader/read-string contents)})
+
+    (seq (:content options))
+    {:ok? true :value [{:block/title (:content options)}]}
+
+    (seq command-args)
+    {:ok? true :value [{:block/title (string/join " " command-args)}]}
+
+    :else
+    {:ok? false
+     :error {:code :missing-content
+             :message "content is required"}}))
+
+(defn- ensure-vector
+  [value]
+  (if (vector? value)
+    {:ok? true :value value}
+    {:ok? false
+     :error {:code :invalid-query
+             :message "query must be a vector"}}))
+
+(defn- ensure-blocks
+  [value]
+  (if (vector? value)
+    {:ok? true :value value}
+    {:ok? false
+     :error {:code :invalid-blocks
+             :message "blocks must be a vector"}}))
+
+(defn- today-page-title
+  [config repo]
+  (p/let [journal (transport/invoke config "thread-api/pull" false
+                                    [repo [:logseq.property.journal/title-format] :logseq.class/Journal])
+          formatter (or (:logseq.property.journal/title-format journal) "MMM do, yyyy")
+          now (tc/from-date (js/Date.))]
+    (date-time-util/format now formatter)))
+
+(defn- ensure-page!
+  [config repo page-name]
+  (p/let [page (transport/invoke config "thread-api/pull" false
+                                 [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])]
+    (if (:db/id page)
+      page
+      (p/let [_ (transport/invoke config "thread-api/apply-outliner-ops" false
+                                  [repo [[:create-page [page-name {}]]] {}])]
+        (transport/invoke config "thread-api/pull" false
+                          [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name]])))))
+
+(defn- resolve-add-target
+  [config {:keys [repo page parent]}]
+  (if (seq parent)
+    (if-not (common-util/uuid-string? parent)
+      (p/rejected (ex-info "parent must be a uuid" {:code :invalid-parent}))
+      (p/let [block (transport/invoke config "thread-api/pull" false
+                                      [repo [:db/id :block/uuid :block/title] [:block/uuid (uuid parent)]])]
+        (if-let [id (:db/id block)]
+          id
+          (throw (ex-info "parent block not found" {:code :parent-not-found})))))
+    (p/let [page-name (if (seq page) page (today-page-title config repo))
+            page-entity (ensure-page! config repo page-name)]
+      (or (:db/id page-entity)
+          (throw (ex-info "page not found" {:code :page-not-found}))))))
+
+(defn- perform-remove
+  [config {:keys [repo block page]}]
+  (cond
+    (seq block)
+    (if-not (common-util/uuid-string? block)
+      (p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
+      (p/let [entity (transport/invoke config "thread-api/pull" false
+                                       [repo [:db/id :block/uuid] [:block/uuid (uuid block)]])]
+        (if-let [id (:db/id entity)]
+          (transport/invoke config "thread-api/apply-outliner-ops" false
+                            [repo [[:delete-blocks [[id] {}]]] {}])
+          (throw (ex-info "block not found" {:code :block-not-found})))))
+
+    (seq page)
+    (p/let [entity (transport/invoke config "thread-api/pull" false
+                                     [repo [:db/id :block/uuid] [:block/name page]])]
+      (if-let [page-uuid (:block/uuid entity)]
+        (transport/invoke config "thread-api/apply-outliner-ops" false
+                          [repo [[:delete-page [page-uuid]]] {}])
+        (throw (ex-info "page not found" {:code :page-not-found}))))
+
+    :else
+    (p/rejected (ex-info "block or page required" {:code :missing-target}))))
+
+(def ^:private tree-block-selector
+  [:db/id :block/uuid :block/title :block/order {:block/parent [:db/id]}])
+
+(defn- fetch-blocks-for-page
+  [config repo page-id]
+  (let [query [:find (list 'pull '?b tree-block-selector)
+               :in '$ '?page-id
+               :where ['?b :block/page '?page-id]]]
+    (p/let [rows (transport/invoke config "thread-api/q" false [repo [query page-id]])]
+      (mapv first rows))))
+
+(defn- build-tree
+  [blocks root-id]
+  (let [parent->children (group-by #(get-in % [:block/parent :db/id]) blocks)
+        sort-children (fn [children]
+                        (vec (sort-by :block/order children)))
+        build (fn build [parent-id]
+                (mapv (fn [b]
+                        (let [children (build (:db/id b))]
+                          (cond-> b
+                            (seq children) (assoc :block/children children))))
+                      (sort-children (get parent->children parent-id))))]
+    (build root-id)))
+
+(defn- fetch-tree
+  [config {:keys [repo block page]}]
+  (if (seq block)
+    (if-not (common-util/uuid-string? block)
+      (p/rejected (ex-info "block must be a uuid" {:code :invalid-block}))
+      (p/let [entity (transport/invoke config "thread-api/pull" false
+                                       [repo [:db/id :block/uuid :block/title {:block/page [:db/id :block/title]}]
+                                        [:block/uuid (uuid block)]])]
+        (if-let [page-id (get-in entity [:block/page :db/id])]
+          (p/let [blocks (fetch-blocks-for-page config repo page-id)
+                  children (build-tree blocks (:db/id entity))]
+            {:root (assoc entity :block/children children)})
+          (throw (ex-info "block not found" {:code :block-not-found})))))
+    (p/let [page-entity (transport/invoke config "thread-api/pull" false
+                                          [repo [:db/id :block/uuid :block/title] [:block/name page]])]
+      (if-let [page-id (:db/id page-entity)]
+        (p/let [blocks (fetch-blocks-for-page config repo page-id)
+                children (build-tree blocks page-id)]
+          {:root (assoc page-entity :block/children children)})
+        (throw (ex-info "page not found" {:code :page-not-found}))))))
+
+(defn- tree->text
+  [{:keys [root]}]
+  (let [title (or (:block/title root) (:block/name root) (str (:block/uuid root)))
+        lines (atom [title])
+        walk (fn walk [node depth]
+               (doseq [child (:block/children node)]
+                 (let [prefix (apply str (repeat depth "  "))
+                       label (or (:block/title child) (:block/name child) (str (:block/uuid child)))]
+                   (swap! lines conj (str prefix "- " label)))
+                 (walk child (inc depth))))]
+    (walk root 1)
+    (string/join "\n" @lines)))
+
+(defn- resolve-repo
+  [graph]
+  (let [graph (some-> graph string/trim)]
+    (when (seq graph)
+      (graph->repo graph))))
+
+(defn build-action
+  [parsed config]
+  (if-not (:ok? parsed)
+    parsed
+    (let [{:keys [command options args]} parsed
+          graph (pick-graph options args config)
+          repo (resolve-repo graph)]
+      (case command
+        :ping
+        {:ok? true :action {:type :ping}}
+
+        :status
+        {:ok? true :action {:type :status}}
+
+        :query
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-repo
+                   :message "repo is required for query"}}
+          (let [query-result (read-query options)]
+            (if-not (:ok? query-result)
+              query-result
+              (let [vector-result (ensure-vector (:value query-result))]
+                (if-not (:ok? vector-result)
+                  vector-result
+                  {:ok? true
+                   :action {:type :invoke
+                            :method "thread-api/q"
+                            :direct-pass? false
+                            :args [repo (:value vector-result)]}})))))
+
+        :export
+        (let [format (some-> (:format options) string/lower-case)
+              out (:out options)
+              repo repo]
+          (cond
+            (not (seq repo))
+            {:ok? false
+             :error {:code :missing-repo
+                     :message "repo is required for export"}}
+
+            (not (seq out))
+            {:ok? false
+             :error {:code :missing-output
+                     :message "output path is required"}}
+
+            (= format "edn")
+            {:ok? true
+             :action {:type :invoke
+                      :method "thread-api/export-edn"
+                      :direct-pass? false
+                      :args [repo {}]
+                      :write {:format :edn
+                              :path out}}}
+
+            (= format "db")
+            {:ok? true
+             :action {:type :invoke
+                      :method "thread-api/export-db"
+                      :direct-pass? true
+                      :args [repo]
+                      :write {:format :db
+                              :path out}}}
+
+            :else
+            {:ok? false
+             :error {:code :unsupported-format
+                     :message (str "unsupported format: " format)}}))
+
+        :graph-list
+        {:ok? true
+         :action {:type :invoke
+                  :method "thread-api/list-db"
+                  :direct-pass? false
+                  :args []}}
+
+        :graph-create
+        (if-not (seq graph)
+          {:ok? false
+           :error {:code :missing-graph
+                   :message "graph name is required"}}
+          {:ok? true
+           :action {:type :invoke
+                    :method "thread-api/create-or-open-db"
+                    :direct-pass? false
+                    :args [repo {}]
+                    :persist-repo (repo->graph repo)}})
+
+        :graph-switch
+        (if-not (seq graph)
+          {:ok? false
+           :error {:code :missing-graph
+                   :message "graph name is required"}}
+          {:ok? true
+           :action {:type :graph-switch
+                    :repo repo
+                    :graph (repo->graph repo)}})
+
+        :graph-remove
+        (if-not (seq graph)
+          {:ok? false
+           :error {:code :missing-graph
+                   :message "graph name is required"}}
+          {:ok? true
+           :action {:type :invoke
+                    :method "thread-api/unsafe-unlink-db"
+                    :direct-pass? false
+                    :args [repo]}})
+
+        :graph-validate
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-graph
+                   :message "graph name is required"}}
+          {:ok? true
+           :action {:type :invoke
+                    :method "thread-api/validate-db"
+                    :direct-pass? false
+                    :args [repo]}})
+
+        :graph-info
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-graph
+                   :message "graph name is required"}}
+          {:ok? true
+           :action {:type :graph-info
+                    :repo repo
+                    :graph (repo->graph repo)}})
+
+        :add
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-repo
+                   :message "repo is required for add"}}
+          (let [blocks-result (read-blocks options args)]
+            (if-not (:ok? blocks-result)
+              blocks-result
+              (let [vector-result (ensure-blocks (:value blocks-result))]
+                (if-not (:ok? vector-result)
+                  vector-result
+                  {:ok? true
+                   :action {:type :add
+                            :repo repo
+                            :graph (repo->graph repo)
+                            :page (:page options)
+                            :parent (:parent options)
+                            :blocks (:value vector-result)}})))))
+
+        :remove
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-repo
+                   :message "repo is required for remove"}}
+          (let [block (:block options)
+                page (:page options)]
+            (if (or (seq block) (seq page))
+              {:ok? true
+               :action {:type :remove
+                        :repo repo
+                        :block block
+                        :page page}}
+              {:ok? false
+               :error {:code :missing-target
+                       :message "block or page is required"}})))
+
+        :search
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-repo
+                   :message "repo is required for search"}}
+          (let [text (or (:text options) (string/join " " args))]
+            (if (seq text)
+              {:ok? true
+               :action {:type :search
+                        :repo repo
+                        :text text
+                        :limit (:limit options)}}
+              {:ok? false
+               :error {:code :missing-search-text
+                       :message "search text is required"}})))
+
+        :tree
+        (if-not (seq repo)
+          {:ok? false
+           :error {:code :missing-repo
+                   :message "repo is required for tree"}}
+          (let [block (:block options)
+                page (:page options)
+                target (or block page)]
+            (if (seq target)
+              {:ok? true
+               :action {:type :tree
+                        :repo repo
+                        :block block
+                        :page page
+                        :format (some-> (:format options) string/lower-case)}}
+              {:ok? false
+               :error {:code :missing-target
+                       :message "block or page is required"}})))
+
+        {:ok? false
+         :error {:code :unknown-command
+                 :message (str "unknown command: " command)}}))))
+
+(defn execute
+  [action config]
+  (case (:type action)
+    :ping
+    (-> (transport/ping config)
+        (p/then (fn [_]
+                  {:status :ok :data {:message "ok"}})))
+
+    :status
+    (-> (p/let [ready? (transport/ready config)
+                dbs (transport/list-db config)]
+          {:status :ok
+           :data {:ready ready?
+                  :dbs dbs}}))
+
+    :invoke
+    (-> (p/let [result (transport/invoke config
+                                         (:method action)
+                                         (:direct-pass? action)
+                                         (:args action))]
+          (when-let [repo (:persist-repo action)]
+            (cli-config/update-config! config {:repo repo}))
+          (if-let [write (:write action)]
+            (let [{:keys [format path]} write]
+              (transport/write-output {:format format :path path :data result})
+              {:status :ok
+               :data {:message (str "wrote " path)}})
+            {:status :ok :data {:result result}})))
+
+    :graph-switch
+    (-> (p/let [exists? (transport/invoke config "thread-api/db-exists" false [(:repo action)])]
+          (if-not exists?
+            {:status :error
+             :error {:code :graph-not-found
+                     :message (str "graph not found: " (:graph action))}}
+            (p/let [_ (transport/invoke config "thread-api/create-or-open-db" false [(:repo action) {}])]
+              (cli-config/update-config! config {:repo (:graph action)})
+              {:status :ok
+               :data {:message (str "switched to " (:graph action))}}))))
+
+    :graph-info
+    (-> (p/let [created (transport/invoke config "thread-api/pull" false [(:repo action) [:kv/value] :logseq.kv/graph-created-at])
+                schema (transport/invoke config "thread-api/pull" false [(:repo action) [:kv/value] :logseq.kv/schema-version])]
+          {:status :ok
+           :data {:graph (:graph action)
+                  :logseq.kv/graph-created-at (:kv/value created)
+                  :logseq.kv/schema-version (:kv/value schema)}}))
+
+    :add
+    (-> (p/let [target-id (resolve-add-target config action)
+                ops [[:insert-blocks [(:blocks action)
+                                      target-id
+                                      {:sibling? false
+                                       :bottom? true
+                                       :outliner-op :insert-blocks}]]]
+                result (transport/invoke config "thread-api/apply-outliner-ops" false [(:repo action) ops {}])]
+          {:status :ok
+           :data {:result result}}))
+
+    :remove
+    (-> (p/let [result (perform-remove config action)]
+          {:status :ok
+           :data {:result result}}))
+
+    :search
+    (-> (p/let [query '[:find ?e ?title
+                        :in $ ?q
+                        :where
+                        [?e :block/title ?title]
+                        [(clojure.string/includes? ?title ?q)]]
+                results (transport/invoke config "thread-api/q" false [(:repo action) [query (:text action)]])
+                mapped (mapv (fn [[id title]] {:db/id id :block/title title}) results)
+                limited (if (some? (:limit action)) (vec (take (:limit action) mapped)) mapped)]
+          {:status :ok
+           :data {:results limited}}))
+
+    :tree
+    (-> (p/let [tree-data (fetch-tree config action)
+                format (or (:format action) (when (:json? config) "json"))]
+          (case format
+            "edn"
+            {:status :ok
+             :data tree-data
+             :output-format :edn}
+
+            "json"
+            {:status :ok
+             :data tree-data
+             :output-format :json}
+
+            {:status :ok
+             :data {:message (tree->text tree-data)}})))
+
+    {:status :error
+     :error {:code :unknown-action
+             :message "unknown action"}}))

+ 83 - 0
src/main/logseq/cli/config.cljs

@@ -0,0 +1,83 @@
+(ns logseq.cli.config
+  (:require [cljs.reader :as reader]
+            [clojure.string :as string]
+            [goog.object :as gobj]
+            ["fs" :as fs]
+            ["os" :as os]
+            ["path" :as path]))
+
+(defn- parse-int
+  [value]
+  (when (and (some? value) (not (string/blank? value)))
+    (js/parseInt value 10)))
+
+(defn- default-config-path
+  []
+  (path/join (.homedir os) ".logseq" "cli.edn"))
+
+(defn- read-config-file
+  [config-path]
+  (when (and (some? config-path) (fs/existsSync config-path))
+    (let [contents (.toString (fs/readFileSync config-path) "utf8")]
+      (reader/read-string contents))))
+
+(defn- ensure-config-dir!
+  [config-path]
+  (when (seq config-path)
+    (let [dir (path/dirname config-path)]
+      (when (and (seq dir) (not (fs/existsSync dir)))
+        (.mkdirSync fs dir #js {:recursive true})))))
+
+(defn update-config!
+  [{:keys [config-path]} updates]
+  (let [path (or config-path (default-config-path))
+        current (or (read-config-file path) {})
+        next (merge current updates)]
+    (ensure-config-dir! path)
+    (.writeFileSync fs path (pr-str next))
+    next))
+
+(defn- env-config
+  []
+  (let [env (.-env js/process)]
+    (cond-> {}
+      (seq (gobj/get env "LOGSEQ_DB_WORKER_URL"))
+      (assoc :base-url (gobj/get env "LOGSEQ_DB_WORKER_URL"))
+
+      (seq (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN"))
+      (assoc :auth-token (gobj/get env "LOGSEQ_DB_WORKER_AUTH_TOKEN"))
+
+      (seq (gobj/get env "LOGSEQ_CLI_REPO"))
+      (assoc :repo (gobj/get env "LOGSEQ_CLI_REPO"))
+
+      (seq (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS"))
+      (assoc :timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_TIMEOUT_MS")))
+
+      (seq (gobj/get env "LOGSEQ_CLI_RETRIES"))
+      (assoc :retries (parse-int (gobj/get env "LOGSEQ_CLI_RETRIES")))
+
+      (seq (gobj/get env "LOGSEQ_CLI_CONFIG"))
+      (assoc :config-path (gobj/get env "LOGSEQ_CLI_CONFIG")))))
+
+(defn- build-base-url
+  [{:keys [host port]}]
+  (when (or (seq host) (some? port))
+    (str "http://" (or host "127.0.0.1") ":" (or port 9101))))
+
+(defn resolve-config
+  [opts]
+  (let [defaults {:base-url "http://127.0.0.1:9101"
+                  :timeout-ms 10000
+                  :retries 0
+                  :json? false
+                  :output-format nil
+                  :config-path (default-config-path)}
+        env (env-config)
+        config-path (or (:config-path opts)
+                        (:config-path env)
+                        (:config-path defaults))
+        file-config (or (read-config-file config-path) {})
+        merged (merge defaults file-config env opts {:config-path config-path})
+        derived (build-base-url merged)]
+    (cond-> merged
+      (seq derived) (assoc :base-url derived))))

+ 54 - 0
src/main/logseq/cli/format.cljs

@@ -0,0 +1,54 @@
+(ns logseq.cli.format
+  (:require [clojure.string :as string]
+            [clojure.walk :as walk]))
+
+(defn- normalize-json
+  [value]
+  (walk/postwalk (fn [entry]
+                   (if (uuid? entry)
+                     (str entry)
+                     entry))
+                 value))
+
+(defn- ->json
+  [{:keys [status data error]}]
+  (let [obj (js-obj)]
+    (set! (.-status obj) (name status))
+    (cond
+      (= status :ok)
+      (set! (.-data obj) (clj->js (normalize-json data)))
+
+      (= status :error)
+      (set! (.-error obj) (clj->js (normalize-json (update error :code name)))))
+    (js/JSON.stringify obj)))
+
+(defn- ->human
+  [{:keys [status data error]}]
+  (case status
+    :ok
+    (if (and (map? data) (contains? data :message))
+      (:message data)
+      (pr-str data))
+
+    :error
+    (str "error: " (:message error))
+
+    (pr-str {:status status :data data :error error})))
+
+(defn- ->edn
+  [{:keys [status data error]}]
+  (pr-str (cond-> {:status status}
+            (= status :ok) (assoc :data data)
+            (= status :error) (assoc :error error))))
+
+(defn format-result
+  [result {:keys [json? output-format]}]
+  (let [format (cond
+                 (= output-format :edn) :edn
+                 (= output-format :json) :json
+                 json? :json
+                 :else :human)]
+    (case format
+      :json (->json result)
+      :edn (->edn result)
+      (->human result))))

+ 65 - 0
src/main/logseq/cli/main.cljs

@@ -0,0 +1,65 @@
+(ns logseq.cli.main
+  (:refer-clojure :exclude [run!])
+  (:require [clojure.string :as string]
+            [logseq.cli.commands :as commands]
+            [logseq.cli.config :as config]
+            [logseq.cli.format :as format]
+            [promesa.core :as p]))
+
+(defn- usage
+  [summary]
+  (string/join "\n"
+               ["logseq-cli <command> [options]"
+                ""
+                "Commands: ping, status, query, export, graph-list, graph-create, graph-switch, graph-remove, graph-validate, graph-info, add, remove, search, tree"
+                ""
+                "Options:"
+                summary]))
+
+(defn run!
+  ([args] (run! args {:exit? true}))
+  ([args {:keys [exit?] :or {exit? true}}]
+   (let [parsed (commands/parse-args args)]
+     (cond
+       (:help? parsed)
+       (p/resolved {:exit-code 0
+                    :output (usage (:summary parsed))})
+
+       (not (:ok? parsed))
+       (p/resolved {:exit-code 1
+                    :output (format/format-result {:status :error
+                                                   :error (:error parsed)}
+                                                  {:json? false})})
+
+       :else
+       (let [cfg (config/resolve-config (:options parsed))
+             action-result (commands/build-action parsed cfg)]
+         (if-not (:ok? action-result)
+           (p/resolved {:exit-code 1
+                        :output (format/format-result {:status :error
+                                                       :error (:error action-result)}
+                                                      cfg)})
+           (-> (commands/execute (:action action-result) cfg)
+               (p/then (fn [result]
+                         (let [opts (cond-> cfg
+                                       (:output-format result)
+                                       (assoc :output-format (:output-format result)))]
+                           {:exit-code 0
+                            :output (format/format-result result opts)})))
+               (p/catch (fn [error]
+                          (let [message (or (some-> (ex-data error) :message)
+                                            (.-message error)
+                                            (str error))]
+                            {:exit-code 1
+                             :output (format/format-result {:status :error
+                                                            :error {:code :exception
+                                                                    :message message}}
+                                                           cfg)}))))))))))
+
+(defn main
+  [& args]
+  (-> (run! args)
+      (p/then (fn [{:keys [exit-code output]}]
+                (when (seq output)
+                  (println output))
+                (.exit js/process exit-code)))))

+ 142 - 0
src/main/logseq/cli/transport.cljs

@@ -0,0 +1,142 @@
+(ns logseq.cli.transport
+  (:require [clojure.string :as string]
+            [logseq.db :as ldb]
+            [promesa.core :as p]
+            ["fs" :as fs]
+            ["http" :as http]
+            ["https" :as https]
+            ["url" :as url]))
+
+(defn- request-module
+  [^js parsed]
+  (if (= "https:" (.-protocol parsed))
+    https
+    http))
+
+(defn- base-headers
+  [auth-token]
+  (cond-> {"Content-Type" "application/json"
+           "Accept" "application/json"}
+    (seq auth-token)
+    (assoc "Authorization" (str "Bearer " auth-token))))
+
+(defn- <raw-request
+  [{:keys [method url headers body timeout-ms]}]
+  (p/create
+   (fn [resolve reject]
+     (let [parsed (url/parse url)
+           module (request-module parsed)
+           timeout-ms (or timeout-ms 10000)
+           req (.request
+                module
+                #js {:method method
+                     :hostname (.-hostname parsed)
+                     :port (or (.-port parsed) (if (= "https:" (.-protocol parsed)) 443 80))
+                     :path (str (.-pathname parsed) (.-search parsed))
+                     :headers (clj->js headers)}
+                (fn [^js res]
+                  (let [chunks (array)]
+                    (.on res "data" (fn [chunk] (.push chunks chunk)))
+                    (.on res "end" (fn []
+                                     (let [buf (js/Buffer.concat chunks)]
+                                       (resolve {:status (.-statusCode res)
+                                                 :body (.toString buf "utf8")}))))
+                    (.on res "error" reject))))
+           timeout-id (js/setTimeout
+                       (fn []
+                         (.destroy req)
+                         (reject (ex-info "request timeout" {:code :timeout})))
+                       timeout-ms)]
+       (.on req "error" (fn [err]
+                           (js/clearTimeout timeout-id)
+                           (reject err)))
+       (when body
+         (.write req body))
+       (.end req)
+       (.on req "response" (fn [_]
+                              (js/clearTimeout timeout-id)))))))
+
+(defn- retryable-error?
+  [error]
+  (let [{:keys [code status]} (ex-data error)]
+    (or (= :timeout code)
+        (and (= :http-error code)
+             (>= (or status 0) 500)))))
+
+(defn request
+  [{:keys [method url headers body timeout-ms retries]
+    :or {retries 0}}]
+  (p/loop [attempt 0]
+    (-> (p/let [response (<raw-request {:method method
+                                        :url url
+                                        :headers headers
+                                        :body body
+                                        :timeout-ms timeout-ms})]
+          (if (<= 200 (:status response) 299)
+            response
+            (throw (ex-info "http request failed"
+                            {:code :http-error
+                             :status (:status response)
+                             :body (:body response)}))))
+        (p/catch (fn [error]
+                   (if (and (< attempt retries) (retryable-error? error))
+                     (p/recur (inc attempt))
+                     (throw error)))))))
+
+(defn ping
+  [{:keys [base-url timeout-ms retries]}]
+  (request {:method "GET"
+            :url (str (string/replace base-url #"/$" "") "/healthz")
+            :timeout-ms timeout-ms
+            :retries retries
+            :headers {}}))
+
+(defn ready
+  [{:keys [base-url timeout-ms retries]}]
+  (-> (request {:method "GET"
+                :url (str (string/replace base-url #"/$" "") "/readyz")
+                :timeout-ms timeout-ms
+                :retries retries
+                :headers {}})
+      (p/then (fn [_] true))))
+
+(defn invoke
+  [{:keys [base-url auth-token timeout-ms retries]}
+   method direct-pass? args]
+  (let [url (str (string/replace base-url #"/$" "") "/v1/invoke")
+        payload (if direct-pass?
+                  {:method method
+                   :directPass true
+                   :args args}
+                  {:method method
+                   :directPass false
+                   :argsTransit (ldb/write-transit-str args)})
+        body (js/JSON.stringify (clj->js payload))]
+    (p/let [{:keys [body]} (request {:method "POST"
+                                    :url url
+                                    :headers (base-headers auth-token)
+                                    :body body
+                                    :timeout-ms timeout-ms
+                                    :retries retries})
+            {:keys [result resultTransit]} (js->clj (js/JSON.parse body) :keywordize-keys true)]
+      (if direct-pass?
+        result
+        (ldb/read-transit-str resultTransit)))))
+
+(defn list-db
+  [config]
+  (invoke config "thread-api/list-db" false []))
+
+(defn write-output
+  [{:keys [format path data]}]
+  (case format
+    :edn
+    (fs/writeFileSync path (pr-str data))
+
+    :db
+    (let [buffer (if (instance? js/Buffer data)
+                   data
+                   (js/Buffer.from data))]
+      (fs/writeFileSync path buffer))
+
+    (throw (ex-info "unsupported output format" {:format format}))))

+ 108 - 0
src/test/logseq/cli/commands_test.cljs

@@ -0,0 +1,108 @@
+(ns logseq.cli.commands-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [logseq.cli.commands :as commands]))
+
+(deftest test-parse-args
+  (testing "parses ping"
+    (let [result (commands/parse-args ["ping"])]
+      (is (true? (:ok? result)))
+      (is (= :ping (:command result)))))
+
+  (testing "errors on missing command"
+    (let [result (commands/parse-args [])]
+      (is (false? (:ok? result)))
+      (is (= :missing-command (get-in result [:error :code])))))
+
+  (testing "errors on unknown command"
+    (let [result (commands/parse-args ["wat"])]
+      (is (false? (:ok? result)))
+      (is (= :unknown-command (get-in result [:error :code]))))))
+
+(deftest test-build-action
+  (testing "query requires repo"
+    (let [parsed {:ok? true
+                  :command :query
+                  :options {:query "[:find ?e :where [?e :block/name]]"}}
+          result (commands/build-action parsed {})]
+      (is (false? (:ok? result)))
+      (is (= :missing-repo (get-in result [:error :code])))))
+
+  (testing "query uses repo from config"
+    (let [parsed {:ok? true
+                  :command :query
+                  :options {:query "[:find ?e :where [?e :block/name]]"}}
+          result (commands/build-action parsed {:repo "test-repo"})]
+      (is (true? (:ok? result)))
+      (is (= "thread-api/q" (get-in result [:action :method])))))
+
+  (testing "export rejects unsupported format"
+    (let [parsed {:ok? true
+                  :command :export
+                  :options {:repo "repo" :format "nope" :out "output.edn"}}
+          result (commands/build-action parsed {})]
+      (is (false? (:ok? result)))
+      (is (= :unsupported-format (get-in result [:error :code])))))
+
+  (testing "export builds edn action"
+    (let [parsed {:ok? true
+                  :command :export
+                  :options {:repo "repo" :format "edn" :out "output.edn"}}
+          result (commands/build-action parsed {})]
+      (is (true? (:ok? result)))
+      (is (= "thread-api/export-edn" (get-in result [:action :method]))))))
+
+(deftest test-graph-commands
+  (testing "graph-list uses list-db"
+    (let [parsed {:ok? true :command :graph-list :options {}}
+          result (commands/build-action parsed {})]
+      (is (true? (:ok? result)))
+      (is (= "thread-api/list-db" (get-in result [:action :method])))))
+
+  (testing "graph-create requires graph name"
+    (let [parsed {:ok? true :command :graph-create :options {}}
+          result (commands/build-action parsed {})]
+      (is (false? (:ok? result)))
+      (is (= :missing-graph (get-in result [:error :code])))))
+
+  (testing "graph-switch uses graph name"
+    (let [parsed {:ok? true :command :graph-switch :options {:graph "demo"}}
+          result (commands/build-action parsed {})]
+      (is (true? (:ok? result)))
+      (is (= :graph-switch (get-in result [:action :type])))))
+
+  (testing "graph-info defaults to config repo"
+    (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]))))))
+
+(deftest test-content-commands
+  (testing "add requires content"
+    (let [parsed {:ok? true :command :add :options {}}
+          result (commands/build-action parsed {:repo "demo"})]
+      (is (false? (:ok? result)))
+      (is (= :missing-content (get-in result [:error :code])))))
+
+  (testing "add builds insert-blocks op"
+    (let [parsed {:ok? true :command :add :options {:content "hello"}}
+          result (commands/build-action parsed {:repo "demo"})]
+      (is (true? (:ok? result)))
+      (is (= :add (get-in result [:action :type])))))
+
+  (testing "remove requires target"
+    (let [parsed {:ok? true :command :remove :options {}}
+          result (commands/build-action parsed {:repo "demo"})]
+      (is (false? (:ok? result)))
+      (is (= :missing-target (get-in result [:error :code])))))
+
+  (testing "search requires text"
+    (let [parsed {:ok? true :command :search :options {}}
+          result (commands/build-action parsed {:repo "demo"})]
+      (is (false? (:ok? result)))
+      (is (= :missing-search-text (get-in result [:error :code])))))
+
+  (testing "tree requires target"
+    (let [parsed {:ok? true :command :tree :options {}}
+          result (commands/build-action parsed {:repo "demo"})]
+      (is (false? (:ok? result)))
+      (is (= :missing-target (get-in result [:error :code]))))))

+ 71 - 0
src/test/logseq/cli/config_test.cljs

@@ -0,0 +1,71 @@
+(ns logseq.cli.config-test
+  (:require [cljs.reader :as reader]
+            [cljs.test :refer [deftest is testing]]
+            [frontend.test.node-helper :as node-helper]
+            [goog.object :as gobj]
+            [logseq.cli.config :as config]
+            ["fs" :as fs]
+            ["path" :as path]))
+
+(defn- with-env
+  [env f]
+  (let [original (js/Object.assign #js {} (.-env js/process))]
+    (doseq [[k v] env]
+      (if (some? v)
+        (gobj/set (.-env js/process) k v)
+        (gobj/remove (.-env js/process) k)))
+    (try
+      (f)
+      (finally
+        (set! (.-env js/process) original)))))
+
+(deftest test-config-precedence
+  (let [dir (node-helper/create-tmp-dir)
+        cfg-path (path/join dir "cli.edn")
+        _ (fs/writeFileSync cfg-path
+                            (str "{:base-url \"http://file:7777\" "
+                                 ":auth-token \"file-token\" "
+                                 ":repo \"file-repo\" "
+                                 ":timeout-ms 111 "
+                                 ":retries 1}"))
+        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"}
+        opts {:config-path cfg-path
+              :base-url "http://cli:1234"
+              :auth-token "cli-token"
+              :repo "cli-repo"
+              :timeout-ms 333
+              :retries 3}
+        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)))))
+
+(deftest test-host-port-derived-base-url
+  (let [result (config/resolve-config {:host "127.0.0.2" :port 9200})]
+    (is (= "http://127.0.0.2:9200" (:base-url result)))))
+
+(deftest test-env-overrides-file
+  (let [dir (node-helper/create-tmp-dir)
+        cfg-path (path/join dir "cli.edn")
+        _ (fs/writeFileSync cfg-path "{:base-url \"http://file:7777\" :repo \"file-repo\"}")
+        env {"LOGSEQ_DB_WORKER_URL" "http://env:9999"
+             "LOGSEQ_CLI_REPO" "env-repo"}
+        result (with-env env #(config/resolve-config {:config-path cfg-path}))]
+    (is (= "http://env:9999" (:base-url result)))
+    (is (= "env-repo" (:repo result)))))
+
+(deftest test-update-config
+  (let [dir (node-helper/create-tmp-dir "cli")
+        cfg-path (path/join dir "cli.edn")
+        _ (fs/writeFileSync cfg-path "{:repo \"old\"}")
+        _ (config/update-config! {:config-path cfg-path} {:repo "new"})
+        contents (.toString (fs/readFileSync cfg-path) "utf8")
+        parsed (reader/read-string contents)]
+    (is (= "new" (:repo parsed)))))

+ 25 - 0
src/test/logseq/cli/format_test.cljs

@@ -0,0 +1,25 @@
+(ns logseq.cli.format-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [logseq.cli.format :as format]))
+
+(deftest test-format-success
+  (testing "json output"
+    (let [result (format/format-result {:status :ok :data {:message "ok"}}
+                                       {:json? true})]
+      (is (= "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" result))))
+
+  (testing "human output"
+    (let [result (format/format-result {:status :ok :data {:message "ok"}}
+                                       {:json? false})]
+      (is (= "ok" result)))))
+
+(deftest test-format-error
+  (testing "json error"
+    (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}}
+                                       {:json? true})]
+      (is (= "{\"status\":\"error\",\"error\":{\"code\":\"boom\",\"message\":\"nope\"}}" result))))
+
+  (testing "human error"
+    (let [result (format/format-result {:status :error :error {:code :boom :message "nope"}}
+                                       {:json? false})]
+      (is (= "error: nope" result)))))

+ 106 - 0
src/test/logseq/cli/integration_test.cljs

@@ -0,0 +1,106 @@
+(ns logseq.cli.integration-test
+  (:require [cljs.test :refer [deftest is async]]
+            [frontend.test.node-helper :as node-helper]
+            [frontend.worker.db-worker-node :as db-worker-node]
+            [logseq.cli.main :as cli-main]
+            [promesa.core :as p]
+            ["fs" :as fs]
+            ["path" :as path]))
+
+(defn- run-cli
+  [args url cfg-path]
+  (cli-main/run! (vec (concat args ["--base-url" url "--config" cfg-path "--json"]))
+                 {:exit? false}))
+
+(defn- parse-json-output
+  [result]
+  (js->clj (js/JSON.parse (:output result)) :keywordize-keys true))
+
+(deftest test-cli-ping
+  (async done
+    (let [data-dir (node-helper/create-tmp-dir "db-worker")]
+      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
+                                                        :port 0
+                                                        :data-dir data-dir})
+                  url (str "http://127.0.0.1:" (:port daemon))
+                  result (cli-main/run! ["ping" "--base-url" url "--json"] {:exit? false})]
+            (is (= 0 (:exit-code result)))
+            (is (= "{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}" (:output result)))
+            (p/let [_ ((:stop! daemon))]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))
+
+(deftest test-cli-graph-list
+  (async done
+    (let [data-dir (node-helper/create-tmp-dir "db-worker")]
+      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
+                                                        :port 0
+                                                        :data-dir data-dir})
+                  url (str "http://127.0.0.1:" (:port daemon))
+                  cfg-path (path/join (node-helper/create-tmp-dir "cli") "cli.edn")
+                  result (run-cli ["graph-list"] url cfg-path)
+                  payload (parse-json-output result)]
+            (is (= 0 (:exit-code result)))
+            (is (= "ok" (:status payload)))
+            (is (contains? payload :data))
+            (p/let [_ ((:stop! daemon))]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))
+
+(deftest test-cli-graph-create-and-info
+  (async done
+    (let [data-dir (node-helper/create-tmp-dir "db-worker")]
+      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
+                                                        :port 0
+                                                        :data-dir data-dir})
+                  url (str "http://127.0.0.1:" (:port daemon))
+                  cfg-path (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-payload (parse-json-output create-result)
+                  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)))
+            (is (= 0 (:exit-code info-result)))
+            (is (= "ok" (:status info-payload)))
+            (is (= "demo-graph" (get-in info-payload [:data :graph])))
+            (p/let [_ ((:stop! daemon))]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))
+
+(deftest test-cli-add-search-tree-remove
+  (async done
+    (let [data-dir (node-helper/create-tmp-dir "db-worker")]
+      (-> (p/let [daemon (db-worker-node/start-daemon! {:host "127.0.0.1"
+                                                        :port 0
+                                                        :data-dir data-dir})
+                  url (str "http://127.0.0.1:" (:port daemon))
+                  cfg-path (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)
+                  _ (parse-json-output add-result)
+                  search-result (run-cli ["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-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-payload (parse-json-output remove-result)]
+            (is (= 0 (:exit-code add-result)))
+            (is (= "ok" (:status search-payload)))
+            (is (seq (get-in search-payload [:data :results])))
+            (is (= "ok" (:status tree-payload)))
+            (is (= "ok" (:status remove-payload)))
+            (p/let [_ ((:stop! daemon))]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))

+ 64 - 0
src/test/logseq/cli/transport_test.cljs

@@ -0,0 +1,64 @@
+(ns logseq.cli.transport-test
+  (:require [cljs.test :refer [deftest is async testing]]
+            [promesa.core :as p]
+            [logseq.cli.transport :as transport]))
+
+(defn- start-server
+  [handler]
+  (p/create
+   (fn [resolve reject]
+     (let [http (js/require "http")
+           server (.createServer http handler)]
+       (.on server "error" reject)
+       (.listen server 0 "127.0.0.1"
+                (fn []
+                  (let [address (.address server)
+                        port (.-port address)
+                        stop! (fn []
+                                (p/create (fn [resolve _]
+                                            (.close server (fn [] (resolve true))))))]
+                    (resolve {:url (str "http://127.0.0.1:" port)
+                              :stop! stop!}))))))))
+
+(deftest test-request-retries
+  (async done
+    (let [calls (atom 0)]
+      (-> (p/let [{:keys [url stop!]} (start-server
+                                       (fn [_req res]
+                                         (let [attempt (swap! calls inc)]
+                                           (if (= attempt 1)
+                                             (do
+                                               (.writeHead res 500 #js {"Content-Type" "text/plain"})
+                                               (.end res "boom"))
+                                             (do
+                                               (.writeHead res 200 #js {"Content-Type" "text/plain"})
+                                               (.end res "ok"))))))
+                 response (transport/request {:method "GET"
+                                              :url (str url "/retry")
+                                              :retries 1
+                                              :timeout-ms 1000})]
+            (is (= 200 (:status response)))
+            (is (= 2 @calls))
+            (p/let [_ (stop!)]
+              (done)))
+          (p/catch (fn [e]
+                     (is false (str "unexpected error: " e))
+                     (done)))))))
+
+(deftest test-request-timeout
+  (async done
+    (-> (p/let [{:keys [url stop!]} (start-server
+                                     (fn [_req _res]
+                                       nil))]
+          (p/catch
+           (transport/request {:method "GET"
+                               :url (str url "/hang")
+                               :timeout-ms 10
+                               :retries 0})
+           (fn [e]
+             (is (= :timeout (-> (ex-data e) :code)))
+             (p/let [_ (stop!)]
+               (done)))))
+        (p/catch (fn [e]
+                   (is false (str "unexpected error: " e))
+                   (done))))))