pcd-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,21 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -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
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
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,155 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import 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 pcd_cli.catalog import ProjectCatalog
14
+
15
+
16
+ @click.command(hidden=True)
17
+ @click.argument("query")
18
+ @click.pass_obj
19
+ def project(catalog: ProjectCatalog, query: str) -> None:
20
+ """Resolve a project name routed by the root command."""
21
+ navigate_to_project(catalog, query)
22
+
23
+
24
+ @click.command()
25
+ @click.pass_obj
26
+ def init(catalog: ProjectCatalog) -> None:
27
+ """Register the current directory as a scan root."""
28
+ current = Path.cwd()
29
+ parent = catalog.find_parent_root(current)
30
+ added = catalog.add_scan_root(current)
31
+
32
+ if not added:
33
+ click.echo(f"Already a root: {format_path(current)}")
34
+ return
35
+
36
+ if parent is not None:
37
+ click.echo(f"Note: root is inside {format_path(parent)}", err=True)
38
+
39
+ click.echo(f"Added root: {format_path(current)}")
40
+
41
+
42
+ @click.command()
43
+ @click.pass_obj
44
+ def uninit(catalog: ProjectCatalog) -> None:
45
+ """Remove the current directory from scan roots."""
46
+ current = Path.cwd()
47
+ if catalog.remove_scan_root(current):
48
+ click.echo(f"Removed root: {format_path(current)}")
49
+ return
50
+
51
+ click.echo("Current directory is not a pcd root.", err=True)
52
+ raise click.exceptions.Exit(ExitCode.ERROR)
53
+
54
+
55
+ @click.command()
56
+ @click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
57
+ @click.option("--name", help="Custom name for a manual project.")
58
+ @click.pass_obj
59
+ def add(catalog: ProjectCatalog, path: Path | None, name: str | None) -> None:
60
+ """Register a directory as a manual project."""
61
+ if path is None:
62
+ location = Path.cwd()
63
+ else:
64
+ try:
65
+ location = path.expanduser()
66
+ except RuntimeError as exc:
67
+ raise click.BadParameter("Unknown home directory", param_hint="path") from exc
68
+
69
+ if not location.is_absolute():
70
+ location = Path.cwd() / location
71
+ if not location.is_dir():
72
+ raise click.BadParameter(f"Directory does not exist: {location}", param_hint="path")
73
+
74
+ project_name = location.name if name is None else name.strip()
75
+ if not project_name:
76
+ raise click.BadParameter("Project name cannot be empty", param_hint="--name")
77
+
78
+ item = Project(
79
+ name=project_name,
80
+ path=canonical_path(location),
81
+ display_path=location,
82
+ source=ProjectSource.MANUAL,
83
+ )
84
+ if catalog.add_project(item):
85
+ click.echo(f"Added project: {item.name} -> {format_path(item.display_path)}")
86
+ return
87
+
88
+ click.echo(f"Project already exists: {item.name} -> {format_path(item.display_path)}")
89
+
90
+
91
+ @click.command()
92
+ @click.argument(
93
+ "query",
94
+ shell_complete=lambda _ctx, _param, value: project_completions(value),
95
+ )
96
+ @click.pass_obj
97
+ def remove(catalog: ProjectCatalog, query: str) -> None:
98
+ """Remove a manual project registration."""
99
+ matches = catalog.search(query, exact_only=True)
100
+ if not matches:
101
+ click.echo(f"Project not found: {query}", err=True)
102
+ raise click.exceptions.Exit(ExitCode.NOT_FOUND)
103
+
104
+ selected = matches[0] if len(matches) == 1 else select_project(catalog, matches, query)
105
+ if selected is None:
106
+ return
107
+
108
+ if selected.source is ProjectSource.DISCOVERED:
109
+ click.echo(
110
+ f"{selected.name} is discovered automatically and cannot be removed with pcd remove.",
111
+ err=True,
112
+ )
113
+ raise click.exceptions.Exit(ExitCode.ERROR)
114
+
115
+ if catalog.remove_project(selected.path):
116
+ click.echo(f"Removed project: {selected.name}")
117
+ return
118
+
119
+ click.echo("Project is no longer registered.", err=True)
120
+ raise click.exceptions.Exit(ExitCode.ERROR)
121
+
122
+
123
+ @click.command("list")
124
+ @click.pass_obj
125
+ def list_projects(catalog: ProjectCatalog) -> None:
126
+ """List all known projects."""
127
+ projects = catalog.projects()
128
+ if not projects:
129
+ click.echo("No projects found.")
130
+ return
131
+
132
+ name_width = max(len(project.name) for project in projects)
133
+ for item in projects:
134
+ status = "ok" if item.path.is_dir() else "missing"
135
+ click.echo(
136
+ f"{item.name:<{name_width}} {format_path(item.display_path)} {item.source.value:<10} {status}"
137
+ )
138
+
139
+
140
+ @click.command()
141
+ @click.pass_obj
142
+ def roots(catalog: ProjectCatalog) -> None:
143
+ """List registered scan roots."""
144
+ for root in catalog.config.load().roots:
145
+ click.echo(format_path(root))
146
+
147
+
148
+ @click.command()
149
+ @click.pass_obj
150
+ def refresh(catalog: ProjectCatalog) -> None:
151
+ """Rescan roots and rebuild the project cache."""
152
+ click.echo(f"Found {len(catalog.refresh())} projects.")
153
+
154
+
155
+ project_commands = (project, init, uninit, add, remove, list_projects, roots, refresh)
@@ -0,0 +1,111 @@
1
+ import click
2
+
3
+ from pcd_cli.shell_integration import (
4
+ detect_shell,
5
+ render_shell_integration,
6
+ Shell,
7
+ shell_integration_active,
8
+ ShellIntegration,
9
+ ShellIntegrationError,
10
+ ShellIntegrationState,
11
+ )
12
+
13
+
14
+ @click.group("shell")
15
+ def shell_commands() -> None:
16
+ """Install and manage shell integration."""
17
+
18
+
19
+ @shell_commands.command("install")
20
+ @click.argument(
21
+ "shell",
22
+ required=False,
23
+ type=click.Choice([item.value for item in Shell], case_sensitive=False),
24
+ )
25
+ def install_shell(shell: str | None) -> None:
26
+ """Install persistent shell integration into the shell startup file."""
27
+ integration = _shell_integration(shell)
28
+ if integration.install():
29
+ click.echo(f"Installed {integration.shell.value} integration in {integration.config_path}")
30
+ click.echo(f"Restart the shell or run: exec {integration.shell.value}")
31
+ return
32
+
33
+ state = integration.state()
34
+ if state is ShellIntegrationState.MANAGED:
35
+ click.echo(f"Shell integration is already installed in {integration.config_path}")
36
+ return
37
+
38
+ click.echo(
39
+ f"Shell integration is already configured manually in {integration.config_path}; left unchanged."
40
+ )
41
+
42
+
43
+ @shell_commands.command("status")
44
+ @click.argument(
45
+ "shell",
46
+ required=False,
47
+ type=click.Choice([item.value for item in Shell], case_sensitive=False),
48
+ )
49
+ def shell_status(shell: str | None) -> None:
50
+ """Show whether shell integration is configured and active."""
51
+ integration = _shell_integration(shell)
52
+ click.echo(f"Shell: {integration.shell.value}")
53
+ click.echo(f"Config: {integration.config_path}")
54
+ click.echo(f"Configured: {integration.state().value}")
55
+
56
+ active = shell_integration_active()
57
+ click.echo(f"Active in current shell: {'yes' if active else 'no'}")
58
+
59
+
60
+ @shell_commands.command("uninstall")
61
+ @click.argument(
62
+ "shell",
63
+ required=False,
64
+ type=click.Choice([item.value for item in Shell], case_sensitive=False),
65
+ )
66
+ def uninstall_shell(shell: str | None) -> None:
67
+ """Remove integration installed by `pcd shell install`."""
68
+ integration = _shell_integration(shell)
69
+ if integration.uninstall():
70
+ click.echo(f"Removed shell integration from {integration.config_path}")
71
+ return
72
+
73
+ state = integration.state()
74
+ if state is ShellIntegrationState.MANUAL:
75
+ click.echo(f"Integration in {integration.config_path} is managed manually; left unchanged.")
76
+ return
77
+
78
+ click.echo(f"Shell integration is not installed in {integration.config_path}")
79
+
80
+
81
+ @shell_commands.command("print")
82
+ @click.argument(
83
+ "shell",
84
+ required=False,
85
+ type=click.Choice([item.value for item in Shell], case_sensitive=False),
86
+ )
87
+ def print_shell_integration(shell: str | None) -> None:
88
+ """Print shell integration for manual dotfile management."""
89
+ selected = _selected_shell(shell)
90
+ click.echo(render_shell_integration(selected), nl=False)
91
+
92
+
93
+ @click.command("shell-init", hidden=True)
94
+ @click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False))
95
+ def shell_init(shell: str) -> None:
96
+ """Backward-compatible alias for `pcd shell print`."""
97
+ click.echo(render_shell_integration(Shell(shell.casefold())), nl=False)
98
+
99
+
100
+ def _shell_integration(shell: str | None) -> ShellIntegration:
101
+ return ShellIntegration.for_shell(_selected_shell(shell))
102
+
103
+
104
+ def _selected_shell(shell: str | None) -> Shell:
105
+ if shell is not None:
106
+ return Shell(shell.casefold())
107
+
108
+ try:
109
+ return detect_shell()
110
+ except ShellIntegrationError as exc:
111
+ raise click.UsageError(str(exc)) from exc
@@ -1,6 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  from dataclasses import dataclass
2
4
  from pathlib import Path
3
- from typing import TYPE_CHECKING, TypeIs
5
+ from typing import TYPE_CHECKING, TypeGuard
4
6
 
5
7
  import tomlkit
6
8
 
@@ -12,7 +14,7 @@ if TYPE_CHECKING:
12
14
 
13
15
  from tomlkit import TOMLDocument
14
16
 
15
- ROOT_KEYS = frozenset({"roots", "scan", "projects"})
17
+ ROOT_KEYS = frozenset({"roots", "scan", "projects", "editor"})
16
18
  SCAN_KEYS = frozenset({"hidden", "follow_symlinks", "exclude"})
17
19
  PROJECT_KEYS = frozenset({"name", "path"})
18
20
 
@@ -42,6 +44,25 @@ class Config:
42
44
  def load(self) -> ProjectSettings:
43
45
  return _decode_settings(self._read().unwrap())
44
46
 
47
+ def effective_toml(self) -> str:
48
+ settings = self.load()
49
+ document = _default_document()
50
+ document["roots"] = [str(item) for item in settings.roots]
51
+ document["scan"]["hidden"] = settings.include_hidden
52
+ document["scan"]["follow_symlinks"] = settings.follow_symlinks
53
+ document["scan"]["exclude"] = sorted(settings.excluded_names)
54
+ if settings.editor is not None:
55
+ document["editor"] = settings.editor
56
+ document["projects"] = [
57
+ {"name": item.name, "path": str(item.display_path)} for item in settings.manual_projects
58
+ ]
59
+ return tomlkit.dumps(document)
60
+
61
+ def ensure_exists(self) -> None:
62
+ with file_lock(self.path):
63
+ if not self.path.exists():
64
+ self._write(_default_document())
65
+
45
66
  def add_root(self, root: Path) -> bool:
46
67
  display_path = root.expanduser()
47
68
  target = canonical_path(display_path)
@@ -107,11 +128,14 @@ class Config:
107
128
  if not self.path.is_file():
108
129
  return _default_document()
109
130
 
110
- text = self.path.read_text(encoding="utf-8")
131
+ try:
132
+ text = self.path.read_text(encoding="utf-8")
133
+ except UnicodeDecodeError as exc:
134
+ raise InvalidConfigError(f"Config is not valid UTF-8: {self.path}") from exc
111
135
  try:
112
136
  return tomlkit.parse(text)
113
137
  except tomlkit.exceptions.ParseError as exc:
114
- raise InvalidConfigError(f"Invalid TOML in {self.path}") from exc
138
+ raise InvalidConfigError(f"Invalid TOML in {self.path}: {exc}") from exc
115
139
 
116
140
  def _write(self, document: TOMLDocument) -> None:
117
141
  with atomic_write(self.path) as stream:
@@ -152,6 +176,7 @@ def _decode_settings(value: object) -> ProjectSettings:
152
176
  "scan.exclude",
153
177
  )
154
178
  )
179
+ editor = _text(config["editor"], "editor") if "editor" in config else None
155
180
 
156
181
  if len({canonical_path(root) for root in roots}) != len(roots):
157
182
  raise InvalidConfigError("Config contains duplicate roots")
@@ -164,6 +189,7 @@ def _decode_settings(value: object) -> ProjectSettings:
164
189
  include_hidden=include_hidden,
165
190
  follow_symlinks=follow_symlinks,
166
191
  excluded_names=excluded_names,
192
+ editor=editor,
167
193
  )
168
194
 
169
195
 
@@ -186,7 +212,7 @@ def _table(value: object, message: str) -> Mapping[str, object]:
186
212
  return value
187
213
 
188
214
 
189
- def _is_table(value: object) -> TypeIs[Mapping[str, object]]:
215
+ def _is_table(value: object) -> TypeGuard[Mapping[str, object]]:
190
216
  return isinstance(value, dict) and all(isinstance(key, str) for key in value)
191
217
 
192
218
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import fcntl
2
4
  import os
3
5
  import tempfile
@@ -1,8 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import time
3
5
  from dataclasses import dataclass
4
6
  from pathlib import Path
5
- from typing import TypedDict, TypeIs
7
+ from typing import TypedDict, TypeGuard
6
8
 
7
9
  from pcd_cli.filesystem import atomic_write, file_lock
8
10
  from pcd_cli.models import ProjectUsage
@@ -29,7 +31,7 @@ class UsageHistory:
29
31
  try:
30
32
  with self.path.open(encoding="utf-8") as stream:
31
33
  raw: object = json.load(stream)
32
- except OSError, UnicodeError, json.JSONDecodeError:
34
+ except (OSError, UnicodeError, json.JSONDecodeError):
33
35
  return {}
34
36
 
35
37
  return _decode_usage(raw)
@@ -61,13 +63,13 @@ def _decode_usage(value: object) -> dict[Path, ProjectUsage]:
61
63
  }
62
64
 
63
65
 
64
- def _is_usage_mapping(value: object) -> TypeIs[dict[str, UsageHistoryEntry]]:
66
+ def _is_usage_mapping(value: object) -> TypeGuard[dict[str, UsageHistoryEntry]]:
65
67
  return isinstance(value, dict) and all(
66
68
  isinstance(path, str) and _is_usage_entry(entry) for path, entry in value.items()
67
69
  )
68
70
 
69
71
 
70
- def _is_usage_entry(value: object) -> TypeIs[UsageHistoryEntry]:
72
+ def _is_usage_entry(value: object) -> TypeGuard[UsageHistoryEntry]:
71
73
  if not isinstance(value, dict):
72
74
  return False
73
75
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from dataclasses import dataclass
2
4
  from enum import IntEnum, StrEnum
3
5
  from typing import TYPE_CHECKING
@@ -31,6 +33,7 @@ class ProjectSettings:
31
33
  include_hidden: bool
32
34
  follow_symlinks: bool
33
35
  excluded_names: frozenset[str]
36
+ editor: str | None = None
34
37
 
35
38
 
36
39
  @dataclass(frozen=True, slots=True)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import TYPE_CHECKING
2
4
 
3
5
  import click
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import sys
2
4
  from dataclasses import dataclass, field
3
5
  from typing import TYPE_CHECKING
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  from dataclasses import dataclass
3
5
  from pathlib import Path
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import TYPE_CHECKING
2
4
 
3
5
  if TYPE_CHECKING:
@@ -133,7 +133,7 @@ def inactive_shell_message() -> str:
133
133
  try:
134
134
  integration = ShellIntegration.detect()
135
135
  state = integration.state()
136
- except OSError, ShellIntegrationError:
136
+ except (OSError, ShellIntegrationError):
137
137
  return (
138
138
  "Shell integration is not active, so pcd cannot change this shell's directory. "
139
139
  "If you have not configured it manually, run: pcd shell install"
@@ -1,341 +0,0 @@
1
- from importlib import metadata
2
- from pathlib import Path
3
- from typing import TYPE_CHECKING
4
-
5
- import click
6
-
7
- from pcd_cli.catalog import ProjectCatalog
8
- from pcd_cli.config import InvalidConfigError
9
- from pcd_cli.filesystem import canonical_path, format_path
10
- from pcd_cli.models import ExitCode, Project, ProjectSource
11
- from pcd_cli.navigation import navigate_to_project, project_completions, select_project
12
- from pcd_cli.shell_integration import (
13
- detect_shell,
14
- render_shell_integration,
15
- Shell,
16
- shell_integration_active,
17
- ShellIntegration,
18
- ShellIntegrationError,
19
- ShellIntegrationState,
20
- )
21
-
22
- if TYPE_CHECKING:
23
- from click.shell_completion import CompletionItem
24
-
25
-
26
- class ProjectCommandGroup(click.Group):
27
- """Route an unknown first argument to project lookup."""
28
-
29
- def resolve_command(
30
- self,
31
- ctx: click.Context,
32
- args: list[str],
33
- ) -> tuple[str | None, click.Command | None, list[str]]:
34
- candidate = args[0] if args else None
35
- if candidate is None or candidate.startswith("-"):
36
- return super().resolve_command(ctx, args)
37
- if self.get_command(ctx, candidate) is not None:
38
- return super().resolve_command(ctx, args)
39
-
40
- project_command = self.get_command(ctx, "project")
41
- if project_command is None:
42
- return super().resolve_command(ctx, args)
43
- return "project", project_command, args
44
-
45
- def shell_complete(self, ctx: click.Context, incomplete: str) -> list[CompletionItem]:
46
- completions = super().shell_complete(ctx, incomplete)
47
- known_values = {item.value for item in completions}
48
-
49
- for completion in project_completions(incomplete):
50
- if completion.value not in known_values:
51
- completions.append(completion)
52
- return completions
53
-
54
-
55
- def package_version() -> str:
56
- try:
57
- return metadata.version("pcd-cli")
58
- except metadata.PackageNotFoundError:
59
- return "0+unknown"
60
-
61
-
62
- @click.group(cls=ProjectCommandGroup, invoke_without_command=True)
63
- @click.option(
64
- "--project",
65
- "project_name",
66
- shell_complete=lambda _ctx, _param, value: project_completions(value),
67
- help="Resolve a project whose name is reserved by a command.",
68
- )
69
- @click.version_option(version=package_version())
70
- @click.pass_context
71
- def cli(ctx: click.Context, project_name: str | None) -> None:
72
- """Jump to local projects by name."""
73
- catalog = ProjectCatalog.create()
74
- ctx.obj = catalog
75
-
76
- if project_name is not None and ctx.invoked_subcommand is not None:
77
- raise click.UsageError("--project cannot be combined with a command")
78
-
79
- if ctx.invoked_subcommand is not None:
80
- return
81
-
82
- if project_name is None:
83
- click.echo(ctx.get_help())
84
- return
85
-
86
- if ctx.invoked_subcommand is None:
87
- navigate_to_project(catalog, project_name, exact_only=project_name is not None)
88
-
89
- navigate_to_project(catalog, project_name, exact_only=True)
90
-
91
-
92
- @cli.command(hidden=True)
93
- @click.argument("query")
94
- @click.pass_obj
95
- def project(catalog: ProjectCatalog, query: str) -> None:
96
- """Resolve a project name routed by the root command."""
97
- navigate_to_project(catalog, query)
98
-
99
-
100
- @cli.command()
101
- @click.pass_obj
102
- def init(catalog: ProjectCatalog) -> None:
103
- """Register the current directory as a scan root."""
104
- current = Path.cwd()
105
- parent = catalog.find_parent_root(current)
106
- added = catalog.add_scan_root(current)
107
-
108
- if not added:
109
- click.echo(f"Already a root: {format_path(current)}")
110
- return
111
- if parent is not None:
112
- click.echo(f"Note: root is inside {format_path(parent)}", err=True)
113
- click.echo(f"Added root: {format_path(current)}")
114
-
115
-
116
- @cli.command()
117
- @click.pass_obj
118
- def uninit(catalog: ProjectCatalog) -> None:
119
- """Remove the current directory from scan roots."""
120
- current = Path.cwd()
121
- if catalog.remove_scan_root(current):
122
- click.echo(f"Removed root: {format_path(current)}")
123
- return
124
-
125
- click.echo("Current directory is not a pcd root.", err=True)
126
- raise click.exceptions.Exit(ExitCode.ERROR)
127
-
128
-
129
- @cli.command()
130
- @click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
131
- @click.option("--name", help="Custom name for a manual project.")
132
- @click.pass_obj
133
- def add(catalog: ProjectCatalog, path: Path | None, name: str | None) -> None:
134
- """Register a directory as a manual project."""
135
- if path is None:
136
- location = Path.cwd()
137
- else:
138
- try:
139
- location = path.expanduser()
140
- except RuntimeError as exc:
141
- raise click.BadParameter("Unknown home directory", param_hint="path") from exc
142
-
143
- if not location.is_absolute():
144
- location = Path.cwd() / location
145
- if not location.is_dir():
146
- raise click.BadParameter(f"Directory does not exist: {location}", param_hint="path")
147
-
148
- project_name = location.name if name is None else name.strip()
149
- if not project_name:
150
- raise click.BadParameter("Project name cannot be empty", param_hint="--name")
151
-
152
- item = Project(
153
- name=project_name,
154
- path=canonical_path(location),
155
- display_path=location,
156
- source=ProjectSource.MANUAL,
157
- )
158
- if catalog.add_project(item):
159
- click.echo(f"Added project: {item.name} -> {format_path(item.display_path)}")
160
- return
161
- click.echo(f"Project already exists: {item.name} -> {format_path(item.display_path)}")
162
-
163
-
164
- @cli.command()
165
- @click.argument(
166
- "query",
167
- shell_complete=lambda _ctx, _param, value: project_completions(value),
168
- )
169
- @click.pass_obj
170
- def remove(catalog: ProjectCatalog, query: str) -> None:
171
- """Remove a manual project registration."""
172
- matches = catalog.search(query, exact_only=True)
173
- if not matches:
174
- click.echo(f"Project not found: {query}", err=True)
175
- raise click.exceptions.Exit(ExitCode.NOT_FOUND)
176
-
177
- selected = matches[0] if len(matches) == 1 else select_project(catalog, matches, query)
178
- if selected is None:
179
- return
180
- if selected.source is ProjectSource.DISCOVERED:
181
- click.echo(
182
- f"{selected.name} is discovered automatically and cannot be removed with pcd remove.",
183
- err=True,
184
- )
185
- raise click.exceptions.Exit(ExitCode.ERROR)
186
- if catalog.remove_project(selected.path):
187
- click.echo(f"Removed project: {selected.name}")
188
- return
189
-
190
- click.echo("Project is no longer registered.", err=True)
191
- raise click.exceptions.Exit(ExitCode.ERROR)
192
-
193
-
194
- @cli.command("list")
195
- @click.pass_obj
196
- def list_projects(catalog: ProjectCatalog) -> None:
197
- """List all known projects."""
198
- projects = catalog.projects()
199
- if not projects:
200
- click.echo("No projects found.")
201
- return
202
-
203
- name_width = max(len(project.name) for project in projects)
204
- for item in projects:
205
- status = "ok" if item.path.is_dir() else "missing"
206
- click.echo(
207
- f"{item.name:<{name_width}} {format_path(item.display_path)} {item.source.value:<10} {status}"
208
- )
209
-
210
-
211
- @cli.command()
212
- @click.pass_obj
213
- def roots(catalog: ProjectCatalog) -> None:
214
- """List registered scan roots."""
215
- for root in catalog.config.load().roots:
216
- click.echo(format_path(root))
217
-
218
-
219
- @cli.command()
220
- @click.pass_obj
221
- def refresh(catalog: ProjectCatalog) -> None:
222
- """Rescan roots and rebuild the project cache."""
223
- click.echo(f"Found {len(catalog.refresh())} projects.")
224
-
225
-
226
- @cli.command("config-path")
227
- @click.pass_obj
228
- def config_path(catalog: ProjectCatalog) -> None:
229
- """Print the path to the user-editable configuration file."""
230
- click.echo(catalog.config.path)
231
-
232
-
233
- @cli.group("shell")
234
- def shell_commands() -> None:
235
- """Install and manage shell integration."""
236
-
237
-
238
- @shell_commands.command("install")
239
- @click.argument(
240
- "shell",
241
- required=False,
242
- type=click.Choice([item.value for item in Shell], case_sensitive=False),
243
- )
244
- def install_shell(shell: str | None) -> None:
245
- """Install persistent shell integration into the shell startup file."""
246
- integration = _shell_integration(shell)
247
- if integration.install():
248
- click.echo(f"Installed {integration.shell.value} integration in {integration.config_path}")
249
- click.echo(f"Restart the shell or run: exec {integration.shell.value}")
250
- return
251
-
252
- state = integration.state()
253
- if state is ShellIntegrationState.MANAGED:
254
- click.echo(f"Shell integration is already installed in {integration.config_path}")
255
- return
256
- click.echo(
257
- f"Shell integration is already configured manually in {integration.config_path}; left unchanged."
258
- )
259
-
260
-
261
- @shell_commands.command("status")
262
- @click.argument(
263
- "shell",
264
- required=False,
265
- type=click.Choice([item.value for item in Shell], case_sensitive=False),
266
- )
267
- def shell_status(shell: str | None) -> None:
268
- """Show whether shell integration is configured and active."""
269
- integration = _shell_integration(shell)
270
- click.echo(f"Shell: {integration.shell.value}")
271
- click.echo(f"Config: {integration.config_path}")
272
- click.echo(f"Configured: {integration.state().value}")
273
- active = shell_integration_active()
274
- click.echo(f"Active in current shell: {'yes' if active else 'no'}")
275
-
276
-
277
- @shell_commands.command("uninstall")
278
- @click.argument(
279
- "shell",
280
- required=False,
281
- type=click.Choice([item.value for item in Shell], case_sensitive=False),
282
- )
283
- def uninstall_shell(shell: str | None) -> None:
284
- """Remove integration installed by `pcd shell install`."""
285
- integration = _shell_integration(shell)
286
- if integration.uninstall():
287
- click.echo(f"Removed shell integration from {integration.config_path}")
288
- return
289
-
290
- state = integration.state()
291
- if state is ShellIntegrationState.MANUAL:
292
- click.echo(f"Integration in {integration.config_path} is managed manually; left unchanged.")
293
- return
294
- click.echo(f"Shell integration is not installed in {integration.config_path}")
295
-
296
-
297
- @shell_commands.command("print")
298
- @click.argument(
299
- "shell",
300
- required=False,
301
- type=click.Choice([item.value for item in Shell], case_sensitive=False),
302
- )
303
- def print_shell_integration(shell: str | None) -> None:
304
- """Print shell integration for manual dotfile management."""
305
- selected = _selected_shell(shell)
306
- click.echo(render_shell_integration(selected), nl=False)
307
-
308
-
309
- @cli.command("shell-init", hidden=True)
310
- @click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False))
311
- def shell_init(shell: str) -> None:
312
- """Backward-compatible alias for `pcd shell print`."""
313
- click.echo(render_shell_integration(Shell(shell.casefold())), nl=False)
314
-
315
-
316
- def _shell_integration(shell: str | None) -> ShellIntegration:
317
- return ShellIntegration.for_shell(_selected_shell(shell))
318
-
319
-
320
- def _selected_shell(shell: str | None) -> Shell:
321
- if shell is not None:
322
- return Shell(shell.casefold())
323
- try:
324
- return detect_shell()
325
- except ShellIntegrationError as exc:
326
- raise click.UsageError(str(exc)) from exc
327
-
328
-
329
- def main() -> None:
330
- """Console-script boundary for application-specific failures."""
331
- try:
332
- cli.main()
333
- except InvalidConfigError as exc:
334
- click.echo(f"Config error: {exc}", err=True)
335
- raise SystemExit(ExitCode.ERROR) from None
336
- except ShellIntegrationError as exc:
337
- click.echo(f"Shell integration error: {exc}", err=True)
338
- raise SystemExit(ExitCode.ERROR) from None
339
- except OSError as exc:
340
- click.echo(f"Filesystem error: {exc}", err=True)
341
- raise SystemExit(ExitCode.ERROR) from None
File without changes
File without changes