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.
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/PKG-INFO +14 -6
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/README.txt +8 -2
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/pyproject.toml +10 -8
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/cache.py +4 -2
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/catalog.py +2 -0
- pcd_cli-0.2.0/src/pcd_cli/cli/__init__.py +43 -0
- pcd_cli-0.2.0/src/pcd_cli/cli/app.py +104 -0
- pcd_cli-0.2.0/src/pcd_cli/cli/config.py +72 -0
- pcd_cli-0.2.0/src/pcd_cli/cli/projects.py +155 -0
- pcd_cli-0.2.0/src/pcd_cli/cli/shell.py +111 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/config.py +31 -5
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/filesystem.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/history.py +6 -4
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/models.py +3 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/navigation.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/picker.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/scanner.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/search.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/shell_integration.py +1 -1
- pcd_cli-0.1.0/src/pcd_cli/cli.py +0 -341
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/LICENSE +0 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.0}/src/pcd_cli/__init__.py +0 -0
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pcd-cli
|
|
3
|
-
Version: 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.
|
|
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.
|
|
18
|
-
Requires-Dist: platformdirs (>=4.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
22
|
-
"platformdirs>=4.
|
|
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.
|
|
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.
|
|
44
|
+
ruff = "0.16.5"
|
|
43
45
|
|
|
44
46
|
[tool.ruff]
|
|
45
|
-
target-version = "
|
|
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.
|
|
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,
|
|
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) ->
|
|
97
|
+
def _is_cache_entry(value: object) -> TypeGuard[ProjectCacheEntry]:
|
|
96
98
|
if not isinstance(value, dict):
|
|
97
99
|
return False
|
|
98
100
|
|
|
@@ -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,
|
|
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
|
-
|
|
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) ->
|
|
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,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,
|
|
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) ->
|
|
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) ->
|
|
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)
|
|
@@ -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"
|
pcd_cli-0.1.0/src/pcd_cli/cli.py
DELETED
|
@@ -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
|