From 7189692ba6648d74ca240c627af030530e59b7e5 Mon Sep 17 00:00:00 2001 From: Vyacheslav Dmitriev Date: Sun, 6 Sep 2026 12:26:56 +0300 Subject: [PATCH 1/3] feat(shell): add reload command hint --- ROADMAP.txt | 1 - src/pcd_cli/cli/shell.py | 2 +- src/pcd_cli/shell_integration.py | 6 +++++- tests/integration/test_navigation.py | 4 ++-- tests/integration/test_shell_integration.py | 9 ++++++++- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ROADMAP.txt b/ROADMAP.txt index c1d6ad9..133092e 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -13,7 +13,6 @@ CLI SHELL INTEGRATION Improve `pcd shell status` formatting. - Give a useful reload hint after installation. Test Bash, Zsh, and Fish independently. Consider renaming `pcd shell print ` to `pcd shell init `. diff --git a/src/pcd_cli/cli/shell.py b/src/pcd_cli/cli/shell.py index 42bfa45..3c028c6 100644 --- a/src/pcd_cli/cli/shell.py +++ b/src/pcd_cli/cli/shell.py @@ -27,7 +27,7 @@ def install_shell(shell: str | None) -> None: integration = _shell_integration(shell) if integration.install(): click.echo(f"Installed {integration.shell.value} integration in {integration.config_path}") - click.echo(f"Restart the shell or run: exec {integration.shell.value}") + click.echo(f"Reload the current shell with: {integration.reload_command()}") return state = integration.state() diff --git a/src/pcd_cli/shell_integration.py b/src/pcd_cli/shell_integration.py index 4462450..308edd2 100644 --- a/src/pcd_cli/shell_integration.py +++ b/src/pcd_cli/shell_integration.py @@ -1,6 +1,7 @@ """Generate, install, and inspect shell integration for pcd.""" import os +import shlex import stat from dataclasses import dataclass from enum import StrEnum @@ -49,6 +50,9 @@ def for_shell(cls, shell: Shell) -> Self: def state(self) -> ShellIntegrationState: return _integration_state(self._read(), self.shell) + def reload_command(self) -> str: + return f"source {shlex.quote(str(self.config_path))}" + def install(self) -> bool: """Install the managed block. Return whether the config changed.""" with file_lock(self.config_path): @@ -146,7 +150,7 @@ def inactive_shell_message() -> str: ) return ( f"Shell integration is configured in {integration.config_path} but is not active in " - f"this shell. Restart it or run: exec {integration.shell.value}" + f"this shell. Reload it with: {integration.reload_command()}" ) diff --git a/tests/integration/test_navigation.py b/tests/integration/test_navigation.py index b1321b8..ceec396 100644 --- a/tests/integration/test_navigation.py +++ b/tests/integration/test_navigation.py @@ -185,7 +185,7 @@ def test_navigation_recommends_reload_when_integration_is_configured( assert result.exit_code == 0 assert "configured" in result.output - assert "exec zsh" in result.output + assert f"source {Path.home() / '.zshrc'}" in result.output assert "pcd shell install" not in result.output @@ -204,7 +204,7 @@ def test_navigation_recommends_reload_after_managed_install( assert result.exit_code == 0 assert "configured" in result.output - assert "exec zsh" in result.output + assert f"source {Path.home() / '.zshrc'}" in result.output assert "pcd shell install" not in result.output diff --git a/tests/integration/test_shell_integration.py b/tests/integration/test_shell_integration.py index 0578ef9..91e1a6d 100644 --- a/tests/integration/test_shell_integration.py +++ b/tests/integration/test_shell_integration.py @@ -97,7 +97,7 @@ def test_shell_install_detects_zsh_and_is_idempotent( assert installed.exit_code == 0 assert "Installed zsh integration" in installed.output - assert "exec zsh" in installed.output + assert f"source {config}" in installed.output assert repeated.exit_code == 0 assert "already installed" in repeated.output assert content.startswith("export EDITOR=vim\n") @@ -191,6 +191,13 @@ def test_shell_install_can_be_explicit_when_shell_is_unknown( assert explicit.exit_code == 0 +def test_reload_command_quotes_config_path(tmp_path: Path) -> None: + config = tmp_path / "shell config" + integration = ShellIntegration(Shell.BASH, config) + + assert integration.reload_command() == f"source '{config}'" + + def test_fish_config_uses_xdg_config_home(monkeypatch: pytest.MonkeyPatch) -> None: config_home = Path(os.environ["XDG_CONFIG_HOME"]) integration = ShellIntegration.for_shell(Shell.FISH) From 5da4b6bd85e30b28465f4edb83f82eec7a67e3b7 Mon Sep 17 00:00:00 2001 From: Vyacheslav Dmitriev Date: Sun, 6 Sep 2026 12:28:00 +0300 Subject: [PATCH 2/3] test(shell): cover supported shells independently --- ROADMAP.txt | 1 - tests/integration/test_shell_integration.py | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ROADMAP.txt b/ROADMAP.txt index 133092e..16dc4f4 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -13,7 +13,6 @@ CLI SHELL INTEGRATION Improve `pcd shell status` formatting. - Test Bash, Zsh, and Fish independently. Consider renaming `pcd shell print ` to `pcd shell init `. COMPLETION diff --git a/tests/integration/test_shell_integration.py b/tests/integration/test_shell_integration.py index 91e1a6d..ea06909 100644 --- a/tests/integration/test_shell_integration.py +++ b/tests/integration/test_shell_integration.py @@ -83,6 +83,18 @@ def test_render_rejects_unsupported_shell() -> None: render_shell_integration("powershell") # type: ignore[arg-type] +@pytest.mark.parametrize("shell", list(Shell), ids=lambda shell: shell.value) +def test_shell_install_supports_each_shell(runner: CliRunner, shell: Shell) -> None: + integration = ShellIntegration.for_shell(shell) + + result = runner.invoke(cli, ["shell", "install", shell.value]) + + assert result.exit_code == 0 + assert f"Installed {shell.value} integration" in result.output + assert f"Reload the current shell with: {integration.reload_command()}" in result.output + assert f"pcd shell print {shell.value}" in integration.config_path.read_text(encoding="utf-8") + + def test_shell_install_detects_zsh_and_is_idempotent( runner: CliRunner, monkeypatch: pytest.MonkeyPatch, From ba3ebf2a1634bce24a39a7509211001890ceb677 Mon Sep 17 00:00:00 2001 From: Vyacheslav Dmitriev Date: Sun, 6 Sep 2026 12:30:19 +0300 Subject: [PATCH 3/3] feat(shell)!: replace print command with init --- FEATURES.txt | 2 +- README.txt | 1 + ROADMAP.txt | 1 - src/pcd_cli/cli/__init__.py | 6 ++-- src/pcd_cli/cli/app.py | 3 +- src/pcd_cli/cli/shell.py | 15 ++-------- src/pcd_cli/shell_integration.py | 9 +++--- tests/e2e/test_cli_workflow.py | 4 +-- tests/integration/test_cli_commands.py | 7 ----- tests/integration/test_navigation.py | 2 +- tests/integration/test_shell_integration.py | 33 ++++++++------------- 11 files changed, 27 insertions(+), 56 deletions(-) diff --git a/FEATURES.txt b/FEATURES.txt index 6cf3ed3..8e09266 100644 --- a/FEATURES.txt +++ b/FEATURES.txt @@ -80,7 +80,7 @@ SHELL INTEGRATION pcd shell uninstall Print shell integration code: - pcd shell print + pcd shell init Shell integration can also be added manually to the shell config. diff --git a/README.txt b/README.txt index fc4a82f..590564d 100644 --- a/README.txt +++ b/README.txt @@ -52,6 +52,7 @@ COMMANDS pcd config validate Validate the user configuration pcd shell install Install shell integration + pcd shell init Print shell integration code pcd shell status Show shell integration status pcd shell uninstall Remove shell integration diff --git a/ROADMAP.txt b/ROADMAP.txt index 16dc4f4..556567a 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -13,7 +13,6 @@ CLI SHELL INTEGRATION Improve `pcd shell status` formatting. - Consider renaming `pcd shell print ` to `pcd shell init `. COMPLETION Complete project names for `pcd `. diff --git a/src/pcd_cli/cli/__init__.py b/src/pcd_cli/cli/__init__.py index 8f64ee5..85a6354 100644 --- a/src/pcd_cli/cli/__init__.py +++ b/src/pcd_cli/cli/__init__.py @@ -9,10 +9,9 @@ from pcd_cli.cli.projects import add, list_projects, project, remove from pcd_cli.cli.roots import init, refresh, roots, uninit from pcd_cli.cli.shell import ( + init_shell, install_shell, - print_shell_integration, shell_commands, - shell_init, shell_status, uninstall_shell, ) @@ -24,18 +23,17 @@ "config_commands", "edit_config", "init", + "init_shell", "install_shell", "list_projects", "main", "package_version", "print_config_path", - "print_shell_integration", "project", "refresh", "remove", "roots", "shell_commands", - "shell_init", "shell_status", "show_config", "uninit", diff --git a/src/pcd_cli/cli/app.py b/src/pcd_cli/cli/app.py index abfb5c3..b187caa 100644 --- a/src/pcd_cli/cli/app.py +++ b/src/pcd_cli/cli/app.py @@ -9,7 +9,7 @@ from pcd_cli.cli.config import config_commands from pcd_cli.cli.projects import project_commands from pcd_cli.cli.roots import root_commands -from pcd_cli.cli.shell import shell_commands, shell_init +from pcd_cli.cli.shell import shell_commands from pcd_cli.config import InvalidConfigError from pcd_cli.models import ExitCode from pcd_cli.navigation import navigate_to_project, project_completions @@ -89,7 +89,6 @@ def cli(ctx: click.Context, project_name: str | None) -> None: *root_commands, config_commands, shell_commands, - shell_init, ): cli.add_command(command) diff --git a/src/pcd_cli/cli/shell.py b/src/pcd_cli/cli/shell.py index 3c028c6..748500b 100644 --- a/src/pcd_cli/cli/shell.py +++ b/src/pcd_cli/cli/shell.py @@ -78,30 +78,19 @@ def uninstall_shell(shell: str | None) -> None: click.echo(f"Shell integration is not installed in {integration.config_path}") -@shell_commands.command("print") +@shell_commands.command("init") @click.argument( "shell", required=False, type=click.Choice([item.value for item in Shell], case_sensitive=False), ) @click.pass_context -def print_shell_integration(ctx: click.Context, shell: str | None) -> None: +def init_shell(ctx: click.Context, shell: str | None) -> None: """Print shell integration for manual dotfile management.""" selected = _selected_shell(shell) click.echo(render_shell_integration(selected, _registered_command_names(ctx)), nl=False) -@click.command("shell-init", hidden=True) -@click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False)) -@click.pass_context -def shell_init(ctx: click.Context, shell: str) -> None: - """Backward-compatible alias for `pcd shell print`.""" - click.echo( - render_shell_integration(Shell(shell.casefold()), _registered_command_names(ctx)), - nl=False, - ) - - def _shell_integration(shell: str | None) -> ShellIntegration: return ShellIntegration.for_shell(_selected_shell(shell)) diff --git a/src/pcd_cli/shell_integration.py b/src/pcd_cli/shell_integration.py index 308edd2..c6f7a65 100644 --- a/src/pcd_cli/shell_integration.py +++ b/src/pcd_cli/shell_integration.py @@ -157,9 +157,9 @@ def inactive_shell_message() -> str: def render_managed_block(shell: Shell) -> str: """Render the small persistent block written into the shell startup file.""" command = ( - f'eval "$(command pcd shell print {shell.value})"' + f'eval "$(command pcd shell init {shell.value})"' if shell is not Shell.FISH - else f"command pcd shell print {shell.value} | source" + else f"command pcd shell init {shell.value} | source" ) return f"{_MANAGED_BLOCK_START}\n{command}\n{_MANAGED_BLOCK_END}\n" @@ -183,9 +183,8 @@ def _integration_state(content: str, shell: Shell) -> ShellIntegrationState: if bounds is not None: return ShellIntegrationState.MANAGED - legacy = f"pcd shell-init {shell.value}" - current = f"pcd shell print {shell.value}" - if legacy in content or current in content: + current = f"pcd shell init {shell.value}" + if current in content: return ShellIntegrationState.MANUAL return ShellIntegrationState.ABSENT diff --git a/tests/e2e/test_cli_workflow.py b/tests/e2e/test_cli_workflow.py index dfff936..4548f3d 100644 --- a/tests/e2e/test_cli_workflow.py +++ b/tests/e2e/test_cli_workflow.py @@ -86,7 +86,7 @@ def test_bash_wrapper_changes_parent_shell_directory(tmp_path: Path) -> None: [ "bash", "-c", - 'eval "$("$1" shell print bash)"; pcd repo; pwd', + 'eval "$("$1" shell init bash)"; pcd repo; pwd', "pcd-test", str(_pcd_executable()), ], @@ -124,7 +124,7 @@ def test_config_edit_keeps_terminal_attached_through_bash_wrapper(tmp_path: Path [ "bash", "-c", - 'eval "$("$1" shell print bash)"; pcd config edit', + 'eval "$("$1" shell init bash)"; pcd config edit', "pcd-test", str(_pcd_executable()), ], diff --git a/tests/integration/test_cli_commands.py b/tests/integration/test_cli_commands.py index a06cb8c..6fc382b 100644 --- a/tests/integration/test_cli_commands.py +++ b/tests/integration/test_cli_commands.py @@ -220,13 +220,6 @@ def test_roots_and_refresh_commands( assert "Found 1 projects." in refreshed.output -def test_shell_init(runner: CliRunner) -> None: - result = runner.invoke(cli, ["shell-init", "bash"]) - - assert result.exit_code == 0 - assert "bash_source" in result.output - - def test_invalid_add_arguments(runner: CliRunner, tmp_path: Path) -> None: missing = runner.invoke(cli, ["add", str(tmp_path / "missing")]) diff --git a/tests/integration/test_navigation.py b/tests/integration/test_navigation.py index ceec396..1e1202d 100644 --- a/tests/integration/test_navigation.py +++ b/tests/integration/test_navigation.py @@ -174,7 +174,7 @@ def test_navigation_recommends_reload_when_integration_is_configured( ) -> None: monkeypatch.setenv("SHELL", "/bin/zsh") (Path.home() / ".zshrc").write_text( - 'eval "$(pcd shell-init zsh)"\n', + 'eval "$(pcd shell init zsh)"\n', encoding="utf-8", ) repo = tmp_path / "repo" diff --git a/tests/integration/test_shell_integration.py b/tests/integration/test_shell_integration.py index ea06909..6effd3a 100644 --- a/tests/integration/test_shell_integration.py +++ b/tests/integration/test_shell_integration.py @@ -9,7 +9,7 @@ import pytest from pcd_cli.cli import cli -from pcd_cli.cli.shell import shell_init +from pcd_cli.cli.shell import init_shell from pcd_cli.shell_integration import ( inactive_shell_message, render_shell_integration, @@ -24,7 +24,7 @@ def test_bash_native(runner: CliRunner) -> None: - result = runner.invoke(cli, ["shell", "print", "bash"]) + result = runner.invoke(cli, ["shell", "init", "bash"]) assert result.exit_code == 0 assert "pcd()" in result.output @@ -35,29 +35,22 @@ def test_bash_native(runner: CliRunner) -> None: def test_zsh_native(runner: CliRunner) -> None: - result = runner.invoke(cli, ["shell", "print", "zsh"]) + result = runner.invoke(cli, ["shell", "init", "zsh"]) assert result.exit_code == 0 assert "zsh_source" in result.output def test_fish_native(runner: CliRunner) -> None: - result = runner.invoke(cli, ["shell", "print", "fish"]) + result = runner.invoke(cli, ["shell", "init", "fish"]) assert result.exit_code == 0 assert "function pcd" in result.output assert "fish_source" in result.output -def test_legacy_shell_init_remains_available(runner: CliRunner) -> None: - result = runner.invoke(cli, ["shell-init", "zsh"]) - - assert result.exit_code == 0 - assert "zsh_source" in result.output - - -def test_legacy_shell_init_can_render_as_standalone_command(runner: CliRunner) -> None: - result = runner.invoke(shell_init, ["bash"]) +def test_shell_init_can_render_as_standalone_command(runner: CliRunner) -> None: + result = runner.invoke(init_shell, ["bash"]) assert result.exit_code == 0 assert "bash_source" in result.output @@ -72,7 +65,7 @@ def test_shell_wrapper_uses_registered_root_commands( commands = {**cli.commands, "interactive": click.Command("interactive")} monkeypatch.setattr(cli, "commands", commands) - result = runner.invoke(cli, ["shell", "print", shell.value]) + result = runner.invoke(cli, ["shell", "init", shell.value]) assert result.exit_code == 0 assert "interactive" in result.output @@ -92,7 +85,7 @@ def test_shell_install_supports_each_shell(runner: CliRunner, shell: Shell) -> N assert result.exit_code == 0 assert f"Installed {shell.value} integration" in result.output assert f"Reload the current shell with: {integration.reload_command()}" in result.output - assert f"pcd shell print {shell.value}" in integration.config_path.read_text(encoding="utf-8") + assert f"pcd shell init {shell.value}" in integration.config_path.read_text(encoding="utf-8") def test_shell_install_detects_zsh_and_is_idempotent( @@ -114,7 +107,7 @@ def test_shell_install_detects_zsh_and_is_idempotent( assert "already installed" in repeated.output assert content.startswith("export EDITOR=vim\n") assert content.count("# >>> pcd shell integration >>>") == 1 - assert 'eval "$(command pcd shell print zsh)"' in content + assert 'eval "$(command pcd shell init zsh)"' in content def test_shell_install_leaves_manual_configuration_untouched( @@ -123,7 +116,7 @@ def test_shell_install_leaves_manual_configuration_untouched( ) -> None: monkeypatch.setenv("SHELL", "/bin/zsh") config = Path.home() / ".zshrc" - manual = 'eval "$(pcd shell-init zsh)"\n' + manual = 'eval "$(pcd shell init zsh)"\n' config.write_text(manual, encoding="utf-8") result = runner.invoke(cli, ["shell", "install"]) @@ -156,7 +149,7 @@ def test_shell_uninstall_does_not_remove_manual_configuration( ) -> None: monkeypatch.setenv("SHELL", "/bin/bash") config = Path.home() / ".bashrc" - manual = 'eval "$(pcd shell-init bash)"\n' + manual = 'eval "$(pcd shell init bash)"\n' config.write_text(manual, encoding="utf-8") result = runner.invoke(cli, ["shell", "uninstall"]) @@ -243,7 +236,7 @@ def test_install_preserves_symlinked_shell_config( assert result.exit_code == 0 assert config.is_symlink() - assert "pcd shell print zsh" in target.read_text(encoding="utf-8") + assert "pcd shell init zsh" in target.read_text(encoding="utf-8") def test_invalid_managed_block_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: @@ -298,7 +291,7 @@ def test_shell_integration_state_detects_manual_and_absent() -> None: integration = ShellIntegration.for_shell(Shell.BASH) assert integration.state() is ShellIntegrationState.ABSENT - integration.config_path.write_text('eval "$(pcd shell-init bash)"\n', encoding="utf-8") + integration.config_path.write_text('eval "$(pcd shell init bash)"\n', encoding="utf-8") assert integration.state() is ShellIntegrationState.MANUAL