diff --git a/CHANGELOG.md b/CHANGELOG.md index daf902403..bde4b5679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures. +- BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before. ## 0.159.0 diff --git a/docs/config.json b/docs/config.json index e4611b8e2..10d286445 100644 --- a/docs/config.json +++ b/docs/config.json @@ -408,8 +408,8 @@ }, "plugins": { "type": "object", - "description": "Plugin system for loading external configuration from git repos or local paths. Each key (except 'install') is a named plugin source with a 'source' URL or path. 'install' lists plugin names to install from any registered source.", - "markdownDescription": "Plugin system for loading external configuration from git repos or local paths. Each key (except `install`) is a named plugin source with a `source` URL or path. `install` lists plugin names to install from any registered source.", + "description": "Plugin system for loading external configuration from git repos or local paths. Each key except 'install' and 'installMode' is a named plugin source with a 'source' URL or path.", + "markdownDescription": "Plugin system for loading external configuration from git repos or local paths. Each key except `install` and `installMode` is a named plugin source with a `source` URL or path.", "examples": [ { "my-org": { @@ -419,10 +419,17 @@ } ], "properties": { + "installMode": { + "type": "string", + "enum": ["append", "replace"], + "default": "append", + "description": "Use 'replace' so this file's 'install' list stands alone instead of combining with lists from other config sources. Applies only within the same file.", + "markdownDescription": "Use `replace` so this file's `install` list stands alone instead of combining with lists from other config sources. Applies only within the same file." + }, "install": { "type": "array", - "description": "List of plugin names to install from registered sources.", - "markdownDescription": "List of plugin names to install from registered sources.", + "description": "List of plugins to install: 'name' or 'name@source'. Lists from all config sources are combined, not replaced.", + "markdownDescription": "List of plugins to install: `name` or `name@source`. Lists from all config sources are combined, not replaced.", "items": { "type": "string" } diff --git a/docs/config/plugins.md b/docs/config/plugins.md index cf15c0631..4988cc71f 100644 --- a/docs/config/plugins.md +++ b/docs/config/plugins.md @@ -41,6 +41,18 @@ flowchart TD 4. ECA matches `install` names against the marketplace, expands their declared [**dependencies**](#plugin-dependencies) transitively, then **discovers components** from each resolved plugin directory. 5. All components are **merged** into the config waterfall, in the order specified by the `install` key (later plugins override earlier plugins) — user config always takes precedence on conflicts. +## Install lists from multiple config sources + +Normally, an array from a higher-priority [config source](introduction.md#merge-order) replaces the lower-priority one. Install lists are the exception: they combine. Your global config can hold your personal plugins while a project's `.eca/config.json` adds its own; an empty list `[]` simply adds nothing. + +To make a source ignore what the others install, add `"installMode": "replace"` next to its `install` list: + +```javascript title=".eca/config.json" +{ + "plugins": { "installMode": "replace", "install": ["my-plugin"] } +} +``` + ## Commands ### `/plugins` @@ -64,6 +76,14 @@ Use `` to disambiguate when multiple sources provide a If the plugin declares [dependencies](#plugin-dependencies), they are resolved and loaded automatically on startup — no need to install each one individually. +### `/plugin-uninstall` + +``` +/plugin-uninstall +``` + +Removes the plugin from the `install` list in your global config file. If it was installed by another config source, edit that source instead. Plugins that other installed plugins [depend on](#plugin-dependencies) stay loaded. Restart ECA to apply. + ## Pointing to a plugin source / marketplace The official [plugins.eca.dev](https://plugins.eca.dev) marketplace is always available as the built-in `"eca"` source. To install plugins from it, just add their names to `install` — no source configuration needed. diff --git a/src/eca/config.clj b/src/eca/config.clj index f9d7b26c0..3d585a783 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -463,18 +463,28 @@ (some-> (safe-read-json-string (slurp config-file) (var *global-config-error*)) (parse-dynamic-string-values (shared/global-config-dir)))))) -(defn ^:private config-from-local-file [roots] - (reduce - (fn [final-config {:keys [uri]}] - (merge - final-config - (let [config-dir (io/file (shared/uri->filename uri) ".eca") - config-file (io/file config-dir "config.json")] - (when (.exists config-file) - (some-> (safe-read-json-string (slurp config-file) (var *local-config-error*)) - (parse-dynamic-string-values config-dir)))))) - {} - roots)) +(declare merge-config) + +(defn ^:private merge-config-layers + "Merges file config layers into config. Non-plugin keys stay aggregated + across layers with aggregate-merge (historical per-source behavior: shallow + for workspace roots, deep for extraConfigs), then each layer's plugins merge + one by one so install lists combine across layers." + [aggregate-merge config layers] + (reduce merge-config + config + (cons (reduce aggregate-merge {} (map #(dissoc % "plugins") layers)) + (map #(select-keys % ["plugins"]) layers)))) + +(defn ^:private config-from-local-file [roots config] + (let [layers (mapv (fn [{:keys [uri]}] + (let [config-dir (io/file (shared/uri->filename uri) ".eca") + config-file (io/file config-dir "config.json")] + (when (.exists config-file) + (some-> (safe-read-json-string (slurp config-file) (var *local-config-error*)) + (parse-dynamic-string-values config-dir))))) + roots)] + (merge-config-layers merge config layers))) (def initialization-config* (atom {})) @@ -517,24 +527,19 @@ listed order (later entries win). Missing paths are logged and skipped; parse errors are logged, surfaced via `*extra-config-error*` and skipped. Non-recursive: an `:extraConfigs` declared inside an extra file is ignored." - [paths roots] + [paths roots config] (let [paths (cond (string? paths) [paths] (sequential? paths) paths - :else [])] - (reduce - (fn [final-config path] - (let [^File config-file (resolve-extra-config-file path roots)] - (if (.exists config-file) - (deep-merge final-config - (or (some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*)) + :else []) + layers (mapv (fn [path] + (let [^File config-file (resolve-extra-config-file path roots)] + (if (.exists config-file) + (some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*)) (parse-dynamic-string-values (fs/file (fs/parent config-file)))) - {})) - (do - (logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file))) - final-config)))) - {} - paths))) + (logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file)))))) + paths)] + (merge-config-layers deep-merge config layers))) (defn ^:private resolve-agent-inheritance "Resolves :inherit keys in agent configs. When an agent has :inherit \"other\", @@ -703,11 +708,26 @@ (-> (assoc-in [:chat :defaultAgent] (migrate-legacy-agent-name (get-in config [:chat :defaultBehavior]))) (update :chat dissoc :defaultBehavior)))) +(defn ^:private merge-config + "Deep-merges a normalized config layer into config. The plugins install + lists combine across layers: a layer's entries append unless its own + installMode is \"replace\"." + [config layer] + (let [layer (normalize-fields normalization-rules layer) + plugins (:plugins layer) + {:strs [install installMode]} plugins + merged (deep-merge config layer)] + (if (contains? plugins "install") + (assoc-in merged [:plugins "install"] + (->> (concat (when-not (= "replace" installMode) + (get-in config [:plugins "install"])) + install) + reverse distinct reverse vec)) + merged))) + (defn ^:private all* [db] (let [initialization-config @initialization-config* pure-config? (:pureConfig initialization-config) - merge-config (fn [c1 c2] - (deep-merge c1 (normalize-fields normalization-rules c2))) plugin-data (when-not pure-config? @plugin-components*) plugin-config (when plugin-data (let [cfg (:config-fragment plugin-data)] @@ -723,13 +743,14 @@ (config-from-envvar))) (if-let [custom-config (config-from-custom)] (merge-config $ (when-not pure-config? custom-config)) - (-> $ - (merge-config (when-not pure-config? (config-from-global-file))) - (merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db)))))) + (let [config (merge-config $ (when-not pure-config? (config-from-global-file)))] + (if pure-config? + config + (config-from-local-file (:workspace-folders db) config)))) ;; Plugin config merges after all file configs (user local config wins via later merge) (merge-config $ plugin-config) ;; extraConfigs merge last, overriding all previous sources - (merge-config $ (config-from-extra-configs (:extraConfigs $) (:workspace-folders db)))) + (config-from-extra-configs (:extraConfigs $) (:workspace-folders db) $)) ;; Append plugin commands/rules (vector concat, not deep-merge replace) (cond-> (seq plugin-commands) (update :commands #(vec (concat % plugin-commands))) @@ -773,14 +794,12 @@ needed before the server is fully initialized (e.g. network/TLS settings)." [] - (let [merge-config (fn [c1 c2] - (deep-merge c1 (normalize-fields normalization-rules c2)))] - (-> {} - (merge-config (initial-config)) - (merge-config (config-from-envvar)) - (merge-config (if (some? @custom-config-file-path*) - (config-from-custom) - (config-from-global-file)))))) + (-> {} + (merge-config (initial-config)) + (merge-config (config-from-envvar)) + (merge-config (if (some? @custom-config-file-path*) + (config-from-custom) + (config-from-global-file))))) (defn validation-error [] (cond @@ -994,4 +1013,4 @@ root (rj/assoc-in root ["$schema"] config-schema-url)] (io/make-parents file) (spit file (rj/to-string root)) - (clear-cache!))) + (clear-cache!))) \ No newline at end of file diff --git a/src/eca/features/plugins.clj b/src/eca/features/plugins.clj index eef5149dd..36872d351 100644 --- a/src/eca/features/plugins.clj +++ b/src/eca/features/plugins.clj @@ -11,6 +11,7 @@ [babashka.fs :as fs] [babashka.process :as p] [cheshire.core :as json] + [cheshire.factory :as json.factory] [clojure.java.io :as io] [clojure.string :as string] [eca.cache :as cache] @@ -375,11 +376,11 @@ components-list)) (defn ^:private parse-sources - "Extracts plugin sources from config, filtering out the install key. + "Extracts plugin sources from config, filtering out reserved install keys. Returns a seq of [source-name source-url] pairs." [plugins-config] (->> plugins-config - (remove (fn [[k _]] (= "install" (name k)))) + (remove (comp #{"install" "installMode"} name key)) (keep (fn [[source-name source-config]] (when-let [source-url (if (map? source-config) (get source-config :source) @@ -556,14 +557,38 @@ (str "Plugin `" plugin-name "` not found in any configured marketplace."))})))) (defn uninstall-plugin! - "Uninstalls a plugin by removing it from the global config install list. + "Removes an exact plugin reference from the global config install list only. Returns {:status :ok/:error, :message ...}." [plugins-config ^String plugin-name] - (let [current-install (set (get plugins-config "install" []))] - (if (contains? current-install plugin-name) - (let [new-install (vec (sort (disj current-install plugin-name)))] - (config/update-global-config! {:plugins {:install new-install}}) + (let [file (config/global-config-file) + global-config (when (.exists file) + (try + (binding [json.factory/*json-factory* (json.factory/make-json-factory + {:allow-comments true})] + (json/parse-string (slurp file))) + (catch Exception e + (logger/warn logger-tag "Error reading global config file:" (ex-message e)) + ::unreadable))) + global-install (get-in global-config ["plugins" "install"])] + (cond + (= ::unreadable global-config) + {:status :error + :message (str "Could not read the global config file at `" file "`. " + "Fix the JSON error, then retry.")} + + (some #{plugin-name} global-install) + (do + (config/update-global-config! + {:plugins {:install (filterv #(not= plugin-name %) global-install)}}) {:status :ok - :message (str "Plugin `" plugin-name "` uninstalled. Restart ECA to apply.")}) + :message (str "Global install entry for plugin `" plugin-name "` removed. " + "Other config sources can still install it; remove the entry there too. Restart ECA to apply.")}) + + (some #{plugin-name} (get plugins-config "install")) + {:status :error + :message (str "Plugin `" plugin-name "` has no global install entry. " + "Remove it from plugins.install in its source config (for example, ECA_CONFIG, initialization options, or project config).")} + + :else {:status :error :message (str "Plugin `" plugin-name "` is not installed.")}))) diff --git a/test/eca/config_test.clj b/test/eca/config_test.clj index ad72fe12a..db92898ae 100644 --- a/test/eca/config_test.clj +++ b/test/eca/config_test.clj @@ -115,6 +115,153 @@ (is (= "from-rel" (:defaultAgent (#'config/all* {:workspace-folders [{:uri (shared/filename->uri (str dir))}]}))))))) +(defn ^:private plugin-config-flow + [{:keys [global roots init env custom extras pure?]}] + (let [dir (fs/create-temp-dir)] + (try + (let [write-config! (fn [path config] + (io/make-parents path) + (spit path (json/generate-string config)) + path) + global-file (write-config! (fs/file dir "global.json") global) + custom-file (when custom (write-config! (fs/file dir "custom.json") custom)) + folders (mapv (fn [i layer] + (let [root (fs/file dir (str "root-" i))] + (write-config! (fs/file root ".eca" "config.json") layer) + {:uri (shared/filename->uri (str root))})) + (range) roots) + extra-files (mapv (fn [i layer] + (str (write-config! (fs/file dir (str "extra-" i ".json")) layer))) + (range) extras)] + (with-redefs-fn {#'config/initialization-config* (atom (cond-> (or init {}) + pure? (assoc :pureConfig true) + (seq extras) (assoc :extraConfigs extra-files))) + #'config/custom-config-file-path* (atom (some-> custom-file str)) + #'config/plugin-components* (atom nil) + #'config/global-config-file (constantly global-file) + #'config/config-from-envvar (constantly env) + ;; Read the actual custom file without its separate TTL cache. + #'config/config-from-custom #'config/config-from-custom*} + (fn [] + (config/clear-cache!) + {:all (config/all {:workspace-folders folders}) + :files (config/read-file-configs)}))) + (finally + (config/clear-cache!) + (fs/delete-tree dir))))) + +(deftest plugin-install-config-flow-test + (let [install #(get-in % [:all :plugins "install"]) + p (fn [refs] {:plugins {:install refs}})] + (testing "global and multiple roots append; exact duplicates move to their last position" + (let [result (:all (plugin-config-flow + {:global {:plugins {:company {:source "https://example.com/company.git"} + :install ["a" "b" "a" "same@company"]} + :disabledTools ["global"] + :chat {:global true}} + :roots [{:plugins {:first {:source "https://example.com/first.git"} + :install ["c" "a"]} + :disabledTools ["first"] + :chat {:first true}} + {:plugins {:install ["b" "same" "same@other" "d" "d"]} + :disabledTools ["last"] + :chat {:last true}}]}))] + (is (= ["same@company" "c" "a" "b" "same" "same@other" "d"] + (get-in result [:plugins "install"]))) + (is (= "https://example.com/company.git" (get-in result [:plugins "company" :source]))) + (is (= "https://example.com/first.git" (get-in result [:plugins "first" :source]))) + (is (some? (get-in result [:plugins "eca" :source]))) + (is (= ["last"] (:disabledTools result))) + (is (true? (get-in result [:chat :global]))) + (is (true? (get-in result [:chat :last]))) + (is (nil? (get-in result [:chat :first]))))) + (testing "empty append retains inherited plugins" + (is (= ["global"] (install (plugin-config-flow {:global (p ["global"]) + :roots [(p []) (p [])]}))))) + (testing "replace with empty or populated list resets earlier layers, not sources" + (doseq [refs [[] ["new" "new"]]] + (let [result (:all (plugin-config-flow + {:global {:plugins {:company {:source "https://example.com/company.git"} + :install ["global"]}} + :roots [(assoc-in (p refs) [:plugins :installMode] "replace")]}))] + (is (= (vec (distinct refs)) (get-in result [:plugins "install"]))) + (is (some? (get-in result [:plugins "company" :source])))))) + (testing "a reset survives aggregation, but its mode does not apply to later roots" + (doseq [refs [[] ["reset"]]] + (is (= (conj refs "later") + (install (plugin-config-flow + {:global (p ["global"]) + :roots [(assoc-in (p refs) [:plugins :installMode] "replace") + (p ["later"])]})))))) + (testing "mode without an install list does not reset or stick" + (is (= ["global" "later"] (install (plugin-config-flow + {:global (p ["global"]) + :roots [{:plugins {:installMode "replace"}} (p ["later"])]}))))) + (testing "existing order is initial config, init, env, global, roots, then extras" + (is (= ["init" "env" "global" "root" "extra-1" "extra-2"] + (install (plugin-config-flow {:init (p ["init"]) + :env {"plugins" {"install" ["env"]}} + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(p ["extra-1"]) (p ["extra-2"])]}))))) + (testing "extra resets apply to all earlier layers and later extras append" + (doseq [refs [[] ["reset"]]] + (is (= (conj refs "later") + (install (plugin-config-flow + {:init (p ["init"]) + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(assoc-in (p refs) [:plugins :installMode] "replace") + (p ["later"])]}))))) + (is (= [] (install (plugin-config-flow + {:global (p ["global"]) + :extras [(assoc-in (p []) [:plugins :installMode] "replace")]}))))) + (testing "custom file selection and layer-local resets, including early file config" + (doseq [[layers expected files] + [[{:env (p ["env"]) :custom (p ["custom"]) :extras [(p ["extra"])]} + ["init" "env" "custom" "extra"] ["env" "custom"]] + [{:env {"plugins" {"installMode" "replace" "install" ["env"]}}} + ["env" "global" "root"] ["env" "global"]] + [{:env (p ["env"]) + :custom (assoc-in (p []) [:plugins :installMode] "replace") + :extras [(p ["extra"])]} + ["extra"] []]]] + (let [result (plugin-config-flow + (merge {:init (p ["init"]) :global (p ["global"]) :roots [(p ["root"])]} + layers))] + (is (= expected (install result))) + (is (= files (get-in result [:files :plugins "install"])))))) + (testing "explicit append after replace, with unrelated extra config behavior unchanged" + (let [hook {:type "command" :command "echo extra"} + result (:all (plugin-config-flow + {:global {:plugins {:install ["global"]} + :chat {:retained true} + :hooks {:preToolCall [{:type "command" :command "echo global"}]} + :agent {"parent" {:disabledTools ["parent"]} + "child" {:inherit "parent" :disabledTools ["child"]}}} + :extras [{:plugins {:installMode "replace" :install ["reset"]} + :chat false + :disabledTools ["first"]} + {:plugins {:installMode "append" :install ["extra"]} + :chat {:added true} + :disabledTools ["last"] + :hooks {:preToolCall [hook]}}]}))] + (is (= ["reset" "extra"] (get-in result [:plugins "install"]))) + ;; Extras were already combined before merging with the global map. + (is (true? (get-in result [:chat :retained]))) + (is (true? (get-in result [:chat :added]))) + (is (= ["last"] (:disabledTools result))) + (is (= [hook] (get-in result [:hooks :preToolCall]))) + (is (= ["child"] (get-in result [:agent "child" :disabledTools]))))) + (testing "pureConfig still skips env and file layers, but applies extraConfigs" + (is (= ["init" "extra"] (install (plugin-config-flow + {:pure? true + :init (assoc-in (p ["init"]) [:plugins :installMode] "replace") + :env (p ["env"]) + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(p ["extra"])]}))))))) + (deftest deep-merge-test (testing "basic merge" (is (match? diff --git a/test/eca/features/plugins_test.clj b/test/eca/features/plugins_test.clj index 4227b15fb..748921b0d 100644 --- a/test/eca/features/plugins_test.clj +++ b/test/eca/features/plugins_test.clj @@ -9,6 +9,7 @@ [eca.features.plugins :as plugins] [eca.features.rules :as rules] [eca.interpolation :as interpolation] + [eca.shared :as shared] [matcher-combinators.matchers :as m] [matcher-combinators.test :refer [match?]])) @@ -20,6 +21,13 @@ (finally (interpolation/reset-plugin-dirs!))))) +(deftest reserved-install-mode-test + (is (= [["company" "https://example.com/company.git"]] + (#'plugins/parse-sources + {"company" {:source "https://example.com/company.git"} + "install" {:source "not-a-source"} + "installMode" {:source "not-a-source"}})))) + (deftest sanitize-source-url-test (testing "HTTPS URL" (is (= "github.com-my-org-my-plugins" @@ -508,24 +516,82 @@ (fs/delete-tree tmp-dir))))) (deftest uninstall-plugin!-test - (testing "removes plugin from install list" - (let [updated (atom nil)] - (with-redefs [config/update-global-config! (fn [c] (reset! updated c))] - (let [result (plugins/uninstall-plugin! - {"install" ["alpha" "beta" "gamma"]} - "beta")] - (is (= :ok (:status result))) - (is (= ["alpha" "gamma"] (get-in @updated [:plugins :install]))))))) - - (testing "returns error when plugin is not installed" - (let [result (plugins/uninstall-plugin! - {"install" ["alpha"]} - "beta")] - (is (= :error (:status result))) - (is (re-find #"not installed" (:message result))))) - - (testing "returns error when install list is empty" - (let [result (plugins/uninstall-plugin! - {"install" []} - "beta")] - (is (= :error (:status result)))))) + (doseq [[label source global-install expected-install status] + [["environment only" :env [] ["alpha" "inherited"] :error] + ["initialization only" :init [] ["alpha" "inherited"] :error] + ["global only" nil ["zeta" "alpha" "beta" "alpha@company"] + ["zeta" "beta" "alpha@company"] :ok] + ["global and project" :project ["alpha"] ["alpha" "inherited"] :ok] + ["not installed" nil ["beta"] ["beta"] :error]]] + (testing label + (let [dir (fs/create-temp-dir) + global-file (fs/file dir "config.json") + project-dir (fs/file dir "project") + project-config (fs/file project-dir ".eca" "config.json") + inherited {"plugins" {"install" ["alpha" "inherited"]}} + raw (str "{\n// Keep this comment\n\"plugins\": " + (json/generate-string {"install" global-install + "installMode" "append" + "company" {"source" "unchanged"}}) + ", \"unrelated\": true\n}") + writes (atom []) + update-global! config/update-global-config!] + (try + (fs/create-dirs (fs/parent project-config)) + (spit global-file raw) + (spit project-config (json/generate-string (if (= :project source) inherited {}))) + (with-redefs [config/initialization-config* (atom (if (= :init source) inherited {})) + config/plugin-components* (atom nil) + shared/global-config-dir (constantly (str dir)) + config/update-global-config! (fn [c] + (swap! writes conj c) + (update-global! c))] + (with-redefs-fn {#'config/config-from-envvar (constantly (when (= :env source) inherited)) + #'config/config-from-custom (constantly nil)} + (fn [] + (let [db {:workspace-folders [{:uri (str (.toURI project-dir))}]} + before (:plugins (#'config/all* db)) + result (plugins/uninstall-plugin! before "alpha") + after (:plugins (#'config/all* db))] + (is (= status (:status result))) + (is (= expected-install (get after "install"))) + (is (= "append" (get after "installMode"))) + (is (= {:source "unchanged"} (get after "company"))) + (is (true? (:unrelated (#'config/all* db)))) + (is (string/includes? (slurp global-file) "// Keep this comment")) + (if (= :ok status) + (do + (is (= [{:plugins {:install (filterv #(not= "alpha" %) global-install)}}] + @writes)) + (is (string/includes? (:message result) "Global install entry")) + (is (string/includes? (:message result) "Other config sources can still install it"))) + (do + (is (= [] @writes)) + (is (= raw (slurp global-file))) + (is (= before after)) + (if source + (do + (is (string/includes? (:message result) "no global install entry")) + (is (string/includes? (:message result) "source config"))) + (is (string/includes? (:message result) "not installed"))))))))) + (finally + (config/clear-cache!) + (fs/delete-tree dir))))))) + +(deftest uninstall-plugin!-invalid-global-config-test + (testing "returns an error without writing when the global config file is invalid" + (let [dir (fs/create-temp-dir) + global-file (fs/file dir "config.json") + raw "{ invalid json" + writes (atom [])] + (try + (spit global-file raw) + (with-redefs [shared/global-config-dir (constantly (str dir)) + config/update-global-config! (fn [c] (swap! writes conj c))] + (let [result (plugins/uninstall-plugin! {"install" ["alpha"]} "alpha")] + (is (= :error (:status result))) + (is (string/includes? (:message result) "Could not read the global config file")) + (is (= [] @writes)) + (is (= raw (slurp global-file))))) + (finally + (fs/delete-tree dir))))))