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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.2.1
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 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
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 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
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.2.1"
3
+ version = "0.2.3"
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"
@@ -35,7 +35,7 @@ class ProjectCatalog:
35
35
  )
36
36
 
37
37
  def projects(self) -> list[Project]:
38
- if cached := self.cache.load():
38
+ if (cached := self.cache.load()) is not None:
39
39
  return cached
40
40
 
41
41
  return self.refresh()
@@ -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, 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 (
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.shell import shell_commands, shell_init
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 project_commands:
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
- 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)
@@ -1,10 +1,10 @@
1
1
  import click
2
2
 
3
- from pcd_cli.shell_integration import (
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"Restart the shell or run: exec {integration.shell.value}")
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 shell integration is configured and active."""
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
- active = shell_integration_active()
57
- click.echo(f"Active in current shell: {'yes' if active else 'no'}")
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("print")
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 print_shell_integration(ctx: click.Context, shell: str | None) -> None:
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."""
@@ -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. Restart it or run: exec {integration.shell.value}"
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 print {shell.value})"'
169
+ f'eval "$(command pcd shell init {shell.value})"'
157
170
  if shell is not Shell.FISH
158
- else f"command pcd shell print {shell.value} | source"
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
- legacy = f"pcd shell-init {shell.value}"
183
- current = f"pcd shell print {shell.value}"
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 (env {SHELL_MODE_ENV}=1 command pcd $argv)
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.models import ExitCode, ProjectSource
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
- catalog.record_usage(project)
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