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 FEATURES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ SHELL INTEGRATION
pcd shell uninstall

Print shell integration code:
pcd shell print <shell>
pcd shell init <shell>

Shell integration can also be added manually to the shell config.

Expand Down
1 change: 1 addition & 0 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ COMMANDS
pcd config validate Validate the user configuration

pcd shell install Install shell integration
pcd shell init <shell> Print shell integration code
pcd shell status Show shell integration status
pcd shell uninstall Remove shell integration

Expand Down
3 changes: 0 additions & 3 deletions ROADMAP.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +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 <shell>` to `pcd shell init <shell>`.

COMPLETION
Complete project names for `pcd <project>`.
Expand Down
6 changes: 2 additions & 4 deletions src/pcd_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions src/pcd_cli/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
17 changes: 3 additions & 14 deletions src/pcd_cli/cli/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))

Expand Down
15 changes: 9 additions & 6 deletions src/pcd_cli/shell_integration.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -146,16 +150,16 @@ 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()}"
)


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"

Expand All @@ -179,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

Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/test_cli_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
],
Expand Down Expand Up @@ -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()),
],
Expand Down
7 changes: 0 additions & 7 deletions tests/integration/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")])

Expand Down
6 changes: 3 additions & 3 deletions tests/integration/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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


Expand All @@ -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


Expand Down
52 changes: 32 additions & 20 deletions tests/integration/test_shell_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -83,6 +76,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 init {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,
Expand All @@ -97,12 +102,12 @@ 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")
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(
Expand All @@ -111,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"])
Expand Down Expand Up @@ -144,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"])
Expand Down Expand Up @@ -191,6 +196,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)
Expand Down Expand Up @@ -224,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:
Expand Down Expand Up @@ -279,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


Expand Down