Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 11 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
Expand Down
20 changes: 20 additions & 0 deletions docs/config/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -64,6 +76,14 @@ Use `<plugin-name@marketplace>` 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 <plugin-name>
```

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.
Expand Down
101 changes: 60 additions & 41 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}))

Expand Down Expand Up @@ -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\",
Expand Down Expand Up @@ -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)]
Expand All @@ -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)))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!)))
41 changes: 33 additions & 8 deletions src/eca/features/plugins.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.")})))
Loading
Loading