Skip to content

Commit fa1efef

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 d4fc619 commit fa1efef

7 files changed

Lines changed: 357 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Limit plugin uninstall to global install entries; reject inherited-only entries without writes and warn that other config sources can still install the plugin.
6+
- BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before.
7+
58
## 0.159.0
69

710
- Fix `spawn_agent` details to include the agent's configured `variant`; it was only sent when the LLM passed the `variant` argument explicitly.

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. Install lists append across config layers by default; source definitions are retained.",
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. Install lists append across config layers by default; source definitions are retained.",
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": "Applies only to install in the same config layer. Append adds entries; replace discards earlier entries, including when install is []. Without an install list this has no effect. Later layers without a mode append. Does not remove source definitions.",
427+
"markdownDescription": "Applies only to `install` in the same config layer. `append` adds entries; `replace` discards earlier entries, including when `install` is `[]`. Without an install list this has no effect. Later layers without a mode append. Does not remove source definitions. Use `replace` to preserve the old exclusion behavior."
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": "Plugin references: name or name@source. Appends across config layers by default; [] retains inherited entries. Exact duplicates keep their last occurrence at that position. Qualified and unqualified references remain distinct. Set installMode to replace in this layer to exclude inherited plugins.",
432+
"markdownDescription": "Plugin references: `name` or `name@source`. Appends across config layers by default; `[]` retains inherited entries. Exact duplicates keep their last occurrence at that position. Qualified and unqualified references remain distinct. Set `installMode` to `replace` in this layer to exclude inherited plugins.",
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: 53 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,21 @@
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 [config layer]
711+
(let [layer (normalize-fields normalization-rules layer)
712+
plugins (:plugins layer)
713+
merged (deep-merge config layer)]
714+
(if (contains? plugins "install")
715+
(assoc-in merged [:plugins "install"]
716+
(->> (concat (when-not (= "replace" (get plugins "installMode"))
717+
(get-in config [:plugins "install"]))
718+
(get plugins "install"))
719+
reverse distinct reverse vec))
720+
merged)))
721+
706722
(defn ^:private all* [db]
707723
(let [initialization-config @initialization-config*
708724
pure-config? (:pureConfig initialization-config)
709-
merge-config (fn [c1 c2]
710-
(deep-merge c1 (normalize-fields normalization-rules c2)))
711725
plugin-data (when-not pure-config? @plugin-components*)
712726
plugin-config (when plugin-data
713727
(let [cfg (:config-fragment plugin-data)]
@@ -723,13 +737,14 @@
723737
(config-from-envvar)))
724738
(if-let [custom-config (config-from-custom)]
725739
(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))))))
740+
(let [config (merge-config $ (when-not pure-config? (config-from-global-file)))]
741+
(if pure-config?
742+
config
743+
(config-from-local-file (:workspace-folders db) config))))
729744
;; Plugin config merges after all file configs (user local config wins via later merge)
730745
(merge-config $ plugin-config)
731746
;; extraConfigs merge last, overriding all previous sources
732-
(merge-config $ (config-from-extra-configs (:extraConfigs $) (:workspace-folders db))))
747+
(config-from-extra-configs (:extraConfigs $) (:workspace-folders db) $))
733748
;; Append plugin commands/rules (vector concat, not deep-merge replace)
734749
(cond->
735750
(seq plugin-commands) (update :commands #(vec (concat % plugin-commands)))
@@ -773,14 +788,12 @@
773788
needed before the server is fully initialized (e.g. network/TLS
774789
settings)."
775790
[]
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))))))
791+
(-> {}
792+
(merge-config (initial-config))
793+
(merge-config (config-from-envvar))
794+
(merge-config (if (some? @custom-config-file-path*)
795+
(config-from-custom)
796+
(config-from-global-file)))))
784797

785798
(defn validation-error []
786799
(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)