From a64bc768b56ec5738cf374a9bc4c1dae7504f51b Mon Sep 17 00:00:00 2001 From: Saky Date: Thu, 2 Apr 2026 21:35:45 +0200 Subject: [PATCH] release: 0.2.2 fix post-install packaging and robustness --- CHANGELOG.md | 32 +++++++++---- README.md | 16 ++----- pyproject.toml | 4 +- src/app/config.py | 2 +- src/app/filesystem.py | 28 ++++++++++-- src/cd_browser_post_install.py | 84 ++++++++++++++++++++++++++++++++++ tests/test_filesystem.py | 30 ++++++++++++ tests/test_post_install.py | 47 +++++++++++++++++++ 8 files changed, 216 insertions(+), 27 deletions(-) create mode 100644 src/cd_browser_post_install.py create mode 100644 tests/test_post_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 310313c..a4f3615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,33 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project follows Semantic Versioning. -## [0.1.1] - 2026-03-16 +## [0.2.2] - 2026-04-02 +### Fixed +- Include `cd_browser_post_install.py` in wheel/sdist so `cd_browser_post_install` works after `pip install` -### Added -- AI logging system -- AI agent workflow framework -- ai-task tooling +## [0.2.1] - 2026-04-02 + +### Fixed +- Prevent crashes when scanning directories with restricted permissions +- Handle non-interactive post-install runs without failing on EOF +- Fix shell reload message interpolation in post-install output ### Improved -- Terminal navigation -- History mode behavior -- UI scrolling -- Toggle hidden directories with "." key in tree view +- Make post-install shell integration idempotent (avoid duplicate `cd_()` entries) +- Add test coverage for permission errors and post-install flows +- Update default application name to `cd-browser` -## [0.1.0] - 2026-03-14 +## [0.2.0] - 2026-04-02 + +### Added +- Interactive post-install script (`cd_browser_post_install`) for automatic shell integration +- Option to automatically add `cd_()` function to ~/.bashrc or ~/.zshrc during installation +- Improved uninstall instructions in README + +### Improved +- Installation UX: clearer setup process with user prompts +- Shell integration: better guidance for enabling `cd_` command ### Added - Initial release of **cd-browser** diff --git a/README.md b/README.md index 9b9a2e0..8799690 100644 --- a/README.md +++ b/README.md @@ -51,22 +51,16 @@ docs/index.md Install the project in user mode: ```bash -pip install . +pip install cd-browser ``` -For editable local development: +**Important**: After installation, run this to set up the `cd_` command: ```bash -pip install -e '.[dev]' +cd_browser_post_install ``` -After installation, the application command is: - -```bash -cd_browser -``` - -When the interactive session exits, the program prints the final selected directory path. +This interactive script will guide you through enabling `cd_` in your shell. ## Shell Integration For `cd_` @@ -113,7 +107,7 @@ If the project was installed with `pip`, remove it with: pip uninstall cd-browser ``` -If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration. +**Important**: After uninstalling, remove the `cd_()` function from your shell profile (~/.bashrc or ~/.zshrc) to clean up completely. ## Developer Setup diff --git a/pyproject.toml b/pyproject.toml index 5211ba0..46cebc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cd-browser" -version = "0.1.1" +version = "0.2.2" description = "A fast keyboard-driven directory navigator for the terminal." readme = "README.md" requires-python = ">=3.8" @@ -28,6 +28,7 @@ dependencies = [] [project.scripts] cd_browser = "app.main:main" +cd_browser_post_install = "cd_browser_post_install:main" [project.optional-dependencies] dev = [ @@ -40,6 +41,7 @@ dev = [ [tool.setuptools] package-dir = {"" = "src"} +py-modules = ["cd_browser_post_install"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/app/config.py b/src/app/config.py index 2f1708f..8f71179 100644 --- a/src/app/config.py +++ b/src/app/config.py @@ -5,5 +5,5 @@ from dataclasses import dataclass class AppConfig: """Application configuration.""" - app_name: str = "Python AI Dev Template" + app_name: str = "cd-browser" debug: bool = False diff --git a/src/app/filesystem.py b/src/app/filesystem.py index 2862272..b3d72e2 100644 --- a/src/app/filesystem.py +++ b/src/app/filesystem.py @@ -19,8 +19,18 @@ def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEnt directory_path = path.expanduser().resolve() entries: list[DirectoryEntry] = [] - for child in directory_path.iterdir(): - if not child.is_dir(): + try: + children = list(directory_path.iterdir()) + except PermissionError: + return [] + + for child in children: + try: + is_directory = child.is_dir() + except OSError: + continue + + if not is_directory: continue if not show_hidden and child.name.startswith("."): continue @@ -41,8 +51,18 @@ def has_subdirectories(path: Path, show_hidden: bool = False) -> bool: directory_path = path.expanduser().resolve() - for child in directory_path.iterdir(): - if not child.is_dir(): + try: + children = list(directory_path.iterdir()) + except PermissionError: + return False + + for child in children: + try: + is_directory = child.is_dir() + except OSError: + continue + + if not is_directory: continue if not show_hidden and child.name.startswith("."): diff --git a/src/cd_browser_post_install.py b/src/cd_browser_post_install.py new file mode 100644 index 0000000..de36db7 --- /dev/null +++ b/src/cd_browser_post_install.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Post-install script for cd-browser to guide users on shell integration.""" + +import os +import sys + + +def _profile_for_shell(shell: str) -> str: + if shell == "zsh": + return "~/.zshrc" + + return "~/.bashrc" + + +def _is_cd_function_present(profile_path: str) -> bool: + if not os.path.exists(profile_path): + return False + + with open(profile_path, encoding="utf-8") as profile_file: + return "cd_() {" in profile_file.read() + + +def main() -> None: + """Print shell integration instructions after installation.""" + print("\n" + "=" * 60) + print("🎉 cd-browser installed successfully!") + print("=" * 60) + print() + print( + "To enable the 'cd_' command, you need to add a function to your shell profile." + ) + print() + + shell = os.environ.get("SHELL", "").split("/")[-1] + profile = _profile_for_shell(shell) + + cd_function = """cd_() { + local target + target="$(cd_browser)" || return + if [ -n "$target" ] && [ -d "$target" ]; then + cd "$target" + fi +}""" + + print(f"Copy this to the end of {profile}:") + print() + print(cd_function) + print() + + profile_path = os.path.expanduser(profile) + + try: + response = ( + input("Do you want me to add it automatically? (y/n): ").strip().lower() + ) + if response == "y": + if _is_cd_function_present(profile_path): + print(f"\nℹ️ cd_() already exists in {profile}, no changes made.") + else: + with open(profile_path, "a", encoding="utf-8") as f: + f.write("\n" + cd_function + "\n") + print(f"\n✅ Added to {profile}") + print(f"Run 'source {profile}' or restart your terminal to activate cd_.") + else: + print(f"\nAdd the function to {profile} manually.") + print(f"Then run 'source {profile}' or restart terminal.") + except EOFError: + print( + f"\nNo interactive input detected. Add the function to {profile} manually." + ) + print(f"Then run 'source {profile}' or restart terminal.") + except KeyboardInterrupt: + print("\n\nSetup cancelled. You can add the function manually later.") + sys.exit(0) + + print("\n" + "=" * 60) + print( + f"For uninstall: after 'pip uninstall cd-browser', remove cd_() from {profile}." + ) + print("=" * 60 + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index 9569e2f..eea9891 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -44,3 +44,33 @@ def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): list_directories(missing_path) + + +def test_list_directories_skips_permission_errors_for_children(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + allowed = root / "allowed" + blocked = root / "blocked" + allowed.mkdir() + blocked.mkdir() + + blocked.chmod(0) + try: + entries = list_directories(root) + finally: + blocked.chmod(0o700) + + assert any(entry.name == "allowed" for entry in entries) + + +def test_has_subdirectories_returns_false_on_permission_error(tmp_path: Path) -> None: + blocked = tmp_path / "blocked" + blocked.mkdir() + + blocked.chmod(0) + try: + result = has_subdirectories(blocked) + finally: + blocked.chmod(0o700) + + assert result is False diff --git a/tests/test_post_install.py b/tests/test_post_install.py new file mode 100644 index 0000000..8fa8d90 --- /dev/null +++ b/tests/test_post_install.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from cd_browser_post_install import main + +CD_FUNCTION_SNIPPET = "cd_() {" + + +def test_post_install_handles_eof_without_crashing( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.setattr( + "builtins.input", lambda _prompt: (_ for _ in ()).throw(EOFError) + ) + + main() + + output = capsys.readouterr().out + assert "No interactive input detected" in output + assert "source ~/.zshrc" in output + + +def test_post_install_adds_cd_function_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.setenv("HOME", str(tmp_path)) + + responses = iter(["y", "y"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(responses)) + + main() + main() + + profile_path = tmp_path / ".zshrc" + content = profile_path.read_text(encoding="utf-8") + + assert content.count(CD_FUNCTION_SNIPPET) == 1 + + output = capsys.readouterr().out + assert "already exists" in output + assert "source ~/.zshrc" in output