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.
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/PKG-INFO +15 -10
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/README.txt +9 -6
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/pyproject.toml +10 -8
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/cache.py +4 -2
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/catalog.py +2 -0
- pcd_cli-0.2.1/src/pcd_cli/cli/__init__.py +43 -0
- pcd_cli-0.2.1/src/pcd_cli/cli/app.py +104 -0
- pcd_cli-0.2.1/src/pcd_cli/cli/config.py +72 -0
- pcd_cli-0.2.1/src/pcd_cli/cli/projects.py +212 -0
- pcd_cli-0.2.1/src/pcd_cli/cli/shell.py +124 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/config.py +31 -5
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/filesystem.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/history.py +6 -4
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/models.py +3 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/navigation.py +9 -4
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/picker.py +3 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/scanner.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/search.py +2 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/src/pcd_cli/shell_integration.py +41 -7
- pcd_cli-0.1.0/src/pcd_cli/cli.py +0 -341
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/LICENSE +0 -0
- {pcd_cli-0.1.0 → pcd_cli-0.2.1}/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.1
|
|
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.
|
|
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
|
|
@@ -95,10 +103,7 @@ SEE ALSO
|
|
|
95
103
|
FEATURES.txt
|
|
96
104
|
|
|
97
105
|
Planned changes:
|
|
98
|
-
ROADMAP.
|
|
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
|
-
|
|
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
|
|
@@ -73,10 +79,7 @@ SEE ALSO
|
|
|
73
79
|
FEATURES.txt
|
|
74
80
|
|
|
75
81
|
Planned changes:
|
|
76
|
-
ROADMAP.
|
|
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
|
|
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.
|
|
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,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)
|