pcd-cli 0.1.0__tar.gz → 0.2.1__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.
@@ -1,21 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Fast project navigator for Bash, Zsh and Fish
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: cli,cd,projects,navigation,bash,zsh,fish
8
8
  Author: pcd-cli contributors
9
- Requires-Python: >=3.14,<4
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
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
15
17
  Classifier: Programming Language :: Python :: 3.14
16
18
  Classifier: Topic :: Utilities
17
- Requires-Dist: click (>=8.4.2,<9)
18
- Requires-Dist: platformdirs (>=4.10,<5)
19
+ Requires-Dist: click (>=8.5.0,<9)
20
+ Requires-Dist: platformdirs (>=4.11.7,<5)
19
21
  Requires-Dist: prompt-toolkit (>=3.0.53,<4)
20
22
  Requires-Dist: tomlkit (>=0.15.1,<1)
21
23
  Description-Content-Type: text/plain
@@ -38,7 +40,9 @@ Supported shells:
38
40
 
39
41
  INSTALL
40
42
  -------
41
- pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
43
+ Requires Python 3.12 or newer.
44
+
45
+ pipx install git+https://github.com/jestenough/pcd.git
42
46
  pcd shell install
43
47
  Restart your shell after installing shell integration.
44
48
 
@@ -66,7 +70,10 @@ COMMANDS
66
70
  pcd refresh Rescan roots and rebuild the project cache
67
71
 
68
72
  pcd list List all known projects
69
- pcd config-path Print the configuration file path
73
+ pcd config path Print the configuration file path
74
+ pcd config show Print the effective configuration
75
+ pcd config edit Edit the user configuration
76
+ pcd config validate Validate the user configuration
70
77
 
71
78
  pcd shell install Install shell integration
72
79
  pcd shell status Show shell integration status
@@ -78,6 +85,7 @@ COMMANDS
78
85
 
79
86
  * Projects inside registered roots are discovered automatically.
80
87
  * You can register multiple scan roots.
88
+ * Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
81
89
 
82
90
 
83
91
  DEVELOPMENT
@@ -95,10 +103,7 @@ SEE ALSO
95
103
  FEATURES.txt
96
104
 
97
105
  Planned changes:
98
- ROADMAP.md
99
-
100
- Changelog:
101
- CHANGELOG.md
106
+ ROADMAP.txt
102
107
 
103
108
  License:
104
109
  MIT
@@ -16,7 +16,9 @@ Supported shells:
16
16
 
17
17
  INSTALL
18
18
  -------
19
- pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
19
+ Requires Python 3.12 or newer.
20
+
21
+ pipx install git+https://github.com/jestenough/pcd.git
20
22
  pcd shell install
21
23
  Restart your shell after installing shell integration.
22
24
 
@@ -44,7 +46,10 @@ COMMANDS
44
46
  pcd refresh Rescan roots and rebuild the project cache
45
47
 
46
48
  pcd list List all known projects
47
- pcd config-path Print the configuration file path
49
+ pcd config path Print the configuration file path
50
+ pcd config show Print the effective configuration
51
+ pcd config edit Edit the user configuration
52
+ pcd config validate Validate the user configuration
48
53
 
49
54
  pcd shell install Install shell integration
50
55
  pcd shell status Show shell integration status
@@ -56,6 +61,7 @@ COMMANDS
56
61
 
57
62
  * Projects inside registered roots are discovered automatically.
58
63
  * You can register multiple scan roots.
64
+ * Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
59
65
 
60
66
 
61
67
  DEVELOPMENT
@@ -73,10 +79,7 @@ SEE ALSO
73
79
  FEATURES.txt
74
80
 
75
81
  Planned changes:
76
- ROADMAP.md
77
-
78
- Changelog:
79
- CHANGELOG.md
82
+ ROADMAP.txt
80
83
 
81
84
  License:
82
85
  MIT
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "Fast project navigator for Bash, Zsh and Fish"
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
- requires-python = ">=3.14,<4"
9
+ requires-python = ">=3.12,<4"
10
10
  keywords = ["cli", "cd", "projects", "navigation", "bash", "zsh", "fish"]
11
11
  classifiers = [
12
12
  "Development Status :: 3 - Alpha",
@@ -14,12 +14,14 @@ classifiers = [
14
14
  "Operating System :: MacOS",
15
15
  "Operating System :: POSIX :: Linux",
16
16
  "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
17
19
  "Programming Language :: Python :: 3.14",
18
20
  "Topic :: Utilities",
19
21
  ]
20
22
  dependencies = [
21
- "click>=8.4.2,<9",
22
- "platformdirs>=4.10,<5",
23
+ "click>=8.5.0,<9",
24
+ "platformdirs>=4.11.7,<5",
23
25
  "prompt-toolkit>=3.0.53,<4",
24
26
  "tomlkit>=0.15.1,<1",
25
27
  ]
@@ -35,14 +37,14 @@ build-backend = "poetry.core.masonry.api"
35
37
  packages = [{ include = "pcd_cli", from = "src" }]
36
38
 
37
39
  [tool.poetry.group.dev.dependencies]
38
- mypy = "2.3.0"
40
+ mypy = "2.3.1"
39
41
  pre-commit = "4.6.2"
40
42
  pytest = "9.1.1"
41
43
  pytest-cov = "7.1.0"
42
- ruff = "0.16.3"
44
+ ruff = "0.16.5"
43
45
 
44
46
  [tool.ruff]
45
- target-version = "py314"
47
+ target-version = "py312"
46
48
  line-length = 100
47
49
  src = ["src", "tests", "benchmarks"]
48
50
 
@@ -85,7 +87,7 @@ line-ending = "lf"
85
87
  docstring-code-format = true
86
88
 
87
89
  [tool.mypy]
88
- python_version = "3.14"
90
+ python_version = "3.12"
89
91
  strict = true
90
92
  warn_unreachable = true
91
93
  show_error_codes = true
@@ -1,7 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  from dataclasses import dataclass
3
5
  from pathlib import Path
4
- from typing import TYPE_CHECKING, TypedDict, TypeIs
6
+ from typing import TYPE_CHECKING, TypedDict, TypeGuard
5
7
 
6
8
  from pcd_cli.filesystem import atomic_write, file_lock
7
9
  from pcd_cli.models import Project, ProjectSource
@@ -92,7 +94,7 @@ class ProjectCache:
92
94
  raise _CacheReadError("Cannot read cache") from exc
93
95
 
94
96
 
95
- def _is_cache_entry(value: object) -> TypeIs[ProjectCacheEntry]:
97
+ def _is_cache_entry(value: object) -> TypeGuard[ProjectCacheEntry]:
96
98
  if not isinstance(value, dict):
97
99
  return False
98
100
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from dataclasses import dataclass
2
4
  from typing import Self, TYPE_CHECKING
3
5
 
@@ -0,0 +1,43 @@
1
+ from pcd_cli.cli.app import cli, main, package_version, ProjectCommandGroup
2
+ from pcd_cli.cli.config import (
3
+ config_commands,
4
+ edit_config,
5
+ print_config_path,
6
+ show_config,
7
+ validate_config,
8
+ )
9
+ from pcd_cli.cli.projects import add, init, list_projects, project, refresh, remove, roots, uninit
10
+ from pcd_cli.cli.shell import (
11
+ install_shell,
12
+ print_shell_integration,
13
+ shell_commands,
14
+ shell_init,
15
+ shell_status,
16
+ uninstall_shell,
17
+ )
18
+
19
+ __all__ = [
20
+ "ProjectCommandGroup",
21
+ "add",
22
+ "cli",
23
+ "config_commands",
24
+ "edit_config",
25
+ "init",
26
+ "install_shell",
27
+ "list_projects",
28
+ "main",
29
+ "package_version",
30
+ "print_config_path",
31
+ "print_shell_integration",
32
+ "project",
33
+ "refresh",
34
+ "remove",
35
+ "roots",
36
+ "shell_commands",
37
+ "shell_init",
38
+ "shell_status",
39
+ "show_config",
40
+ "uninit",
41
+ "uninstall_shell",
42
+ "validate_config",
43
+ ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import metadata
4
+ from typing import TYPE_CHECKING
5
+
6
+ import click
7
+
8
+ from pcd_cli.catalog import ProjectCatalog
9
+ from pcd_cli.cli.config import config_commands
10
+ from pcd_cli.cli.projects import project_commands
11
+ from pcd_cli.cli.shell import shell_commands, shell_init
12
+ from pcd_cli.config import InvalidConfigError
13
+ from pcd_cli.models import ExitCode
14
+ from pcd_cli.navigation import navigate_to_project, project_completions
15
+ from pcd_cli.shell_integration import ShellIntegrationError
16
+
17
+ if TYPE_CHECKING:
18
+ from click.shell_completion import CompletionItem
19
+
20
+
21
+ class ProjectCommandGroup(click.Group):
22
+ """Route an unknown first argument to project lookup."""
23
+
24
+ def resolve_command(
25
+ self,
26
+ ctx: click.Context,
27
+ args: list[str],
28
+ ) -> tuple[str | None, click.Command | None, list[str]]:
29
+ candidate = args[0] if args else None
30
+ if candidate is None or candidate.startswith("-"):
31
+ return super().resolve_command(ctx, args)
32
+ if self.get_command(ctx, candidate) is not None:
33
+ return super().resolve_command(ctx, args)
34
+
35
+ project_command = self.get_command(ctx, "project")
36
+ if project_command is None:
37
+ return super().resolve_command(ctx, args)
38
+
39
+ return "project", project_command, args
40
+
41
+ def shell_complete(self, ctx: click.Context, incomplete: str) -> list[CompletionItem]:
42
+ completions = super().shell_complete(ctx, incomplete)
43
+ known_values = {item.value for item in completions}
44
+
45
+ for completion in project_completions(incomplete):
46
+ if completion.value not in known_values:
47
+ completions.append(completion)
48
+
49
+ return completions
50
+
51
+
52
+ def package_version() -> str:
53
+ try:
54
+ return metadata.version("pcd-cli")
55
+ except metadata.PackageNotFoundError:
56
+ return "0+unknown"
57
+
58
+
59
+ @click.group(cls=ProjectCommandGroup, invoke_without_command=True)
60
+ @click.option(
61
+ "--project",
62
+ "project_name",
63
+ shell_complete=lambda _ctx, _param, value: project_completions(value),
64
+ help="Resolve a project whose name is reserved by a command.",
65
+ )
66
+ @click.version_option(version=package_version())
67
+ @click.pass_context
68
+ def cli(ctx: click.Context, project_name: str | None) -> None:
69
+ """Jump to local projects by name."""
70
+ catalog = ProjectCatalog.create()
71
+ ctx.obj = catalog
72
+
73
+ if project_name is not None and ctx.invoked_subcommand is not None:
74
+ raise click.UsageError("--project cannot be combined with a command")
75
+
76
+ if ctx.invoked_subcommand is not None:
77
+ return
78
+
79
+ if project_name is None:
80
+ click.echo(ctx.get_help())
81
+ return
82
+
83
+ navigate_to_project(catalog, project_name, exact_only=True)
84
+
85
+
86
+ for command in project_commands:
87
+ cli.add_command(command)
88
+ cli.add_command(config_commands)
89
+ cli.add_command(shell_commands)
90
+ cli.add_command(shell_init)
91
+
92
+
93
+ def main() -> None:
94
+ try:
95
+ cli.main()
96
+ except InvalidConfigError as exc:
97
+ click.echo(f"Config error: {exc}", err=True)
98
+ raise SystemExit(ExitCode.ERROR) from None
99
+ except ShellIntegrationError as exc:
100
+ click.echo(f"Shell integration error: {exc}", err=True)
101
+ raise SystemExit(ExitCode.ERROR) from None
102
+ except OSError as exc:
103
+ click.echo(f"Filesystem error: {exc}", err=True)
104
+ raise SystemExit(ExitCode.ERROR) from None
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+
10
+ from pcd_cli.config import InvalidConfigError
11
+
12
+ if TYPE_CHECKING:
13
+ from pcd_cli.catalog import ProjectCatalog
14
+
15
+
16
+ @click.group("config")
17
+ def config_commands() -> None:
18
+ """User configuration commands."""
19
+
20
+
21
+ @config_commands.command("path")
22
+ @click.pass_obj
23
+ def print_config_path(catalog: ProjectCatalog) -> None:
24
+ """Print the path to the user-editable configuration file."""
25
+ click.echo(catalog.config.path)
26
+
27
+
28
+ @config_commands.command("show")
29
+ @click.pass_obj
30
+ def show_config(catalog: ProjectCatalog) -> None:
31
+ """Print the effective user configuration."""
32
+ click.echo(catalog.config.effective_toml(), nl=False)
33
+
34
+
35
+ @config_commands.command("edit")
36
+ @click.pass_obj
37
+ def edit_config(catalog: ProjectCatalog) -> None:
38
+ """Open the user configuration in $VISUAL or $EDITOR."""
39
+ try:
40
+ editor = catalog.config.load().editor
41
+ except InvalidConfigError:
42
+ editor = None
43
+ else:
44
+ editor = editor or os.environ.get("VISUAL") or os.environ.get("EDITOR")
45
+
46
+ if editor is None:
47
+ raise click.UsageError("Set $VISUAL or $EDITOR before running `pcd config edit`")
48
+
49
+ try:
50
+ command = shlex.split(editor)
51
+ except ValueError as exc:
52
+ raise click.UsageError(f"Invalid editor command: {exc}") from exc
53
+ if not command:
54
+ raise click.UsageError("Set $VISUAL or $EDITOR before running `pcd config edit`")
55
+
56
+ catalog.config.ensure_exists()
57
+
58
+ result = subprocess.run([*command, str(catalog.config.path)], check=False)
59
+ if result.returncode:
60
+ raise click.ClickException(f"Editor exited with status {result.returncode}")
61
+
62
+
63
+ @config_commands.command("validate")
64
+ @click.pass_obj
65
+ def validate_config(catalog: ProjectCatalog) -> None:
66
+ """Validate the user configuration."""
67
+ try:
68
+ catalog.config.load()
69
+ except InvalidConfigError as exc:
70
+ raise click.ClickException(str(exc)) from exc
71
+
72
+ click.echo(f"Configuration is valid: {catalog.config.path}")
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import assert_never, ClassVar, Literal, NamedTuple, TYPE_CHECKING
5
+
6
+ import click
7
+
8
+ from pcd_cli.filesystem import canonical_path, format_path
9
+ from pcd_cli.models import ExitCode, Project, ProjectSource
10
+ from pcd_cli.navigation import navigate_to_project, project_completions, select_project
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ from pcd_cli.catalog import ProjectCatalog
16
+
17
+ type ProjectSourceLabel = Literal["manual", "scanned"]
18
+ type ProjectStatusLabel = Literal["available", "missing"]
19
+
20
+
21
+ class ProjectListRow(NamedTuple):
22
+ name: str
23
+ path: str
24
+ source: ProjectSourceLabel
25
+ status: ProjectStatusLabel
26
+
27
+ @classmethod
28
+ def from_project(cls, project: Project) -> ProjectListRow:
29
+ return cls(
30
+ name=project.name,
31
+ path=format_path(project.display_path),
32
+ source=cls._source_label(project.source),
33
+ status=cls._status_label(project.path),
34
+ )
35
+
36
+ @staticmethod
37
+ def _source_label(source: ProjectSource) -> ProjectSourceLabel:
38
+ match source:
39
+ case ProjectSource.DISCOVERED:
40
+ return "scanned"
41
+ case ProjectSource.MANUAL:
42
+ return "manual"
43
+ case _:
44
+ assert_never(source)
45
+
46
+ @staticmethod
47
+ def _status_label(path: Path) -> ProjectStatusLabel:
48
+ if path.is_dir():
49
+ return "available"
50
+ else:
51
+ return "missing"
52
+
53
+
54
+ class ProjectListTable:
55
+ HEADERS: ClassVar[tuple[str, str, str, str]] = ("NAME", "PATH", "SOURCE", "STATUS")
56
+
57
+ _rows: tuple[ProjectListRow, ...]
58
+
59
+ def __init__(self, projects: Sequence[Project]) -> None:
60
+ self._rows = tuple(ProjectListRow.from_project(project) for project in projects)
61
+
62
+ def render(self) -> str:
63
+ if self._rows:
64
+ return self._render_table()
65
+ else:
66
+ return "No projects found."
67
+
68
+ def _render_table(self) -> str:
69
+ table = (self.HEADERS, *self._rows)
70
+ columns = zip(*table, strict=True)
71
+ widths = tuple(max(len(value) for value in column) for column in columns)
72
+
73
+ return "\n".join(self._render_row(row, widths) for row in table)
74
+
75
+ @staticmethod
76
+ def _render_row(row: tuple[str, ...], widths: tuple[int, ...]) -> str:
77
+ return " ".join(
78
+ value.ljust(width) for value, width in zip(row, widths, strict=True)
79
+ ).rstrip()
80
+
81
+
82
+ @click.command(hidden=True)
83
+ @click.argument("query")
84
+ @click.pass_obj
85
+ def project(catalog: ProjectCatalog, query: str) -> None:
86
+ """Resolve a project name routed by the root command."""
87
+ navigate_to_project(catalog, query)
88
+
89
+
90
+ @click.command()
91
+ @click.pass_obj
92
+ def init(catalog: ProjectCatalog) -> None:
93
+ """Register the current directory as a scan root."""
94
+ current = Path.cwd()
95
+ parent = catalog.find_parent_root(current)
96
+ added = catalog.add_scan_root(current)
97
+
98
+ if not added:
99
+ click.echo(f"Already a root: {format_path(current)}")
100
+ return
101
+
102
+ if parent is not None:
103
+ click.echo(f"Note: root is inside {format_path(parent)}", err=True)
104
+
105
+ click.echo(f"Added root: {format_path(current)}")
106
+
107
+
108
+ @click.command()
109
+ @click.pass_obj
110
+ def uninit(catalog: ProjectCatalog) -> None:
111
+ """Remove the current directory from scan roots."""
112
+ current = Path.cwd()
113
+ if catalog.remove_scan_root(current):
114
+ click.echo(f"Removed root: {format_path(current)}")
115
+ return
116
+
117
+ click.echo("Current directory is not a pcd root.", err=True)
118
+ raise click.exceptions.Exit(ExitCode.ERROR)
119
+
120
+
121
+ @click.command()
122
+ @click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
123
+ @click.option("--name", help="Custom name for a manual project.")
124
+ @click.pass_obj
125
+ def add(catalog: ProjectCatalog, path: Path | None, name: str | None) -> None:
126
+ """Register a directory as a manual project."""
127
+ if path is None:
128
+ location = Path.cwd()
129
+ else:
130
+ try:
131
+ location = path.expanduser()
132
+ except RuntimeError as exc:
133
+ raise click.BadParameter("Unknown home directory", param_hint="path") from exc
134
+
135
+ if not location.is_absolute():
136
+ location = Path.cwd() / location
137
+ if not location.is_dir():
138
+ raise click.BadParameter(f"Directory does not exist: {location}", param_hint="path")
139
+
140
+ project_name = location.name if name is None else name.strip()
141
+ if not project_name:
142
+ raise click.BadParameter("Project name cannot be empty", param_hint="--name")
143
+
144
+ item = Project(
145
+ name=project_name,
146
+ path=canonical_path(location),
147
+ display_path=location,
148
+ source=ProjectSource.MANUAL,
149
+ )
150
+ if catalog.add_project(item):
151
+ click.echo(f"Added project: {item.name} -> {format_path(item.display_path)}")
152
+ return
153
+
154
+ click.echo(f"Project already exists: {item.name} -> {format_path(item.display_path)}")
155
+
156
+
157
+ @click.command()
158
+ @click.argument(
159
+ "query",
160
+ shell_complete=lambda _ctx, _param, value: project_completions(value),
161
+ )
162
+ @click.pass_obj
163
+ def remove(catalog: ProjectCatalog, query: str) -> None:
164
+ """Remove a manual project registration."""
165
+ matches = catalog.search(query, exact_only=True)
166
+ if not matches:
167
+ click.echo(f"Project not found: {query}", err=True)
168
+ raise click.exceptions.Exit(ExitCode.NOT_FOUND)
169
+
170
+ selected = select_project(catalog, matches, query)
171
+ if selected is None:
172
+ return
173
+
174
+ if selected.source is ProjectSource.DISCOVERED:
175
+ click.echo(
176
+ f"{selected.name} is discovered automatically and cannot be removed with pcd remove.",
177
+ err=True,
178
+ )
179
+ raise click.exceptions.Exit(ExitCode.ERROR)
180
+
181
+ if catalog.remove_project(selected.path):
182
+ click.echo(f"Removed project: {selected.name}")
183
+ return
184
+
185
+ click.echo("Project is no longer registered.", err=True)
186
+ raise click.exceptions.Exit(ExitCode.ERROR)
187
+
188
+
189
+ @click.command("list")
190
+ @click.pass_obj
191
+ def list_projects(catalog: ProjectCatalog) -> None:
192
+ """List all known projects."""
193
+ table = ProjectListTable(catalog.projects())
194
+ click.echo(table.render())
195
+
196
+
197
+ @click.command()
198
+ @click.pass_obj
199
+ def roots(catalog: ProjectCatalog) -> None:
200
+ """List registered scan roots."""
201
+ for root in catalog.config.load().roots:
202
+ click.echo(format_path(root))
203
+
204
+
205
+ @click.command()
206
+ @click.pass_obj
207
+ def refresh(catalog: ProjectCatalog) -> None:
208
+ """Rescan roots and rebuild the project cache."""
209
+ click.echo(f"Found {len(catalog.refresh())} projects.")
210
+
211
+
212
+ project_commands = (project, init, uninit, add, remove, list_projects, roots, refresh)