pcd-cli 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.2.1
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 List all known projects
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
 
@@ -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 List all known projects
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Fast project navigator for Bash, Zsh and Fish"
5
5
  readme = { file = "README.txt", content-type = "text/plain" }
6
6
  license = "MIT"
@@ -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, init, list_projects, project, refresh, remove, roots, uninit
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 project_commands:
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
- else:
44
- editor = editor or os.environ.get("VISUAL") or os.environ.get("EDITOR")
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
- type ProjectStatusLabel = Literal["available", "missing"]
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: ProjectStatusLabel
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) -> ProjectStatusLabel:
53
+ def _status_label(path: Path) -> ProjectStatus:
48
54
  if path.is_dir():
49
- return "available"
55
+ return ProjectStatus.AVAILABLE
50
56
  else:
51
- return "missing"
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(catalog: ProjectCatalog) -> None:
192
- """List all known projects."""
193
- table = ProjectListTable(catalog.projects())
194
- click.echo(table.render())
195
-
196
-
197
- @click.command()
198
- @click.pass_obj
199
- def roots(catalog: ProjectCatalog) -> None:
200
- """List registered scan roots."""
201
- for root in catalog.config.load().roots:
202
- click.echo(format_path(root))
203
-
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
- @click.command()
206
- @click.pass_obj
207
- def refresh(catalog: ProjectCatalog) -> None:
208
- """Rescan roots and rebuild the project cache."""
209
- click.echo(f"Found {len(catalog.refresh())} projects.")
213
+ table = ProjectListTable.from_rows(tuple(row for _, row in entries))
214
+ click.echo(table.render())
210
215
 
211
216
 
212
- project_commands = (project, init, uninit, add, remove, list_projects, roots, refresh)
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)
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