aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-21 17:37:31 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-21 17:37:43 +0300
commit1955406eafa37ae3d02d433d043b476e4a9a0bc4 (patch)
tree8081633269d4371a9613f98b129e734243e400f0
parent6382bd433a66683d29455409e1dbb16776727bbf (diff)
fix: link plugins without a running server
refs #1670
-rw-r--r--docs/next/website/src/content/docs/plugins.mdx7
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx5
-rw-r--r--src/cli/plugin.rs41
-rw-r--r--tests/cli/plugins.rs58
4 files changed, 96 insertions, 15 deletions
diff --git a/docs/next/website/src/content/docs/plugins.mdx b/docs/next/website/src/content/docs/plugins.mdx
index 21ef2c85..1d93bedc 100644
--- a/docs/next/website/src/content/docs/plugins.mdx
+++ b/docs/next/website/src/content/docs/plugins.mdx
@@ -195,9 +195,10 @@ terminals, runs supported build commands, then stores the checkout under
Herdr-managed plugin data and registers it. Use `--yes` for noninteractive
installs. Reinstalling a GitHub-managed plugin replaces that managed checkout.
Installed and linked plugins, including their enabled state, are global to the
-current user and available in every Herdr session. Plugins installed only in a
-named session on Herdr 0.7.3 must be installed or linked again. Existing plugin
-config and state remain in place.
+current user and available in every Herdr session. Both `plugin install` and
+`plugin link` can register plugins while no Herdr server is running. Plugins
+installed only in a named session on Herdr 0.7.3 must be installed or linked
+again. Existing plugin config and state remain in place.
Installing over a locally linked plugin is refused; unlink or uninstall the
local plugin first. `plugin install` and `plugin link` create the plugin's
config and state directories, and `plugin config-dir <id>` prints the config
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 68045ecb..db62db7f 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -453,8 +453,9 @@ argv pane creation are not part of v1.
Installed and linked plugins persist across restarts. Herdr writes a
`plugins.json` registry file alongside `session.json` on `plugin.link`,
`plugin.unlink`, `plugin.enable`, and `plugin.disable`. The
-`herdr plugin install` CLI also writes the same registry when Herdr is not
-running, then startup loads it automatically. On startup, Herdr re-reads each
+`herdr plugin install` and `herdr plugin link` CLIs also write the same registry
+when Herdr is not running, then startup loads it automatically. On startup,
+Herdr re-reads each
manifest from its original path; if the file is missing or unparseable the entry
is kept with a `warnings` field so `plugin.list` surfaces it.
diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs
index bd162e40..6bee42be 100644
--- a/src/cli/plugin.rs
+++ b/src/cli/plugin.rs
@@ -69,11 +69,20 @@ fn plugin_link(args: &[String]) -> std::io::Result<i32> {
}
}
}
- print_plugin_response(Method::PluginLink(PluginLinkParams {
+ let params = PluginLinkParams {
path,
enabled,
source: None,
- }))
+ };
+ let response = match super::send_request(&Request {
+ id: "cli:plugin".into(),
+ method: Method::PluginLink(params.clone()),
+ }) {
+ Ok(response) => response,
+ Err(err) if is_connection_error(&err) => offline_plugin_link_response(&params)?,
+ Err(err) => return Err(err),
+ };
+ super::print_response(&response)
}
fn plugin_config_dir_command(args: &[String]) -> std::io::Result<i32> {
@@ -893,6 +902,15 @@ fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result<Insta
.map_err(|(_, message)| std::io::Error::other(message))
}
+fn persist_plugin_offline(plugin: &InstalledPluginInfo) -> std::io::Result<()> {
+ crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id)?;
+ crate::persist::plugin_registry::update(|plugins| {
+ plugins.retain(|entry| entry.plugin_id != plugin.plugin_id);
+ plugins.push(plugin.clone());
+ })?;
+ Ok(())
+}
+
fn register_installed_plugin(
plugin: InstalledPluginInfo,
source: PluginSourceInfo,
@@ -948,14 +966,7 @@ fn register_installed_plugin(
Ok(())
}
Err(err) if is_connection_error(&err) => {
- crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id)
- .map_err(InstallFailure::Rollback)?;
- crate::persist::plugin_registry::update(|plugins| {
- plugins.retain(|entry| entry.plugin_id != plugin.plugin_id);
- plugins.push(plugin);
- })
- .map(|_| ())
- .map_err(InstallFailure::Rollback)
+ persist_plugin_offline(&plugin).map_err(InstallFailure::Rollback)
}
Err(err) => Err(InstallFailure::Rollback(err)),
}
@@ -1085,6 +1096,16 @@ fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPlu
&& plugin.source.subdir.as_deref() == source.subdir.as_deref()
}
+fn offline_plugin_link_response(params: &PluginLinkParams) -> std::io::Result<serde_json::Value> {
+ let plugin = load_cli_plugin_manifest(Path::new(&params.path), params.enabled)?;
+ persist_plugin_offline(&plugin)?;
+ serde_json::to_value(SuccessResponse {
+ id: "cli:plugin".into(),
+ result: ResponseResult::PluginLinked { plugin },
+ })
+ .map_err(std::io::Error::other)
+}
+
fn offline_plugin_list_response(params: &PluginListParams) -> std::io::Result<serde_json::Value> {
let entries = crate::persist::plugin_registry::load();
let mut plugins =
diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs
index f76ae0a0..6726c243 100644
--- a/tests/cli/plugins.rs
+++ b/tests/cli/plugins.rs
@@ -111,6 +111,64 @@ fn named_sessions_share_live_plugin_registry() {
}
#[test]
+fn plugin_link_works_offline_and_is_global() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let state_home = base.join("state");
+ let plugin_dir = base.join("plugins").join("offline");
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join("herdr-plugin.toml"),
+ r#"
+id = "example.offline"
+name = "Offline Plugin"
+version = "0.1.0"
+min_herdr_version = "0.6.10"
+platforms = ["linux", "macos", "windows"]
+"#,
+ )
+ .unwrap();
+
+ let link_args = [
+ "--session",
+ "offline",
+ "plugin",
+ "link",
+ plugin_dir.to_str().unwrap(),
+ "--disabled",
+ ];
+ let linked = parse_cli_json_output(
+ &link_args,
+ run_named_cli_with_env(
+ &config_home,
+ &runtime_dir,
+ &link_args,
+ &[("XDG_STATE_HOME", &state_home)],
+ ),
+ );
+ assert_eq!(linked["result"]["type"], "plugin_linked");
+ assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.offline");
+ assert_eq!(linked["result"]["plugin"]["enabled"], false);
+ assert_eq!(linked["result"]["plugin"]["source"]["kind"], "local");
+ assert!(!named_session_socket(&config_home, "offline").exists());
+
+ let listed = run_named_cli_json(
+ &config_home,
+ &runtime_dir,
+ &["--session", "other", "plugin", "list", "--json"],
+ );
+ assert_eq!(listed["result"]["plugins"].as_array().unwrap().len(), 1);
+ assert_eq!(
+ listed["result"]["plugins"][0]["plugin_id"],
+ "example.offline"
+ );
+ assert_eq!(listed["result"]["plugins"][0]["enabled"], false);
+
+ cleanup_test_base(&base);
+}
+
+#[test]
fn plugin_install_through_named_server_is_global() {
let base = unique_test_dir();
let config_home = base.join("config");