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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Special-character rendering for the display lives in `graphics/special_char.rs`

Plugins are Lua scripts (mlua, `lua54` vendored) returning a table `M`. The engine calls lifecycle/event hooks by name: `on_load`, `on_unload`, `on_serial_connect`/`on_serial_disconnect`/`on_serial_send`/`on_serial_recv` (and `on_rtt_*` equivalents), plus any `M.<name>` the user calls via `!plugin <name>`. Plugins reach back into the app through the `bridge`/`method_call` gates. See `plugins/README.md` for the plugin developer guide.

- **Installed plugins** (`plugin/installed.rs`, issue #36): `!plugin install <file>` loads a plugin *and* records its name in a TOML manifest `<config_dir>/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall is issue #37.
- **Installed plugins** (`plugin/installed.rs`, issues #36/#37): `!plugin install <file>` loads a plugin *and* records its name in a TOML manifest `<config_dir>/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin uninstall <name>` removes it from the manifest and deletes the staged copy; `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,UninstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall errors if the plugin isn't installed and is orthogonal to `unload` — it does **not** stop a running instance (that's `!plugin unload`); the name is normalized via `get_plugin_name` so `foo`/`foo.lua` both work, and it never deletes a bundled `STDLIB` file (`scope.lua`/`shell.lua`) even if a hand-edited manifest lists a reserved name.

## Logging

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ Anything typed on the command bar that starts with `!` is a command. A line with
| `!plugin load <file>` | Load a Lua plugin from a file (for this session only). |
| `!plugin reload <file>` | Reload a plugin from a file. |
| `!plugin install <file>` | Load a plugin **and** install it, so every subsequent `Scope` session auto-loads it. |
| `!plugin uninstall <name>` | Uninstall a plugin: remove it from the auto-load set and delete its staged copy (a running instance stays loaded for the session — use `!plugin unload` to stop it now). |
| `!plugin list` | List the installed (auto-loaded) plugins. |
| `!plugin unload <name>` | Unload a plugin by name (for this session; does not uninstall). |
| `!<plugin> <command> [args...]` | Call a command exported by a loaded plugin (see [Plugins](#plugins)). |
Expand Down Expand Up @@ -354,7 +355,7 @@ end
return M
```

Load a plugin with `!plugin load <file>` (and `!plugin reload <file>` / `!plugin unload <name>` to reload or unload it). To keep a plugin across sessions, use `!plugin install <file>`: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md).
Load a plugin with `!plugin load <file>` (and `!plugin reload <file>` / `!plugin unload <name>` to reload or unload it). To keep a plugin across sessions, use `!plugin install <file>`: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set, and `!plugin uninstall <name>` to remove one from it). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md).

![Plugin usage](videos/011_plugin/video.gif)

Expand Down
2 changes: 1 addition & 1 deletion plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ return M

To execute this plugin you need to load it into the `Scope`. With the `Scope` open, you could type `!plugin load hello.lua`. If you remember the analogy of the chip and the handheld tool, then you need to insert the chip into handheld to it works. Likewise, we need to "insert" (or load) our plugin into our "handheld" (or the `Scope` program). With the plugin loaded, all messages will be replied. The replied message will have the following suffix: `Hello,`.

`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `<config_dir>/scope/plugins/`). Use `!plugin list` to see what's installed.
`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `<config_dir>/scope/plugins/`). Use `!plugin list` to see what's installed, and `!plugin uninstall hello` to remove one from the auto-load set (it also deletes the staged copy; a plugin already running this session stays loaded until you `!plugin unload` it or restart).

## Hello, World

Expand Down
9 changes: 7 additions & 2 deletions src/inputs/inputs_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,7 @@ impl InputsTask {
let Some(subcommand) = command_line_split.get(1).map(String::as_str) else {
error!(
private.logger,
"Please, use \"load\", \"reload\", \"install\", \"list\" or \"unload\" subcommands"
"Please, use \"load\", \"reload\", \"install\", \"uninstall\", \"list\" or \"unload\" subcommands"
);
return;
};
Expand Down Expand Up @@ -1356,6 +1356,11 @@ impl InputsTask {
.plugin_engine_cmd_sender
.send(PluginEngineCommand::InstallPlugin { filepath: arg });
}
"uninstall" => {
let _ = private
.plugin_engine_cmd_sender
.send(PluginEngineCommand::UninstallPlugin { plugin_name: arg });
}
"unload" => {
let _ = private
.plugin_engine_cmd_sender
Expand All @@ -1364,7 +1369,7 @@ impl InputsTask {
_ => {
error!(
private.logger,
"Invalid command. Please, choose one of these options: load, reload, install, list, unload"
"Invalid command. Please, choose one of these options: load, reload, install, uninstall, list, unload"
);
}
}
Expand Down
64 changes: 64 additions & 0 deletions src/plugin/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ pub enum PluginEngineCommand {
InstallPlugin {
filepath: String,
},
/// Remove a plugin from the persistent manifest and delete its staged copy,
/// so it no longer auto-loads at start-up (issue #37). Does not unload a
/// running instance — that is `UnloadPlugin`.
UninstallPlugin {
plugin_name: String,
},
/// Log the names of the currently installed (auto-loaded) plugins.
ListInstalledPlugins,
UnloadPlugin {
Expand Down Expand Up @@ -301,6 +307,64 @@ impl PluginEngine {
);
}
}
PluginEngineCommand::UninstallPlugin { plugin_name } => {
// Accept `foo`, `foo.lua`, or a path — normalize to the
// bare name the manifest stores.
let Some(plugin_name) = Self::get_plugin_name(&plugin_name) else {
continue 'plugin_engine_loop;
};

let dir = plugins_dir();

let mut installed = match Installed::load(&dir) {
Ok(installed) => installed,
Err(err) => {
error!(private.logger, "{}", err);
continue 'plugin_engine_loop;
}
};

if !installed.remove(&plugin_name) {
error!(
private.logger,
"Plugin \"{}\" is not installed", plugin_name
);
continue 'plugin_engine_loop;
}
if let Err(err) = installed.save(&dir) {
error!(private.logger, "{}", err);
continue 'plugin_engine_loop;
}

// Delete the staged copy so it's gone from the plugins
// folder. A missing file is fine (already gone); any other
// error is a non-fatal warning — the manifest, which drives
// auto-load, is already updated. A bundled stdlib file is
// never deleted: `!plugin install` rejects reserved names,
// so a manifest listing `scope`/`shell` can only come from a
// hand-edited file, and losing a user-customized `scope.lua`
// to it would be a nasty surprise (the manifest entry is
// still cleaned above).
let staged_name = format!("{}.lua", plugin_name);
let is_stdlib = STDLIB.iter().any(|(lib, _)| *lib == staged_name);
let staged = dir.join(&staged_name);
if !is_stdlib
&& let Err(err) = std::fs::remove_file(&staged)
&& err.kind() != std::io::ErrorKind::NotFound
{
warning!(
private.logger,
"Could not delete staged plugin {:?}: {}",
staged,
err
);
}

// A running instance is intentionally left loaded for this
// session (use `!plugin unload` to stop it now); uninstall
// only removes it from the persistent auto-load set.
success!(private.logger, "Plugin \"{}\" uninstalled", plugin_name);
}
PluginEngineCommand::ListInstalledPlugins => {
match Installed::load(&plugins_dir()) {
Ok(installed) if installed.names().is_empty() => {
Expand Down
19 changes: 19 additions & 0 deletions src/plugin/installed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ impl Installed {
true
}

/// Remove `name` from the installed set. Returns `true` if it was present
/// (and thus removed), or `false` if it wasn't installed.
pub fn remove(&mut self, name: &str) -> bool {
let before = self.plugins.len();
self.plugins.retain(|p| p != name);
self.plugins.len() != before
}

/// Persist the manifest to `dir`, creating the directory if needed.
pub fn save(&self, dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir)
Expand Down Expand Up @@ -134,6 +142,17 @@ mod tests {
assert_eq!(installed.names(), ["analytics", "auto_test"]);
}

#[test]
fn remove_reports_presence_and_deletes() {
let mut installed = Installed::default();
installed.add("analytics");
installed.add("auto_test");
assert!(installed.remove("analytics"));
assert!(!installed.remove("analytics")); // already gone
assert!(!installed.remove("never_there"));
assert_eq!(installed.names(), ["auto_test"]);
}

#[test]
fn save_then_load_round_trips() {
let tmp = TempDir::new("roundtrip");
Expand Down
80 changes: 80 additions & 0 deletions tests/tui_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,3 +756,83 @@ fn plugin_install_rejects_reserved_name() {
"a reserved name must not be installed.\n{recorded}"
);
}

#[test]
fn plugin_uninstall_removes_from_manifest_and_deletes_file() {
// Issue #37: `!plugin uninstall <name>` drops the plugin from the manifest
// (so it no longer auto-loads) and deletes its staged copy. Pre-install one,
// uninstall it, and assert both are gone.
let tui = {
let mut tui = Tui::start_with(StartOpts {
installed_plugins: &[("uninst_plugin", "local M = {}\nreturn M\n")],
..Default::default()
});
tui.wait_until_ready();

tui.type_text("!plugin uninstall uninst_plugin");
tui.press_enter();
tui.wait_for("uninstalled", SETTLE);
tui
};

let manifest =
std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default();
assert!(
!manifest.contains("uninst_plugin"),
"manifest must no longer list the uninstalled plugin.\n{manifest}"
);
assert!(
!tui.plugins_dir().join("uninst_plugin.lua").exists(),
"the staged plugin file must be deleted on uninstall"
);
}

#[test]
fn plugin_uninstall_of_not_installed_reports_error() {
// Uninstalling something that was never installed is a clear error, not a
// silent no-op.
let mut tui = Tui::start(&[]);
tui.wait_until_ready();

tui.type_text("!plugin uninstall ghost");
tui.press_enter();
tui.wait_for("is not installed", SETTLE);
}

#[test]
fn uninstall_never_deletes_bundled_stdlib() {
// Defense-in-depth: a hand-corrupted manifest that lists a reserved stdlib
// name must never cause its file to be deleted. Seed a real plugin (so the
// stdlib is provisioned at start-up) alongside a bogus `scope` manifest
// entry, then uninstall `scope`: the manifest entry is cleaned but the
// bundled `scope.lua` survives.
let tui = {
let mut tui = Tui::start_with(StartOpts {
installed_plugins: &[("realplug", "local M = {}\nreturn M\n")],
raw_manifest: Some("plugins = [\"realplug\", \"scope\"]\n"),
..Default::default()
});
tui.wait_until_ready();
tui.wait_for("realplug", SETTLE); // real plugin auto-loaded -> stdlib staged

tui.type_text("!plugin uninstall scope");
tui.press_enter();
tui.wait_for("uninstalled", SETTLE);
tui
};

assert!(
tui.plugins_dir().join("scope.lua").exists(),
"uninstall must never delete the bundled scope.lua"
);
let manifest =
std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default();
assert!(
!manifest.contains("scope"),
"the bogus scope entry should be cleaned from the manifest.\n{manifest}"
);
assert!(
manifest.contains("realplug"),
"a legitimately-installed plugin must stay installed.\n{manifest}"
);
}
Loading