ccmd 2.0.0__tar.gz → 2.0.1__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.
Files changed (54) hide show
  1. {ccmd-2.0.0/ccmd.egg-info → ccmd-2.0.1}/PKG-INFO +1 -1
  2. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/__init__.py +1 -1
  3. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/cli.py +18 -4
  4. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/schema.py +1 -1
  5. ccmd-2.0.1/ccmd/core/fs.py +141 -0
  6. ccmd-2.0.1/ccmd/tui/screens/browser.py +340 -0
  7. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/hub.py +11 -1
  8. {ccmd-2.0.0 → ccmd-2.0.1/ccmd.egg-info}/PKG-INFO +1 -1
  9. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/SOURCES.txt +2 -0
  10. {ccmd-2.0.0 → ccmd-2.0.1}/pyproject.toml +1 -1
  11. {ccmd-2.0.0 → ccmd-2.0.1}/LICENSE +0 -0
  12. {ccmd-2.0.0 → ccmd-2.0.1}/MANIFEST.in +0 -0
  13. {ccmd-2.0.0 → ccmd-2.0.1}/README.md +0 -0
  14. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/__main__.py +0 -0
  15. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/__init__.py +0 -0
  16. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/config/paths.py +0 -0
  17. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/__init__.py +0 -0
  18. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/__init__.py +0 -0
  19. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/aider.py +0 -0
  20. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/base.py +0 -0
  21. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/chatgpt.py +0 -0
  22. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/claude.py +0 -0
  23. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/codex.py +0 -0
  24. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/cursor.py +0 -0
  25. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/goose.py +0 -0
  26. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/agents/grok.py +0 -0
  27. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/launcher.py +0 -0
  28. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/migrate.py +0 -0
  29. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/profiles.py +0 -0
  30. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/projects.py +0 -0
  31. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/scan.py +0 -0
  32. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/sessions.py +0 -0
  33. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/core/ssh.py +0 -0
  34. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/mcp/__init__.py +0 -0
  35. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/mcp/server.py +0 -0
  36. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/__init__.py +0 -0
  37. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/app.py +0 -0
  38. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/ascii.py +0 -0
  39. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/__init__.py +0 -0
  40. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/agents.py +0 -0
  41. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/aliases.py +0 -0
  42. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/project_edit.py +0 -0
  43. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/sessions.py +0 -0
  44. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/screens/ssh.py +0 -0
  45. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/theme.py +0 -0
  46. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/widgets/__init__.py +0 -0
  47. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd/tui/widgets/spinner.py +0 -0
  48. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/dependency_links.txt +0 -0
  49. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/entry_points.txt +0 -0
  50. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/requires.txt +0 -0
  51. {ccmd-2.0.0 → ccmd-2.0.1}/ccmd.egg-info/top_level.txt +0 -0
  52. {ccmd-2.0.0 → ccmd-2.0.1}/commands.yaml +0 -0
  53. {ccmd-2.0.0 → ccmd-2.0.1}/requirements.txt +0 -0
  54. {ccmd-2.0.0 → ccmd-2.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: ccmd — Agentic Project Hub. Pick a project, pick an agent, launch.
5
5
  Author-email: De Catalyst <Robert5560newton@gmail.com>
6
6
  License: MIT
@@ -1,4 +1,4 @@
1
1
  """ccmd 2.0 — Agentic Project Hub."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.0.1"
4
4
  __all__ = ["__version__"]
@@ -6,7 +6,7 @@ import argparse
6
6
  import json
7
7
  import shutil
8
8
  import sys
9
- from pathlib import Path
9
+ from pathlib import Path # noqa: F401 — used by project browse/add
10
10
 
11
11
  from ccmd import __version__
12
12
  from ccmd.config.paths import ensure_dirs, home
@@ -118,7 +118,21 @@ def _cmd_project(args: argparse.Namespace) -> int:
118
118
  print(f"{r['name']:<24} {r['path']} ({', '.join(r['markers'])})")
119
119
  print(f"{len(rows)} project-like dirs" + (" (registered new)" if args.register else ""))
120
120
  return 0
121
- print("usage: ccmd project add|rm|scan", file=sys.stderr)
121
+ if args.project_cmd == "browse":
122
+ from ccmd.tui.app import CcmdApp
123
+ from ccmd.tui.screens.browser import DirBrowserScreen
124
+
125
+ class _BrowseApp(CcmdApp):
126
+ def on_mount(self) -> None:
127
+ def _done(ok: bool | None) -> None:
128
+ self.exit(0 if ok else 1)
129
+
130
+ start = Path(args.path).expanduser() if args.path else None
131
+ self.push_screen(DirBrowserScreen(start=start), _done)
132
+
133
+ _BrowseApp().run()
134
+ return 0
135
+ print("usage: ccmd project add|rm|scan|browse", file=sys.stderr)
122
136
  return 1
123
137
 
124
138
 
@@ -214,9 +228,9 @@ def build_parser() -> argparse.ArgumentParser:
214
228
  mig.add_argument("--apply", action="store_true")
215
229
 
216
230
  proj = sub.add_parser("project", help="Manage projects")
217
- proj.add_argument("project_cmd", choices=["add", "rm", "scan"])
231
+ proj.add_argument("project_cmd", choices=["add", "rm", "scan", "browse"])
218
232
  proj.add_argument("--name")
219
- proj.add_argument("--path")
233
+ proj.add_argument("--path", help="browse start path, or add path")
220
234
  proj.add_argument("--agent")
221
235
  proj.add_argument("--register", action="store_true", help="scan: add found projects")
222
236
 
@@ -20,7 +20,7 @@ class UIConfig:
20
20
  @dataclass
21
21
  class Config:
22
22
  scan_roots: list[str] = field(
23
- default_factory=lambda: ["~/projects", "~/code", "/mnt/vylth"]
23
+ default_factory=lambda: ["~/projects", "~/code", "~/dev", "/mnt"]
24
24
  )
25
25
  scan_depth: int = 3
26
26
  default_agent: str = "claude"
@@ -0,0 +1,141 @@
1
+ """Filesystem helpers for browse / scan roots (no machine-specific hardcoding)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ccmd.config.schema import load_config
8
+
9
+ # Never show these as browsable children (noise / huge trees)
10
+ SKIP_DIR_NAMES = frozenset(
11
+ {
12
+ ".git",
13
+ ".svn",
14
+ ".hg",
15
+ "node_modules",
16
+ "__pycache__",
17
+ ".venv",
18
+ "venv",
19
+ "dist",
20
+ "build",
21
+ "target",
22
+ ".tox",
23
+ ".mypy_cache",
24
+ ".pytest_cache",
25
+ ".ruff_cache",
26
+ "site-packages",
27
+ }
28
+ )
29
+
30
+
31
+ def default_browse_roots() -> list[Path]:
32
+ """Portable roots: home, config scan_roots, and any existing /mnt/* tops."""
33
+ roots: list[Path] = []
34
+ seen: set[str] = set()
35
+
36
+ def add(p: Path) -> None:
37
+ try:
38
+ r = p.expanduser().resolve()
39
+ except OSError:
40
+ return
41
+ if not r.is_dir():
42
+ return
43
+ key = str(r)
44
+ if key in seen:
45
+ return
46
+ seen.add(key)
47
+ roots.append(r)
48
+
49
+ add(Path.home())
50
+ add(Path.home() / "projects")
51
+ add(Path.home() / "code")
52
+ add(Path.home() / "dev")
53
+ add(Path.home() / "src")
54
+ add(Path.cwd())
55
+
56
+ try:
57
+ cfg = load_config()
58
+ for s in cfg.scan_roots:
59
+ add(Path(s))
60
+ except Exception:
61
+ pass
62
+
63
+ # Any mount under /mnt (WSL drives, shared volumes) — not just one folder
64
+ mnt = Path("/mnt")
65
+ if mnt.is_dir():
66
+ add(mnt)
67
+ try:
68
+ for child in sorted(mnt.iterdir()):
69
+ if child.is_dir() and not child.name.startswith("."):
70
+ add(child)
71
+ except OSError:
72
+ pass
73
+
74
+ # Common Unix work locations
75
+ for p in (Path("/opt"), Path("/srv"), Path("/var/www")):
76
+ add(p)
77
+
78
+ return roots
79
+
80
+
81
+ def list_dir_entries(
82
+ path: Path,
83
+ *,
84
+ show_hidden: bool = False,
85
+ dirs_only: bool = True,
86
+ ) -> list[tuple[str, Path, bool]]:
87
+ """Return sorted (label, path, is_dir) for children of path."""
88
+ path = path.expanduser()
89
+ try:
90
+ path = path.resolve()
91
+ except OSError:
92
+ return []
93
+
94
+ entries: list[tuple[str, Path, bool]] = []
95
+ try:
96
+ children = list(path.iterdir())
97
+ except OSError:
98
+ return []
99
+
100
+ for child in children:
101
+ name = child.name
102
+ if not show_hidden and name.startswith("."):
103
+ continue
104
+ if name in SKIP_DIR_NAMES:
105
+ continue
106
+ try:
107
+ is_dir = child.is_dir()
108
+ except OSError:
109
+ continue
110
+ if dirs_only and not is_dir:
111
+ continue
112
+ # skip symlinks that loop / broken
113
+ if child.is_symlink():
114
+ try:
115
+ if not child.resolve().exists():
116
+ continue
117
+ except OSError:
118
+ continue
119
+ entries.append((name, child, is_dir))
120
+
121
+ entries.sort(key=lambda t: (not t[2], t[0].lower()))
122
+ return entries
123
+
124
+
125
+ def is_projectish(path: Path) -> bool:
126
+ markers = {
127
+ ".git",
128
+ "package.json",
129
+ "pyproject.toml",
130
+ "Cargo.toml",
131
+ "go.mod",
132
+ "CLAUDE.md",
133
+ "AGENTS.md",
134
+ "Makefile",
135
+ "composer.json",
136
+ }
137
+ try:
138
+ names = {p.name for p in path.iterdir()}
139
+ except OSError:
140
+ return False
141
+ return bool(names & markers)
@@ -0,0 +1,340 @@
1
+ """Interactive directory browser — pick any folder as a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual import on
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical
11
+ from textual.screen import Screen
12
+ from textual.widgets import Footer, Input, OptionList, Static
13
+ from textual.widgets.option_list import Option
14
+
15
+ from ccmd.config.schema import load_config
16
+ from ccmd.core.fs import default_browse_roots, is_projectish, list_dir_entries
17
+ from ccmd.core.projects import ProjectRegistry
18
+
19
+
20
+ class DirBrowserScreen(Screen[bool | None]):
21
+ """Browse the filesystem; select a directory to register as a project.
22
+
23
+ Keys:
24
+ enter open directory / enter root
25
+ backspace go up
26
+ s / a select highlighted (or current) dir as project
27
+ r jump to roots list
28
+ . toggle hidden
29
+ / focus filter
30
+ esc cancel
31
+ """
32
+
33
+ BINDINGS = [
34
+ Binding("escape", "cancel", "Back", show=True),
35
+ Binding("enter", "open_or_select", "Open", show=True),
36
+ Binding("backspace", "up", "Up", show=True),
37
+ Binding("s", "select", "Select", show=True),
38
+ Binding("a", "select", "Add", show=False),
39
+ Binding("r", "roots", "Roots", show=True),
40
+ Binding("period", "toggle_hidden", "Hidden", show=True),
41
+ Binding("slash", "focus_filter", "Filter", show=False),
42
+ Binding("/", "focus_filter", "Filter", show=True),
43
+ Binding("g", "go_path", "Go path", show=True),
44
+ ]
45
+
46
+ def __init__(self, start: Path | None = None) -> None:
47
+ super().__init__()
48
+ self._mode: str = "browse" # browse | roots | goto
49
+ self._cwd: Path = (start or Path.home()).expanduser()
50
+ try:
51
+ self._cwd = self._cwd.resolve()
52
+ except OSError:
53
+ self._cwd = Path.home()
54
+ self._show_hidden = False
55
+ self._entries: list[tuple[str, Path, bool]] = [] # label, path, is_dir
56
+ self._filter = ""
57
+ self._agent = load_config().default_agent or "claude"
58
+
59
+ def compose(self) -> ComposeResult:
60
+ yield Static("directory browser", id="subtitle")
61
+ yield Static("", id="path-bar")
62
+ yield Input(placeholder="filter this folder…", id="filter")
63
+ with Horizontal(id="main"):
64
+ with Vertical(id="projects-panel"):
65
+ yield Static("entries", classes="panel-title", id="list-title")
66
+ yield OptionList(id="dir-list")
67
+ with Vertical(id="detail-panel"):
68
+ yield Static("preview", classes="panel-title")
69
+ yield Static("", id="preview")
70
+ yield Static(
71
+ "↵ open · ⌫ up · s select as project · r roots · g go path · . hidden · esc back",
72
+ id="footer-bar",
73
+ )
74
+ yield Footer()
75
+
76
+ def on_mount(self) -> None:
77
+ self._render_list()
78
+ self.query_one("#dir-list", OptionList).focus()
79
+
80
+ # ── listing ──────────────────────────────────────────────
81
+
82
+ def _set_path_bar(self, extra: str = "") -> None:
83
+ bar = self.query_one("#path-bar", Static)
84
+ mark = " · project?" if is_projectish(self._cwd) else ""
85
+ hidden = " · hidden" if self._show_hidden else ""
86
+ bar.update(
87
+ f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden}"
88
+ + (f" [#6a6a80]{extra}[/]" if extra else "")
89
+ )
90
+
91
+ def _render_list(self) -> None:
92
+ if self._mode == "roots":
93
+ self._render_roots()
94
+ return
95
+
96
+ self._set_path_bar()
97
+ self.query_one("#list-title", Static).update("directories")
98
+ ol = self.query_one("#dir-list", OptionList)
99
+ ol.clear_options()
100
+
101
+ entries = list_dir_entries(
102
+ self._cwd, show_hidden=self._show_hidden, dirs_only=True
103
+ )
104
+ q = self._filter.strip().lower()
105
+ if q:
106
+ entries = [e for e in entries if q in e[0].lower()]
107
+
108
+ self._entries = []
109
+ # parent always first when not at filesystem root
110
+ if self._cwd.parent != self._cwd:
111
+ self._entries.append(("..", self._cwd.parent, True))
112
+ ol.add_option(Option(" ../", id="__parent__"))
113
+
114
+ if not entries and not self._entries:
115
+ ol.add_option(Option(" (empty)", id="__empty__"))
116
+ self._update_preview(None)
117
+ return
118
+
119
+ for name, path, is_dir in entries:
120
+ self._entries.append((name, path, is_dir))
121
+ badge = "◆" if is_projectish(path) else "·"
122
+ ol.add_option(Option(f" {badge} {name}/", id=str(path)))
123
+
124
+ ol.highlighted = 0
125
+ self._update_preview(self._selected_path())
126
+
127
+ def _render_roots(self) -> None:
128
+ self._mode = "roots"
129
+ self.query_one("#path-bar", Static).update(
130
+ "[bold #c84bff]browse roots[/] · pick a starting point"
131
+ )
132
+ self.query_one("#list-title", Static).update("roots")
133
+ ol = self.query_one("#dir-list", OptionList)
134
+ ol.clear_options()
135
+ self._entries = []
136
+ for root in default_browse_roots():
137
+ self._entries.append((root.name or str(root), root, True))
138
+ label = str(root)
139
+ badge = "◆" if is_projectish(root) else "·"
140
+ ol.add_option(Option(f" {badge} {label}", id=str(root)))
141
+ if not self._entries:
142
+ ol.add_option(Option(" (no roots)", id="__empty__"))
143
+ ol.highlighted = 0
144
+ self._update_preview(self._selected_path())
145
+
146
+ def _selected_path(self) -> Path | None:
147
+ ol = self.query_one("#dir-list", OptionList)
148
+ if ol.highlighted is None:
149
+ return None
150
+ try:
151
+ opt = ol.get_option_at_index(ol.highlighted)
152
+ except Exception:
153
+ return None
154
+ if opt.id in (None, "__empty__"):
155
+ return None
156
+ if opt.id == "__parent__":
157
+ return self._cwd.parent
158
+ return Path(str(opt.id))
159
+
160
+ def _update_preview(self, path: Path | None) -> None:
161
+ prev = self.query_one("#preview", Static)
162
+ if path is None:
163
+ prev.update("—")
164
+ return
165
+ try:
166
+ resolved = path.resolve()
167
+ except OSError:
168
+ resolved = path
169
+ kids = list_dir_entries(resolved, show_hidden=self._show_hidden, dirs_only=True)
170
+ project = is_projectish(resolved)
171
+ lines = [
172
+ f"[bold #00ffc8]{resolved.name or resolved}[/]",
173
+ f"path {resolved}",
174
+ f"project {'yes ◆' if project else 'no (still selectable)'}",
175
+ f"subdirs {len(kids)}",
176
+ "",
177
+ "[#6a6a80]children[/]",
178
+ ]
179
+ for name, _, _ in kids[:12]:
180
+ lines.append(f" · {name}/")
181
+ if len(kids) > 12:
182
+ lines.append(f" … +{len(kids) - 12} more")
183
+ lines.extend(
184
+ [
185
+ "",
186
+ "[#6a6a80]↵ open folder · s select as project[/]",
187
+ ]
188
+ )
189
+ prev.update("\n".join(lines))
190
+
191
+ # ── events ───────────────────────────────────────────────
192
+
193
+ @on(Input.Changed, "#filter")
194
+ def on_filter(self, event: Input.Changed) -> None:
195
+ self._filter = event.value
196
+ if self._mode != "roots":
197
+ self._render_list()
198
+
199
+ @on(OptionList.OptionHighlighted)
200
+ def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
201
+ self._update_preview(self._selected_path())
202
+
203
+ @on(OptionList.OptionSelected)
204
+ def on_select_event(self, event: OptionList.OptionSelected) -> None:
205
+ self.action_open_or_select()
206
+
207
+ # ── actions ──────────────────────────────────────────────
208
+
209
+ def action_focus_filter(self) -> None:
210
+ self.query_one("#filter", Input).focus()
211
+
212
+ def action_toggle_hidden(self) -> None:
213
+ self._show_hidden = not self._show_hidden
214
+ self._render_list()
215
+
216
+ def action_roots(self) -> None:
217
+ self._filter = ""
218
+ self.query_one("#filter", Input).value = ""
219
+ self._render_roots()
220
+
221
+ def action_up(self) -> None:
222
+ if self._mode == "roots":
223
+ return
224
+ parent = self._cwd.parent
225
+ if parent == self._cwd:
226
+ self.notify("already at filesystem root", severity="warning")
227
+ return
228
+ self._cwd = parent
229
+ self._filter = ""
230
+ self.query_one("#filter", Input).value = ""
231
+ self._render_list()
232
+
233
+ def action_open_or_select(self) -> None:
234
+ path = self._selected_path()
235
+ if path is None:
236
+ return
237
+ if self._mode == "roots":
238
+ self._mode = "browse"
239
+ self._cwd = path
240
+ self._filter = ""
241
+ self.query_one("#filter", Input).value = ""
242
+ self._render_list()
243
+ return
244
+ # open into directory
245
+ if path.is_dir():
246
+ self._cwd = path
247
+ self._filter = ""
248
+ self.query_one("#filter", Input).value = ""
249
+ self._render_list()
250
+
251
+ def action_select(self) -> None:
252
+ """Register highlighted path, or current directory if nothing useful."""
253
+ path = self._selected_path()
254
+ # if on .. or empty, use cwd
255
+ if path is None or (path == self._cwd.parent and self._mode == "browse"):
256
+ path = self._cwd
257
+ if self._mode == "roots" and path:
258
+ # selecting a root without entering — still ok as project
259
+ pass
260
+ if not path or not path.is_dir():
261
+ self.notify("not a directory", severity="error")
262
+ return
263
+ try:
264
+ resolved = path.resolve()
265
+ except OSError as e:
266
+ self.notify(str(e), severity="error")
267
+ return
268
+
269
+ reg = ProjectRegistry()
270
+ existing = reg.find_by_path(resolved)
271
+ if existing:
272
+ self.notify(f"already registered as {existing.id}", severity="warning")
273
+ self.dismiss(True)
274
+ return
275
+
276
+ name = resolved.name or str(resolved)
277
+ p = reg.add(
278
+ name=name,
279
+ path=resolved,
280
+ default_agent=self._agent,
281
+ notes="added via browser",
282
+ )
283
+ self.notify(f"added {p.id} → {resolved}", severity="information")
284
+ self.dismiss(True)
285
+
286
+ def action_go_path(self) -> None:
287
+ """Type an absolute/relative path and jump there."""
288
+ self.app.push_screen(_GoPathModal(self._cwd), self._after_go)
289
+
290
+ def _after_go(self, result: str | None) -> None:
291
+ if not result:
292
+ return
293
+ path = Path(result).expanduser()
294
+ if not path.is_dir():
295
+ self.notify(f"not a directory: {result}", severity="error")
296
+ return
297
+ try:
298
+ self._cwd = path.resolve()
299
+ except OSError:
300
+ self._cwd = path
301
+ self._mode = "browse"
302
+ self._filter = ""
303
+ self.query_one("#filter", Input).value = ""
304
+ self._render_list()
305
+
306
+ def action_cancel(self) -> None:
307
+ self.dismiss(False)
308
+
309
+
310
+ class _GoPathModal(Screen[str | None]):
311
+ """Minimal path jump prompt."""
312
+
313
+ BINDINGS = [
314
+ Binding("escape", "cancel", "Cancel", show=True),
315
+ Binding("enter", "submit", "Go", show=True),
316
+ ]
317
+
318
+ def __init__(self, cwd: Path) -> None:
319
+ super().__init__()
320
+ self._cwd = cwd
321
+
322
+ def compose(self) -> ComposeResult:
323
+ yield Static("go to path", id="subtitle")
324
+ yield Input(value=str(self._cwd), id="path-input", placeholder="/home/you/project")
325
+ yield Static("enter jump · esc cancel", id="footer-bar")
326
+
327
+ def on_mount(self) -> None:
328
+ inp = self.query_one("#path-input", Input)
329
+ inp.focus()
330
+ inp.cursor_position = len(inp.value)
331
+
332
+ def action_submit(self) -> None:
333
+ self.dismiss(self.query_one("#path-input", Input).value.strip())
334
+
335
+ def action_cancel(self) -> None:
336
+ self.dismiss(None)
337
+
338
+ @on(Input.Submitted, "#path-input")
339
+ def on_submit(self, event: Input.Submitted) -> None:
340
+ self.dismiss(event.value.strip())
@@ -22,6 +22,7 @@ class HubScreen(Screen):
22
22
  Binding("enter", "open_project", "Open", show=True),
23
23
  Binding("a", "pick_agent", "Agents", show=True),
24
24
  Binding("n", "new_project", "New", show=True),
25
+ Binding("b", "browse", "Browse", show=True),
25
26
  Binding("s", "sessions", "Sessions", show=True),
26
27
  Binding("h", "ssh", "SSH", show=True),
27
28
  Binding("l", "aliases", "Aliases", show=True),
@@ -50,7 +51,7 @@ class HubScreen(Screen):
50
51
  yield Static("Select a project", id="detail")
51
52
  yield Static("", id="status")
52
53
  yield Static(
53
- "↵ open · a agents · n new · s sessions · h ssh · l aliases · / filter · q quit",
54
+ "↵ open · a agents · b browse · n new · s sessions · h ssh · l aliases · / filter · q quit",
54
55
  id="footer-bar",
55
56
  )
56
57
  yield Footer()
@@ -176,6 +177,15 @@ class HubScreen(Screen):
176
177
 
177
178
  self.app.push_screen(ProjectEditScreen(), _done)
178
179
 
180
+ def action_browse(self) -> None:
181
+ from ccmd.tui.screens.browser import DirBrowserScreen
182
+
183
+ def _done(ok: bool | None) -> None:
184
+ if ok:
185
+ self._load_projects(self.query_one("#filter", Input).value)
186
+
187
+ self.app.push_screen(DirBrowserScreen(), _done)
188
+
179
189
  def action_sessions(self) -> None:
180
190
  from ccmd.tui.screens.sessions import SessionsScreen
181
191
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: ccmd — Agentic Project Hub. Pick a project, pick an agent, launch.
5
5
  Author-email: De Catalyst <Robert5560newton@gmail.com>
6
6
  License: MIT
@@ -17,6 +17,7 @@ ccmd/config/__init__.py
17
17
  ccmd/config/paths.py
18
18
  ccmd/config/schema.py
19
19
  ccmd/core/__init__.py
20
+ ccmd/core/fs.py
20
21
  ccmd/core/launcher.py
21
22
  ccmd/core/migrate.py
22
23
  ccmd/core/profiles.py
@@ -42,6 +43,7 @@ ccmd/tui/theme.py
42
43
  ccmd/tui/screens/__init__.py
43
44
  ccmd/tui/screens/agents.py
44
45
  ccmd/tui/screens/aliases.py
46
+ ccmd/tui/screens/browser.py
45
47
  ccmd/tui/screens/hub.py
46
48
  ccmd/tui/screens/project_edit.py
47
49
  ccmd/tui/screens/sessions.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ccmd"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "ccmd — Agentic Project Hub. Pick a project, pick an agent, launch."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
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
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
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
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
File without changes