From 769cee7688e70569a5cbb91dda02d00c89b6d65f Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Thu, 7 May 2026 16:24:33 +0200 Subject: [PATCH] feat: add nimble remove command to uninstall community skills Co-Authored-By: Claude Sonnet 4.6 --- .../implementation-artifacts/deferred-work.md | 5 + .../spec-add-nimble-remove-command.md | 110 ++++++++++++++++++ nimble/cli/commands.py | 56 +++++++++ nimble/manifest/lock.py | 11 ++ nimble/manifest/parser.py | 28 +++++ tests/unit/cli/test_commands.py | 98 ++++++++++++++++ tests/unit/manifest/test_lock.py | 27 ++++- 7 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 docs/bmad_output/implementation-artifacts/spec-add-nimble-remove-command.md diff --git a/docs/bmad_output/implementation-artifacts/deferred-work.md b/docs/bmad_output/implementation-artifacts/deferred-work.md index 3f115c9..aa5d530 100644 --- a/docs/bmad_output/implementation-artifacts/deferred-work.md +++ b/docs/bmad_output/implementation-artifacts/deferred-work.md @@ -1,5 +1,10 @@ # Deferred Work +## Deferred from: add-nimble-remove-command (2026-05-07) + +- `nimble/cli/commands.py:remove` + `nimble/manifest/installer.py`: skill directory path is constructed from an unvalidated name (`repo_root / ".nimble" / "skills" / skill_name`) — a malicious manifest with `name: "../../etc"` could cause `nimble add` to clone and `nimble remove` to delete outside `.nimble/skills/`. The `remove` command is gated by `remove_skill_from_config` (name must be in config.yaml), but the underlying path is not asserted to resolve within the expected subtree. Harden by resolving `skill_dir` and asserting it is relative to `repo_root / ".nimble" / "skills"` in both `add` and `remove`. +- `nimble/manifest/parser.py:remove_skill_from_config` + `remove_skill_entry_from_config`: both assume `yaml.safe_load` returns a `dict` (call `.get(...)` directly after a `None` check), but a valid YAML file containing a top-level list or scalar raises `AttributeError` instead of a clean `ConfigError`. Pre-existing pattern; fix with `if not isinstance(raw, dict): raise ConfigError(...)` in a parser-hardening pass. + ## Deferred from: nimble start config auto-create (2026-05-07) - `nimble/cli/commands.py:_repo_root()`: derives repo root from `Path(__file__).resolve().parent.parent.parent` (the installed package location), not from CWD or a git root. On a system-wide or pipx install, `config.yaml` is created inside the read-only package directory rather than the user's project. Pre-existing across all commands; resolve with a proper CWD-or-git-root detection strategy. diff --git a/docs/bmad_output/implementation-artifacts/spec-add-nimble-remove-command.md b/docs/bmad_output/implementation-artifacts/spec-add-nimble-remove-command.md new file mode 100644 index 0000000..3c34329 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-add-nimble-remove-command.md @@ -0,0 +1,110 @@ +--- +title: 'Add nimble remove command' +type: 'feature' +created: '2026-05-07' +status: 'done' +baseline_commit: '8634d55e93f04aa3ef305e7a09b2efff98ce6ba6' +context: [] +--- + + + +## Intent + +**Problem:** There is no way to uninstall a skill installed via `nimble add` — users must manually edit `config.yaml`, delete the `.nimble/skills//` directory, and remove the lock entry. + +**Approach:** Add a `nimble remove ` CLI command that removes the config entry, lock entry, and skill directory in a single step after a `y/N` confirmation prompt. + +## Boundaries & Constraints + +**Always:** +- Require `y/Y` confirmation before any state is modified. +- Remove the skill entry from `config.yaml` first; abort with exit 1 on failure. +- Remove the lock entry from `manifest.lock` after config update — warn on `OSError`, do not abort. +- Delete `.nimble/skills//` after config update — warn if absent, do not abort. +- Print a restart hint if the daemon is running at removal time. + +**Ask First:** None — the operation is clear enough to proceed without mid-task human decisions. + +**Never:** +- Add a `--yes`/`--force` flag — explicit confirmation is intentional UX. +- Stop the daemon before removing — let the user decide when to restart. +- Fail the removal if the skill directory or lock entry is absent (may have been cleaned up manually). + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Happy path | skill in config + lock + dir; user enters `y` | config entry removed, lock entry removed, dir deleted, "Skill 'name' removed." | N/A | +| Confirmation declined | user enters `n` or empty | "Removal cancelled.", exit 0, no state changed | N/A | +| Skill not in config | no matching entry in config.yaml | "Skill 'name' not found in config.yaml", exit 1 | ConfigError propagated | +| Dir missing | config entry present, dir absent | config + lock updated, warn "Skill directory not found — skipping.", exit 0 | warn only | +| Lock entry missing | config entry present, no lock entry for name | config updated, no warning needed (silent no-op), exit 0 | silent | +| Daemon running | removal succeeds while daemon is running | success + "Nimble is running — restart with 'nimble restart' to apply changes." | N/A | + + + +## Code Map + +- `nimble/manifest/lock.py` — add `remove_lock_entry(lock_path, skill_name)` here; `read_lock` / `write_lock_entry` already present +- `nimble/manifest/parser.py:277` — existing `remove_skill_entry_from_config` (rollback helper, "Rollback failed:" prefix); add new `remove_skill_from_config` with user-friendly messages +- `nimble/cli/commands.py` — all CLI commands live here; `state`, `_repo_root`, `_do_stop` already imported +- `tests/unit/manifest/test_lock.py` — existing lock tests use `read_lock` / `write_lock_entry`; append tests for `remove_lock_entry` +- `tests/unit/cli/test_commands.py` — existing CLI tests; append tests for `remove` + +## Tasks & Acceptance + +**Execution:** +- [x] `nimble/manifest/lock.py` -- add `remove_lock_entry(lock_path: Path, skill_name: str) -> None` -- reads the lock, removes the named key if present (silently no-ops if absent or file missing), rewrites atomically via `atomic_write` +- [x] `nimble/manifest/parser.py` -- add `remove_skill_from_config(config_path: Path, skill_name: str) -> None` -- iterates the `skills` list, deletes all entries whose `name` matches, rewrites atomically; raises `ConfigError(f"Skill {skill_name!r} not found in config.yaml")` if none found +- [x] `nimble/cli/commands.py` -- add `remove` Typer command: prompt `y/N`, call `remove_skill_from_config` (exit 1 on `ConfigError`), call `remove_lock_entry` (warn on `OSError`), `shutil.rmtree` the skill dir (warn if absent), print success, print restart hint if daemon running +- [x] `tests/unit/manifest/test_lock.py` -- add tests: existing key removed and file updated; missing key is a no-op; missing lock file is a no-op +- [x] `tests/unit/cli/test_commands.py` -- add tests: happy path removes all three; confirmation declined leaves state unchanged; skill absent from config exits 1; dir absent still succeeds; daemon running shows restart hint + +**Acceptance Criteria:** +- Given a skill exists in config, lock, and dir, when `nimble remove ` is confirmed with `y`, then the config entry is removed, the lock entry is removed, the skill dir is deleted, "Skill '' removed." is printed, and the process exits 0. +- Given the user enters `n` at the prompt, when `nimble remove ` is run, then nothing is modified, "Removal cancelled." is printed, and the process exits 0. +- Given the skill has no entry in config.yaml, when `nimble remove ` is confirmed, then "Skill '' not found in config.yaml" is printed on stderr and the process exits 1. +- Given the skill directory is absent, when `nimble remove ` is confirmed, then config and lock are still updated, a warning about the missing dir is printed, and the process exits 0. +- Given the daemon is running when removal succeeds, then "Nimble is running — restart with 'nimble restart' to apply changes." is printed. + +## Verification + +**Commands:** +- `pytest tests/unit/manifest/test_lock.py tests/unit/cli/test_commands.py -q` -- expected: all new tests pass, zero regressions +- `flake8 nimble/manifest/lock.py nimble/manifest/parser.py nimble/cli/commands.py` -- expected: exit 0 +- `mypy nimble/manifest/lock.py nimble/manifest/parser.py nimble/cli/commands.py` -- expected: no errors + +## Suggested Review Order + +**Uninstall operation flow** + +- Entry point: command signature and y/N confirmation gate before any state changes + [`commands.py:474`](../../../nimble/cli/commands.py#L474) + +- Ordered teardown: config first (abort on failure), then lock and dir (warn-only) + [`commands.py:497`](../../../nimble/cli/commands.py#L497) + +- Directory deletion with OSError guard; dir-absent is warn-not-abort + [`commands.py:510`](../../../nimble/cli/commands.py#L510) + +- Daemon-running hint appended only after all mutations succeed + [`commands.py:522`](../../../nimble/cli/commands.py#L522) + +**Config mutation** + +- New user-facing function: removes all matches; contrast with rollback helper's last-match + [`parser.py:307`](../../../nimble/manifest/parser.py#L307) + +**Lock mutation** + +- Idempotent removal: early-return on absent key avoids a needless atomic write + [`lock.py:38`](../../../nimble/manifest/lock.py#L38) + +**Tests** + +- Happy path verifies skill directory is actually deleted, not just output text + [`test_commands.py:759`](../../../tests/unit/cli/test_commands.py#L759) + +- Lock unit tests: key removed, missing key no-op, missing file no-op + [`test_lock.py:63`](../../../tests/unit/manifest/test_lock.py#L63) diff --git a/nimble/cli/commands.py b/nimble/cli/commands.py index cd58c09..d238062 100644 --- a/nimble/cli/commands.py +++ b/nimble/cli/commands.py @@ -470,5 +470,61 @@ def add( typer.echo(f"Skill '{spec.name}' installed and bound to {shortcut}.") +@app.command() +def remove( + skill_name: str = typer.Argument(..., help="Name of the skill to remove"), +) -> None: + """Remove a skill installed via nimble add.""" + from nimble.manifest.lock import remove_lock_entry + from nimble.manifest.parser import ConfigError, remove_skill_from_config + + typer.echo(f"Remove skill '{skill_name}'? [y/N]: ", nl=False) + try: + line = sys.stdin.readline() + except (OSError, UnicodeDecodeError): + typer.echo("Removal cancelled.") + raise typer.Exit(0) + if line.rstrip("\r\n") not in ("y", "Y"): + typer.echo("Removal cancelled.") + raise typer.Exit(0) + + repo_root = _repo_root() + config_path = repo_root / "config.yaml" + lock_path = repo_root / ".nimble" / "manifest.lock" + skill_dir = repo_root / ".nimble" / "skills" / skill_name + + try: + remove_skill_from_config(config_path, skill_name) + except ConfigError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + except OSError as exc: + typer.echo(f"Failed to update config.yaml: {exc}", err=True) + raise typer.Exit(1) + + try: + remove_lock_entry(lock_path, skill_name) + except OSError as exc: + typer.echo(f"Warning: failed to update manifest.lock: {exc}", err=True) + + if skill_dir.exists(): + import shutil + + try: + shutil.rmtree(skill_dir) + except OSError as exc: + typer.echo(f"Warning: failed to delete skill directory: {exc}", err=True) + else: + typer.echo("Skill directory not found — skipping.") + + typer.echo(f"Skill '{skill_name}' removed.") + + pid = state.read_pid() + if pid is not None and state.is_running(pid): + typer.echo( + "Nimble is running — restart with 'nimble restart' to apply changes." + ) + + if __name__ == "__main__": app() diff --git a/nimble/manifest/lock.py b/nimble/manifest/lock.py index b191be2..621696e 100644 --- a/nimble/manifest/lock.py +++ b/nimble/manifest/lock.py @@ -33,3 +33,14 @@ def write_lock_entry( {"skills": skills}, default_flow_style=False, allow_unicode=True ) atomic_write(lock_path, content) + + +def remove_lock_entry(lock_path: Path, skill_name: str) -> None: + skills = read_lock(lock_path) + if skill_name not in skills: + return + del skills[skill_name] + atomic_write( + lock_path, + yaml.dump({"skills": skills}, default_flow_style=False, allow_unicode=True), + ) diff --git a/nimble/manifest/parser.py b/nimble/manifest/parser.py index 9888363..77462b7 100644 --- a/nimble/manifest/parser.py +++ b/nimble/manifest/parser.py @@ -304,6 +304,34 @@ def remove_skill_entry_from_config(config_path: Path, skill_name: str) -> None: ) +def remove_skill_from_config(config_path: Path, skill_name: str) -> None: + """Remove all skills[] entries whose name matches (user-facing uninstall).""" + try: + with config_path.open(encoding="utf-8") as f: + raw = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + raise ConfigError(f"Failed to read config.yaml: {exc}") from exc + + if raw is None: + raise ConfigError(f"Skill {skill_name!r} not found in config.yaml") + + skills = raw.get("skills", []) + if not isinstance(skills, list): + raise ConfigError("config.yaml 'skills' is not a list") + + updated = [ + e for e in skills if not (isinstance(e, dict) and e.get("name") == skill_name) + ] + if len(updated) == len(skills): + raise ConfigError(f"Skill {skill_name!r} not found in config.yaml") + + raw["skills"] = updated + atomic_write( + config_path, + yaml.dump(raw, default_flow_style=False, allow_unicode=True), + ) + + def read_skill_manifest(config: SkillConfig, base_path: Path) -> dict[str, Any] | None: base_root = base_path.resolve() skill_path = Path(config.path) diff --git a/tests/unit/cli/test_commands.py b/tests/unit/cli/test_commands.py index 3084d42..3d48af1 100644 --- a/tests/unit/cli/test_commands.py +++ b/tests/unit/cli/test_commands.py @@ -756,6 +756,104 @@ def test_add_config_field_trims_allowed_value_whitespace() -> None: assert configuration == {"target_language": "es"} +def test_remove_happy_path(tmp_path: Path) -> None: + skill_dir = tmp_path / ".nimble" / "skills" / "my-skill" + skill_dir.mkdir(parents=True) + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch("nimble.manifest.parser.remove_skill_from_config"), + patch("nimble.manifest.lock.remove_lock_entry"), + patch("nimble.cli.commands.state.read_pid", return_value=None), + ): + result = runner.invoke(app, ["remove", "my-skill"], input="y\n") + assert result.exit_code == 0 + assert "removed" in result.output + assert not skill_dir.exists() + + +def test_remove_confirmation_declined(tmp_path: Path) -> None: + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch( + "nimble.manifest.parser.remove_skill_from_config" + ) as mock_rm, + ): + result = runner.invoke(app, ["remove", "my-skill"], input="n\n") + assert result.exit_code == 0 + assert "cancelled" in result.output + mock_rm.assert_not_called() + + +def test_remove_confirmation_empty_declines(tmp_path: Path) -> None: + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch( + "nimble.manifest.parser.remove_skill_from_config" + ) as mock_rm, + ): + result = runner.invoke(app, ["remove", "my-skill"], input="\n") + assert result.exit_code == 0 + assert "cancelled" in result.output + mock_rm.assert_not_called() + + +def test_remove_skill_not_in_config(tmp_path: Path) -> None: + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch( + "nimble.manifest.parser.remove_skill_from_config", + side_effect=ConfigError("Skill 'my-skill' not found in config.yaml"), + ), + ): + result = runner.invoke(app, ["remove", "my-skill"], input="y\n") + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_remove_dir_absent_warns_and_succeeds(tmp_path: Path) -> None: + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch("nimble.manifest.parser.remove_skill_from_config"), + patch("nimble.manifest.lock.remove_lock_entry"), + patch("nimble.cli.commands.state.read_pid", return_value=None), + ): + result = runner.invoke(app, ["remove", "my-skill"], input="y\n") + assert result.exit_code == 0 + assert "directory not found" in result.output + assert "removed" in result.output + + +def test_remove_lock_oserror_warns_but_succeeds(tmp_path: Path) -> None: + skill_dir = tmp_path / ".nimble" / "skills" / "my-skill" + skill_dir.mkdir(parents=True) + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch("nimble.manifest.parser.remove_skill_from_config"), + patch( + "nimble.manifest.lock.remove_lock_entry", + side_effect=OSError("disk full"), + ), + patch("nimble.cli.commands.state.read_pid", return_value=None), + ): + result = runner.invoke(app, ["remove", "my-skill"], input="y\n") + assert result.exit_code == 0 + assert "manifest.lock" in result.output + assert "removed" in result.output + + +def test_remove_daemon_running_shows_restart_hint(tmp_path: Path) -> None: + with ( + patch("nimble.cli.commands._repo_root", return_value=tmp_path), + patch("nimble.manifest.parser.remove_skill_from_config"), + patch("nimble.manifest.lock.remove_lock_entry"), + patch("nimble.cli.commands.state.read_pid", return_value=12345), + patch("nimble.cli.commands.state.is_running", return_value=True), + ): + result = runner.invoke(app, ["remove", "my-skill"], input="y\n") + assert result.exit_code == 0 + assert "restart" in result.output + + def test_terminate_windows_openprocess_failure_raises() -> None: fake_kernel32 = MagicMock() fake_kernel32.OpenProcess.return_value = 0 diff --git a/tests/unit/manifest/test_lock.py b/tests/unit/manifest/test_lock.py index 871672f..ac788ea 100644 --- a/tests/unit/manifest/test_lock.py +++ b/tests/unit/manifest/test_lock.py @@ -4,7 +4,7 @@ import yaml -from nimble.manifest.lock import read_lock, write_lock_entry +from nimble.manifest.lock import read_lock, remove_lock_entry, write_lock_entry def test_read_lock_missing_file(tmp_path: Path) -> None: @@ -58,3 +58,28 @@ def test_write_lock_entry_creates_nimble_dir(tmp_path: Path) -> None: assert lock_path.exists() data = yaml.safe_load(lock_path.read_text(encoding="utf-8")) assert "my-skill" in data["skills"] + + +def test_remove_lock_entry_removes_named_key(tmp_path: Path) -> None: + lock_path = tmp_path / "manifest.lock" + write_lock_entry(lock_path, "skill-a", "https://github.com/u/a", "1.0.0") + write_lock_entry(lock_path, "skill-b", "https://github.com/u/b", "2.0.0") + remove_lock_entry(lock_path, "skill-a") + data = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert "skill-a" not in data["skills"] + assert "skill-b" in data["skills"] + + +def test_remove_lock_entry_missing_key_is_noop(tmp_path: Path) -> None: + lock_path = tmp_path / "manifest.lock" + write_lock_entry(lock_path, "skill-b", "https://github.com/u/b", "2.0.0") + remove_lock_entry(lock_path, "skill-a") + data = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert "skill-b" in data["skills"] + + +def test_remove_lock_entry_missing_file_is_noop(tmp_path: Path) -> None: + lock_path = tmp_path / "manifest.lock" + assert not lock_path.exists() + remove_lock_entry(lock_path, "skill-a") + assert not lock_path.exists()