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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.2.0
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
 
@@ -103,10 +105,7 @@ SEE ALSO
103
105
  FEATURES.txt
104
106
 
105
107
  Planned changes:
106
- ROADMAP.md
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 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
 
@@ -79,10 +81,7 @@ SEE ALSO
79
81
  FEATURES.txt
80
82
 
81
83
  Planned changes:
82
- ROADMAP.md
83
-
84
- Changelog:
85
- CHANGELOG.md
84
+ ROADMAP.txt
86
85
 
87
86
  License:
88
87
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.2.0"
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`")
@@ -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
- def print_shell_integration(shell: str | None) -> None:
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
- def shell_init(shell: str) -> None:
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(render_shell_integration(Shell(shell.casefold())), nl=False)
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 = matches[0] if len(matches) == 1 else select_project(catalog, matches, query or "")
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
- # Import lazily so shell completion never initializes terminal UI machinery.
45
- from pcd_cli.picker import ProjectPicker
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
- return ProjectPicker(projects, catalog.history.load(), query).run()
50
+ return ProjectPicker(projects, catalog.history.load(), query).run()
48
51
 
49
52
 
50
53
  def project_completions(value: str) -> list[CompletionItem]:
@@ -139,4 +139,5 @@ class ProjectPicker:
139
139
 
140
140
  if len(self.matches) > PAGE_SIZE:
141
141
  lines.append(("dim", f"\n{self.selected_index + 1}/{len(self.matches)}\n"))
142
+
142
143
  return lines
@@ -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
- return _fish() if shell is Shell.FISH else _posix(shell)
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(shell: Shell) -> str:
204
- completion = "bash_source" if shell is Shell.BASH else "zsh_source"
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