pcd-cli 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- pcd_cli/cache.py +118 -0
- pcd_cli/catalog.py +157 -0
- pcd_cli/cli.py +341 -0
- pcd_cli/config.py +226 -0
- pcd_cli/filesystem.py +79 -0
- pcd_cli/history.py +95 -0
- pcd_cli/models.py +39 -0
- pcd_cli/navigation.py +80 -0
- pcd_cli/picker.py +140 -0
- pcd_cli/scanner.py +116 -0
- pcd_cli/search.py +108 -0
- pcd_cli/shell_integration.py +264 -0
- pcd_cli-0.1.0.dist-info/METADATA +105 -0
- pcd_cli-0.1.0.dist-info/RECORD +18 -0
- pcd_cli-0.1.0.dist-info/WHEEL +4 -0
- pcd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pcd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pcd_cli/scanner.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from pcd_cli.filesystem import canonical_path
|
|
7
|
+
from pcd_cli.models import Project, ProjectSource
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from pcd_cli.models import ProjectSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ProjectScanner:
|
|
17
|
+
settings: ProjectSettings
|
|
18
|
+
|
|
19
|
+
def scan(self) -> Iterator[Project]:
|
|
20
|
+
for root, display_path in self._scan_roots():
|
|
21
|
+
yield from self._walk(root, display_path)
|
|
22
|
+
|
|
23
|
+
def _scan_roots(self) -> list[tuple[Path, Path]]:
|
|
24
|
+
roots = sorted(
|
|
25
|
+
((canonical_path(root), root.expanduser()) for root in self.settings.roots),
|
|
26
|
+
key=lambda item: (len(item[0].parts), str(item[0])),
|
|
27
|
+
)
|
|
28
|
+
selected: dict[Path, Path] = {}
|
|
29
|
+
|
|
30
|
+
for root, display_path in roots:
|
|
31
|
+
parent = next((item for item in root.parents if item in selected), None)
|
|
32
|
+
if parent is not None and self._reachable_from_parent(root, parent):
|
|
33
|
+
continue
|
|
34
|
+
selected[root] = display_path
|
|
35
|
+
|
|
36
|
+
return list(selected.items())
|
|
37
|
+
|
|
38
|
+
def _reachable_from_parent(self, root: Path, parent: Path) -> bool:
|
|
39
|
+
relative = root.relative_to(parent)
|
|
40
|
+
current = parent
|
|
41
|
+
|
|
42
|
+
for part in relative.parts:
|
|
43
|
+
blocked = part in self.settings.excluded_names
|
|
44
|
+
hidden = not self.settings.include_hidden and part.startswith(".")
|
|
45
|
+
if blocked or hidden or _is_git_repository(current):
|
|
46
|
+
return False
|
|
47
|
+
current /= part
|
|
48
|
+
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def _walk(self, root: Path, display_path: Path) -> Iterator[Project]:
|
|
52
|
+
if not root.is_dir():
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
visited = set[tuple[int, int]]() if self.settings.follow_symlinks else None
|
|
56
|
+
|
|
57
|
+
for directory, directories, _files in os.walk(
|
|
58
|
+
root,
|
|
59
|
+
topdown=True,
|
|
60
|
+
followlinks=self.settings.follow_symlinks,
|
|
61
|
+
):
|
|
62
|
+
current = Path(directory)
|
|
63
|
+
active = True if visited is None else _mark_visited(current, visited)
|
|
64
|
+
if not active:
|
|
65
|
+
directories.clear()
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
relative = current.relative_to(root)
|
|
69
|
+
shown = display_path / relative
|
|
70
|
+
|
|
71
|
+
if _is_git_repository(current):
|
|
72
|
+
directories.clear()
|
|
73
|
+
path = canonical_path(current) if self.settings.follow_symlinks else current
|
|
74
|
+
yield Project(current.name, path, shown, ProjectSource.DISCOVERED)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
descend_into: list[str] = []
|
|
78
|
+
for name in directories:
|
|
79
|
+
child = current / name
|
|
80
|
+
blocked = name == ".git" or name in self.settings.excluded_names
|
|
81
|
+
hidden = not self.settings.include_hidden and name.startswith(".")
|
|
82
|
+
linked = not self.settings.follow_symlinks and child.is_symlink()
|
|
83
|
+
|
|
84
|
+
if blocked or hidden:
|
|
85
|
+
continue
|
|
86
|
+
if linked:
|
|
87
|
+
if _is_git_repository(child):
|
|
88
|
+
yield Project(
|
|
89
|
+
name,
|
|
90
|
+
canonical_path(child),
|
|
91
|
+
shown / name,
|
|
92
|
+
ProjectSource.DISCOVERED,
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
descend_into.append(name)
|
|
96
|
+
|
|
97
|
+
# os.walk descends only into names left in directories when topdown=True.
|
|
98
|
+
directories[:] = descend_into
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _mark_visited(path: Path, visited: set[tuple[int, int]]) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
stat = path.stat()
|
|
104
|
+
except OSError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
key = (stat.st_dev, stat.st_ino)
|
|
108
|
+
if key in visited:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
visited.add(key)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_git_repository(path: Path) -> bool:
|
|
116
|
+
return (path / ".git").exists()
|
pcd_cli/search.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pcd_cli.models import Project, ProjectUsage
|
|
8
|
+
|
|
9
|
+
ProjectRank = tuple[int, int, float, int, int, str, str]
|
|
10
|
+
|
|
11
|
+
EXACT_WEIGHT = 16
|
|
12
|
+
PREFIX_WEIGHT = 12
|
|
13
|
+
SUBSTRING_WEIGHT = 10
|
|
14
|
+
MATCH_POINTS = 4
|
|
15
|
+
BOUNDARY_BONUS = 5
|
|
16
|
+
ADJACENT_BONUS = 3
|
|
17
|
+
MAX_GAP_PENALTY = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def rank_matches(
|
|
21
|
+
projects: Iterable[Project],
|
|
22
|
+
query: str,
|
|
23
|
+
usage: Mapping[Path, ProjectUsage] | None = None,
|
|
24
|
+
) -> list[Project]:
|
|
25
|
+
needle = query.casefold()
|
|
26
|
+
matches: list[tuple[ProjectRank, Project]] = []
|
|
27
|
+
|
|
28
|
+
for project in projects:
|
|
29
|
+
score = _score(needle, project.name.casefold())
|
|
30
|
+
if score is None:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
match_kind, points = score
|
|
34
|
+
project_usage = None if usage is None else usage.get(project.path)
|
|
35
|
+
used_at = 0.0 if project_usage is None else project_usage.used_at
|
|
36
|
+
count = 0 if project_usage is None else project_usage.count
|
|
37
|
+
rank: ProjectRank = (
|
|
38
|
+
match_kind,
|
|
39
|
+
-points,
|
|
40
|
+
-used_at,
|
|
41
|
+
-count,
|
|
42
|
+
len(project.name),
|
|
43
|
+
project.name.casefold(),
|
|
44
|
+
str(project.path),
|
|
45
|
+
)
|
|
46
|
+
matches.append((rank, project))
|
|
47
|
+
|
|
48
|
+
matches.sort(key=lambda item: item[0])
|
|
49
|
+
return [project for _, project in matches]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rank_recent(
|
|
53
|
+
projects: Iterable[Project],
|
|
54
|
+
usage: Mapping[Path, ProjectUsage],
|
|
55
|
+
) -> list[Project]:
|
|
56
|
+
ranked_projects = list(projects)
|
|
57
|
+
|
|
58
|
+
def usage_rank(project: Project) -> tuple[float, int, str, str]:
|
|
59
|
+
project_usage = usage.get(project.path)
|
|
60
|
+
return (
|
|
61
|
+
0.0 if project_usage is None else -project_usage.used_at,
|
|
62
|
+
0 if project_usage is None else -project_usage.count,
|
|
63
|
+
project.name.casefold(),
|
|
64
|
+
str(project.path),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ranked_projects.sort(key=usage_rank)
|
|
68
|
+
return ranked_projects
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _score(query: str, name: str) -> tuple[int, int] | None:
|
|
72
|
+
if name == query:
|
|
73
|
+
return 0, len(query) * EXACT_WEIGHT
|
|
74
|
+
if name.startswith(query):
|
|
75
|
+
return 1, len(query) * PREFIX_WEIGHT - (len(name) - len(query))
|
|
76
|
+
|
|
77
|
+
position = name.find(query)
|
|
78
|
+
if position >= 0:
|
|
79
|
+
return 2, len(query) * SUBSTRING_WEIGHT - position
|
|
80
|
+
|
|
81
|
+
query_index = 0
|
|
82
|
+
previous = -2
|
|
83
|
+
points = 0
|
|
84
|
+
|
|
85
|
+
for index, char in enumerate(name):
|
|
86
|
+
if query_index == len(query):
|
|
87
|
+
break
|
|
88
|
+
if char != query[query_index]:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
points += MATCH_POINTS
|
|
92
|
+
points += BOUNDARY_BONUS if index == 0 or name[index - 1] in "-_./ " else 0
|
|
93
|
+
|
|
94
|
+
if previous >= 0:
|
|
95
|
+
points += (
|
|
96
|
+
ADJACENT_BONUS
|
|
97
|
+
if index == previous + 1
|
|
98
|
+
else -min(index - previous - 1, MAX_GAP_PENALTY)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
previous = index
|
|
102
|
+
query_index += 1
|
|
103
|
+
|
|
104
|
+
if query_index != len(query):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
penalty = max(len(name) - len(query), 0) // 2
|
|
108
|
+
return 3, points - penalty
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Generate, install, and inspect shell integration for pcd."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from textwrap import dedent
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from pcd_cli.filesystem import atomic_write, file_lock
|
|
12
|
+
|
|
13
|
+
SHELL_MODE_ENV = "PCD_SHELL"
|
|
14
|
+
# Internal success status: stdout contains the destination path when this is returned.
|
|
15
|
+
SHELL_CD_EXIT_CODE = 10
|
|
16
|
+
_MANAGED_BLOCK_START = "# >>> pcd shell integration >>>"
|
|
17
|
+
_MANAGED_BLOCK_END = "# <<< pcd shell integration <<<"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ShellIntegrationError(Exception):
|
|
21
|
+
"""Persistent shell integration cannot be inspected or managed safely."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Shell(StrEnum):
|
|
25
|
+
BASH = "bash"
|
|
26
|
+
ZSH = "zsh"
|
|
27
|
+
FISH = "fish"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShellIntegrationState(StrEnum):
|
|
31
|
+
ABSENT = "not installed"
|
|
32
|
+
MANUAL = "configured manually"
|
|
33
|
+
MANAGED = "installed by pcd"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ShellIntegration:
|
|
38
|
+
shell: Shell
|
|
39
|
+
config_path: Path
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def detect(cls) -> Self:
|
|
43
|
+
return cls.for_shell(detect_shell())
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def for_shell(cls, shell: Shell) -> Self:
|
|
47
|
+
return cls(shell=shell, config_path=shell_config_path(shell))
|
|
48
|
+
|
|
49
|
+
def state(self) -> ShellIntegrationState:
|
|
50
|
+
return _integration_state(self._read(), self.shell)
|
|
51
|
+
|
|
52
|
+
def install(self) -> bool:
|
|
53
|
+
"""Install the managed block. Return whether the config changed."""
|
|
54
|
+
with file_lock(self.config_path):
|
|
55
|
+
content = self._read()
|
|
56
|
+
if _integration_state(content, self.shell) is not ShellIntegrationState.ABSENT:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
separator = "" if not content or content.endswith("\n") else "\n"
|
|
60
|
+
self._write(f"{content}{separator}{render_managed_block(self.shell)}")
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def uninstall(self) -> bool:
|
|
64
|
+
"""Remove only a pcd-managed block, leaving manual setup untouched."""
|
|
65
|
+
with file_lock(self.config_path):
|
|
66
|
+
content = self._read()
|
|
67
|
+
bounds = _managed_block_bounds(content)
|
|
68
|
+
if bounds is None:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
start, end = bounds
|
|
72
|
+
self._write(f"{content[:start]}{content[end:]}")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _read(self) -> str:
|
|
76
|
+
try:
|
|
77
|
+
return self.config_path.read_text(encoding="utf-8")
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return ""
|
|
80
|
+
except UnicodeError as exc:
|
|
81
|
+
raise ShellIntegrationError(
|
|
82
|
+
f"Shell config is not valid UTF-8: {self.config_path}"
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
def _write(self, content: str) -> None:
|
|
86
|
+
target = (
|
|
87
|
+
self.config_path.resolve(strict=False)
|
|
88
|
+
if self.config_path.is_symlink()
|
|
89
|
+
else self.config_path
|
|
90
|
+
)
|
|
91
|
+
mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None
|
|
92
|
+
|
|
93
|
+
with atomic_write(target) as stream:
|
|
94
|
+
stream.write(content)
|
|
95
|
+
|
|
96
|
+
if mode is not None:
|
|
97
|
+
target.chmod(mode)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_shell() -> Shell:
|
|
101
|
+
"""Detect the user's login shell from the standard SHELL environment variable."""
|
|
102
|
+
executable = os.environ.get("SHELL", "")
|
|
103
|
+
name = Path(executable).name.casefold()
|
|
104
|
+
try:
|
|
105
|
+
return Shell(name)
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
supported = ", ".join(shell.value for shell in Shell)
|
|
108
|
+
raise ShellIntegrationError(
|
|
109
|
+
f"Cannot detect a supported shell from SHELL={executable!r}; choose one of: {supported}"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def shell_config_path(shell: Shell) -> Path:
|
|
114
|
+
"""Return the startup file where persistent integration should be installed."""
|
|
115
|
+
home = Path.home()
|
|
116
|
+
if shell is Shell.BASH:
|
|
117
|
+
return home / ".bashrc"
|
|
118
|
+
if shell is Shell.ZSH:
|
|
119
|
+
zdotdir = os.environ.get("ZDOTDIR")
|
|
120
|
+
return (Path(zdotdir).expanduser() if zdotdir else home) / ".zshrc"
|
|
121
|
+
|
|
122
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
123
|
+
config_home = Path(xdg_config_home).expanduser() if xdg_config_home else home / ".config"
|
|
124
|
+
return config_home / "fish" / "config.fish"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def shell_integration_active() -> bool:
|
|
128
|
+
return os.environ.get(SHELL_MODE_ENV) == "1"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def inactive_shell_message() -> str:
|
|
132
|
+
"""Explain how to activate directory changes when the wrapper is not running."""
|
|
133
|
+
try:
|
|
134
|
+
integration = ShellIntegration.detect()
|
|
135
|
+
state = integration.state()
|
|
136
|
+
except OSError, ShellIntegrationError:
|
|
137
|
+
return (
|
|
138
|
+
"Shell integration is not active, so pcd cannot change this shell's directory. "
|
|
139
|
+
"If you have not configured it manually, run: pcd shell install"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if state is ShellIntegrationState.ABSENT:
|
|
143
|
+
return (
|
|
144
|
+
"Shell integration is not installed, so pcd cannot change this shell's directory. "
|
|
145
|
+
"Install it with: pcd shell install"
|
|
146
|
+
)
|
|
147
|
+
return (
|
|
148
|
+
f"Shell integration is configured in {integration.config_path} but is not active in "
|
|
149
|
+
f"this shell. Restart it or run: exec {integration.shell.value}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_managed_block(shell: Shell) -> str:
|
|
154
|
+
"""Render the small persistent block written into the shell startup file."""
|
|
155
|
+
command = (
|
|
156
|
+
f'eval "$(command pcd shell print {shell.value})"'
|
|
157
|
+
if shell is not Shell.FISH
|
|
158
|
+
else f"command pcd shell print {shell.value} | source"
|
|
159
|
+
)
|
|
160
|
+
return f"{_MANAGED_BLOCK_START}\n{command}\n{_MANAGED_BLOCK_END}\n"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_shell_integration(shell: Shell) -> str:
|
|
164
|
+
"""Generate shell integration for directory changes and Click completion."""
|
|
165
|
+
return _fish() if shell is Shell.FISH else _posix(shell)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _integration_state(content: str, shell: Shell) -> ShellIntegrationState:
|
|
169
|
+
bounds = _managed_block_bounds(content)
|
|
170
|
+
if bounds is not None:
|
|
171
|
+
return ShellIntegrationState.MANAGED
|
|
172
|
+
|
|
173
|
+
legacy = f"pcd shell-init {shell.value}"
|
|
174
|
+
current = f"pcd shell print {shell.value}"
|
|
175
|
+
if legacy in content or current in content:
|
|
176
|
+
return ShellIntegrationState.MANUAL
|
|
177
|
+
return ShellIntegrationState.ABSENT
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _managed_block_bounds(content: str) -> tuple[int, int] | None:
|
|
181
|
+
starts = content.count(_MANAGED_BLOCK_START)
|
|
182
|
+
ends = content.count(_MANAGED_BLOCK_END)
|
|
183
|
+
if starts == 0 and ends == 0:
|
|
184
|
+
return None
|
|
185
|
+
if starts != 1 or ends != 1:
|
|
186
|
+
raise ShellIntegrationError(
|
|
187
|
+
"Shell config contains an invalid pcd-managed integration block"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
start = content.index(_MANAGED_BLOCK_START)
|
|
191
|
+
end_marker = content.index(_MANAGED_BLOCK_END)
|
|
192
|
+
if end_marker < start:
|
|
193
|
+
raise ShellIntegrationError(
|
|
194
|
+
"Shell config contains an invalid pcd-managed integration block"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
end = end_marker + len(_MANAGED_BLOCK_END)
|
|
198
|
+
if end < len(content) and content[end] == "\n":
|
|
199
|
+
end += 1
|
|
200
|
+
return start, end
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _posix(shell: Shell) -> str:
|
|
204
|
+
completion = "bash_source" if shell is Shell.BASH else "zsh_source"
|
|
205
|
+
return dedent(
|
|
206
|
+
f"""\
|
|
207
|
+
pcd() {{
|
|
208
|
+
if [ -n "${{_PCD_COMPLETE:-}}" ]; then
|
|
209
|
+
command pcd "$@"
|
|
210
|
+
return $?
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
local output code
|
|
214
|
+
if output="$({SHELL_MODE_ENV}=1 command pcd "$@")"; then
|
|
215
|
+
code=0
|
|
216
|
+
else
|
|
217
|
+
code=$?
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
if [ "$code" -eq {SHELL_CD_EXIT_CODE} ]; then
|
|
221
|
+
builtin cd -- "$output"
|
|
222
|
+
return $?
|
|
223
|
+
fi
|
|
224
|
+
if [ "$code" -ne 0 ]; then
|
|
225
|
+
return "$code"
|
|
226
|
+
fi
|
|
227
|
+
if [ -n "$output" ]; then
|
|
228
|
+
printf '%s\n' "$output"
|
|
229
|
+
fi
|
|
230
|
+
}}
|
|
231
|
+
|
|
232
|
+
eval "$(_PCD_COMPLETE={completion} command pcd)"
|
|
233
|
+
"""
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fish() -> str:
|
|
238
|
+
return dedent(
|
|
239
|
+
f"""\
|
|
240
|
+
function pcd
|
|
241
|
+
if set -q _PCD_COMPLETE
|
|
242
|
+
command pcd $argv
|
|
243
|
+
return $status
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
set -l output (env {SHELL_MODE_ENV}=1 command pcd $argv)
|
|
247
|
+
set -l code $status
|
|
248
|
+
if test $code -eq {SHELL_CD_EXIT_CODE}
|
|
249
|
+
cd -- "$output"
|
|
250
|
+
return $status
|
|
251
|
+
end
|
|
252
|
+
if test $code -ne 0
|
|
253
|
+
return $code
|
|
254
|
+
end
|
|
255
|
+
if test -n "$output"
|
|
256
|
+
printf '%s\n' "$output"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
set -lx _PCD_COMPLETE fish_source
|
|
261
|
+
command pcd | source
|
|
262
|
+
set -e _PCD_COMPLETE
|
|
263
|
+
"""
|
|
264
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pcd-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast project navigator for Bash, Zsh and Fish
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: cli,cd,projects,navigation,bash,zsh,fish
|
|
8
|
+
Author: pcd-cli contributors
|
|
9
|
+
Requires-Python: >=3.14,<4
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Dist: click (>=8.4.2,<9)
|
|
18
|
+
Requires-Dist: platformdirs (>=4.10,<5)
|
|
19
|
+
Requires-Dist: prompt-toolkit (>=3.0.53,<4)
|
|
20
|
+
Requires-Dist: tomlkit (>=0.15.1,<1)
|
|
21
|
+
Description-Content-Type: text/plain
|
|
22
|
+
|
|
23
|
+
Project cd
|
|
24
|
+
==========
|
|
25
|
+
|
|
26
|
+
pcd (project cd) - Is a fast way to jump between projects from your terminal
|
|
27
|
+
|
|
28
|
+
Instead of:
|
|
29
|
+
cd ~/development/github/palantir/backend/pigeon-surveillance
|
|
30
|
+
Just:
|
|
31
|
+
pcd pigeon-surveillance
|
|
32
|
+
|
|
33
|
+
Supported shells:
|
|
34
|
+
* Bash
|
|
35
|
+
* Zsh
|
|
36
|
+
* Fish
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
INSTALL
|
|
40
|
+
-------
|
|
41
|
+
pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
|
|
42
|
+
pcd shell install
|
|
43
|
+
Restart your shell after installing shell integration.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
QUICK START
|
|
47
|
+
----------
|
|
48
|
+
cd <your root folder, e.g. ~/Github/>
|
|
49
|
+
pcd init
|
|
50
|
+
pcd <project name from your root folder>
|
|
51
|
+
pcd discovers projects inside registered scan roots.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
COMMANDS
|
|
55
|
+
--------
|
|
56
|
+
|
|
57
|
+
pcd <project> Jump to a project
|
|
58
|
+
pcd Show help
|
|
59
|
+
|
|
60
|
+
pcd add <path> Add a project manually
|
|
61
|
+
pcd remove <project> Remove a manual project
|
|
62
|
+
|
|
63
|
+
pcd init Add the current directory as a scan root
|
|
64
|
+
pcd uninit Remove the current directory from scan roots
|
|
65
|
+
pcd roots List scan roots
|
|
66
|
+
pcd refresh Rescan roots and rebuild the project cache
|
|
67
|
+
|
|
68
|
+
pcd list List all known projects
|
|
69
|
+
pcd config-path Print the configuration file path
|
|
70
|
+
|
|
71
|
+
pcd shell install Install shell integration
|
|
72
|
+
pcd shell status Show shell integration status
|
|
73
|
+
pcd shell uninstall Remove shell integration
|
|
74
|
+
|
|
75
|
+
pcd --project <project> Jump to a project whose name matches a command
|
|
76
|
+
pcd --version Show version
|
|
77
|
+
pcd --help Show help
|
|
78
|
+
|
|
79
|
+
* Projects inside registered roots are discovered automatically.
|
|
80
|
+
* You can register multiple scan roots.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
DEVELOPMENT
|
|
84
|
+
-----------
|
|
85
|
+
make install
|
|
86
|
+
make check
|
|
87
|
+
make build
|
|
88
|
+
See ./dist folder
|
|
89
|
+
You can install *.whl with your python package manager
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
SEE ALSO
|
|
93
|
+
--------
|
|
94
|
+
Features:
|
|
95
|
+
FEATURES.txt
|
|
96
|
+
|
|
97
|
+
Planned changes:
|
|
98
|
+
ROADMAP.md
|
|
99
|
+
|
|
100
|
+
Changelog:
|
|
101
|
+
CHANGELOG.md
|
|
102
|
+
|
|
103
|
+
License:
|
|
104
|
+
MIT
|
|
105
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pcd_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pcd_cli/cache.py,sha256=tVXS4yNc5vKHwjfnpro39Fm9cSQg3qTVHYX4UHfQKo8,3655
|
|
3
|
+
pcd_cli/catalog.py,sha256=kGnR1hOkMmDS_N1AX5PP0PD3NJXCZuffuD2ryFZOg6c,4841
|
|
4
|
+
pcd_cli/cli.py,sha256=91Px-y00OC-K7-6dUKnzXCRlzjVhBgJSXRNspdUJYV8,11469
|
|
5
|
+
pcd_cli/config.py,sha256=U1kT6ehbklI6-YhQjWqmQWftDaskqp9MjVqtI5OmrVo,7519
|
|
6
|
+
pcd_cli/filesystem.py,sha256=FcUJKRm0y9ujd4vB5vRgahcIOJFt5sVTmgpVMywuZfI,2268
|
|
7
|
+
pcd_cli/history.py,sha256=phlefu3isprF-Xo95DRdjPRVAbspI8vZf19C3Ai1IHc,2825
|
|
8
|
+
pcd_cli/models.py,sha256=7oKtD_c3PEEbpXFVABCf62OqMBzvSiaHI5ZTBtm_XBQ,731
|
|
9
|
+
pcd_cli/navigation.py,sha256=Z9zXpMzohDxIGCwoMNAhmZaSDUAB7srIQmozDe70Ysc,2523
|
|
10
|
+
pcd_cli/picker.py,sha256=Jbja0SAH6H6G0nWVsDooXckdlqpA4KjNxMPKcTSYuLw,5163
|
|
11
|
+
pcd_cli/scanner.py,sha256=2a0tgEg-D-sx7q5OsK7yRCSlhssaQXlICmyFaTWhPAw,3869
|
|
12
|
+
pcd_cli/search.py,sha256=cTI7qWc35Y3vTOk9nPVzb1wzikd0fBjUUHMiF4nzWAA,2976
|
|
13
|
+
pcd_cli/shell_integration.py,sha256=1i_-efl4kIqh8YY5hRdLCk55T469Wb9ZGTLVM7NaJTQ,8321
|
|
14
|
+
pcd_cli-0.1.0.dist-info/METADATA,sha256=Oh7vjV2RDsKXDlPT4QbqQai_RbrUlvfnzrDZb3zNnHg,2752
|
|
15
|
+
pcd_cli-0.1.0.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
|
|
16
|
+
pcd_cli-0.1.0.dist-info/entry_points.txt,sha256=3ZMnsucwvrxf64c1yhIJnXmB_Xxz-4ICL74ibZdpjSU,40
|
|
17
|
+
pcd_cli-0.1.0.dist-info/licenses/LICENSE,sha256=X2MjaqUh65CgyVR4rWS1nWM82z1Z6FLV7R8bBaH6590,1077
|
|
18
|
+
pcd_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pcd-cli contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|