Skip to content

Commit 9c7ed8c

Browse files
committed
Keep project plugin installs additive across config layers
Preserve inherited plugins when a project adds its own, with an explicit installMode replacement option for users who need the previous behavior. Limit uninstall to global declarations so inherited plugins are not reported as removed when they remain active.
1 parent 7fc5a0c commit 9c7ed8c

7 files changed

Lines changed: 359 additions & 73 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures.
6+
- BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before.
67

78
## 0.159.0
89

‎docs/config.json‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,8 @@
408408
},
409409
"plugins": {
410410
"type": "object",
411-
"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.",
412-
"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.",
411+
"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.",
412+
"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.",
413413
"examples": [
414414
{
415415
"my-org": {
@@ -419,10 +419,17 @@
419419
}
420420
],
421421
"properties": {
422+
"installMode": {
423+
"type": "string",
424+
"enum": ["append", "replace"],
425+
"default": "append",
426+
"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.",
427+
"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."
428+
},
422429
"install": {
423430
"type": "array",
424-
"description": "List of plugin names to install from registered sources.",
425-
"markdownDescription": "List of plugin names to install from registered sources.",
431+
"description": "List of plugins to install: 'name' or 'name@source'. Lists from all config sources are combined, not replaced.",
432+
"markdownDescription": "List of plugins to install: `name` or `name@source`. Lists from all config sources are combined, not replaced.",
426433
"items": {
427434
"type": "string"
428435
}

‎docs/config/plugins.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ flowchart TD
4141
4. ECA matches `install` names against the marketplace, expands their declared [**dependencies**](#plugin-dependencies) transitively, then **discovers components** from each resolved plugin directory.
4242
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.
4343

44+
## Install lists from multiple config sources
45+
46+
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.
47+
48+
To make a source ignore what the others install, add `"installMode": "replace"` next to its `install` list:
49+
50+
```javascript title=".eca/config.json"
51+
{
52+
"plugins": { "installMode": "replace", "install": ["my-plugin"] }
53+
}
54+
```
55+
56+
!!! warning "Behavior change"
57+
Project install lists previously replaced the global list. To keep that behavior, add `"installMode": "replace"` to the project config as shown above (an empty `install` excludes everything the other sources install), then restart ECA.
58+
4459
## Commands
4560

4661
### `/plugins`
@@ -64,6 +79,14 @@ Use `<plugin-name@marketplace>` to disambiguate when multiple sources provide a
6479

6580
If the plugin declares [dependencies](#plugin-dependencies), they are resolved and loaded automatically on startup — no need to install each one individually.
6681

82+
### `/plugin-uninstall`
83+
84+
```
85+
/plugin-uninstall <plugin-name>
86+
```
87+
88+
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.
89+
6790
## Pointing to a plugin source / marketplace
6891

6992
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.

‎src/eca/config.clj‎

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -463,18 +463,21 @@
463463
(some-> (safe-read-json-string (slurp config-file) (var *global-config-error*))
464464
(parse-dynamic-string-values (shared/global-config-dir))))))
465465

466-
(defn ^:private config-from-local-file [roots]
467-
(reduce
468-
(fn [final-config {:keys [uri]}]
469-
(merge
470-
final-config
471-
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
472-
config-file (io/file config-dir "config.json")]
473-
(when (.exists config-file)
474-
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
475-
(parse-dynamic-string-values config-dir))))))
476-
{}
477-
roots))
466+
(declare merge-config)
467+
468+
(defn ^:private config-from-local-file [roots config]
469+
(let [layers (mapv (fn [{:keys [uri]}]
470+
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
471+
config-file (io/file config-dir "config.json")]
472+
(when (.exists config-file)
473+
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
474+
(parse-dynamic-string-values config-dir)))))
475+
roots)
476+
;; Keep the existing shallow merge across roots for unrelated fields.
477+
local-config (reduce merge {} (map #(dissoc % "plugins") layers))]
478+
(reduce merge-config
479+
(merge-config config local-config)
480+
(map #(select-keys % ["plugins"]) layers))))
478481

479482
(def initialization-config* (atom {}))
480483

@@ -517,24 +520,25 @@
517520
listed order (later entries win). Missing paths are logged and skipped;
518521
parse errors are logged, surfaced via `*extra-config-error*` and skipped.
519522
Non-recursive: an `:extraConfigs` declared inside an extra file is ignored."
520-
[paths roots]
523+
[paths roots config]
521524
(let [paths (cond
522525
(string? paths) [paths]
523526
(sequential? paths) paths
524-
:else [])]
525-
(reduce
526-
(fn [final-config path]
527-
(let [^File config-file (resolve-extra-config-file path roots)]
528-
(if (.exists config-file)
529-
(deep-merge final-config
530-
(or (some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*))
527+
:else [])
528+
layers (mapv (fn [path]
529+
(let [^File config-file (resolve-extra-config-file path roots)]
530+
(if (.exists config-file)
531+
(some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*))
531532
(parse-dynamic-string-values (fs/file (fs/parent config-file))))
532-
{}))
533-
(do
534-
(logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file)))
535-
final-config))))
536-
{}
537-
paths)))
533+
(do
534+
(logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file)))
535+
nil))))
536+
paths)
537+
;; Preserve the existing aggregation for everything except plugins.
538+
extra-config (reduce deep-merge {} (map #(dissoc % "plugins") layers))]
539+
(reduce merge-config
540+
(merge-config config extra-config)
541+
(map #(select-keys % ["plugins"]) layers))))
538542

539543
(defn ^:private resolve-agent-inheritance
540544
"Resolves :inherit keys in agent configs. When an agent has :inherit \"other\",
@@ -703,11 +707,25 @@
703707
(-> (assoc-in [:chat :defaultAgent] (migrate-legacy-agent-name (get-in config [:chat :defaultBehavior])))
704708
(update :chat dissoc :defaultBehavior))))
705709

710+
(defn ^:private merge-config
711+
"Deep-merges a normalized config layer into config. The plugins install
712+
lists combine across layers: a layer's entries append unless its own
713+
installMode is \"replace\"."
714+
[config layer]
715+
(let [layer (normalize-fields normalization-rules layer)
716+
plugins (:plugins layer)
717+
merged (deep-merge config layer)]
718+
(if (contains? plugins "install")
719+
(assoc-in merged [:plugins "install"]
720+
(->> (concat (when-not (= "replace" (get plugins "installMode"))
721+
(get-in config [:plugins "install"]))
722+
(get plugins "install"))
723+
reverse distinct reverse vec))
724+
merged)))
725+
706726
(defn ^:private all* [db]
707727
(let [initialization-config @initialization-config*
708728
pure-config? (:pureConfig initialization-config)
709-
merge-config (fn [c1 c2]
710-
(deep-merge c1 (normalize-fields normalization-rules c2)))
711729
plugin-data (when-not pure-config? @plugin-components*)
712730
plugin-config (when plugin-data
713731
(let [cfg (:config-fragment plugin-data)]
@@ -723,13 +741,14 @@
723741
(config-from-envvar)))
724742
(if-let [custom-config (config-from-custom)]
725743
(merge-config $ (when-not pure-config? custom-config))
726-
(-> $
727-
(merge-config (when-not pure-config? (config-from-global-file)))
728-
(merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db))))))
744+
(let [config (merge-config $ (when-not pure-config? (config-from-global-file)))]
745+
(if pure-config?
746+
config
747+
(config-from-local-file (:workspace-folders db) config))))
729748
;; Plugin config merges after all file configs (user local config wins via later merge)
730749
(merge-config $ plugin-config)
731750
;; extraConfigs merge last, overriding all previous sources
732-
(merge-config $ (config-from-extra-configs (:extraConfigs $) (:workspace-folders db))))
751+
(config-from-extra-configs (:extraConfigs $) (:workspace-folders db) $))
733752
;; Append plugin commands/rules (vector concat, not deep-merge replace)
734753
(cond->
735754
(seq plugin-commands) (update :commands #(vec (concat % plugin-commands)))
@@ -773,14 +792,12 @@
773792
needed before the server is fully initialized (e.g. network/TLS
774793
settings)."
775794
[]
776-
(let [merge-config (fn [c1 c2]
777-
(deep-merge c1 (normalize-fields normalization-rules c2)))]
778-
(-> {}
779-
(merge-config (initial-config))
780-
(merge-config (config-from-envvar))
781-
(merge-config (if (some? @custom-config-file-path*)
782-
(config-from-custom)
783-
(config-from-global-file))))))
795+
(-> {}
796+
(merge-config (initial-config))
797+
(merge-config (config-from-envvar))
798+
(merge-config (if (some? @custom-config-file-path*)
799+
(config-from-custom)
800+
(config-from-global-file)))))
784801

785802
(defn validation-error []
786803
(cond

‎src/eca/features/plugins.clj‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
[babashka.fs :as fs]
1212
[babashka.process :as p]
1313
[cheshire.core :as json]
14+
[cheshire.factory :as json.factory]
1415
[clojure.java.io :as io]
1516
[clojure.string :as string]
1617
[eca.cache :as cache]
@@ -375,11 +376,11 @@
375376
components-list))
376377

377378
(defn ^:private parse-sources
378-
"Extracts plugin sources from config, filtering out the install key.
379+
"Extracts plugin sources from config, filtering out reserved install keys.
379380
Returns a seq of [source-name source-url] pairs."
380381
[plugins-config]
381382
(->> plugins-config
382-
(remove (fn [[k _]] (= "install" (name k))))
383+
(remove (fn [[k _]] (contains? #{"install" "installMode"} (name k))))
383384
(keep (fn [[source-name source-config]]
384385
(when-let [source-url (if (map? source-config)
385386
(get source-config :source)
@@ -556,14 +557,38 @@
556557
(str "Plugin `" plugin-name "` not found in any configured marketplace."))}))))
557558

558559
(defn uninstall-plugin!
559-
"Uninstalls a plugin by removing it from the global config install list.
560+
"Removes an exact plugin reference from the global config install list only.
560561
Returns {:status :ok/:error, :message ...}."
561562
[plugins-config ^String plugin-name]
562-
(let [current-install (set (get plugins-config "install" []))]
563-
(if (contains? current-install plugin-name)
564-
(let [new-install (vec (sort (disj current-install plugin-name)))]
565-
(config/update-global-config! {:plugins {:install new-install}})
563+
(let [file (config/global-config-file)
564+
global-config (when (.exists file)
565+
(try
566+
(binding [json.factory/*json-factory* (json.factory/make-json-factory
567+
{:allow-comments true})]
568+
(json/parse-string (slurp file)))
569+
(catch Exception e
570+
(logger/warn logger-tag "Error reading global config file:" (ex-message e))
571+
::unreadable)))
572+
global-install (get-in global-config ["plugins" "install"])]
573+
(cond
574+
(= ::unreadable global-config)
575+
{:status :error
576+
:message (str "Could not read the global config file at `" file "`. "
577+
"Fix the JSON error, then retry.")}
578+
579+
(some #{plugin-name} global-install)
580+
(do
581+
(config/update-global-config!
582+
{:plugins {:install (filterv #(not= plugin-name %) global-install)}})
566583
{:status :ok
567-
:message (str "Plugin `" plugin-name "` uninstalled. Restart ECA to apply.")})
584+
:message (str "Global install entry for plugin `" plugin-name "` removed. "
585+
"Other config sources can still install it; remove the entry there too. Restart ECA to apply.")})
586+
587+
(some #{plugin-name} (get plugins-config "install"))
588+
{:status :error
589+
:message (str "Plugin `" plugin-name "` has no global install entry. "
590+
"Remove it from plugins.install in its source config (for example, ECA_CONFIG, initialization options, or project config).")}
591+
592+
:else
568593
{:status :error
569594
:message (str "Plugin `" plugin-name "` is not installed.")})))

0 commit comments

Comments
 (0)