diff --git a/skills/logseq-cli/SKILL.md b/skills/logseq-cli/SKILL.md index 5533054c4c..51536dcf31 100644 --- a/skills/logseq-cli/SKILL.md +++ b/skills/logseq-cli/SKILL.md @@ -19,13 +19,13 @@ Use `logseq` to inspect and edit graph entities, run Datascript queries, and con ## Command groups (from `logseq --help`) - Graph inspect/edit: -- `list page`, `list tag`, `list property` -- `upsert block`, `upsert page`, `upsert tag`, `upsert property` +- `list node`, `list page`, `list tag`, `list property`, `list task`, `list asset` +- `upsert block`, `upsert page`, `upsert tag`, `upsert property`, `upsert task`, `upsert assert` - `remove block`, `remove page`, `remove tag`, `remove property` -- `query`, `query list`, `show` -- Graph management: `graph list|create|switch|remove|validate|info|export|import` -- Server management: `server list|status|start|stop|restart` -- Diagnostics: `doctor` +- `query`, `query list`, `show`, `search` +- Graph management: `graph list|create|switch|remove|validate|info|export|import|backup` +- Server management: `server list|cleanup|start|stop|restart` +- Diagnostics: `doctor`, `debug` ## Global options @@ -84,8 +84,4 @@ Use `logseq` to inspect and edit graph entities, run Datascript queries, and con - `upsert block` enters update mode when `--id` or `--uuid` is provided. - Always verify command flags with `logseq --help` and `logseq <...> --help` before execution. - If `logseq` reports that it doesn’t have read/write permission for data-dir, then add read/write permission for data-dir in the agent’s config. -- In sandboxed environments, `graph create` may print a process-scan warning to stderr; if command status is `ok`, the graph is still created. - -## References - -- Built-in tags and properties: See `references/logseq-builtins.md` when you need canonical built-ins for `list ... --include-built-in` or for tag/property upsert fields. +- In sandboxed environments, `graph create` may print a process-scan warning to stderr; if command status is `ok`, the graph is still created. \ No newline at end of file diff --git a/src/main/frontend/worker/platform/node.cljs b/src/main/frontend/worker/platform/node.cljs index e5143d3f26..ec376a63d5 100644 --- a/src/main/frontend/worker/platform/node.cljs +++ b/src/main/frontend/worker/platform/node.cljs @@ -406,9 +406,8 @@ :close-db (fn [db] (.close db)) :exec (fn [db sql-or-opts] (.exec db sql-or-opts)) :transaction (fn [db f] (.transaction db f)) - :backup-db (fn [db path] - (let [backup-fn (gobj/get db "backup")] - (backup-fn path)))} + :backup-db (fn [^js db path] + (.backup db path))} :crypto {:save-secret-text! (fn [key text] ((:set! kv) (secret-key key) text)) :read-secret-text (fn [key] diff --git a/src/main/logseq/cli/command/add.cljs b/src/main/logseq/cli/command/add.cljs index 44ad650a63..eb18f8b3d7 100644 --- a/src/main/logseq/cli/command/add.cljs +++ b/src/main/logseq/cli/command/add.cljs @@ -69,22 +69,6 @@ (transport/invoke config :thread-api/pull false [repo [:db/id :block/uuid :block/name :block/title] [:block/name page-name-lc]])))))) -;; TODO: Replace uses of this fn with ensure-page! when users are able to specify ids for contexts this -;; is used in -(defn- ensure-first-page! - "Unlike ensure-page!, chooses the first random page and doesn't ensure the page is unique. Only use this - when the user unable to specificy the specific page e.g. page refs in a block/title" - [config repo page-name] - (let [page-name-lc (common-util/page-name-sanity-lc 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-lc]])] - (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-lc]])))))) - (defn pull-tag-by-name "Look up a tag by name, constrained to entities tagged with :logseq.class/Tag." [config repo tag-name selector] @@ -266,6 +250,10 @@ (remove string/blank?) vec)) +(defn- integer-string? + [s] + (boolean (re-matches #"-?\d+" s))) + (defn- partition-ref-values [refs] (reduce @@ -278,9 +266,12 @@ (common-util/uuid-string? value) (update acc :uuid-refs conj value) + (integer-string? value) + (update acc :id-refs conj value) + :else (update acc :page-refs conj value)))) - {:uuid-refs [] :page-refs []} + {:uuid-refs [] :page-refs [] :id-refs []} refs)) (defn- resolve-page-ref-entities @@ -295,7 +286,7 @@ page-refs)] (p/let [resolved (p/all (map (fn [[_ page-name]] - (p/let [page (ensure-first-page! config repo page-name) + (p/let [page (ensure-page! config repo page-name) page-uuid (:block/uuid page)] (when-not page-uuid (throw (ex-info "page not found" @@ -320,6 +311,26 @@ :uuid uuid-ref}))))) (distinct uuid-refs))))) +(defn- resolve-id-ref-entities + "Resolve integer id refs (db/id values) to entity maps with :block/uuid and + :block/title so they can be normalized like page-name refs." + [config repo id-refs] + (if (seq id-refs) + (p/let [entities (p/all + (map (fn [id-str] + (let [id (parse-long id-str)] + (p/let [entity (transport/invoke config :thread-api/pull false + [repo [:db/id :block/uuid :block/title] id])] + (when-not (:db/id entity) + (throw (ex-info (str "id ref not found: " id-str) + {:code :id-ref-not-found + :id id-str}))) + {:block/uuid (:block/uuid entity) + :block/title id-str}))) + (distinct id-refs)))] + (vec entities)) + (p/resolved nil))) + (defn- normalize-block-title-refs [blocks refs] (mapv (fn update-block [block] @@ -754,7 +765,7 @@ (and (string? value) (common-util/uuid-string? (string/trim value))) (resolve-entity-id config repo [:block/uuid (uuid (string/trim value))]) (string? value) - (p/let [page (ensure-first-page! config repo value)] + (p/let [page (ensure-page! config repo value)] (or (:db/id page) (throw (ex-info "page not found" {:code :page-not-found :value value})))) :else @@ -1138,9 +1149,11 @@ (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) target-block-uuid (resolve-add-target cfg action) ref-values (collect-page-refs (:blocks action)) - {:keys [uuid-refs page-refs]} (partition-ref-values ref-values) + {:keys [uuid-refs page-refs id-refs]} (partition-ref-values ref-values) _ (ensure-block-refs-exist! cfg (:repo action) uuid-refs) - refs (or (resolve-page-ref-entities cfg (:repo action) page-refs) []) + page-refs' (or (resolve-page-ref-entities cfg (:repo action) page-refs) []) + id-refs' (or (resolve-id-ref-entities cfg (:repo action) id-refs) []) + refs (into page-refs' id-refs') blocks (if (seq refs) (normalize-block-title-refs (:blocks action) refs) (:blocks action)) diff --git a/src/main/logseq/cli/completion_generator.cljs b/src/main/logseq/cli/completion_generator.cljs index 0f445c18bf..ecb2b5eba5 100644 --- a/src/main/logseq/cli/completion_generator.cljs +++ b/src/main/logseq/cli/completion_generator.cljs @@ -358,41 +358,30 @@ _logseq_multi_values() { [cmds] (str "_logseq_" (string/join "_" cmds))) -(defn- zsh-group-function - "Generate a group dispatcher function. - Handles groups that have both a root command (e.g. [\"query\"]) and - subcommands (e.g. [\"query\" \"list\"])." - [group-name subentries global-spec] - (let [func-name (str "_logseq_" group-name) - ;; Separate root entry from subcommand entries - root-entry (first (filter #(= 1 (count (:cmds %))) subentries)) - sub-entries (filter #(> (count (:cmds %)) 1) subentries) - ;; Root-level options (global + root command's own spec if present) - root-spec (if root-entry - (:spec root-entry) - global-spec) +(defn- zsh-subgroup-function + "Generate a dispatcher for a 3-level subgroup (e.g. graph backup)." + [parent-name subgroup-name sub-entries global-spec] + (let [func-name (str "_logseq_" parent-name "_" subgroup-name) global-keys (set (keys global-spec)) - root-tokens (zsh-arguments-tokens root-spec global-keys) - root-lines (string/join " \\\n " root-tokens) + tokens (zsh-arguments-tokens global-spec global-keys) + options-lines (string/join " \\\n " tokens) subcmds (->> sub-entries (mapv (fn [entry] - (let [subcmd (second (:cmds entry)) + (let [sub-subcmd (nth (:cmds entry) 2) desc (or (:desc entry) "")] - (str " '" subcmd ":" desc "'"))))) + (str " '" sub-subcmd ":" desc "'"))))) subcmd-lines (string/join "\n" subcmds) dispatches (->> sub-entries (mapv (fn [entry] - (let [subcmd (second (:cmds entry)) - sub-func (cmd->func-name (:cmds entry))] - (str " " subcmd ") " sub-func " ;;")))) - ) - dispatch-lines (string/join "\n" dispatches)] + (let [sub-subcmd (nth (:cmds entry) 2) + leaf-func (cmd->func-name (:cmds entry))] + (str " " sub-subcmd ") " leaf-func " ;;")))))] (str func-name "() {\n" " local curcontext=\"$curcontext\" state line\n" " typeset -A opt_args\n" "\n" " _arguments -C -s \\\n" - " " root-lines " \\\n" + " " options-lines " \\\n" " '1:subcommand:->subcmd' \\\n" " '*::args:->args'\n" "\n" @@ -406,12 +395,90 @@ _logseq_multi_values() { " ;;\n" " args)\n" " case $line[1] in\n" - dispatch-lines "\n" + (string/join "\n" dispatches) "\n" " esac\n" " ;;\n" " esac\n" "}\n"))) +(defn- zsh-group-function + "Generate a group dispatcher function. + Handles groups that have both a root command (e.g. [\"query\"]) and + subcommands (e.g. [\"query\" \"list\"]). + Also handles nested subgroups (e.g. [\"graph\" \"backup\" \"list\"])." + [group-name subentries global-spec] + (let [func-name (str "_logseq_" group-name) + ;; Separate root entry from subcommand entries + root-entry (first (filter #(= 1 (count (:cmds %))) subentries)) + sub-entries (filter #(> (count (:cmds %)) 1) subentries) + ;; Partition into leaf subcmds (2 elements) and nested (3+ elements) + leaf-entries (filter #(= 2 (count (:cmds %))) sub-entries) + nested-entries (filter #(> (count (:cmds %)) 2) sub-entries) + ;; Group nested entries by their 2nd element to find subgroups + nested-groups (group-by #(second (:cmds %)) nested-entries) + ;; Generate subgroup dispatcher functions + subgroup-fns (when (seq nested-groups) + (->> nested-groups + (mapv (fn [[sg-name sg-entries]] + (zsh-subgroup-function group-name sg-name sg-entries global-spec))) + (string/join "\n"))) + ;; Root-level options (global + root command's own spec if present) + root-spec (if root-entry + (:spec root-entry) + global-spec) + global-keys (set (keys global-spec)) + root-tokens (zsh-arguments-tokens root-spec global-keys) + root-lines (string/join " \\\n " root-tokens) + ;; Build subcmd descriptions: leaf entries + subgroup names + leaf-subcmds (->> leaf-entries + (mapv (fn [entry] + (let [subcmd (second (:cmds entry)) + desc (or (:desc entry) "")] + (str " '" subcmd ":" desc "'"))))) + subgroup-subcmds (->> (keys nested-groups) + sort + (mapv (fn [sg-name] + (str " '" sg-name ":" sg-name " commands'")))) + subcmd-lines (string/join "\n" (concat leaf-subcmds subgroup-subcmds)) + ;; Build dispatch: leaf entries dispatch to leaf fns, subgroups to subgroup fns + leaf-dispatches (->> leaf-entries + (mapv (fn [entry] + (let [subcmd (second (:cmds entry)) + sub-func (cmd->func-name (:cmds entry))] + (str " " subcmd ") " sub-func " ;;"))))) + subgroup-dispatches (->> (keys nested-groups) + sort + (mapv (fn [sg-name] + (str " " sg-name ") _logseq_" group-name "_" sg-name " ;;")))) + dispatch-lines (string/join "\n" (concat leaf-dispatches subgroup-dispatches)) + group-fn (str func-name "() {\n" + " local curcontext=\"$curcontext\" state line\n" + " typeset -A opt_args\n" + "\n" + " _arguments -C -s \\\n" + " " root-lines " \\\n" + " '1:subcommand:->subcmd' \\\n" + " '*::args:->args'\n" + "\n" + " case $state in\n" + " subcmd)\n" + " local -a subcmds\n" + " subcmds=(\n" + subcmd-lines "\n" + " )\n" + " _describe 'subcommand' subcmds\n" + " ;;\n" + " args)\n" + " case $line[1] in\n" + dispatch-lines "\n" + " esac\n" + " ;;\n" + " esac\n" + "}\n")] + (if subgroup-fns + (str subgroup-fns "\n" group-fn) + group-fn))) + (defn- zsh-toplevel-function "Generate the _logseq() root dispatcher." [table global-spec] @@ -434,8 +501,7 @@ _logseq_multi_values() { (= 1 (count (:cmds (first entries))))) (cmd->func-name (:cmds (first entries))) (str "_logseq_" g))] - (str " " g ") " func " ;;")))) - ) + (str " " g ") " func " ;;"))))) dispatch-lines (string/join "\n" dispatches) global-keys (set (keys global-spec)) global-tokens (zsh-arguments-tokens global-spec global-keys) @@ -686,7 +752,7 @@ _logseq_multi_values_bash() { [] "_logseq_cmd_and_subcmd() { local i skip=0 - __cmd='' __subcmd='' + __cmd='' __subcmd='' __subsubcmd='' for (( i = 1; i < COMP_CWORD; i++ )); do local w=\"${COMP_WORDS[i]}\" if (( skip )); then skip=0; continue; fi @@ -698,6 +764,8 @@ _logseq_multi_values_bash() { __cmd=\"$w\" elif [[ -z \"$__subcmd\" ]]; then __subcmd=\"$w\" + elif [[ -z \"$__subsubcmd\" ]]; then + __subsubcmd=\"$w\" fi done }\n") @@ -739,31 +807,52 @@ _logseq_multi_values_bash() { (let [;; Root entry opts go at group level, sub-entries get case branches root-entry (first (filter #(= 1 (count (:cmds %))) entries)) sub-entries (filter #(> (count (:cmds %)) 1) entries) + leaf-subs (filter #(= 2 (count (:cmds %))) sub-entries) + nested-subs (filter #(> (count (:cmds %)) 2) sub-entries) + nested-groups (group-by #(second (:cmds %)) nested-subs) root-opts (when root-entry (let [cmd-spec (apply dissoc (:spec root-entry) (keys global-spec))] (->> (keys cmd-spec) (mapcat (fn [k] (bash-option-names k (get cmd-spec k)))) (string/join " ")))) - sub-branches - (->> sub-entries + leaf-branches + (->> leaf-subs (mapv (fn [entry] (let [subcmd (second (:cmds entry)) cmd-spec (apply dissoc (:spec entry) (keys global-spec)) cmd-opts (->> (keys cmd-spec) (mapcat (fn [k] (bash-option-names k (get cmd-spec k)))) (string/join " "))] - (str " " subcmd ") opts+=' " cmd-opts "' ;;")))) - (string/join "\n"))] + (str " " subcmd ") opts+=' " cmd-opts "' ;;"))))) + ;; For nested subgroups, dispatch on subsubcmd + nested-branches + (->> (sort-by first nested-groups) + (mapv (fn [[sg-name sg-entries]] + (let [inner (->> sg-entries + (mapv (fn [entry] + (let [sub-subcmd (nth (:cmds entry) 2) + cmd-spec (apply dissoc (:spec entry) (keys global-spec)) + cmd-opts (->> (keys cmd-spec) + (mapcat (fn [k] (bash-option-names k (get cmd-spec k)))) + (string/join " "))] + (str " " sub-subcmd ") opts+=' " cmd-opts "' ;;")))) + (string/join "\n"))] + (str " " sg-name ")\n" + " case \"$subsubcmd\" in\n" + inner "\n" + " esac\n" + " ;;"))))) + all-branches (concat leaf-branches nested-branches) + sub-branches (string/join "\n" all-branches)] (str " " group-name ")\n" (when (seq root-opts) (str " opts+=' " root-opts "'\n")) " case \"$subcmd\" in\n" sub-branches "\n" " esac\n" - " ;;")))) - ))] + " ;;"))))))] (str "_logseq_opts_for() {\n" - " local cmd=\"$1\" subcmd=\"$2\"\n" + " local cmd=\"$1\" subcmd=\"$2\" subsubcmd=\"$3\"\n" " local opts=\"" global-str "\"\n" "\n" " case \"$cmd\" in\n" @@ -841,7 +930,9 @@ _logseq_multi_values_bash() { nil))) (defn- bash-subcommand-cases - "Generate subcommand completion for each group." + "Generate subcommand completion for each group. + Deduplicates subcmd names so that 3-level commands like + [\"graph\" \"backup\" \"list\"] produce a single \"backup\" entry." [table] (let [groups (extract-groups table) cases (->> (sort-by first groups) @@ -850,12 +941,35 @@ _logseq_multi_values_bash() { (> (count (:cmds (first entries))) 1)) (let [subcmds (->> entries (keep #(second (:cmds %))) + distinct (string/join " "))] (when (seq subcmds) (str " " group-name ") COMPREPLY=( $(compgen -W '" subcmds "' -- \"$cur\") ) ;;")))))))] (string/join "\n" cases))) +(defn- bash-sub-subcommand-cases + "Generate sub-subcommand completion for 3-level command groups. + Produces cases like: graph:backup) COMPREPLY=( $(compgen -W 'list create ...' ...) ) ;;" + [table] + (let [groups (extract-groups table) + cases (->> (sort-by first groups) + (mapcat (fn [[group-name entries]] + (let [nested (filter #(> (count (:cmds %)) 2) entries) + nested-groups (group-by #(second (:cmds %)) nested)] + (->> (sort-by first nested-groups) + (mapv (fn [[sg-name sg-entries]] + (let [sub-subcmds (->> sg-entries + (map #(nth (:cmds %) 2)) + distinct + (string/join " "))] + (str " " group-name ":" sg-name + ") COMPREPLY=( $(compgen -W '" + sub-subcmds "' -- \"$cur\") ) ;;"))))))))) + cases (remove nil? cases)] + (when (seq cases) + (string/join "\n" cases)))) + (defn- bash-toplevel-commands "Get all top-level command names." [table] @@ -1003,6 +1117,8 @@ _logseq_multi_values_bash() { varied-cases (bash-varied-prev-cases table varied-keys global-keys) ;; Subcommand completion subcmd-cases (bash-subcommand-cases table) + ;; Sub-subcommand completion for 3-level commands + sub-subcmd-cases (bash-sub-subcommand-cases table) ;; Top-level commands top-cmds (bash-toplevel-commands table)] (str "_logseq() {\n" @@ -1011,7 +1127,7 @@ _logseq_multi_values_bash() { " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n" " COMPREPLY=()\n" "\n" - " local __cmd __subcmd\n" + " local __cmd __subcmd __subsubcmd\n" " _logseq_cmd_and_subcmd\n" "\n" " # --- Option value completion ---\n" @@ -1024,7 +1140,7 @@ _logseq_multi_values_bash() { " # --- Flag / positional completion ---\n" " if [[ \"$cur\" == -* ]]; then\n" " # shellcheck disable=SC2046\n" - " COMPREPLY=( $(compgen -W \"$(_logseq_opts_for \"$__cmd\" \"$__subcmd\")\" -- \"$cur\") )\n" + " COMPREPLY=( $(compgen -W \"$(_logseq_opts_for \"$__cmd\" \"$__subcmd\" \"$__subsubcmd\")\" -- \"$cur\") )\n" " return\n" " fi\n" "\n" @@ -1039,6 +1155,14 @@ _logseq_multi_values_bash() { " esac\n" " return\n" " fi\n" + "\n" + (when (seq sub-subcmd-cases) + (str " if [[ -z \"$__subsubcmd\" ]]; then\n" + " case \"$__cmd:$__subcmd\" in\n" + sub-subcmd-cases "\n" + " esac\n" + " return\n" + " fi\n")) "}\n"))) (defn generate-bash diff --git a/src/test/logseq/cli/command/add_test.cljs b/src/test/logseq/cli/command/add_test.cljs index 2dd0f8da74..f2d399037c 100644 --- a/src/test/logseq/cli/command/add_test.cljs +++ b/src/test/logseq/cli/command/add_test.cljs @@ -47,6 +47,30 @@ (is (= :add-id-resolution-failed (-> error ex-data :code))) (is (= [uuid-b] (-> error ex-data :missing-uuids)))))) +(deftest test-partition-ref-values + (testing "partitions uuid, integer id, and page-name refs" + (let [result (#'add-command/partition-ref-values + ["some page" + "550e8400-e29b-41d4-a716-446655440000" + "101" + " 42 " + "another page" + "" + " "])] + (is (= ["550e8400-e29b-41d4-a716-446655440000"] (:uuid-refs result))) + (is (= ["101" "42"] (:id-refs result))) + (is (= ["some page" "another page"] (:page-refs result))))) + + (testing "negative integers are recognized as id refs" + (let [result (#'add-command/partition-ref-values ["-5"])] + (is (= ["-5"] (:id-refs result))) + (is (empty? (:page-refs result))))) + + (testing "non-integer numbers stay as page refs" + (let [result (#'add-command/partition-ref-values ["3.14" "1e5"])] + (is (empty? (:id-refs result))) + (is (= ["3.14" "1e5"] (:page-refs result)))))) + (def ^:private mock-transport-invoke (fn [_ method _ args] (case method @@ -77,6 +101,26 @@ (p/rejected (ex-info "unexpected method" {:method method :args args}))))) +(deftest test-resolve-id-ref-entities + (testing "resolves integer id refs to uuid+title maps" + (async done + (let [page-uuid (random-uuid) + mock-invoke (fn [_ _ _ args] + (let [[_ _ lookup] args] + (p/resolved + (cond + (= lookup 101) + {:db/id 101 :block/uuid page-uuid :block/title "My Page"} + :else {}))))] + (-> (p/with-redefs [transport/invoke mock-invoke] + (p/let [result (#'add-command/resolve-id-ref-entities {} "demo" ["101"])] + (is (= 1 (count result))) + (is (= page-uuid (:block/uuid (first result)))) + (is (= "101" (:block/title (first result))) + "title is the original id string for title-ref->id-ref replacement"))) + (p/catch (fn [e] (is false (str "unexpected error: " e)))) + (p/finally done)))))) + (deftest test-resolve-tags-accepts-valid-tag (async done (-> (p/with-redefs [transport/invoke mock-transport-invoke] diff --git a/src/test/logseq/cli/completion_generator_test.cljs b/src/test/logseq/cli/completion_generator_test.cljs index 61ce045f38..8c310e2868 100644 --- a/src/test/logseq/cli/completion_generator_test.cljs +++ b/src/test/logseq/cli/completion_generator_test.cljs @@ -563,6 +563,45 @@ (testing "uniform options like --cardinality are not varied" (is (not (contains? varied :cardinality)))))) +(deftest test-zsh-nested-subcommand-completion + (let [output (gen/generate-completions "zsh" full-table)] + (testing "zsh generates subgroup dispatcher for graph backup" + (is (string/includes? output "_logseq_graph_backup()")) + (is (re-find #"(?s)_logseq_graph_backup\(\).*?'list:" output) + "graph backup dispatcher lists 'list' subcommand") + (is (re-find #"(?s)_logseq_graph_backup\(\).*?'create:" output) + "graph backup dispatcher lists 'create' subcommand") + (is (re-find #"(?s)_logseq_graph_backup\(\).*?'restore:" output) + "graph backup dispatcher lists 'restore' subcommand") + (is (re-find #"(?s)_logseq_graph_backup\(\).*?'remove:" output) + "graph backup dispatcher lists 'remove' subcommand")) + (testing "graph dispatcher dispatches backup to subgroup function" + (is (re-find #"(?s)_logseq_graph\(\).*?backup\) _logseq_graph_backup" output))) + (testing "graph backup remove leaf has its own function with --src option" + (is (string/includes? output "_logseq_graph_backup_remove()")) + (is (re-find #"(?s)_logseq_graph_backup\(\).*?remove\) _logseq_graph_backup_remove" output))))) + +(deftest test-bash-nested-subcommand-completion + (let [output (gen/generate-completions "bash" full-table)] + (testing "bash subcmd completion for graph includes backup (deduplicated)" + (is (re-find #"graph\) COMPREPLY=.*backup" output)) + ;; backup should appear only once, not repeated per sub-subcommand + (let [graph-case (re-find #"graph\) COMPREPLY=\( \$\(compgen -W '([^']*)'" output) + subcmds (when graph-case (string/split (second graph-case) #" "))] + (is (= (count (filter #(= "backup" %) subcmds)) 1) + "backup appears exactly once in graph subcmd list"))) + (testing "bash sub-subcommand dispatch for graph:backup" + (is (string/includes? output "graph:backup)"))) + (testing "bash sub-subcommand completions for graph backup include list, create, restore, remove" + (let [case-match (re-find #"graph:backup\) COMPREPLY=\( \$\(compgen -W '([^']*)'" output)] + (is (some? case-match) "graph:backup case exists") + (when case-match + (let [sub-subcmds (set (string/split (second case-match) #" "))] + (is (contains? sub-subcmds "list")) + (is (contains? sub-subcmds "create")) + (is (contains? sub-subcmds "restore")) + (is (contains? sub-subcmds "remove")))))))) + (deftest test-e2e-generated-header (testing "zsh output includes do-not-edit header" (let [output (gen/generate-completions "zsh" full-table)]