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.
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/PKG-INFO +9 -3
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/README.txt +4 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/pyproject.toml +6 -4
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/app.py +7 -4
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/config.py +6 -1
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/shell.py +30 -17
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/filesystem.py +2 -15
- pcd_cli-0.2.4/src/pcd_cli/integrations/shell.py +146 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/__init__.py +1 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/cmd.py +176 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/cmd_autorun.py +78 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/common.py +27 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/powershell.py +125 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/registry.py +37 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/startup_file.py +114 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/unix.py +115 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/windows_registry.py +74 -0
- pcd_cli-0.2.4/src/pcd_cli/integrations/shells/windows_shell.py +30 -0
- pcd_cli-0.2.4/src/pcd_cli/locking/__init__.py +33 -0
- pcd_cli-0.2.4/src/pcd_cli/locking/unix.py +27 -0
- pcd_cli-0.2.4/src/pcd_cli/locking/windows.py +39 -0
- pcd_cli-0.2.3/src/pcd_cli/integrations/shell.py +0 -315
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/LICENSE +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/__init__.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cache.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/catalog.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/__init__.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/projects.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/cli/roots.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/config.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/history.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/integrations/__init__.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/models.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/navigation.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/picker.py +0 -0
- {pcd_cli-0.2.3 → pcd_cli-0.2.4}/src/pcd_cli/scanner.py +0 -0
- {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.
|
|
4
|
-
Summary: Fast project navigator for Bash, Zsh and
|
|
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
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pcd-cli"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
description = "Fast project navigator for Bash, Zsh and
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
92
|
-
click.echo(render_shell_integration(selected
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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")
|