pcd-cli 0.2.0__tar.gz → 0.2.2__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.2.0 → pcd_cli-0.2.2}/PKG-INFO +5 -6
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/README.txt +4 -5
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/pyproject.toml +1 -1
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/cli/__init__.py +2 -1
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/cli/app.py +8 -4
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/cli/config.py +2 -2
- pcd_cli-0.2.2/src/pcd_cli/cli/projects.py +217 -0
- pcd_cli-0.2.2/src/pcd_cli/cli/roots.py +113 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/cli/shell.py +17 -4
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/navigation.py +7 -4
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/picker.py +1 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/shell_integration.py +40 -6
- pcd_cli-0.2.0/src/pcd_cli/cli/projects.py +0 -155
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/LICENSE +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/__init__.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/cache.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/catalog.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/config.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/filesystem.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/history.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/models.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/scanner.py +0 -0
- {pcd_cli-0.2.0 → pcd_cli-0.2.2}/src/pcd_cli/search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pcd-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Fast project navigator for Bash, Zsh and Fish
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -69,7 +69,7 @@ COMMANDS
|
|
|
69
69
|
pcd roots List scan roots
|
|
70
70
|
pcd refresh Rescan roots and rebuild the project cache
|
|
71
71
|
|
|
72
|
-
pcd list
|
|
72
|
+
pcd list [options] List and filter known projects
|
|
73
73
|
pcd config path Print the configuration file path
|
|
74
74
|
pcd config show Print the effective configuration
|
|
75
75
|
pcd config edit Edit the user configuration
|
|
@@ -85,6 +85,8 @@ COMMANDS
|
|
|
85
85
|
|
|
86
86
|
* Projects inside registered roots are discovered automatically.
|
|
87
87
|
* You can register multiple scan roots.
|
|
88
|
+
* Filter `pcd list` with `--manual`, `--discovered`, or `--missing`.
|
|
89
|
+
* Use `pcd list --json` for machine-readable output.
|
|
88
90
|
* Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
|
|
89
91
|
|
|
90
92
|
|
|
@@ -103,10 +105,7 @@ SEE ALSO
|
|
|
103
105
|
FEATURES.txt
|
|
104
106
|
|
|
105
107
|
Planned changes:
|
|
106
|
-
ROADMAP.
|
|
107
|
-
|
|
108
|
-
Changelog:
|
|
109
|
-
CHANGELOG.md
|
|
108
|
+
ROADMAP.txt
|
|
110
109
|
|
|
111
110
|
License:
|
|
112
111
|
MIT
|
|
@@ -45,7 +45,7 @@ COMMANDS
|
|
|
45
45
|
pcd roots List scan roots
|
|
46
46
|
pcd refresh Rescan roots and rebuild the project cache
|
|
47
47
|
|
|
48
|
-
pcd list
|
|
48
|
+
pcd list [options] List and filter known projects
|
|
49
49
|
pcd config path Print the configuration file path
|
|
50
50
|
pcd config show Print the effective configuration
|
|
51
51
|
pcd config edit Edit the user configuration
|
|
@@ -61,6 +61,8 @@ COMMANDS
|
|
|
61
61
|
|
|
62
62
|
* Projects inside registered roots are discovered automatically.
|
|
63
63
|
* You can register multiple scan roots.
|
|
64
|
+
* Filter `pcd list` with `--manual`, `--discovered`, or `--missing`.
|
|
65
|
+
* Use `pcd list --json` for machine-readable output.
|
|
64
66
|
* Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
|
|
65
67
|
|
|
66
68
|
|
|
@@ -79,10 +81,7 @@ SEE ALSO
|
|
|
79
81
|
FEATURES.txt
|
|
80
82
|
|
|
81
83
|
Planned changes:
|
|
82
|
-
ROADMAP.
|
|
83
|
-
|
|
84
|
-
Changelog:
|
|
85
|
-
CHANGELOG.md
|
|
84
|
+
ROADMAP.txt
|
|
86
85
|
|
|
87
86
|
License:
|
|
88
87
|
MIT
|
|
@@ -6,7 +6,8 @@ from pcd_cli.cli.config import (
|
|
|
6
6
|
show_config,
|
|
7
7
|
validate_config,
|
|
8
8
|
)
|
|
9
|
-
from pcd_cli.cli.projects import add,
|
|
9
|
+
from pcd_cli.cli.projects import add, list_projects, project, remove
|
|
10
|
+
from pcd_cli.cli.roots import init, refresh, roots, uninit
|
|
10
11
|
from pcd_cli.cli.shell import (
|
|
11
12
|
install_shell,
|
|
12
13
|
print_shell_integration,
|
|
@@ -8,6 +8,7 @@ import click
|
|
|
8
8
|
from pcd_cli.catalog import ProjectCatalog
|
|
9
9
|
from pcd_cli.cli.config import config_commands
|
|
10
10
|
from pcd_cli.cli.projects import project_commands
|
|
11
|
+
from pcd_cli.cli.roots import root_commands
|
|
11
12
|
from pcd_cli.cli.shell import shell_commands, shell_init
|
|
12
13
|
from pcd_cli.config import InvalidConfigError
|
|
13
14
|
from pcd_cli.models import ExitCode
|
|
@@ -83,11 +84,14 @@ def cli(ctx: click.Context, project_name: str | None) -> None:
|
|
|
83
84
|
navigate_to_project(catalog, project_name, exact_only=True)
|
|
84
85
|
|
|
85
86
|
|
|
86
|
-
for command in
|
|
87
|
+
for command in (
|
|
88
|
+
*project_commands,
|
|
89
|
+
*root_commands,
|
|
90
|
+
config_commands,
|
|
91
|
+
shell_commands,
|
|
92
|
+
shell_init,
|
|
93
|
+
):
|
|
87
94
|
cli.add_command(command)
|
|
88
|
-
cli.add_command(config_commands)
|
|
89
|
-
cli.add_command(shell_commands)
|
|
90
|
-
cli.add_command(shell_init)
|
|
91
95
|
|
|
92
96
|
|
|
93
97
|
def main() -> None:
|
|
@@ -40,8 +40,8 @@ def edit_config(catalog: ProjectCatalog) -> None:
|
|
|
40
40
|
editor = catalog.config.load().editor
|
|
41
41
|
except InvalidConfigError:
|
|
42
42
|
editor = None
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
|
|
44
|
+
editor = editor or os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
45
45
|
|
|
46
46
|
if editor is None:
|
|
47
47
|
raise click.UsageError("Set $VISUAL or $EDITOR before running `pcd config edit`")
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import assert_never, ClassVar, Literal, NamedTuple, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from pcd_cli.filesystem import canonical_path, format_path
|
|
11
|
+
from pcd_cli.models import ExitCode, Project, ProjectSource
|
|
12
|
+
from pcd_cli.navigation import navigate_to_project, project_completions, select_project
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from pcd_cli.catalog import ProjectCatalog
|
|
18
|
+
|
|
19
|
+
type ProjectSourceLabel = Literal["manual", "scanned"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectStatus(StrEnum):
|
|
23
|
+
AVAILABLE = "available"
|
|
24
|
+
MISSING = "missing"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProjectListRow(NamedTuple):
|
|
28
|
+
name: str
|
|
29
|
+
path: str
|
|
30
|
+
source: ProjectSourceLabel
|
|
31
|
+
status: ProjectStatus
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_project(cls, project: Project) -> ProjectListRow:
|
|
35
|
+
return cls(
|
|
36
|
+
name=project.name,
|
|
37
|
+
path=format_path(project.display_path),
|
|
38
|
+
source=cls._source_label(project.source),
|
|
39
|
+
status=cls._status_label(project.path),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _source_label(source: ProjectSource) -> ProjectSourceLabel:
|
|
44
|
+
match source:
|
|
45
|
+
case ProjectSource.DISCOVERED:
|
|
46
|
+
return "scanned"
|
|
47
|
+
case ProjectSource.MANUAL:
|
|
48
|
+
return "manual"
|
|
49
|
+
case _:
|
|
50
|
+
assert_never(source)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _status_label(path: Path) -> ProjectStatus:
|
|
54
|
+
if path.is_dir():
|
|
55
|
+
return ProjectStatus.AVAILABLE
|
|
56
|
+
else:
|
|
57
|
+
return ProjectStatus.MISSING
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProjectListTable:
|
|
61
|
+
HEADERS: ClassVar[tuple[str, str, str, str]] = ("NAME", "PATH", "SOURCE", "STATUS")
|
|
62
|
+
|
|
63
|
+
_rows: tuple[ProjectListRow, ...]
|
|
64
|
+
|
|
65
|
+
def __init__(self, projects: Sequence[Project]) -> None:
|
|
66
|
+
self._rows = tuple(ProjectListRow.from_project(project) for project in projects)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_rows(cls, rows: Sequence[ProjectListRow]) -> ProjectListTable:
|
|
70
|
+
table = cls(())
|
|
71
|
+
table._rows = tuple(rows)
|
|
72
|
+
return table
|
|
73
|
+
|
|
74
|
+
def render(self) -> str:
|
|
75
|
+
if self._rows:
|
|
76
|
+
return self._render_table()
|
|
77
|
+
else:
|
|
78
|
+
return "No projects found."
|
|
79
|
+
|
|
80
|
+
def _render_table(self) -> str:
|
|
81
|
+
table = (self.HEADERS, *self._rows)
|
|
82
|
+
columns = zip(*table, strict=True)
|
|
83
|
+
widths = tuple(max(len(value) for value in column) for column in columns)
|
|
84
|
+
|
|
85
|
+
return "\n".join(self._render_row(row, widths) for row in table)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _render_row(row: tuple[str, ...], widths: tuple[int, ...]) -> str:
|
|
89
|
+
return " ".join(
|
|
90
|
+
value.ljust(width) for value, width in zip(row, widths, strict=True)
|
|
91
|
+
).rstrip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.command(hidden=True)
|
|
95
|
+
@click.argument("query")
|
|
96
|
+
@click.pass_obj
|
|
97
|
+
def project(catalog: ProjectCatalog, query: str) -> None:
|
|
98
|
+
"""Resolve a project name routed by the root command."""
|
|
99
|
+
navigate_to_project(catalog, query)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@click.command()
|
|
103
|
+
@click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
|
|
104
|
+
@click.option("--name", help="Custom name for a manual project.")
|
|
105
|
+
@click.pass_obj
|
|
106
|
+
def add(catalog: ProjectCatalog, path: Path | None, name: str | None) -> None:
|
|
107
|
+
"""Register a directory as a manual project."""
|
|
108
|
+
if path is None:
|
|
109
|
+
location = Path.cwd()
|
|
110
|
+
else:
|
|
111
|
+
try:
|
|
112
|
+
location = path.expanduser()
|
|
113
|
+
except RuntimeError as exc:
|
|
114
|
+
raise click.BadParameter("Unknown home directory", param_hint="path") from exc
|
|
115
|
+
|
|
116
|
+
if not location.is_absolute():
|
|
117
|
+
location = Path.cwd() / location
|
|
118
|
+
if not location.is_dir():
|
|
119
|
+
raise click.BadParameter(f"Directory does not exist: {location}", param_hint="path")
|
|
120
|
+
|
|
121
|
+
project_name = location.name if name is None else name.strip()
|
|
122
|
+
if not project_name:
|
|
123
|
+
raise click.BadParameter("Project name cannot be empty", param_hint="--name")
|
|
124
|
+
|
|
125
|
+
item = Project(
|
|
126
|
+
name=project_name,
|
|
127
|
+
path=canonical_path(location),
|
|
128
|
+
display_path=location,
|
|
129
|
+
source=ProjectSource.MANUAL,
|
|
130
|
+
)
|
|
131
|
+
if catalog.add_project(item):
|
|
132
|
+
click.echo(f"Added project: {item.name} -> {format_path(item.display_path)}")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
click.echo(f"Project already exists: {item.name} -> {format_path(item.display_path)}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@click.command()
|
|
139
|
+
@click.argument(
|
|
140
|
+
"query",
|
|
141
|
+
shell_complete=lambda _ctx, _param, value: project_completions(value),
|
|
142
|
+
)
|
|
143
|
+
@click.pass_obj
|
|
144
|
+
def remove(catalog: ProjectCatalog, query: str) -> None:
|
|
145
|
+
"""Remove a manual project registration."""
|
|
146
|
+
matches = catalog.search(query, exact_only=True)
|
|
147
|
+
if not matches:
|
|
148
|
+
click.echo(f"Project not found: {query}", err=True)
|
|
149
|
+
raise click.exceptions.Exit(ExitCode.NOT_FOUND)
|
|
150
|
+
|
|
151
|
+
selected = select_project(catalog, matches, query)
|
|
152
|
+
if selected is None:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if selected.source is ProjectSource.DISCOVERED:
|
|
156
|
+
click.echo(
|
|
157
|
+
f"{selected.name} is discovered automatically and cannot be removed with pcd remove.",
|
|
158
|
+
err=True,
|
|
159
|
+
)
|
|
160
|
+
raise click.exceptions.Exit(ExitCode.ERROR)
|
|
161
|
+
|
|
162
|
+
if catalog.remove_project(selected.path):
|
|
163
|
+
click.echo(f"Removed project: {selected.name}")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
click.echo("Project is no longer registered.", err=True)
|
|
167
|
+
raise click.exceptions.Exit(ExitCode.ERROR)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@click.command("list")
|
|
171
|
+
@click.option("--manual", is_flag=True, help="Show manually registered projects.")
|
|
172
|
+
@click.option("--discovered", is_flag=True, help="Show automatically discovered projects.")
|
|
173
|
+
@click.option("--missing", is_flag=True, help="Show projects whose directories are missing.")
|
|
174
|
+
@click.option("--json", "as_json", is_flag=True, help="Print projects as JSON.")
|
|
175
|
+
@click.pass_obj
|
|
176
|
+
def list_projects(
|
|
177
|
+
catalog: ProjectCatalog,
|
|
178
|
+
manual: bool,
|
|
179
|
+
discovered: bool,
|
|
180
|
+
missing: bool,
|
|
181
|
+
as_json: bool,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""List and filter known projects."""
|
|
184
|
+
entries: list[tuple[Project, ProjectListRow]] = []
|
|
185
|
+
for project in catalog.projects():
|
|
186
|
+
source_matches = (
|
|
187
|
+
not (manual or discovered)
|
|
188
|
+
or (manual and project.source is ProjectSource.MANUAL)
|
|
189
|
+
or (discovered and project.source is ProjectSource.DISCOVERED)
|
|
190
|
+
)
|
|
191
|
+
if not source_matches:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
row = ProjectListRow.from_project(project)
|
|
195
|
+
if missing and row.status is not ProjectStatus.MISSING:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
entries.append((project, row))
|
|
199
|
+
|
|
200
|
+
if as_json:
|
|
201
|
+
items = [
|
|
202
|
+
{
|
|
203
|
+
"name": project.name,
|
|
204
|
+
"path": str(project.display_path),
|
|
205
|
+
"source": project.source.value,
|
|
206
|
+
"status": row.status,
|
|
207
|
+
}
|
|
208
|
+
for project, row in entries
|
|
209
|
+
]
|
|
210
|
+
click.echo(json.dumps(items, indent=2))
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
table = ProjectListTable.from_rows(tuple(row for _, row in entries))
|
|
214
|
+
click.echo(table.render())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
project_commands = (project, add, remove, list_projects)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import 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, ProjectSource
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from pcd_cli.catalog import ProjectCatalog
|
|
15
|
+
from pcd_cli.models import Project
|
|
16
|
+
|
|
17
|
+
type RootStatusLabel = Literal["available", "missing"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RootListRow(NamedTuple):
|
|
21
|
+
path: str
|
|
22
|
+
projects: str
|
|
23
|
+
status: RootStatusLabel
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_root(cls, root: Path, projects: Sequence[Project]) -> RootListRow:
|
|
27
|
+
resolved_root = canonical_path(root)
|
|
28
|
+
project_count = sum(
|
|
29
|
+
project.path.is_relative_to(resolved_root)
|
|
30
|
+
for project in projects
|
|
31
|
+
if project.source is ProjectSource.DISCOVERED
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return cls(
|
|
35
|
+
path=format_path(root),
|
|
36
|
+
projects=str(project_count),
|
|
37
|
+
status="available" if root.is_dir() else "missing",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RootListTable:
|
|
42
|
+
HEADERS: ClassVar[tuple[str, str, str]] = ("PATH", "PROJECTS", "STATUS")
|
|
43
|
+
|
|
44
|
+
_rows: tuple[RootListRow, ...]
|
|
45
|
+
|
|
46
|
+
def __init__(self, roots: Sequence[Path], projects: Sequence[Project]) -> None:
|
|
47
|
+
self._rows = tuple(RootListRow.from_root(root, projects) for root in roots)
|
|
48
|
+
|
|
49
|
+
def render(self) -> str:
|
|
50
|
+
if not self._rows:
|
|
51
|
+
return "No roots found."
|
|
52
|
+
|
|
53
|
+
table = (self.HEADERS, *self._rows)
|
|
54
|
+
columns = zip(*table, strict=True)
|
|
55
|
+
widths = tuple(max(len(value) for value in column) for column in columns)
|
|
56
|
+
|
|
57
|
+
return "\n".join(self._render_row(row, widths) for row in table)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _render_row(row: tuple[str, ...], widths: tuple[int, ...]) -> str:
|
|
61
|
+
return " ".join(
|
|
62
|
+
value.ljust(width) for value, width in zip(row, widths, strict=True)
|
|
63
|
+
).rstrip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command()
|
|
67
|
+
@click.pass_obj
|
|
68
|
+
def init(catalog: ProjectCatalog) -> None:
|
|
69
|
+
"""Register the current directory as a scan root."""
|
|
70
|
+
current = Path.cwd()
|
|
71
|
+
parent = catalog.find_parent_root(current)
|
|
72
|
+
added = catalog.add_scan_root(current)
|
|
73
|
+
|
|
74
|
+
if not added:
|
|
75
|
+
click.echo(f"Already a root: {format_path(current)}")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if parent is not None:
|
|
79
|
+
click.echo(f"Note: root is inside {format_path(parent)}", err=True)
|
|
80
|
+
|
|
81
|
+
click.echo(f"Added root: {format_path(current)}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@click.command()
|
|
85
|
+
@click.pass_obj
|
|
86
|
+
def uninit(catalog: ProjectCatalog) -> None:
|
|
87
|
+
"""Remove the current directory from scan roots."""
|
|
88
|
+
current = Path.cwd()
|
|
89
|
+
if catalog.remove_scan_root(current):
|
|
90
|
+
click.echo(f"Removed root: {format_path(current)}")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
click.echo("Current directory is not a pcd root.", err=True)
|
|
94
|
+
raise click.exceptions.Exit(ExitCode.ERROR)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.command()
|
|
98
|
+
@click.pass_obj
|
|
99
|
+
def roots(catalog: ProjectCatalog) -> None:
|
|
100
|
+
"""List registered scan roots with project counts and status."""
|
|
101
|
+
settings = catalog.config.load()
|
|
102
|
+
table = RootListTable(settings.roots, catalog.projects())
|
|
103
|
+
click.echo(table.render())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@click.command()
|
|
107
|
+
@click.pass_obj
|
|
108
|
+
def refresh(catalog: ProjectCatalog) -> None:
|
|
109
|
+
"""Rescan roots and rebuild the project cache."""
|
|
110
|
+
click.echo(f"Found {len(catalog.refresh())} projects.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
root_commands = (init, uninit, roots, refresh)
|
|
@@ -84,17 +84,22 @@ def uninstall_shell(shell: str | None) -> None:
|
|
|
84
84
|
required=False,
|
|
85
85
|
type=click.Choice([item.value for item in Shell], case_sensitive=False),
|
|
86
86
|
)
|
|
87
|
-
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def print_shell_integration(ctx: click.Context, shell: str | None) -> None:
|
|
88
89
|
"""Print shell integration for manual dotfile management."""
|
|
89
90
|
selected = _selected_shell(shell)
|
|
90
|
-
click.echo(render_shell_integration(selected), nl=False)
|
|
91
|
+
click.echo(render_shell_integration(selected, _registered_command_names(ctx)), nl=False)
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
@click.command("shell-init", hidden=True)
|
|
94
95
|
@click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False))
|
|
95
|
-
|
|
96
|
+
@click.pass_context
|
|
97
|
+
def shell_init(ctx: click.Context, shell: str) -> None:
|
|
96
98
|
"""Backward-compatible alias for `pcd shell print`."""
|
|
97
|
-
click.echo(
|
|
99
|
+
click.echo(
|
|
100
|
+
render_shell_integration(Shell(shell.casefold()), _registered_command_names(ctx)),
|
|
101
|
+
nl=False,
|
|
102
|
+
)
|
|
98
103
|
|
|
99
104
|
|
|
100
105
|
def _shell_integration(shell: str | None) -> ShellIntegration:
|
|
@@ -109,3 +114,11 @@ def _selected_shell(shell: str | None) -> Shell:
|
|
|
109
114
|
return detect_shell()
|
|
110
115
|
except ShellIntegrationError as exc:
|
|
111
116
|
raise click.UsageError(str(exc)) from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _registered_command_names(ctx: click.Context) -> tuple[str, ...]:
|
|
120
|
+
root = ctx.find_root()
|
|
121
|
+
if not isinstance(root.command, click.Group):
|
|
122
|
+
return ()
|
|
123
|
+
|
|
124
|
+
return tuple(root.command.list_commands(root))
|
|
@@ -31,7 +31,7 @@ def navigate_to_project(
|
|
|
31
31
|
click.echo(f"Project not found{suffix}", err=True)
|
|
32
32
|
raise click.exceptions.Exit(ExitCode.NOT_FOUND)
|
|
33
33
|
|
|
34
|
-
selected =
|
|
34
|
+
selected = select_project(catalog, matches, query or "")
|
|
35
35
|
if selected is not None:
|
|
36
36
|
_open_project(catalog, selected)
|
|
37
37
|
|
|
@@ -41,10 +41,13 @@ def select_project(
|
|
|
41
41
|
projects: Sequence[Project],
|
|
42
42
|
query: str = "",
|
|
43
43
|
) -> Project | None:
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
if len(projects) == 1:
|
|
45
|
+
return projects[0]
|
|
46
|
+
else:
|
|
47
|
+
# Import lazily so shell completion never initializes terminal UI machinery.
|
|
48
|
+
from pcd_cli.picker import ProjectPicker
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
return ProjectPicker(projects, catalog.history.load(), query).run()
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def project_completions(value: str) -> list[CompletionItem]:
|
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from enum import StrEnum
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from textwrap import dedent
|
|
9
|
-
from typing import Self
|
|
9
|
+
from typing import Literal, Self
|
|
10
10
|
|
|
11
11
|
from pcd_cli.filesystem import atomic_write, file_lock
|
|
12
12
|
|
|
@@ -160,9 +160,18 @@ def render_managed_block(shell: Shell) -> str:
|
|
|
160
160
|
return f"{_MANAGED_BLOCK_START}\n{command}\n{_MANAGED_BLOCK_END}\n"
|
|
161
161
|
|
|
162
162
|
|
|
163
|
-
def render_shell_integration(shell: Shell) -> str:
|
|
163
|
+
def render_shell_integration(shell: Shell, direct_commands: tuple[str, ...] = ()) -> str:
|
|
164
164
|
"""Generate shell integration for directory changes and Click completion."""
|
|
165
|
-
|
|
165
|
+
commands = tuple(sorted(set(direct_commands)))
|
|
166
|
+
match shell:
|
|
167
|
+
case Shell.FISH:
|
|
168
|
+
return _fish(commands)
|
|
169
|
+
case Shell.BASH:
|
|
170
|
+
return _posix("bash_source", commands)
|
|
171
|
+
case Shell.ZSH:
|
|
172
|
+
return _posix("zsh_source", commands)
|
|
173
|
+
case _:
|
|
174
|
+
raise ValueError(f"Unsupported shell: {shell}")
|
|
166
175
|
|
|
167
176
|
|
|
168
177
|
def _integration_state(content: str, shell: Shell) -> ShellIntegrationState:
|
|
@@ -200,8 +209,8 @@ def _managed_block_bounds(content: str) -> tuple[int, int] | None:
|
|
|
200
209
|
return start, end
|
|
201
210
|
|
|
202
211
|
|
|
203
|
-
def _posix(
|
|
204
|
-
|
|
212
|
+
def _posix(completion: Literal["bash_source", "zsh_source"], commands: tuple[str, ...]) -> str:
|
|
213
|
+
direct_patterns = "|".join(("-*", *commands))
|
|
205
214
|
return dedent(
|
|
206
215
|
f"""\
|
|
207
216
|
pcd() {{
|
|
@@ -210,6 +219,18 @@ def _posix(shell: Shell) -> str:
|
|
|
210
219
|
return $?
|
|
211
220
|
fi
|
|
212
221
|
|
|
222
|
+
if [ "$#" -eq 0 ]; then
|
|
223
|
+
command pcd
|
|
224
|
+
return $?
|
|
225
|
+
fi
|
|
226
|
+
case "$1" in
|
|
227
|
+
--project|--project=*) : ;;
|
|
228
|
+
{direct_patterns})
|
|
229
|
+
command pcd "$@"
|
|
230
|
+
return $?
|
|
231
|
+
;;
|
|
232
|
+
esac
|
|
233
|
+
|
|
213
234
|
local output code
|
|
214
235
|
if output="$({SHELL_MODE_ENV}=1 command pcd "$@")"; then
|
|
215
236
|
code=0
|
|
@@ -234,7 +255,8 @@ def _posix(shell: Shell) -> str:
|
|
|
234
255
|
)
|
|
235
256
|
|
|
236
257
|
|
|
237
|
-
def _fish() -> str:
|
|
258
|
+
def _fish(commands: tuple[str, ...]) -> str:
|
|
259
|
+
direct_patterns = " ".join(("'-*'", *commands))
|
|
238
260
|
return dedent(
|
|
239
261
|
f"""\
|
|
240
262
|
function pcd
|
|
@@ -243,6 +265,18 @@ def _fish() -> str:
|
|
|
243
265
|
return $status
|
|
244
266
|
end
|
|
245
267
|
|
|
268
|
+
if test (count $argv) -eq 0
|
|
269
|
+
command pcd
|
|
270
|
+
return $status
|
|
271
|
+
end
|
|
272
|
+
switch $argv[1]
|
|
273
|
+
case '--project' '--project=*'
|
|
274
|
+
true
|
|
275
|
+
case {direct_patterns}
|
|
276
|
+
command pcd $argv
|
|
277
|
+
return $status
|
|
278
|
+
end
|
|
279
|
+
|
|
246
280
|
set -l output (env {SHELL_MODE_ENV}=1 command pcd $argv)
|
|
247
281
|
set -l code $status
|
|
248
282
|
if test $code -eq {SHELL_CD_EXIT_CODE}
|
|
@@ -1,155 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|