pcd-cli 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- pcd_cli/cache.py +118 -0
- pcd_cli/catalog.py +157 -0
- pcd_cli/cli.py +341 -0
- pcd_cli/config.py +226 -0
- pcd_cli/filesystem.py +79 -0
- pcd_cli/history.py +95 -0
- pcd_cli/models.py +39 -0
- pcd_cli/navigation.py +80 -0
- pcd_cli/picker.py +140 -0
- pcd_cli/scanner.py +116 -0
- pcd_cli/search.py +108 -0
- pcd_cli/shell_integration.py +264 -0
- pcd_cli-0.1.0.dist-info/METADATA +105 -0
- pcd_cli-0.1.0.dist-info/RECORD +18 -0
- pcd_cli-0.1.0.dist-info/WHEEL +4 -0
- pcd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pcd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pcd_cli/__init__.py
ADDED
|
File without changes
|
pcd_cli/cache.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, TypedDict, TypeIs
|
|
5
|
+
|
|
6
|
+
from pcd_cli.filesystem import atomic_write, file_lock
|
|
7
|
+
from pcd_cli.models import Project, ProjectSource
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _CacheReadError(Exception):
|
|
14
|
+
"""The on-disk cache cannot be read as a valid cache snapshot."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectCacheEntry(TypedDict):
|
|
18
|
+
name: str
|
|
19
|
+
path: str
|
|
20
|
+
display: str
|
|
21
|
+
source: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ProjectCache:
|
|
26
|
+
"""Disposable JSONL index of known projects."""
|
|
27
|
+
|
|
28
|
+
path: Path
|
|
29
|
+
|
|
30
|
+
def load(self) -> list[Project] | None:
|
|
31
|
+
if not self.path.is_file():
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
return [_decode_project(entry) for entry in self._entries()]
|
|
36
|
+
except _CacheReadError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def find_exact(self, query: str) -> list[Project] | None:
|
|
40
|
+
if not self.path.is_file():
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
needle = query.casefold()
|
|
44
|
+
try:
|
|
45
|
+
return [
|
|
46
|
+
_decode_project(entry)
|
|
47
|
+
for entry in self._entries()
|
|
48
|
+
if entry["name"].casefold() == needle
|
|
49
|
+
]
|
|
50
|
+
except _CacheReadError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def find_prefix(self, query: str) -> list[Project] | None:
|
|
54
|
+
if not self.path.is_file():
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
needle = query.casefold()
|
|
58
|
+
try:
|
|
59
|
+
return [
|
|
60
|
+
_decode_project(entry)
|
|
61
|
+
for entry in self._entries()
|
|
62
|
+
if entry["name"].casefold().startswith(needle)
|
|
63
|
+
]
|
|
64
|
+
except _CacheReadError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def save(self, projects: Iterable[Project]) -> None:
|
|
68
|
+
# JSONL keeps refresh streaming and avoids a second in-memory JSON tree.
|
|
69
|
+
with file_lock(self.path), atomic_write(self.path) as stream:
|
|
70
|
+
for project in projects:
|
|
71
|
+
entry: ProjectCacheEntry = {
|
|
72
|
+
"name": project.name,
|
|
73
|
+
"path": str(project.path),
|
|
74
|
+
"display": str(project.display_path),
|
|
75
|
+
"source": project.source.value,
|
|
76
|
+
}
|
|
77
|
+
json.dump(entry, stream, ensure_ascii=False, separators=(",", ":"))
|
|
78
|
+
stream.write("\n")
|
|
79
|
+
|
|
80
|
+
def _entries(self) -> Iterator[ProjectCacheEntry]:
|
|
81
|
+
try:
|
|
82
|
+
with self.path.open(encoding="utf-8") as stream:
|
|
83
|
+
for line in stream:
|
|
84
|
+
try:
|
|
85
|
+
raw: object = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise _CacheReadError("Invalid cache JSON") from exc
|
|
88
|
+
if not _is_cache_entry(raw):
|
|
89
|
+
raise _CacheReadError("Invalid cache entry")
|
|
90
|
+
yield raw
|
|
91
|
+
except (OSError, UnicodeError) as exc:
|
|
92
|
+
raise _CacheReadError("Cannot read cache") from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_cache_entry(value: object) -> TypeIs[ProjectCacheEntry]:
|
|
96
|
+
if not isinstance(value, dict):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
isinstance(value.get("name"), str)
|
|
101
|
+
and isinstance(value.get("path"), str)
|
|
102
|
+
and isinstance(value.get("display"), str)
|
|
103
|
+
and isinstance(value.get("source"), str)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _decode_project(entry: ProjectCacheEntry) -> Project:
|
|
108
|
+
try:
|
|
109
|
+
source = ProjectSource(entry["source"])
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
raise _CacheReadError("Invalid project source") from exc
|
|
112
|
+
|
|
113
|
+
return Project(
|
|
114
|
+
name=entry["name"],
|
|
115
|
+
path=Path(entry["path"]),
|
|
116
|
+
display_path=Path(entry["display"]),
|
|
117
|
+
source=source,
|
|
118
|
+
)
|
pcd_cli/catalog.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Self, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from pcd_cli.cache import ProjectCache
|
|
5
|
+
from pcd_cli.config import Config
|
|
6
|
+
from pcd_cli.filesystem import ApplicationPaths, canonical_path
|
|
7
|
+
from pcd_cli.history import UsageHistory
|
|
8
|
+
from pcd_cli.scanner import ProjectScanner
|
|
9
|
+
from pcd_cli.search import rank_matches
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pcd_cli.models import Project
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ProjectCatalog:
|
|
20
|
+
"""Coordinate project configuration, discovery, cache, and usage history."""
|
|
21
|
+
|
|
22
|
+
config: Config
|
|
23
|
+
cache: ProjectCache
|
|
24
|
+
history: UsageHistory
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create(cls) -> Self:
|
|
28
|
+
paths = ApplicationPaths.resolve()
|
|
29
|
+
return cls(
|
|
30
|
+
config=Config(paths.config),
|
|
31
|
+
cache=ProjectCache(paths.cache),
|
|
32
|
+
history=UsageHistory(paths.history),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def projects(self) -> list[Project]:
|
|
36
|
+
if cached := self.cache.load():
|
|
37
|
+
return cached
|
|
38
|
+
|
|
39
|
+
return self.refresh()
|
|
40
|
+
|
|
41
|
+
def completion_candidates(self, query: str) -> Iterator[Project]:
|
|
42
|
+
matches = self.cache.find_prefix(query)
|
|
43
|
+
if matches is not None:
|
|
44
|
+
yield from matches
|
|
45
|
+
|
|
46
|
+
def search(self, query: str, exact_only: bool = False) -> list[Project]:
|
|
47
|
+
# The common exact-hit path avoids loading the full cache and history.
|
|
48
|
+
exact_matches = self.cache.find_exact(query)
|
|
49
|
+
if exact_matches is None:
|
|
50
|
+
return self._search_projects(self.refresh(), query, exact_only)
|
|
51
|
+
|
|
52
|
+
if exact_matches:
|
|
53
|
+
if len(exact_matches) == 1:
|
|
54
|
+
return exact_matches
|
|
55
|
+
return rank_matches(exact_matches, query, self.history.load())
|
|
56
|
+
|
|
57
|
+
if exact_only:
|
|
58
|
+
return self._search_projects(self.refresh(), query, exact_only=True)
|
|
59
|
+
|
|
60
|
+
cached = self.cache.load()
|
|
61
|
+
if cached is None:
|
|
62
|
+
return self._search_projects(self.refresh(), query, exact_only=False)
|
|
63
|
+
|
|
64
|
+
usage = self.history.load()
|
|
65
|
+
matches = rank_matches(cached, query, usage)
|
|
66
|
+
if matches:
|
|
67
|
+
return matches
|
|
68
|
+
|
|
69
|
+
# A miss can mean a repository appeared after the last refresh.
|
|
70
|
+
return rank_matches(self.refresh(), query, usage)
|
|
71
|
+
|
|
72
|
+
def refresh(self) -> list[Project]:
|
|
73
|
+
settings = self.config.load()
|
|
74
|
+
projects = list(settings.manual_projects)
|
|
75
|
+
known_paths = {project.path for project in projects}
|
|
76
|
+
|
|
77
|
+
for project in ProjectScanner(settings).scan():
|
|
78
|
+
if project.path in known_paths:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
known_paths.add(project.path)
|
|
82
|
+
projects.append(project)
|
|
83
|
+
|
|
84
|
+
projects.sort(key=lambda project: (project.name.casefold(), str(project.path)))
|
|
85
|
+
self.cache.save(projects)
|
|
86
|
+
|
|
87
|
+
return projects
|
|
88
|
+
|
|
89
|
+
def record_usage(self, project: Project) -> None:
|
|
90
|
+
self.history.record(project.path)
|
|
91
|
+
|
|
92
|
+
def add_scan_root(self, root: Path) -> bool:
|
|
93
|
+
if not self.config.add_root(root):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
self.refresh()
|
|
97
|
+
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def remove_scan_root(self, root: Path) -> bool:
|
|
101
|
+
if not self.config.remove_root(root):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
self.refresh()
|
|
105
|
+
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def add_project(self, project: Project) -> bool:
|
|
109
|
+
# An authoritative scan prevents stale cache data from hiding a repository.
|
|
110
|
+
if any(item.path == project.path for item in self.refresh()):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
if not self.config.add_project(project):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Rebuild from committed config instead of extending a potentially stale snapshot.
|
|
117
|
+
self.refresh()
|
|
118
|
+
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def remove_project(self, path: Path) -> bool:
|
|
122
|
+
if not self.config.remove_project(path):
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
self.refresh()
|
|
126
|
+
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def find_parent_root(self, path: Path) -> Path | None:
|
|
130
|
+
target = canonical_path(path)
|
|
131
|
+
closest_root: Path | None = None
|
|
132
|
+
closest_depth = -1
|
|
133
|
+
|
|
134
|
+
for root in self.config.load().roots:
|
|
135
|
+
resolved_root = canonical_path(root)
|
|
136
|
+
if resolved_root == target or not target.is_relative_to(resolved_root):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
root_depth = len(resolved_root.parts)
|
|
140
|
+
if root_depth > closest_depth:
|
|
141
|
+
closest_root = root
|
|
142
|
+
closest_depth = root_depth
|
|
143
|
+
|
|
144
|
+
return closest_root
|
|
145
|
+
|
|
146
|
+
def _search_projects(
|
|
147
|
+
self,
|
|
148
|
+
projects: list[Project],
|
|
149
|
+
query: str,
|
|
150
|
+
exact_only: bool,
|
|
151
|
+
) -> list[Project]:
|
|
152
|
+
needle = query.casefold()
|
|
153
|
+
exact_matches = [project for project in projects if project.name.casefold() == needle]
|
|
154
|
+
if exact_matches or exact_only:
|
|
155
|
+
return exact_matches
|
|
156
|
+
|
|
157
|
+
return rank_matches(projects, query, self.history.load())
|
pcd_cli/cli.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
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
|