pcd-cli 0.1.0__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.1.0/LICENSE ADDED
@@ -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.
pcd_cli-0.1.0/PKG-INFO ADDED
@@ -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,82 @@
1
+ Project cd
2
+ ==========
3
+
4
+ pcd (project cd) - Is a fast way to jump between projects from your terminal
5
+
6
+ Instead of:
7
+ cd ~/development/github/palantir/backend/pigeon-surveillance
8
+ Just:
9
+ pcd pigeon-surveillance
10
+
11
+ Supported shells:
12
+ * Bash
13
+ * Zsh
14
+ * Fish
15
+
16
+
17
+ INSTALL
18
+ -------
19
+ pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
20
+ pcd shell install
21
+ Restart your shell after installing shell integration.
22
+
23
+
24
+ QUICK START
25
+ ----------
26
+ cd <your root folder, e.g. ~/Github/>
27
+ pcd init
28
+ pcd <project name from your root folder>
29
+ pcd discovers projects inside registered scan roots.
30
+
31
+
32
+ COMMANDS
33
+ --------
34
+
35
+ pcd <project> Jump to a project
36
+ pcd Show help
37
+
38
+ pcd add <path> Add a project manually
39
+ pcd remove <project> Remove a manual project
40
+
41
+ pcd init Add the current directory as a scan root
42
+ pcd uninit Remove the current directory from scan roots
43
+ pcd roots List scan roots
44
+ pcd refresh Rescan roots and rebuild the project cache
45
+
46
+ pcd list List all known projects
47
+ pcd config-path Print the configuration file path
48
+
49
+ pcd shell install Install shell integration
50
+ pcd shell status Show shell integration status
51
+ pcd shell uninstall Remove shell integration
52
+
53
+ pcd --project <project> Jump to a project whose name matches a command
54
+ pcd --version Show version
55
+ pcd --help Show help
56
+
57
+ * Projects inside registered roots are discovered automatically.
58
+ * You can register multiple scan roots.
59
+
60
+
61
+ DEVELOPMENT
62
+ -----------
63
+ make install
64
+ make check
65
+ make build
66
+ See ./dist folder
67
+ You can install *.whl with your python package manager
68
+
69
+
70
+ SEE ALSO
71
+ --------
72
+ Features:
73
+ FEATURES.txt
74
+
75
+ Planned changes:
76
+ ROADMAP.md
77
+
78
+ Changelog:
79
+ CHANGELOG.md
80
+
81
+ License:
82
+ MIT
@@ -0,0 +1,109 @@
1
+ [project]
2
+ name = "pcd-cli"
3
+ version = "0.1.0"
4
+ description = "Fast project navigator for Bash, Zsh and Fish"
5
+ readme = { file = "README.txt", content-type = "text/plain" }
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "pcd-cli contributors" }]
9
+ requires-python = ">=3.14,<4"
10
+ keywords = ["cli", "cd", "projects", "navigation", "bash", "zsh", "fish"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Operating System :: MacOS",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Utilities",
19
+ ]
20
+ dependencies = [
21
+ "click>=8.4.2,<9",
22
+ "platformdirs>=4.10,<5",
23
+ "prompt-toolkit>=3.0.53,<4",
24
+ "tomlkit>=0.15.1,<1",
25
+ ]
26
+
27
+ [project.scripts]
28
+ pcd = "pcd_cli.cli:main"
29
+
30
+ [build-system]
31
+ requires = ["poetry-core==2.4.1"]
32
+ build-backend = "poetry.core.masonry.api"
33
+
34
+ [tool.poetry]
35
+ packages = [{ include = "pcd_cli", from = "src" }]
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ mypy = "2.3.0"
39
+ pre-commit = "4.6.2"
40
+ pytest = "9.1.1"
41
+ pytest-cov = "7.1.0"
42
+ ruff = "0.16.3"
43
+
44
+ [tool.ruff]
45
+ target-version = "py314"
46
+ line-length = 100
47
+ src = ["src", "tests", "benchmarks"]
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E4",
52
+ "E7",
53
+ "E9",
54
+ "F",
55
+ "I",
56
+ "UP",
57
+ "A",
58
+ "B",
59
+ "N",
60
+ "SIM",
61
+ "PERF",
62
+ "PIE",
63
+ "RUF",
64
+ "TC",
65
+ "TID",
66
+ "ANN",
67
+ ]
68
+
69
+ [tool.ruff.lint.flake8-type-checking]
70
+ strict = true
71
+
72
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
73
+ "typing.Any".msg = "Use precise types instead of Any."
74
+ "typing.cast".msg = "Narrow types explicitly instead of using cast()."
75
+
76
+ [tool.ruff.lint.isort]
77
+ known-first-party = ["pcd_cli"]
78
+ combine-as-imports = true
79
+ order-by-type = false
80
+
81
+ [tool.ruff.format]
82
+ quote-style = "double"
83
+ indent-style = "space"
84
+ line-ending = "lf"
85
+ docstring-code-format = true
86
+
87
+ [tool.mypy]
88
+ python_version = "3.14"
89
+ strict = true
90
+ warn_unreachable = true
91
+ show_error_codes = true
92
+ pretty = true
93
+ files = ["src", "tests", "benchmarks"]
94
+
95
+ [tool.pytest.ini_options]
96
+ addopts = "-ra --strict-config --strict-markers"
97
+ testpaths = ["tests"]
98
+
99
+ [tool.coverage.run]
100
+ branch = true
101
+ source = ["pcd_cli"]
102
+
103
+ [tool.coverage.report]
104
+ fail_under = 95
105
+ show_missing = true
106
+ skip_covered = true
107
+ exclude_also = [
108
+ "if TYPE_CHECKING:",
109
+ ]
File without changes
@@ -0,0 +1,118 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, TypedDict, TypeIs
5
+
6
+ from pcd_cli.filesystem import atomic_write, file_lock
7
+ from pcd_cli.models import Project, ProjectSource
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable, Iterator
11
+
12
+
13
+ class _CacheReadError(Exception):
14
+ """The on-disk cache cannot be read as a valid cache snapshot."""
15
+
16
+
17
+ class ProjectCacheEntry(TypedDict):
18
+ name: str
19
+ path: str
20
+ display: str
21
+ source: str
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ProjectCache:
26
+ """Disposable JSONL index of known projects."""
27
+
28
+ path: Path
29
+
30
+ def load(self) -> list[Project] | None:
31
+ if not self.path.is_file():
32
+ return None
33
+
34
+ try:
35
+ return [_decode_project(entry) for entry in self._entries()]
36
+ except _CacheReadError:
37
+ return None
38
+
39
+ def find_exact(self, query: str) -> list[Project] | None:
40
+ if not self.path.is_file():
41
+ return None
42
+
43
+ needle = query.casefold()
44
+ try:
45
+ return [
46
+ _decode_project(entry)
47
+ for entry in self._entries()
48
+ if entry["name"].casefold() == needle
49
+ ]
50
+ except _CacheReadError:
51
+ return None
52
+
53
+ def find_prefix(self, query: str) -> list[Project] | None:
54
+ if not self.path.is_file():
55
+ return None
56
+
57
+ needle = query.casefold()
58
+ try:
59
+ return [
60
+ _decode_project(entry)
61
+ for entry in self._entries()
62
+ if entry["name"].casefold().startswith(needle)
63
+ ]
64
+ except _CacheReadError:
65
+ return None
66
+
67
+ def save(self, projects: Iterable[Project]) -> None:
68
+ # JSONL keeps refresh streaming and avoids a second in-memory JSON tree.
69
+ with file_lock(self.path), atomic_write(self.path) as stream:
70
+ for project in projects:
71
+ entry: ProjectCacheEntry = {
72
+ "name": project.name,
73
+ "path": str(project.path),
74
+ "display": str(project.display_path),
75
+ "source": project.source.value,
76
+ }
77
+ json.dump(entry, stream, ensure_ascii=False, separators=(",", ":"))
78
+ stream.write("\n")
79
+
80
+ def _entries(self) -> Iterator[ProjectCacheEntry]:
81
+ try:
82
+ with self.path.open(encoding="utf-8") as stream:
83
+ for line in stream:
84
+ try:
85
+ raw: object = json.loads(line)
86
+ except json.JSONDecodeError as exc:
87
+ raise _CacheReadError("Invalid cache JSON") from exc
88
+ if not _is_cache_entry(raw):
89
+ raise _CacheReadError("Invalid cache entry")
90
+ yield raw
91
+ except (OSError, UnicodeError) as exc:
92
+ raise _CacheReadError("Cannot read cache") from exc
93
+
94
+
95
+ def _is_cache_entry(value: object) -> TypeIs[ProjectCacheEntry]:
96
+ if not isinstance(value, dict):
97
+ return False
98
+
99
+ return (
100
+ isinstance(value.get("name"), str)
101
+ and isinstance(value.get("path"), str)
102
+ and isinstance(value.get("display"), str)
103
+ and isinstance(value.get("source"), str)
104
+ )
105
+
106
+
107
+ def _decode_project(entry: ProjectCacheEntry) -> Project:
108
+ try:
109
+ source = ProjectSource(entry["source"])
110
+ except ValueError as exc:
111
+ raise _CacheReadError("Invalid project source") from exc
112
+
113
+ return Project(
114
+ name=entry["name"],
115
+ path=Path(entry["path"]),
116
+ display_path=Path(entry["display"]),
117
+ source=source,
118
+ )
@@ -0,0 +1,157 @@
1
+ from dataclasses import dataclass
2
+ from typing import Self, TYPE_CHECKING
3
+
4
+ from pcd_cli.cache import ProjectCache
5
+ from pcd_cli.config import Config
6
+ from pcd_cli.filesystem import ApplicationPaths, canonical_path
7
+ from pcd_cli.history import UsageHistory
8
+ from pcd_cli.scanner import ProjectScanner
9
+ from pcd_cli.search import rank_matches
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+ from pathlib import Path
14
+
15
+ from pcd_cli.models import Project
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ProjectCatalog:
20
+ """Coordinate project configuration, discovery, cache, and usage history."""
21
+
22
+ config: Config
23
+ cache: ProjectCache
24
+ history: UsageHistory
25
+
26
+ @classmethod
27
+ def create(cls) -> Self:
28
+ paths = ApplicationPaths.resolve()
29
+ return cls(
30
+ config=Config(paths.config),
31
+ cache=ProjectCache(paths.cache),
32
+ history=UsageHistory(paths.history),
33
+ )
34
+
35
+ def projects(self) -> list[Project]:
36
+ if cached := self.cache.load():
37
+ return cached
38
+
39
+ return self.refresh()
40
+
41
+ def completion_candidates(self, query: str) -> Iterator[Project]:
42
+ matches = self.cache.find_prefix(query)
43
+ if matches is not None:
44
+ yield from matches
45
+
46
+ def search(self, query: str, exact_only: bool = False) -> list[Project]:
47
+ # The common exact-hit path avoids loading the full cache and history.
48
+ exact_matches = self.cache.find_exact(query)
49
+ if exact_matches is None:
50
+ return self._search_projects(self.refresh(), query, exact_only)
51
+
52
+ if exact_matches:
53
+ if len(exact_matches) == 1:
54
+ return exact_matches
55
+ return rank_matches(exact_matches, query, self.history.load())
56
+
57
+ if exact_only:
58
+ return self._search_projects(self.refresh(), query, exact_only=True)
59
+
60
+ cached = self.cache.load()
61
+ if cached is None:
62
+ return self._search_projects(self.refresh(), query, exact_only=False)
63
+
64
+ usage = self.history.load()
65
+ matches = rank_matches(cached, query, usage)
66
+ if matches:
67
+ return matches
68
+
69
+ # A miss can mean a repository appeared after the last refresh.
70
+ return rank_matches(self.refresh(), query, usage)
71
+
72
+ def refresh(self) -> list[Project]:
73
+ settings = self.config.load()
74
+ projects = list(settings.manual_projects)
75
+ known_paths = {project.path for project in projects}
76
+
77
+ for project in ProjectScanner(settings).scan():
78
+ if project.path in known_paths:
79
+ continue
80
+
81
+ known_paths.add(project.path)
82
+ projects.append(project)
83
+
84
+ projects.sort(key=lambda project: (project.name.casefold(), str(project.path)))
85
+ self.cache.save(projects)
86
+
87
+ return projects
88
+
89
+ def record_usage(self, project: Project) -> None:
90
+ self.history.record(project.path)
91
+
92
+ def add_scan_root(self, root: Path) -> bool:
93
+ if not self.config.add_root(root):
94
+ return False
95
+
96
+ self.refresh()
97
+
98
+ return True
99
+
100
+ def remove_scan_root(self, root: Path) -> bool:
101
+ if not self.config.remove_root(root):
102
+ return False
103
+
104
+ self.refresh()
105
+
106
+ return True
107
+
108
+ def add_project(self, project: Project) -> bool:
109
+ # An authoritative scan prevents stale cache data from hiding a repository.
110
+ if any(item.path == project.path for item in self.refresh()):
111
+ return False
112
+
113
+ if not self.config.add_project(project):
114
+ return False
115
+
116
+ # Rebuild from committed config instead of extending a potentially stale snapshot.
117
+ self.refresh()
118
+
119
+ return True
120
+
121
+ def remove_project(self, path: Path) -> bool:
122
+ if not self.config.remove_project(path):
123
+ return False
124
+
125
+ self.refresh()
126
+
127
+ return True
128
+
129
+ def find_parent_root(self, path: Path) -> Path | None:
130
+ target = canonical_path(path)
131
+ closest_root: Path | None = None
132
+ closest_depth = -1
133
+
134
+ for root in self.config.load().roots:
135
+ resolved_root = canonical_path(root)
136
+ if resolved_root == target or not target.is_relative_to(resolved_root):
137
+ continue
138
+
139
+ root_depth = len(resolved_root.parts)
140
+ if root_depth > closest_depth:
141
+ closest_root = root
142
+ closest_depth = root_depth
143
+
144
+ return closest_root
145
+
146
+ def _search_projects(
147
+ self,
148
+ projects: list[Project],
149
+ query: str,
150
+ exact_only: bool,
151
+ ) -> list[Project]:
152
+ needle = query.casefold()
153
+ exact_matches = [project for project in projects if project.name.casefold() == needle]
154
+ if exact_matches or exact_only:
155
+ return exact_matches
156
+
157
+ return rank_matches(projects, query, self.history.load())