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 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