pcd-cli 0.2.1__tar.gz → 0.2.3__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.1 → pcd_cli-0.2.3}/PKG-INFO +5 -2
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/README.txt +4 -1
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/pyproject.toml +1 -1
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/catalog.py +1 -1
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cli/__init__.py +4 -5
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cli/app.py +9 -6
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cli/config.py +2 -2
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cli/projects.py +60 -55
- pcd_cli-0.2.3/src/pcd_cli/cli/roots.py +113 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cli/shell.py +9 -19
- pcd_cli-0.2.3/src/pcd_cli/integrations/__init__.py +1 -0
- pcd_cli-0.2.1/src/pcd_cli/shell_integration.py → pcd_cli-0.2.3/src/pcd_cli/integrations/shell.py +24 -7
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/navigation.py +6 -3
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/LICENSE +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/__init__.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/cache.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/config.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/filesystem.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/history.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/models.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/picker.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/src/pcd_cli/scanner.py +0 -0
- {pcd_cli-0.2.1 → pcd_cli-0.2.3}/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.3
|
|
4
4
|
Summary: Fast project navigator for Bash, Zsh and Fish
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -69,13 +69,14 @@ 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
|
|
76
76
|
pcd config validate Validate the user configuration
|
|
77
77
|
|
|
78
78
|
pcd shell install Install shell integration
|
|
79
|
+
pcd shell init <shell> Print shell integration code
|
|
79
80
|
pcd shell status Show shell integration status
|
|
80
81
|
pcd shell uninstall Remove shell integration
|
|
81
82
|
|
|
@@ -85,6 +86,8 @@ COMMANDS
|
|
|
85
86
|
|
|
86
87
|
* Projects inside registered roots are discovered automatically.
|
|
87
88
|
* You can register multiple scan roots.
|
|
89
|
+
* Filter `pcd list` with `--manual`, `--discovered`, or `--missing`.
|
|
90
|
+
* Use `pcd list --json` for machine-readable output.
|
|
88
91
|
* Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
|
|
89
92
|
|
|
90
93
|
|
|
@@ -45,13 +45,14 @@ 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
|
|
52
52
|
pcd config validate Validate the user configuration
|
|
53
53
|
|
|
54
54
|
pcd shell install Install shell integration
|
|
55
|
+
pcd shell init <shell> Print shell integration code
|
|
55
56
|
pcd shell status Show shell integration status
|
|
56
57
|
pcd shell uninstall Remove shell integration
|
|
57
58
|
|
|
@@ -61,6 +62,8 @@ COMMANDS
|
|
|
61
62
|
|
|
62
63
|
* Projects inside registered roots are discovered automatically.
|
|
63
64
|
* You can register multiple scan roots.
|
|
65
|
+
* Filter `pcd list` with `--manual`, `--discovered`, or `--missing`.
|
|
66
|
+
* Use `pcd list --json` for machine-readable output.
|
|
64
67
|
* Set `editor = "nvim"` in the configuration to choose an editor for `pcd config edit`.
|
|
65
68
|
|
|
66
69
|
|
|
@@ -6,12 +6,12 @@ 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 (
|
|
12
|
+
init_shell,
|
|
11
13
|
install_shell,
|
|
12
|
-
print_shell_integration,
|
|
13
14
|
shell_commands,
|
|
14
|
-
shell_init,
|
|
15
15
|
shell_status,
|
|
16
16
|
uninstall_shell,
|
|
17
17
|
)
|
|
@@ -23,18 +23,17 @@ __all__ = [
|
|
|
23
23
|
"config_commands",
|
|
24
24
|
"edit_config",
|
|
25
25
|
"init",
|
|
26
|
+
"init_shell",
|
|
26
27
|
"install_shell",
|
|
27
28
|
"list_projects",
|
|
28
29
|
"main",
|
|
29
30
|
"package_version",
|
|
30
31
|
"print_config_path",
|
|
31
|
-
"print_shell_integration",
|
|
32
32
|
"project",
|
|
33
33
|
"refresh",
|
|
34
34
|
"remove",
|
|
35
35
|
"roots",
|
|
36
36
|
"shell_commands",
|
|
37
|
-
"shell_init",
|
|
38
37
|
"shell_status",
|
|
39
38
|
"show_config",
|
|
40
39
|
"uninit",
|
|
@@ -8,11 +8,12 @@ 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.
|
|
11
|
+
from pcd_cli.cli.roots import root_commands
|
|
12
|
+
from pcd_cli.cli.shell import shell_commands
|
|
12
13
|
from pcd_cli.config import InvalidConfigError
|
|
14
|
+
from pcd_cli.integrations.shell import ShellIntegrationError
|
|
13
15
|
from pcd_cli.models import ExitCode
|
|
14
16
|
from pcd_cli.navigation import navigate_to_project, project_completions
|
|
15
|
-
from pcd_cli.shell_integration import ShellIntegrationError
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from click.shell_completion import CompletionItem
|
|
@@ -83,11 +84,13 @@ 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
|
+
):
|
|
87
93
|
cli.add_command(command)
|
|
88
|
-
cli.add_command(config_commands)
|
|
89
|
-
cli.add_command(shell_commands)
|
|
90
|
-
cli.add_command(shell_init)
|
|
91
94
|
|
|
92
95
|
|
|
93
96
|
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`")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
from enum import StrEnum
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import assert_never, ClassVar, Literal, NamedTuple, TYPE_CHECKING
|
|
5
7
|
|
|
@@ -15,14 +17,18 @@ if TYPE_CHECKING:
|
|
|
15
17
|
from pcd_cli.catalog import ProjectCatalog
|
|
16
18
|
|
|
17
19
|
type ProjectSourceLabel = Literal["manual", "scanned"]
|
|
18
|
-
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectStatus(StrEnum):
|
|
23
|
+
AVAILABLE = "available"
|
|
24
|
+
MISSING = "missing"
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
class ProjectListRow(NamedTuple):
|
|
22
28
|
name: str
|
|
23
29
|
path: str
|
|
24
30
|
source: ProjectSourceLabel
|
|
25
|
-
status:
|
|
31
|
+
status: ProjectStatus
|
|
26
32
|
|
|
27
33
|
@classmethod
|
|
28
34
|
def from_project(cls, project: Project) -> ProjectListRow:
|
|
@@ -44,11 +50,11 @@ class ProjectListRow(NamedTuple):
|
|
|
44
50
|
assert_never(source)
|
|
45
51
|
|
|
46
52
|
@staticmethod
|
|
47
|
-
def _status_label(path: Path) ->
|
|
53
|
+
def _status_label(path: Path) -> ProjectStatus:
|
|
48
54
|
if path.is_dir():
|
|
49
|
-
return
|
|
55
|
+
return ProjectStatus.AVAILABLE
|
|
50
56
|
else:
|
|
51
|
-
return
|
|
57
|
+
return ProjectStatus.MISSING
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
class ProjectListTable:
|
|
@@ -59,6 +65,12 @@ class ProjectListTable:
|
|
|
59
65
|
def __init__(self, projects: Sequence[Project]) -> None:
|
|
60
66
|
self._rows = tuple(ProjectListRow.from_project(project) for project in projects)
|
|
61
67
|
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_rows(cls, rows: Sequence[ProjectListRow]) -> ProjectListTable:
|
|
70
|
+
table = cls(())
|
|
71
|
+
table._rows = tuple(rows)
|
|
72
|
+
return table
|
|
73
|
+
|
|
62
74
|
def render(self) -> str:
|
|
63
75
|
if self._rows:
|
|
64
76
|
return self._render_table()
|
|
@@ -87,37 +99,6 @@ def project(catalog: ProjectCatalog, query: str) -> None:
|
|
|
87
99
|
navigate_to_project(catalog, query)
|
|
88
100
|
|
|
89
101
|
|
|
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
102
|
@click.command()
|
|
122
103
|
@click.argument("path", type=click.Path(path_type=Path, file_okay=False), required=False)
|
|
123
104
|
@click.option("--name", help="Custom name for a manual project.")
|
|
@@ -187,26 +168,50 @@ def remove(catalog: ProjectCatalog, query: str) -> None:
|
|
|
187
168
|
|
|
188
169
|
|
|
189
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.")
|
|
190
175
|
@click.pass_obj
|
|
191
|
-
def list_projects(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
204
212
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def refresh(catalog: ProjectCatalog) -> None:
|
|
208
|
-
"""Rescan roots and rebuild the project cache."""
|
|
209
|
-
click.echo(f"Found {len(catalog.refresh())} projects.")
|
|
213
|
+
table = ProjectListTable.from_rows(tuple(row for _, row in entries))
|
|
214
|
+
click.echo(table.render())
|
|
210
215
|
|
|
211
216
|
|
|
212
|
-
project_commands = (project,
|
|
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)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
|
-
from pcd_cli.
|
|
3
|
+
from pcd_cli.integrations.shell import (
|
|
4
4
|
detect_shell,
|
|
5
|
+
invoking_shell,
|
|
5
6
|
render_shell_integration,
|
|
6
7
|
Shell,
|
|
7
|
-
shell_integration_active,
|
|
8
8
|
ShellIntegration,
|
|
9
9
|
ShellIntegrationError,
|
|
10
10
|
ShellIntegrationState,
|
|
@@ -27,7 +27,7 @@ def install_shell(shell: str | None) -> None:
|
|
|
27
27
|
integration = _shell_integration(shell)
|
|
28
28
|
if integration.install():
|
|
29
29
|
click.echo(f"Installed {integration.shell.value} integration in {integration.config_path}")
|
|
30
|
-
click.echo(f"
|
|
30
|
+
click.echo(f"Reload the current shell with: {integration.reload_command()}")
|
|
31
31
|
return
|
|
32
32
|
|
|
33
33
|
state = integration.state()
|
|
@@ -47,14 +47,15 @@ def install_shell(shell: str | None) -> None:
|
|
|
47
47
|
type=click.Choice([item.value for item in Shell], case_sensitive=False),
|
|
48
48
|
)
|
|
49
49
|
def shell_status(shell: str | None) -> None:
|
|
50
|
-
"""Show whether
|
|
50
|
+
"""Show shell configuration and whether this invocation used a wrapper."""
|
|
51
51
|
integration = _shell_integration(shell)
|
|
52
52
|
click.echo(f"Shell: {integration.shell.value}")
|
|
53
53
|
click.echo(f"Config: {integration.config_path}")
|
|
54
54
|
click.echo(f"Configured: {integration.state().value}")
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
wrapper: Shell | None = invoking_shell()
|
|
57
|
+
invoked = "no" if wrapper is None else f"yes ({wrapper.value})"
|
|
58
|
+
click.echo(f"Invoked through wrapper: {invoked}")
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
@shell_commands.command("uninstall")
|
|
@@ -78,30 +79,19 @@ def uninstall_shell(shell: str | None) -> None:
|
|
|
78
79
|
click.echo(f"Shell integration is not installed in {integration.config_path}")
|
|
79
80
|
|
|
80
81
|
|
|
81
|
-
@shell_commands.command("
|
|
82
|
+
@shell_commands.command("init")
|
|
82
83
|
@click.argument(
|
|
83
84
|
"shell",
|
|
84
85
|
required=False,
|
|
85
86
|
type=click.Choice([item.value for item in Shell], case_sensitive=False),
|
|
86
87
|
)
|
|
87
88
|
@click.pass_context
|
|
88
|
-
def
|
|
89
|
+
def init_shell(ctx: click.Context, shell: str | None) -> None:
|
|
89
90
|
"""Print shell integration for manual dotfile management."""
|
|
90
91
|
selected = _selected_shell(shell)
|
|
91
92
|
click.echo(render_shell_integration(selected, _registered_command_names(ctx)), nl=False)
|
|
92
93
|
|
|
93
94
|
|
|
94
|
-
@click.command("shell-init", hidden=True)
|
|
95
|
-
@click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False))
|
|
96
|
-
@click.pass_context
|
|
97
|
-
def shell_init(ctx: click.Context, shell: str) -> None:
|
|
98
|
-
"""Backward-compatible alias for `pcd shell print`."""
|
|
99
|
-
click.echo(
|
|
100
|
-
render_shell_integration(Shell(shell.casefold()), _registered_command_names(ctx)),
|
|
101
|
-
nl=False,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
|
|
105
95
|
def _shell_integration(shell: str | None) -> ShellIntegration:
|
|
106
96
|
return ShellIntegration.for_shell(_selected_shell(shell))
|
|
107
97
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""External environment integrations."""
|
pcd_cli-0.2.1/src/pcd_cli/shell_integration.py → pcd_cli-0.2.3/src/pcd_cli/integrations/shell.py
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Generate, install, and inspect shell integration for pcd."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shlex
|
|
4
5
|
import stat
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from enum import StrEnum
|
|
@@ -11,6 +12,7 @@ from typing import Literal, Self
|
|
|
11
12
|
from pcd_cli.filesystem import atomic_write, file_lock
|
|
12
13
|
|
|
13
14
|
SHELL_MODE_ENV = "PCD_SHELL"
|
|
15
|
+
SHELL_WRAPPER_ENV = "PCD_WRAPPER"
|
|
14
16
|
# Internal success status: stdout contains the destination path when this is returned.
|
|
15
17
|
SHELL_CD_EXIT_CODE = 10
|
|
16
18
|
_MANAGED_BLOCK_START = "# >>> pcd shell integration >>>"
|
|
@@ -49,6 +51,9 @@ class ShellIntegration:
|
|
|
49
51
|
def state(self) -> ShellIntegrationState:
|
|
50
52
|
return _integration_state(self._read(), self.shell)
|
|
51
53
|
|
|
54
|
+
def reload_command(self) -> str:
|
|
55
|
+
return f"source {shlex.quote(str(self.config_path))}"
|
|
56
|
+
|
|
52
57
|
def install(self) -> bool:
|
|
53
58
|
"""Install the managed block. Return whether the config changed."""
|
|
54
59
|
with file_lock(self.config_path):
|
|
@@ -128,6 +133,14 @@ def shell_integration_active() -> bool:
|
|
|
128
133
|
return os.environ.get(SHELL_MODE_ENV) == "1"
|
|
129
134
|
|
|
130
135
|
|
|
136
|
+
def invoking_shell() -> Shell | None:
|
|
137
|
+
"""Identify the wrapper for this invocation, not the parent shell's state."""
|
|
138
|
+
try:
|
|
139
|
+
return Shell(os.environ.get(SHELL_WRAPPER_ENV, ""))
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
131
144
|
def inactive_shell_message() -> str:
|
|
132
145
|
"""Explain how to activate directory changes when the wrapper is not running."""
|
|
133
146
|
try:
|
|
@@ -146,16 +159,16 @@ def inactive_shell_message() -> str:
|
|
|
146
159
|
)
|
|
147
160
|
return (
|
|
148
161
|
f"Shell integration is configured in {integration.config_path} but is not active in "
|
|
149
|
-
f"this shell.
|
|
162
|
+
f"this shell. Reload it with: {integration.reload_command()}"
|
|
150
163
|
)
|
|
151
164
|
|
|
152
165
|
|
|
153
166
|
def render_managed_block(shell: Shell) -> str:
|
|
154
167
|
"""Render the small persistent block written into the shell startup file."""
|
|
155
168
|
command = (
|
|
156
|
-
f'eval "$(command pcd shell
|
|
169
|
+
f'eval "$(command pcd shell init {shell.value})"'
|
|
157
170
|
if shell is not Shell.FISH
|
|
158
|
-
else f"command pcd shell
|
|
171
|
+
else f"command pcd shell init {shell.value} | source"
|
|
159
172
|
)
|
|
160
173
|
return f"{_MANAGED_BLOCK_START}\n{command}\n{_MANAGED_BLOCK_END}\n"
|
|
161
174
|
|
|
@@ -179,9 +192,8 @@ def _integration_state(content: str, shell: Shell) -> ShellIntegrationState:
|
|
|
179
192
|
if bounds is not None:
|
|
180
193
|
return ShellIntegrationState.MANAGED
|
|
181
194
|
|
|
182
|
-
|
|
183
|
-
current
|
|
184
|
-
if legacy in content or current in content:
|
|
195
|
+
current = f"pcd shell init {shell.value}"
|
|
196
|
+
if current in content:
|
|
185
197
|
return ShellIntegrationState.MANUAL
|
|
186
198
|
return ShellIntegrationState.ABSENT
|
|
187
199
|
|
|
@@ -214,6 +226,7 @@ def _posix(completion: Literal["bash_source", "zsh_source"], commands: tuple[str
|
|
|
214
226
|
return dedent(
|
|
215
227
|
f"""\
|
|
216
228
|
pcd() {{
|
|
229
|
+
local -x {SHELL_WRAPPER_ENV}={completion.removesuffix("_source")}
|
|
217
230
|
if [ -n "${{_PCD_COMPLETE:-}}" ]; then
|
|
218
231
|
command pcd "$@"
|
|
219
232
|
return $?
|
|
@@ -260,6 +273,7 @@ def _fish(commands: tuple[str, ...]) -> str:
|
|
|
260
273
|
return dedent(
|
|
261
274
|
f"""\
|
|
262
275
|
function pcd
|
|
276
|
+
set -lx {SHELL_WRAPPER_ENV} fish
|
|
263
277
|
if set -q _PCD_COMPLETE
|
|
264
278
|
command pcd $argv
|
|
265
279
|
return $status
|
|
@@ -277,7 +291,10 @@ def _fish(commands: tuple[str, ...]) -> str:
|
|
|
277
291
|
return $status
|
|
278
292
|
end
|
|
279
293
|
|
|
280
|
-
set -l output (
|
|
294
|
+
set -l output (begin
|
|
295
|
+
set -lx {SHELL_MODE_ENV} 1
|
|
296
|
+
command pcd $argv
|
|
297
|
+
end)
|
|
281
298
|
set -l code $status
|
|
282
299
|
if test $code -eq {SHELL_CD_EXIT_CODE}
|
|
283
300
|
cd -- "$output"
|
|
@@ -7,12 +7,12 @@ from click.shell_completion import CompletionItem
|
|
|
7
7
|
|
|
8
8
|
from pcd_cli.catalog import ProjectCatalog
|
|
9
9
|
from pcd_cli.filesystem import format_path
|
|
10
|
-
from pcd_cli.
|
|
11
|
-
from pcd_cli.shell_integration import (
|
|
10
|
+
from pcd_cli.integrations.shell import (
|
|
12
11
|
inactive_shell_message,
|
|
13
12
|
SHELL_CD_EXIT_CODE,
|
|
14
13
|
shell_integration_active,
|
|
15
14
|
)
|
|
15
|
+
from pcd_cli.models import ExitCode, ProjectSource
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from collections.abc import Sequence
|
|
@@ -65,7 +65,10 @@ def project_completions(value: str) -> list[CompletionItem]:
|
|
|
65
65
|
|
|
66
66
|
def _open_project(catalog: ProjectCatalog, project: Project) -> None:
|
|
67
67
|
if project.path.is_dir():
|
|
68
|
-
|
|
68
|
+
try:
|
|
69
|
+
catalog.record_usage(project)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
click.echo(f"Warning: could not write usage history: {exc}", err=True)
|
|
69
72
|
elif project.source is ProjectSource.MANUAL:
|
|
70
73
|
click.echo(
|
|
71
74
|
f"Project path no longer exists: {format_path(project.display_path)}",
|
|
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
|