pcd-cli 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/PKG-INFO +9 -3
  2. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/README.txt +4 -0
  3. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/pyproject.toml +6 -4
  4. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/app.py +7 -4
  5. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/config.py +6 -1
  6. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/shell.py +30 -17
  7. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/filesystem.py +2 -15
  8. pcd_cli-0.2.4/src/pcd_cli/integrations/shell.py +146 -0
  9. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/__init__.py +1 -0
  10. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/cmd.py +176 -0
  11. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/cmd_autorun.py +78 -0
  12. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/common.py +27 -0
  13. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/powershell.py +125 -0
  14. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/registry.py +37 -0
  15. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/startup_file.py +114 -0
  16. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/unix.py +115 -0
  17. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/windows_registry.py +74 -0
  18. pcd_cli-0.2.4/src/pcd_cli/integrations/shells/windows_shell.py +30 -0
  19. pcd_cli-0.2.4/src/pcd_cli/locking/__init__.py +33 -0
  20. pcd_cli-0.2.4/src/pcd_cli/locking/unix.py +27 -0
  21. pcd_cli-0.2.4/src/pcd_cli/locking/windows.py +39 -0
  22. pcd_cli-0.2.3/src/pcd_cli/integrations/shell.py +0 -315
  23. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/LICENSE +0 -0
  24. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/__init__.py +0 -0
  25. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cache.py +0 -0
  26. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/catalog.py +0 -0
  27. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/__init__.py +0 -0
  28. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/projects.py +0 -0
  29. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/roots.py +0 -0
  30. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/config.py +0 -0
  31. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/history.py +0 -0
  32. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/integrations/__init__.py +0 -0
  33. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/models.py +0 -0
  34. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/navigation.py +0 -0
  35. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/picker.py +0 -0
  36. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/scanner.py +0 -0
  37. {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/search.py +0 -0
@@ -1,16 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.2.3
4
- Summary: Fast project navigator for Bash, Zsh and Fish
3
+ Version: 0.2.4
4
+ Summary: Fast project navigator for Bash, Zsh, Fish, PowerShell and CMD
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
- Keywords: cli,cd,projects,navigation,bash,zsh,fish
7
+ Keywords: cli,cd,projects,navigation,bash,zsh,fish,powershell,cmd
8
8
  Author: pcd-cli contributors
9
9
  Requires-Python: >=3.12,<4
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Operating System :: MacOS
13
13
  Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: Microsoft :: Windows
14
15
  Classifier: Programming Language :: Python :: 3 :: Only
15
16
  Classifier: Programming Language :: Python :: 3.12
16
17
  Classifier: Programming Language :: Python :: 3.13
@@ -19,6 +20,7 @@ Classifier: Topic :: Utilities
19
20
  Requires-Dist: click (>=8.5.0,<9)
20
21
  Requires-Dist: platformdirs (>=4.11.7,<5)
21
22
  Requires-Dist: prompt-toolkit (>=3.0.53,<4)
23
+ Requires-Dist: shellingham (>=1.5.4,<2) ; sys_platform == "win32"
22
24
  Requires-Dist: tomlkit (>=0.15.1,<1)
23
25
  Description-Content-Type: text/plain
24
26
 
@@ -36,6 +38,10 @@ Supported shells:
36
38
  * Bash
37
39
  * Zsh
38
40
  * Fish
41
+ * PowerShell 7+
42
+ * Windows PowerShell 5.1
43
+ * Command Prompt (cmd)
44
+ * Git Bash on Windows
39
45
 
40
46
 
41
47
  INSTALL
@@ -12,6 +12,10 @@ Supported shells:
12
12
  * Bash
13
13
  * Zsh
14
14
  * Fish
15
+ * PowerShell 7+
16
+ * Windows PowerShell 5.1
17
+ * Command Prompt (cmd)
18
+ * Git Bash on Windows
15
19
 
16
20
 
17
21
  INSTALL
@@ -1,18 +1,19 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.2.3"
4
- description = "Fast project navigator for Bash, Zsh and Fish"
3
+ version = "0.2.4"
4
+ description = "Fast project navigator for Bash, Zsh, Fish, PowerShell and CMD"
5
5
  readme = { file = "README.txt", content-type = "text/plain" }
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [{ name = "pcd-cli contributors" }]
9
9
  requires-python = ">=3.12,<4"
10
- keywords = ["cli", "cd", "projects", "navigation", "bash", "zsh", "fish"]
10
+ keywords = ["cli", "cd", "projects", "navigation", "bash", "zsh", "fish", "powershell", "cmd"]
11
11
  classifiers = [
12
12
  "Development Status :: 3 - Alpha",
13
13
  "Environment :: Console",
14
14
  "Operating System :: MacOS",
15
15
  "Operating System :: POSIX :: Linux",
16
+ "Operating System :: Microsoft :: Windows",
16
17
  "Programming Language :: Python :: 3 :: Only",
17
18
  "Programming Language :: Python :: 3.12",
18
19
  "Programming Language :: Python :: 3.13",
@@ -23,6 +24,7 @@ dependencies = [
23
24
  "click>=8.5.0,<9",
24
25
  "platformdirs>=4.11.7,<5",
25
26
  "prompt-toolkit>=3.0.53,<4",
27
+ "shellingham>=1.5.4,<2; sys_platform == 'win32'",
26
28
  "tomlkit>=0.15.1,<1",
27
29
  ]
28
30
 
@@ -107,5 +109,5 @@ fail_under = 95
107
109
  show_missing = true
108
110
  skip_covered = true
109
111
  exclude_also = [
110
- "if TYPE_CHECKING:",
112
+ "if TYPE_CHECKING",
111
113
  ]
@@ -68,12 +68,15 @@ def package_version() -> str:
68
68
  @click.pass_context
69
69
  def cli(ctx: click.Context, project_name: str | None) -> None:
70
70
  """Jump to local projects by name."""
71
- catalog = ProjectCatalog.create()
72
- ctx.obj = catalog
73
-
74
71
  if project_name is not None and ctx.invoked_subcommand is not None:
75
72
  raise click.UsageError("--project cannot be combined with a command")
76
73
 
74
+ if ctx.invoked_subcommand == "shell":
75
+ return
76
+
77
+ catalog = ProjectCatalog.create()
78
+ ctx.obj = catalog
79
+
77
80
  if ctx.invoked_subcommand is not None:
78
81
  return
79
82
 
@@ -103,5 +106,5 @@ def main() -> None:
103
106
  click.echo(f"Shell integration error: {exc}", err=True)
104
107
  raise SystemExit(ExitCode.ERROR) from None
105
108
  except OSError as exc:
106
- click.echo(f"Filesystem error: {exc}", err=True)
109
+ click.echo(f"I/O error: {exc}", err=True)
107
110
  raise SystemExit(ExitCode.ERROR) from None
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import os
4
4
  import shlex
5
5
  import subprocess
6
+ import sys
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  import click
@@ -55,7 +56,11 @@ def edit_config(catalog: ProjectCatalog) -> None:
55
56
 
56
57
  catalog.config.ensure_exists()
57
58
 
58
- result = subprocess.run([*command, str(catalog.config.path)], check=False)
59
+ arguments = [*command, str(catalog.config.path)]
60
+ if os.environ.get("PCD_SHELL") == "1":
61
+ result = subprocess.run(arguments, check=False, stdout=sys.stderr)
62
+ else:
63
+ result = subprocess.run(arguments, check=False)
59
64
  if result.returncode:
60
65
  raise click.ClickException(f"Editor exited with status {result.returncode}")
61
66
 
@@ -1,3 +1,5 @@
1
+ import sys
2
+
1
3
  import click
2
4
 
3
5
  from pcd_cli.integrations.shell import (
@@ -24,7 +26,8 @@ def shell_commands() -> None:
24
26
  )
25
27
  def install_shell(shell: str | None) -> None:
26
28
  """Install persistent shell integration into the shell startup file."""
27
- integration = _shell_integration(shell)
29
+ selected = _select_shell(shell, prompt=True)
30
+ integration = ShellIntegration.for_shell(selected)
28
31
  if integration.install():
29
32
  click.echo(f"Installed {integration.shell.value} integration in {integration.config_path}")
30
33
  click.echo(f"Reload the current shell with: {integration.reload_command()}")
@@ -48,7 +51,7 @@ def install_shell(shell: str | None) -> None:
48
51
  )
49
52
  def shell_status(shell: str | None) -> None:
50
53
  """Show shell configuration and whether this invocation used a wrapper."""
51
- integration = _shell_integration(shell)
54
+ integration = ShellIntegration.for_shell(_select_shell(shell))
52
55
  click.echo(f"Shell: {integration.shell.value}")
53
56
  click.echo(f"Config: {integration.config_path}")
54
57
  click.echo(f"Configured: {integration.state().value}")
@@ -66,7 +69,7 @@ def shell_status(shell: str | None) -> None:
66
69
  )
67
70
  def uninstall_shell(shell: str | None) -> None:
68
71
  """Remove integration installed by `pcd shell install`."""
69
- integration = _shell_integration(shell)
72
+ integration = ShellIntegration.for_shell(_select_shell(shell))
70
73
  if integration.uninstall():
71
74
  click.echo(f"Removed shell integration from {integration.config_path}")
72
75
  return
@@ -85,30 +88,40 @@ def uninstall_shell(shell: str | None) -> None:
85
88
  required=False,
86
89
  type=click.Choice([item.value for item in Shell], case_sensitive=False),
87
90
  )
88
- @click.pass_context
89
- def init_shell(ctx: click.Context, shell: str | None) -> None:
91
+ def init_shell(shell: str | None) -> None:
90
92
  """Print shell integration for manual dotfile management."""
91
- selected = _selected_shell(shell)
92
- click.echo(render_shell_integration(selected, _registered_command_names(ctx)), nl=False)
93
-
93
+ selected = _select_shell(shell)
94
+ click.echo(render_shell_integration(selected), nl=False)
94
95
 
95
- def _shell_integration(shell: str | None) -> ShellIntegration:
96
- return ShellIntegration.for_shell(_selected_shell(shell))
97
96
 
98
-
99
- def _selected_shell(shell: str | None) -> Shell:
97
+ def _select_shell(shell: str | None, *, prompt: bool = False) -> Shell:
100
98
  if shell is not None:
101
99
  return Shell(shell.casefold())
102
100
 
103
101
  try:
104
102
  return detect_shell()
105
103
  except ShellIntegrationError as exc:
104
+ if prompt and sys.platform == "win32":
105
+ return _prompt_windows_shell()
106
106
  raise click.UsageError(str(exc)) from exc
107
107
 
108
108
 
109
- def _registered_command_names(ctx: click.Context) -> tuple[str, ...]:
110
- root = ctx.find_root()
111
- if not isinstance(root.command, click.Group):
112
- return ()
109
+ def _prompt_windows_shell() -> Shell:
110
+ choices = (
111
+ ("PowerShell 7+", Shell.PWSH),
112
+ ("Windows PowerShell 5.1", Shell.POWERSHELL),
113
+ ("Command Prompt (cmd)", Shell.CMD),
114
+ ("Git Bash", Shell.BASH),
115
+ )
116
+
117
+ click.echo("Select shell:")
118
+ for index, (label, _shell) in enumerate(choices, start=1):
119
+ click.echo(f" {index}. {label}")
120
+
121
+ choice = click.prompt(
122
+ "Enter number",
123
+ type=click.IntRange(1, len(choices)),
124
+ show_choices=False,
125
+ )
113
126
 
114
- return tuple(root.command.list_commands(root))
127
+ return choices[choice - 1][1]
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import fcntl
4
3
  import os
5
4
  import tempfile
6
5
  from contextlib import contextmanager
@@ -10,6 +9,8 @@ from typing import Self, TextIO, TYPE_CHECKING
10
9
 
11
10
  from platformdirs import PlatformDirs
12
11
 
12
+ from pcd_cli.locking import file_lock as file_lock
13
+
13
14
  if TYPE_CHECKING:
14
15
  from collections.abc import Generator
15
16
 
@@ -48,20 +49,6 @@ def format_path(path: Path) -> str:
48
49
  return "~" if not relative.parts else f"~/{relative}"
49
50
 
50
51
 
51
- @contextmanager
52
- def file_lock(path: Path) -> Generator[None]:
53
- """Prevent concurrent writers from overwriting each other's changes."""
54
- lock_path = path.with_name(f".{path.name}.lock")
55
- lock_path.parent.mkdir(parents=True, exist_ok=True)
56
-
57
- with lock_path.open("a+", encoding="utf-8") as stream:
58
- fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
59
- try:
60
- yield
61
- finally:
62
- fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
63
-
64
-
65
52
  @contextmanager
66
53
  def atomic_write(path: Path) -> Generator[TextIO]:
67
54
  """Write through a temporary file so readers never observe partial data."""
@@ -0,0 +1,146 @@
1
+ """Generate, install, and inspect shell integration for pcd."""
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Self
8
+
9
+ from pcd_cli.integrations.shells import startup_file
10
+ from pcd_cli.integrations.shells.common import (
11
+ Shell as Shell,
12
+ SHELL_CD_EXIT_CODE as SHELL_CD_EXIT_CODE,
13
+ SHELL_MODE_ENV,
14
+ SHELL_WRAPPER_ENV,
15
+ ShellIntegrationError as ShellIntegrationError,
16
+ ShellIntegrationState as ShellIntegrationState,
17
+ )
18
+ from pcd_cli.integrations.shells.registry import backend_for
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ShellIntegration:
23
+ shell: Shell
24
+ config_path: Path
25
+
26
+ @classmethod
27
+ def detect(cls) -> Self:
28
+ return cls.for_shell(detect_shell())
29
+
30
+ @classmethod
31
+ def for_shell(cls, shell: Shell) -> Self:
32
+ return cls(shell=shell, config_path=shell_config_path(shell))
33
+
34
+ def state(self) -> ShellIntegrationState:
35
+ if self.shell is Shell.CMD:
36
+ from pcd_cli.integrations.shells import cmd
37
+
38
+ return cmd.state(self.config_path)
39
+ return startup_file.state(self.config_path, self.shell)
40
+
41
+ def reload_command(self) -> str:
42
+ return backend_for(self.shell).reload_command(self.shell, self.config_path)
43
+
44
+ def install(self) -> bool:
45
+ """Install managed integration. Return whether persistent state changed."""
46
+ if self.shell is Shell.CMD:
47
+ from pcd_cli.integrations.shells import cmd
48
+
49
+ return cmd.install(self.config_path)
50
+ command = backend_for(self.shell).startup_command(self.shell)
51
+ if command is None:
52
+ raise ShellIntegrationError(f"Shell does not use a startup file: {self.shell}")
53
+ return startup_file.install(self.config_path, self.shell, command)
54
+
55
+ def uninstall(self) -> bool:
56
+ """Remove only integration installed by pcd, leaving manual setup untouched."""
57
+ if self.shell is Shell.CMD:
58
+ from pcd_cli.integrations.shells import cmd
59
+
60
+ return cmd.uninstall(self.config_path)
61
+ return startup_file.uninstall(self.config_path)
62
+
63
+
64
+ def detect_shell() -> Shell:
65
+ """Detect the active wrapper or surrounding shell."""
66
+ wrapper = invoking_shell()
67
+ if wrapper is not None:
68
+ return wrapper
69
+
70
+ if sys.platform == "win32":
71
+ from pcd_cli.integrations.shells import windows_shell
72
+
73
+ name = windows_shell.detect_shell()
74
+ source = "the parent process"
75
+ else:
76
+ executable = os.environ.get("SHELL", "")
77
+ name = Path(executable).name.casefold().removesuffix(".exe")
78
+ source = f"SHELL={executable!r}"
79
+
80
+ try:
81
+ return Shell(name)
82
+ except ValueError as exc:
83
+ supported = ", ".join(shell.value for shell in Shell)
84
+ raise ShellIntegrationError(
85
+ f"Cannot detect a supported shell from {source}; choose one of: {supported}"
86
+ ) from exc
87
+
88
+
89
+ def shell_config_path(shell: Shell) -> Path:
90
+ """Return the persistent integration file for a shell."""
91
+ return backend_for(shell).config_path(shell, Path.home())
92
+
93
+
94
+ def shell_integration_active() -> bool:
95
+ return os.environ.get(SHELL_MODE_ENV) == "1"
96
+
97
+
98
+ def invoking_shell() -> Shell | None:
99
+ """Identify the wrapper for this invocation, not the parent shell's state."""
100
+ try:
101
+ return Shell(os.environ.get(SHELL_WRAPPER_ENV, ""))
102
+ except ValueError:
103
+ return None
104
+
105
+
106
+ def inactive_shell_message() -> str:
107
+ """Explain how to activate directory changes when the wrapper is not running."""
108
+ install_command = "pcd shell install"
109
+ if sys.platform == "win32" and invoking_shell() is None:
110
+ return (
111
+ "Shell integration is not active, so pcd cannot change this shell's directory. "
112
+ f"If you have not configured it manually, run: {install_command}"
113
+ )
114
+
115
+ try:
116
+ integration = ShellIntegration.detect()
117
+ state = integration.state()
118
+ except (OSError, ShellIntegrationError):
119
+ return (
120
+ "Shell integration is not active, so pcd cannot change this shell's directory. "
121
+ f"If you have not configured it manually, run: {install_command}"
122
+ )
123
+
124
+ if state is ShellIntegrationState.ABSENT:
125
+ return (
126
+ "Shell integration is not installed, so pcd cannot change this shell's directory. "
127
+ f"Install it with: {install_command}"
128
+ )
129
+
130
+ return (
131
+ f"Shell integration is configured in {integration.config_path} but is not active in "
132
+ f"this shell. Reload it with: {integration.reload_command()}"
133
+ )
134
+
135
+
136
+ def render_managed_block(shell: Shell) -> str:
137
+ """Render the small persistent block written into a shell startup file."""
138
+ command = backend_for(shell).startup_command(shell)
139
+ if command is None:
140
+ raise ValueError(f"Shell does not use a startup file: {shell}")
141
+ return startup_file.managed_block(command)
142
+
143
+
144
+ def render_shell_integration(shell: Shell) -> str:
145
+ """Generate shell integration for directory changes and completion."""
146
+ return backend_for(shell).render(shell)
@@ -0,0 +1 @@
1
+ """Shell-specific integration backends."""
@@ -0,0 +1,176 @@
1
+ """CMD wrapper backend and installation lifecycle."""
2
+
3
+ import os
4
+ import stat
5
+ import sys
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+
9
+ from pcd_cli.filesystem import atomic_write, file_lock
10
+ from pcd_cli.integrations.shells import cmd_autorun
11
+ from pcd_cli.integrations.shells.common import (
12
+ Shell,
13
+ SHELL_CD_EXIT_CODE,
14
+ SHELL_MODE_ENV,
15
+ SHELL_WRAPPER_ENV,
16
+ ShellIntegrationError,
17
+ ShellIntegrationState,
18
+ )
19
+
20
+ _MANAGED_HEADER = "@echo off\nrem Managed by pcd. Changes will be overwritten.\n"
21
+
22
+
23
+ def config_path(shell: Shell, home: Path) -> Path:
24
+ local_app_data = os.environ.get("LOCALAPPDATA")
25
+ data_home = Path(local_app_data) if local_app_data else home / "AppData" / "Local"
26
+ return data_home / "pcd-cli" / "cmd" / "pcd.cmd"
27
+
28
+
29
+ def reload_command(shell: Shell, wrapper: Path) -> str:
30
+ return cmd_autorun.doskey_command(wrapper)
31
+
32
+
33
+ def startup_command(shell: Shell) -> None:
34
+ return None
35
+
36
+
37
+ def state(wrapper: Path) -> ShellIntegrationState:
38
+ _require_windows("inspected")
39
+
40
+ if not wrapper.exists():
41
+ if cmd_autorun.configured(wrapper):
42
+ raise ShellIntegrationError(
43
+ "CMD integration is incomplete: the registered wrapper is missing"
44
+ )
45
+
46
+ return ShellIntegrationState.ABSENT
47
+
48
+ if not _is_managed(_read(wrapper)):
49
+ return ShellIntegrationState.MANUAL
50
+
51
+ if not cmd_autorun.configured(wrapper):
52
+ raise ShellIntegrationError(
53
+ "CMD integration is incomplete: AutoRun registration is missing"
54
+ )
55
+
56
+ return ShellIntegrationState.MANAGED
57
+
58
+
59
+ def install(wrapper: Path) -> bool:
60
+ _require_windows("installed")
61
+
62
+ content = render(Shell.CMD)
63
+
64
+ with file_lock(wrapper):
65
+ previous = _read(wrapper) if wrapper.exists() else None
66
+ if previous is not None and not _is_managed(previous):
67
+ return False
68
+
69
+ wrapper_changed = previous != content
70
+ try:
71
+ if wrapper_changed:
72
+ _write(wrapper, content)
73
+ autorun_changed = cmd_autorun.add(wrapper)
74
+ except BaseException as error:
75
+ if wrapper_changed:
76
+ try:
77
+ if previous is None:
78
+ wrapper.unlink(missing_ok=True)
79
+ else:
80
+ _write(wrapper, previous)
81
+ except OSError as rollback_error:
82
+ raise ShellIntegrationError(
83
+ f"CMD installation failed: {error}; "
84
+ f"could not restore wrapper {wrapper}: {rollback_error}"
85
+ ) from error
86
+ raise
87
+
88
+ return wrapper_changed or autorun_changed
89
+
90
+
91
+ def uninstall(wrapper: Path) -> bool:
92
+ _require_windows("uninstalled")
93
+
94
+ with file_lock(wrapper):
95
+ if not wrapper.exists():
96
+ if not cmd_autorun.configured(wrapper):
97
+ return False
98
+
99
+ cmd_autorun.remove(wrapper)
100
+ return True
101
+
102
+ if not _is_managed(_read(wrapper)):
103
+ return False
104
+
105
+ cmd_autorun.remove(wrapper)
106
+ try:
107
+ wrapper.unlink(missing_ok=True)
108
+ except OSError as error:
109
+ try:
110
+ cmd_autorun.add(wrapper)
111
+ except (OSError, ShellIntegrationError) as rollback_error:
112
+ raise ShellIntegrationError(
113
+ f"Could not remove CMD wrapper {wrapper}: {error}; "
114
+ f"could not restore AutoRun: {rollback_error}"
115
+ ) from error
116
+ raise
117
+
118
+ return True
119
+
120
+
121
+ def render(shell: Shell) -> str:
122
+ return dedent(
123
+ f"""\
124
+ @echo off
125
+ rem Managed by pcd. Changes will be overwritten.
126
+ setlocal
127
+ set "{SHELL_WRAPPER_ENV}=cmd"
128
+ if not defined _PCD_EXECUTABLE set "_PCD_EXECUTABLE=pcd.exe"
129
+
130
+ if defined _PCD_COMPLETE goto pcd_direct
131
+
132
+ set "{SHELL_MODE_ENV}=1"
133
+ set "pcd_output=%TEMP%\\pcd-%RANDOM%-%RANDOM%.tmp"
134
+ call "%_PCD_EXECUTABLE%" %* > "%pcd_output%"
135
+ set "pcd_code=%ERRORLEVEL%"
136
+ if "%pcd_code%"=="{SHELL_CD_EXIT_CODE}" goto pcd_change_directory
137
+ type "%pcd_output%"
138
+ del /q "%pcd_output%"
139
+ endlocal & exit /b %pcd_code%
140
+
141
+ :pcd_change_directory
142
+ set /p "pcd_destination=" < "%pcd_output%"
143
+ del /q "%pcd_output%"
144
+ endlocal & cd /d "%pcd_destination%"
145
+ exit /b %ERRORLEVEL%
146
+
147
+ :pcd_direct
148
+ call "%_PCD_EXECUTABLE%" %*
149
+ exit /b %ERRORLEVEL%
150
+ """
151
+ )
152
+
153
+
154
+ def _write(path: Path, content: str) -> None:
155
+ mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
156
+ with atomic_write(path) as stream:
157
+ stream.write(content)
158
+
159
+ if mode is not None:
160
+ path.chmod(mode)
161
+
162
+
163
+ def _read(path: Path) -> str:
164
+ try:
165
+ return path.read_text(encoding="utf-8")
166
+ except UnicodeError as exc:
167
+ raise ShellIntegrationError(f"CMD wrapper is not valid UTF-8: {path}") from exc
168
+
169
+
170
+ def _is_managed(content: str) -> bool:
171
+ return content.startswith(_MANAGED_HEADER)
172
+
173
+
174
+ def _require_windows(action: str) -> None:
175
+ if sys.platform != "win32":
176
+ raise ShellIntegrationError(f"CMD integration can only be {action} on Windows")
@@ -0,0 +1,78 @@
1
+ """Manage the pcd DOSKEY command in the CMD AutoRun registry value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import suppress
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pcd_cli.integrations.shells import windows_registry
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ _COMMAND_PROCESSOR_KEY = r"Software\Microsoft\Command Processor"
14
+
15
+
16
+ def doskey_command(wrapper: Path) -> str:
17
+ return f'doskey pcd=call "{wrapper}" $*'
18
+
19
+
20
+ def add(wrapper: Path) -> bool:
21
+ """Add the pcd command, returning whether AutoRun was changed."""
22
+ value, value_type = _read()
23
+ managed_command = doskey_command(wrapper)
24
+
25
+ if _without_command(value, managed_command) is not None:
26
+ return False
27
+
28
+ separator = "" if not value.strip() else " & "
29
+ _write(f"{value}{separator}{managed_command}", value_type)
30
+
31
+ return True
32
+
33
+
34
+ def configured(wrapper: Path) -> bool:
35
+ value, _value_type = _read()
36
+ return _without_command(value, doskey_command(wrapper)) is not None
37
+
38
+
39
+ def remove(wrapper: Path) -> None:
40
+ value, value_type = _read()
41
+ updated = _without_command(value, doskey_command(wrapper))
42
+ if updated is not None:
43
+ _write(updated, value_type)
44
+
45
+
46
+ def _without_command(value: str, managed_command: str) -> str | None:
47
+ """Return AutoRun without an exact managed command, wherever it appears."""
48
+ index = value.casefold().find(managed_command.casefold())
49
+ if index < 0:
50
+ return None
51
+
52
+ before = value[:index]
53
+ after = value[index + len(managed_command) :]
54
+ if before and not before.endswith(" & "):
55
+ return None
56
+ if after and not after.startswith(" & "):
57
+ return None
58
+
59
+ if before:
60
+ return f"{before[:-3]}{after}"
61
+
62
+ if after:
63
+ return after[3:]
64
+
65
+ return ""
66
+
67
+
68
+ def _read() -> tuple[str, int]:
69
+ result = windows_registry.read(_COMMAND_PROCESSOR_KEY, "AutoRun")
70
+ return result if result is not None else ("", windows_registry.string_value_type())
71
+
72
+
73
+ def _write(value: str, value_type: int) -> None:
74
+ if value:
75
+ windows_registry.write(_COMMAND_PROCESSOR_KEY, "AutoRun", value, value_type)
76
+ else:
77
+ with suppress(FileNotFoundError):
78
+ windows_registry.delete(_COMMAND_PROCESSOR_KEY, "AutoRun")