diff --git a/integrations.json b/integrations.json index 93bc37ae..44e521c5 100644 --- a/integrations.json +++ b/integrations.json @@ -1028,7 +1028,7 @@ "name": "Amp", "category": "coding", "type": "plugin", - "version": "0.1.0", + "version": "0.1.1", "directory": "nowledge-mem-amp-plugin", "transport": "cli+http", "capabilities": { diff --git a/nowledge-mem-amp-plugin/CHANGELOG.md b/nowledge-mem-amp-plugin/CHANGELOG.md index 2a86c3b1..64887453 100644 --- a/nowledge-mem-amp-plugin/CHANGELOG.md +++ b/nowledge-mem-amp-plugin/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.1.1] - 2026-08-10 + +### Fixed + +- Restored the Amp-discoverable root plugin entry at `plugins/nowledge-mem.ts` + during install and update. Existing `0.1.0` installs may have copied the + bundle without the root entry, so Amp could miss the plugin. +- Hardened install rollback so failed updates preserve the previous entry, + bundle, and skill, and failed first installs do not leave a partial Amp + plugin behind. + ## [0.1.0] - 2026-08-08 ### Added diff --git a/nowledge-mem-amp-plugin/README.md b/nowledge-mem-amp-plugin/README.md index ec5eb623..2bbaf74d 100644 --- a/nowledge-mem-amp-plugin/README.md +++ b/nowledge-mem-amp-plugin/README.md @@ -133,7 +133,7 @@ For multi-agent setups, set `NMEM_AGENT_ID=` per spawned Amp worker. - **nmem not found.** Install with `pip install nmem-cli`, or on Arch Linux `yay -S nmem-cli` / `paru -S nmem-cli`, then run `nmem status` to verify. - **Server not responding.** Start the Nowledge Mem desktop app, or check `nmem status` for diagnostics. -- **Plugin not loading.** Re-run `./scripts/install.sh`, confirm the files exist under `~/.config/amp/plugins/nowledge-mem/`, and restart Amp. +- **Plugin not loading.** Re-run `bash nowledge-mem-amp-plugin/scripts/install.sh` from the `community` repository root, confirm the root entry `${XDG_CONFIG_HOME:-$HOME/.config}/amp/plugins/nowledge-mem.ts` and the bundle beside it exist, and restart Amp. ## Links diff --git a/nowledge-mem-amp-plugin/package.json b/nowledge-mem-amp-plugin/package.json index 24d9b45e..ad0dfaf9 100644 --- a/nowledge-mem-amp-plugin/package.json +++ b/nowledge-mem-amp-plugin/package.json @@ -1,6 +1,6 @@ { "name": "amp-nowledge-mem", - "version": "0.1.0", + "version": "0.1.1", "description": "Nowledge Mem plugin for Amp. Cross-tool knowledge with agent-end thread capture, Context Bundle, search, save, and handoffs.", "type": "module", "main": "src/index.ts", diff --git a/nowledge-mem-amp-plugin/scripts/install.sh b/nowledge-mem-amp-plugin/scripts/install.sh index ff5890f7..7ad3ba29 100755 --- a/nowledge-mem-amp-plugin/scripts/install.sh +++ b/nowledge-mem-amp-plugin/scripts/install.sh @@ -2,11 +2,15 @@ # # Installs the Nowledge Mem plugin and skill into Amp's per-user directories. # +# entry -> ${XDG_CONFIG_HOME:-~/.config}/amp/plugins/nowledge-mem.ts # plugin -> ${XDG_CONFIG_HOME:-~/.config}/amp/plugins/nowledge-mem/ # skill -> ${XDG_CONFIG_HOME:-~/.config}/amp/skills/nowledge-mem/ # -# Re-running the script updates an existing installation. Restart Amp after -# installing or updating so the plugin and skill are picked up. +# Amp discovers a single-file plugin from a root .ts/.js entry inside the +# plugins directory; the entry re-exports the bundle's default plugin so a +# bare bundle directory alone is not loadable. Re-running the script updates +# an existing installation. Restart Amp after installing or updating so the +# plugin and skill are picked up. set -euo pipefail @@ -15,6 +19,7 @@ AMP_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/amp" PLUGINS_DIR="$AMP_CONFIG_DIR/plugins" SKILLS_DIR="$AMP_CONFIG_DIR/skills" PLUGIN_DEST="$PLUGINS_DIR/$PLUGIN_NAME" +PLUGIN_ENTRY_DEST="$PLUGINS_DIR/$PLUGIN_NAME.ts" SKILL_DEST="$SKILLS_DIR/$PLUGIN_NAME" # Resolve the directory this script lives in, so the command works regardless @@ -33,50 +38,111 @@ trap cleanup EXIT STAGED_PLUGIN="$STAGING_DIR/plugin" STAGED_SKILL="$STAGING_DIR/skill" +STAGED_ENTRY="$STAGING_DIR/entry.ts" BACKUP_PLUGIN="$STAGING_DIR/old-plugin" BACKUP_SKILL="$STAGING_DIR/old-skill" +BACKUP_ENTRY="$STAGING_DIR/old-entry.ts" +# Track which backups were created so restore_previous only removes/restores +# artifacts that have a valid backup. A failed backup leaves the live artifact +# in place (a failed mv keeps its source); the matching flag stays 0, so the +# live artifact is never deleted without a backup to restore from. +ENTRY_BACKED_UP=0 +BUNDLE_BACKED_UP=0 +SKILL_BACKED_UP=0 +ENTRY_INSTALLED=0 +BUNDLE_INSTALLED=0 +SKILL_INSTALLED=0 + +# Restore the previously active entry, bundle, and skill after a failed +# replacement. Only destinations whose backup succeeded are removed, and only +# those backups are restored. Fresh artifacts that were installed before a +# later step failed are removed so failed first installs do not leave partial +# Amp-visible state behind. restore_previous() { - rm -rf "$PLUGIN_DEST" "$SKILL_DEST" - if [ -e "$BACKUP_PLUGIN" ]; then + if [ "$ENTRY_BACKED_UP" -eq 1 ]; then + rm -rf "$PLUGIN_ENTRY_DEST" + mv "$BACKUP_ENTRY" "$PLUGIN_ENTRY_DEST" + elif [ "$ENTRY_INSTALLED" -eq 1 ]; then + rm -rf "$PLUGIN_ENTRY_DEST" + fi + if [ "$BUNDLE_BACKED_UP" -eq 1 ]; then + rm -rf "$PLUGIN_DEST" mv "$BACKUP_PLUGIN" "$PLUGIN_DEST" + elif [ "$BUNDLE_INSTALLED" -eq 1 ]; then + rm -rf "$PLUGIN_DEST" fi - if [ -e "$BACKUP_SKILL" ]; then + if [ "$SKILL_BACKED_UP" -eq 1 ]; then + rm -rf "$SKILL_DEST" mv "$BACKUP_SKILL" "$SKILL_DEST" + elif [ "$SKILL_INSTALLED" -eq 1 ]; then + rm -rf "$SKILL_DEST" fi } install_staged() { - if [ -e "$PLUGIN_DEST" ] && ! mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then - return 1 + if [ -e "$PLUGIN_ENTRY_DEST" ]; then + if mv "$PLUGIN_ENTRY_DEST" "$BACKUP_ENTRY"; then + ENTRY_BACKED_UP=1 + else + return 1 + fi fi - if [ -d "$STAGED_SKILL" ] && [ -e "$SKILL_DEST" ] && ! mv "$SKILL_DEST" "$BACKUP_SKILL"; then - restore_previous - return 1 + if [ -e "$PLUGIN_DEST" ]; then + if mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then + BUNDLE_BACKED_UP=1 + else + restore_previous + return 1 + fi + fi + if [ -d "$STAGED_SKILL" ] && [ -e "$SKILL_DEST" ]; then + if mv "$SKILL_DEST" "$BACKUP_SKILL"; then + SKILL_BACKED_UP=1 + else + restore_previous + return 1 + fi fi - if ! mv "$STAGED_PLUGIN" "$PLUGIN_DEST"; then + if mv "$STAGED_PLUGIN" "$PLUGIN_DEST"; then + BUNDLE_INSTALLED=1 + else restore_previous return 1 fi - if [ -d "$STAGED_SKILL" ] && ! mv "$STAGED_SKILL" "$SKILL_DEST"; then + if [ -d "$STAGED_SKILL" ]; then + if mv "$STAGED_SKILL" "$SKILL_DEST"; then + SKILL_INSTALLED=1 + else + restore_previous + return 1 + fi + fi + if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then + ENTRY_INSTALLED=1 + else restore_previous return 1 fi - rm -rf "$BACKUP_PLUGIN" "$BACKUP_SKILL" + rm -rf "$BACKUP_PLUGIN" "$BACKUP_SKILL" "$BACKUP_ENTRY" } echo "Installing Nowledge Mem for Amp" echo " source: $PLUGIN_SRC" +echo " entry: $PLUGIN_ENTRY_DEST" echo " plugin: $PLUGIN_DEST" echo " skill: $SKILL_DEST" -# Stage the plugin and skill before touching the active installation. +# Stage the plugin, skill, and root entry before touching the active +# installation. The entry re-exports the bundle's default plugin so Amp +# discovers it as a single-file plugin (see the header comment). cp -R "$PLUGIN_SRC" "$STAGED_PLUGIN" if [ -d "$STAGED_PLUGIN/skills/$PLUGIN_NAME" ]; then cp -R "$STAGED_PLUGIN/skills/$PLUGIN_NAME" "$STAGED_SKILL" fi +printf 'export { default } from "./nowledge-mem/src/index.ts"\n' > "$STAGED_ENTRY" -# Validate the staged plugin entrypoint exists before replacement. +# Validate the staged bundle, skill, and entry before replacement. if [ ! -f "$STAGED_PLUGIN/src/index.ts" ]; then echo "Error: staged plugin is missing src/index.ts" >&2 exit 1 @@ -85,6 +151,14 @@ if [ ! -f "$STAGED_PLUGIN/skills/$PLUGIN_NAME/SKILL.md" ]; then echo "Error: staged plugin is missing skills/$PLUGIN_NAME/SKILL.md" >&2 exit 1 fi +if [ ! -f "$STAGED_ENTRY" ]; then + echo "Error: staged root entry was not written" >&2 + exit 1 +fi +if ! grep -q 'export { default } from "./nowledge-mem/src/index.ts"' "$STAGED_ENTRY"; then + echo "Error: staged root entry does not re-export the bundle default" >&2 + exit 1 +fi if ! install_staged; then echo "Error: could not replace the active installation; previous files were restored." >&2 diff --git a/tests/plugin_e2e/test_key_plugins_e2e.py b/tests/plugin_e2e/test_key_plugins_e2e.py index 59aad4a4..84df5476 100644 --- a/tests/plugin_e2e/test_key_plugins_e2e.py +++ b/tests/plugin_e2e/test_key_plugins_e2e.py @@ -1626,7 +1626,7 @@ def test_amp_plugin_static_contract_is_self_contained(): # Package manifest and registry entry agree on the basics. assert pkg["name"] == "amp-nowledge-mem" - assert pkg["version"] == "0.1.0" + assert pkg["version"] == "0.1.1" assert pkg["type"] == "module" assert pkg["main"] == "src/index.ts" assert "@ampcode/plugin" in pkg["peerDependencies"] @@ -1688,6 +1688,15 @@ def test_amp_plugin_static_contract_is_self_contained(): assert "STAGED_PLUGIN=" in install_sh assert "install_staged" in install_sh assert 'skills/$PLUGIN_NAME/SKILL.md' in install_sh + # The script installs an Amp-discoverable root plugin entry that re-exports + # the bundle default, alongside the bundle dir and skill. + assert "PLUGIN_ENTRY_DEST=" in install_sh + assert "STAGED_ENTRY=" in install_sh + assert "BACKUP_ENTRY=" in install_sh + assert "ENTRY_INSTALLED=" in install_sh + assert "BUNDLE_INSTALLED=" in install_sh + assert "SKILL_INSTALLED=" in install_sh + assert 'export { default } from "./nowledge-mem/src/index.ts"' in install_sh # No raw secrets are checked into the package. assert "NMEM_API_KEY" in readme @@ -2677,3 +2686,154 @@ def test_opencode_live_tool_thread_capture(e2e_context: E2EContext, tmp_path: Pa space=e2e_context.space, env=env, ) + + +def _run_amp_install(xdg_home: Path, *, script: Path | None = None) -> subprocess.CompletedProcess[str]: + """Run the Amp plugin install script isolated under an XDG_CONFIG_HOME root. + + Returns the CompletedProcess regardless of exit code so callers can assert + on success or failure. The script resolves its own source dir, so `script` + defaults to the in-tree install.sh. + """ + install_script = script if script is not None else AMP_PLUGIN / "scripts" / "install.sh" + return subprocess.run( + ["bash", str(install_script)], + env={**os.environ, "XDG_CONFIG_HOME": str(xdg_home)}, + text=True, + capture_output=True, + timeout=60, + ) + + +def _assert_amp_artifacts(xdg_home: Path) -> None: + plugins_dir = xdg_home / "amp" / "plugins" + entry = plugins_dir / "nowledge-mem.ts" + bundle_index = plugins_dir / "nowledge-mem" / "src" / "index.ts" + skill = xdg_home / "amp" / "skills" / "nowledge-mem" / "SKILL.md" + assert entry.is_file(), f"root plugin entry missing: {entry}" + assert ( + 'export { default } from "./nowledge-mem/src/index.ts"' in entry.read_text(encoding="utf-8") + ), "root entry does not re-export the bundle default" + # The entry's import target must resolve on disk, otherwise Amp cannot load it. + assert bundle_index.is_file(), f"entry target missing: {bundle_index}" + assert skill.is_file(), f"skill missing: {skill}" + + +def test_amp_install_script_creates_root_entry_bundle_and_skill(tmp_path: Path): + """First install writes the Amp-discoverable root entry, the bundle it + re-exports, and the skill, all under the resolved XDG config directory.""" + result = _run_amp_install(tmp_path) + assert result.returncode == 0, result.stderr + _assert_amp_artifacts(tmp_path) + + +def test_amp_install_script_is_idempotent(tmp_path: Path): + """Re-running the script is a safe atomic update: all artifacts survive and + the root entry content is stable across runs.""" + first = _run_amp_install(tmp_path) + assert first.returncode == 0, first.stderr + entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts" + bundle_index = tmp_path / "amp" / "plugins" / "nowledge-mem" / "src" / "index.ts" + entry_before = entry.read_text(encoding="utf-8") + bundle_before = bundle_index.read_text(encoding="utf-8") + + second = _run_amp_install(tmp_path) + assert second.returncode == 0, second.stderr + _assert_amp_artifacts(tmp_path) + assert entry.read_text(encoding="utf-8") == entry_before + assert bundle_index.read_text(encoding="utf-8") == bundle_before + + +def test_amp_install_script_keeps_prior_install_when_preflight_fails(tmp_path: Path): + """A failed pre-flight check must not disturb a prior valid installation: + the script stages everything before touching the active install, so a + missing source file fails fast and leaves the live entry/bundle/skill + intact.""" + # Establish a known-good installation first. + seed = _run_amp_install(tmp_path) + assert seed.returncode == 0, seed.stderr + entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts" + bundle_index = tmp_path / "amp" / "plugins" / "nowledge-mem" / "src" / "index.ts" + skill = tmp_path / "amp" / "skills" / "nowledge-mem" / "SKILL.md" + entry_before = entry.read_text(encoding="utf-8") + bundle_before = bundle_index.read_text(encoding="utf-8") + skill_before = skill.read_text(encoding="utf-8") + + # Build a broken source tree (missing src/index.ts) and run its install + # script against the same config root; the staged-bundle pre-flight check + # must abort before the live install is touched. + broken_src = tmp_path / "broken-amp-plugin-src" + shutil.copytree(AMP_PLUGIN, broken_src) + (broken_src / "src" / "index.ts").unlink() + failed = _run_amp_install(tmp_path, script=broken_src / "scripts" / "install.sh") + + assert failed.returncode != 0 + assert "staged plugin is missing src/index.ts" in failed.stderr + # The prior installation is untouched. + _assert_amp_artifacts(tmp_path) + assert entry.read_text(encoding="utf-8") == entry_before + assert bundle_index.read_text(encoding="utf-8") == bundle_before + assert skill.read_text(encoding="utf-8") == skill_before + + +def test_amp_install_script_preserves_prior_install_when_backup_move_fails(tmp_path: Path): + """A failed backup of the live bundle must not delete it. restore_previous + only removes/restores artifacts whose backup succeeded, so a failed bundle + backup leaves the bundle in place and restores the entry from its successful + backup.""" + seed = _run_amp_install(tmp_path) + assert seed.returncode == 0, seed.stderr + entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts" + bundle_dir = tmp_path / "amp" / "plugins" / "nowledge-mem" + bundle_index = bundle_dir / "src" / "index.ts" + skill = tmp_path / "amp" / "skills" / "nowledge-mem" / "SKILL.md" + entry_before = entry.read_text(encoding="utf-8") + bundle_before = bundle_index.read_text(encoding="utf-8") + skill_before = skill.read_text(encoding="utf-8") + + # Inject a deterministic bundle-backup failure after the entry backup has + # succeeded. chmod on the bundle dir is not portable here: POSIX rename + # checks the parent directory, not the moved directory's own write bit. + broken_src = tmp_path / "broken-backup-amp-plugin-src" + shutil.copytree(AMP_PLUGIN, broken_src) + install_script = broken_src / "scripts" / "install.sh" + script = install_script.read_text(encoding="utf-8") + script = script.replace( + 'if mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then', + 'if false && mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then', + 1, + ) + install_script.write_text(script, encoding="utf-8") + failed = _run_amp_install(tmp_path, script=install_script) + + assert failed.returncode != 0 + assert "could not replace the active installation" in failed.stderr + # The prior installation is fully intact; the bundle was never deleted. + _assert_amp_artifacts(tmp_path) + assert entry.read_text(encoding="utf-8") == entry_before + assert bundle_index.read_text(encoding="utf-8") == bundle_before + assert skill.read_text(encoding="utf-8") == skill_before + + +def test_amp_install_script_removes_fresh_partial_install_when_entry_move_fails(tmp_path: Path): + """If a first install fails after moving the bundle and skill but before + moving the root entry, rollback removes the new partial install. Amp should + not see a half-installed bundle without the discoverable entry.""" + broken_src = tmp_path / "broken-entry-amp-plugin-src" + shutil.copytree(AMP_PLUGIN, broken_src) + install_script = broken_src / "scripts" / "install.sh" + script = install_script.read_text(encoding="utf-8") + script = script.replace( + 'if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then', + 'rm -f "$STAGED_ENTRY"\n if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then', + 1, + ) + install_script.write_text(script, encoding="utf-8") + + failed = _run_amp_install(tmp_path, script=install_script) + + assert failed.returncode != 0 + assert "could not replace the active installation" in failed.stderr + assert not (tmp_path / "amp" / "plugins" / "nowledge-mem.ts").exists() + assert not (tmp_path / "amp" / "plugins" / "nowledge-mem").exists() + assert not (tmp_path / "amp" / "skills" / "nowledge-mem").exists()