ccmd 2.0.1__tar.gz → 2.0.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.
Files changed (55) hide show
  1. {ccmd-2.0.1/ccmd.egg-info → ccmd-2.0.2}/PKG-INFO +1 -1
  2. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/__init__.py +1 -1
  3. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/agents.py +32 -10
  4. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/browser.py +72 -7
  5. ccmd-2.0.2/ccmd/tui/screens/hub.py +452 -0
  6. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/theme.py +12 -0
  7. {ccmd-2.0.1 → ccmd-2.0.2/ccmd.egg-info}/PKG-INFO +1 -1
  8. {ccmd-2.0.1 → ccmd-2.0.2}/pyproject.toml +1 -1
  9. ccmd-2.0.1/ccmd/tui/screens/hub.py +0 -238
  10. {ccmd-2.0.1 → ccmd-2.0.2}/LICENSE +0 -0
  11. {ccmd-2.0.1 → ccmd-2.0.2}/MANIFEST.in +0 -0
  12. {ccmd-2.0.1 → ccmd-2.0.2}/README.md +0 -0
  13. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/__main__.py +0 -0
  14. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/cli.py +0 -0
  15. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/config/__init__.py +0 -0
  16. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/config/paths.py +0 -0
  17. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/config/schema.py +0 -0
  18. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/__init__.py +0 -0
  19. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/__init__.py +0 -0
  20. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/aider.py +0 -0
  21. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/base.py +0 -0
  22. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/chatgpt.py +0 -0
  23. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/claude.py +0 -0
  24. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/codex.py +0 -0
  25. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/cursor.py +0 -0
  26. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/goose.py +0 -0
  27. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/agents/grok.py +0 -0
  28. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/fs.py +0 -0
  29. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/launcher.py +0 -0
  30. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/migrate.py +0 -0
  31. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/profiles.py +0 -0
  32. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/projects.py +0 -0
  33. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/scan.py +0 -0
  34. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/sessions.py +0 -0
  35. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/core/ssh.py +0 -0
  36. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/mcp/__init__.py +0 -0
  37. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/mcp/server.py +0 -0
  38. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/__init__.py +0 -0
  39. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/app.py +0 -0
  40. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/ascii.py +0 -0
  41. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/__init__.py +0 -0
  42. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/aliases.py +0 -0
  43. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/project_edit.py +0 -0
  44. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/sessions.py +0 -0
  45. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/screens/ssh.py +0 -0
  46. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/widgets/__init__.py +0 -0
  47. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd/tui/widgets/spinner.py +0 -0
  48. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd.egg-info/SOURCES.txt +0 -0
  49. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd.egg-info/dependency_links.txt +0 -0
  50. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd.egg-info/entry_points.txt +0 -0
  51. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd.egg-info/requires.txt +0 -0
  52. {ccmd-2.0.1 → ccmd-2.0.2}/ccmd.egg-info/top_level.txt +0 -0
  53. {ccmd-2.0.1 → ccmd-2.0.2}/commands.yaml +0 -0
  54. {ccmd-2.0.1 → ccmd-2.0.2}/requirements.txt +0 -0
  55. {ccmd-2.0.1 → ccmd-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.1
3
+ Version: 2.0.2
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.1"
3
+ __version__ = "2.0.2"
4
4
  __all__ = ["__version__"]
@@ -8,9 +8,8 @@ from textual.containers import Grid, Vertical
8
8
  from textual.screen import Screen
9
9
  from textual.widgets import Footer, Static
10
10
 
11
- from ccmd.core.agents import all_agents, detect_agents
11
+ from ccmd.core.agents import detect_agents
12
12
  from ccmd.core.projects import Project
13
- from ccmd.tui.ascii import agent_tile
14
13
 
15
14
 
16
15
  class AgentScreen(Screen[str | None]):
@@ -20,26 +19,43 @@ class AgentScreen(Screen[str | None]):
20
19
  Binding("right", "right", show=False),
21
20
  Binding("up", "up", show=False),
22
21
  Binding("down", "down", show=False),
23
- Binding("enter", "select", "Launch", show=True),
22
+ Binding("enter", "select", "Select", show=True),
24
23
  Binding("i", "hint", "Install hint", show=True),
25
24
  ]
26
25
 
27
- def __init__(self, project: Project | None = None) -> None:
26
+ def __init__(
27
+ self,
28
+ project: Project | None = None,
29
+ mode: str = "launch", # launch | set
30
+ ) -> None:
28
31
  super().__init__()
29
32
  self.project = project
33
+ self.mode = mode
30
34
  self._agents = detect_agents()
31
35
  self._index = 0
36
+ # prefer project's default
37
+ if project:
38
+ for i, a in enumerate(self._agents):
39
+ if a["id"] == project.default_agent:
40
+ self._index = i
41
+ break
32
42
 
33
43
  def compose(self) -> ComposeResult:
34
- title = "choose agent"
44
+ if self.mode == "set":
45
+ title = "set default agent"
46
+ else:
47
+ title = "choose agent · saves default · launches"
35
48
  if self.project:
36
- title = f"agent for [bold #00ffc8]{self.project.name}[/]"
49
+ title = f"{title} · [bold #00ffc8]{self.project.name}[/]"
37
50
  yield Static(title, id="subtitle")
38
51
  yield Static("", id="spinner-line")
39
52
  with Vertical():
40
53
  yield Grid(id="agent-grid")
41
54
  yield Static("", id="agent-detail")
42
- yield Static("←→↑↓ move · ↵ select · i install hint · esc back", id="footer-bar")
55
+ yield Static(
56
+ "←→↑↓ move · ↵ select · i install hint · esc back",
57
+ id="footer-bar",
58
+ )
43
59
  yield Footer()
44
60
 
45
61
  def on_mount(self) -> None:
@@ -47,7 +63,8 @@ class AgentScreen(Screen[str | None]):
47
63
  self._render_grid()
48
64
  installed = sum(1 for a in self._agents if a["installed"])
49
65
  self.query_one("#spinner-line", Static).update(
50
- f"✦ {installed}/{len(self._agents)} agents ready"
66
+ f"✦ {installed}/{len(self._agents)} agents ready · "
67
+ f"not just claude — pick any"
51
68
  )
52
69
 
53
70
  def _render_grid(self) -> None:
@@ -59,8 +76,9 @@ class AgentScreen(Screen[str | None]):
59
76
  if i == self._index:
60
77
  classes.append("-focus")
61
78
  color = a["color"] if a["installed"] else "#6a6a80"
79
+ num = i + 1
62
80
  body = (
63
- f"[{color}]{a['logo']}[/]\n"
81
+ f"[#6a6a80]{num}[/] [{color}]{a['logo']}[/]\n"
64
82
  f"[bold]{a['name']}[/]\n"
65
83
  f"{'ready' if a['installed'] else 'not found'}"
66
84
  )
@@ -69,12 +87,14 @@ class AgentScreen(Screen[str | None]):
69
87
 
70
88
  def _update_detail(self) -> None:
71
89
  a = self._agents[self._index]
90
+ action = "set default only" if self.mode == "set" else "set default + launch"
72
91
  lines = [
73
92
  f"[bold]{a['logo']} {a['name']}[/] ({a['id']})",
74
93
  f"status {'installed' if a['installed'] else 'missing'}",
75
94
  f"version {a['version'] or '—'}",
76
95
  f"binary {a['binary'] or '—'}",
77
96
  f"hint {a['hint']}",
97
+ f"action {action}",
78
98
  ]
79
99
  self.query_one("#agent-detail", Static).update("\n".join(lines))
80
100
 
@@ -98,7 +118,9 @@ class AgentScreen(Screen[str | None]):
98
118
  def action_select(self) -> None:
99
119
  a = self._agents[self._index]
100
120
  if not a["installed"] and a["id"] != "chatgpt":
101
- self.notify(f"{a['name']} not installed — press i for hint", severity="warning")
121
+ self.notify(
122
+ f"{a['name']} not installed — press i for hint", severity="warning"
123
+ )
102
124
  return
103
125
  self.dismiss(a["id"])
104
126
 
@@ -13,9 +13,12 @@ from textual.widgets import Footer, Input, OptionList, Static
13
13
  from textual.widgets.option_list import Option
14
14
 
15
15
  from ccmd.config.schema import load_config
16
+ from ccmd.core.agents import all_agents, get_agent
16
17
  from ccmd.core.fs import default_browse_roots, is_projectish, list_dir_entries
17
18
  from ccmd.core.projects import ProjectRegistry
18
19
 
20
+ _AGENT_IDS = [a.id for a in all_agents()]
21
+
19
22
 
20
23
  class DirBrowserScreen(Screen[bool | None]):
21
24
  """Browse the filesystem; select a directory to register as a project.
@@ -41,6 +44,15 @@ class DirBrowserScreen(Screen[bool | None]):
41
44
  Binding("slash", "focus_filter", "Filter", show=False),
42
45
  Binding("/", "focus_filter", "Filter", show=True),
43
46
  Binding("g", "go_path", "Go path", show=True),
47
+ Binding("left_square_bracket", "agent_prev", "Agent−", show=True),
48
+ Binding("right_square_bracket", "agent_next", "Agent+", show=True),
49
+ Binding("1", "agent_n_1", show=False),
50
+ Binding("2", "agent_n_2", show=False),
51
+ Binding("3", "agent_n_3", show=False),
52
+ Binding("4", "agent_n_4", show=False),
53
+ Binding("5", "agent_n_5", show=False),
54
+ Binding("6", "agent_n_6", show=False),
55
+ Binding("7", "agent_n_7", show=False),
44
56
  ]
45
57
 
46
58
  def __init__(self, start: Path | None = None) -> None:
@@ -68,7 +80,7 @@ class DirBrowserScreen(Screen[bool | None]):
68
80
  yield Static("preview", classes="panel-title")
69
81
  yield Static("", id="preview")
70
82
  yield Static(
71
- "↵ open · ⌫ up · s select as project · r roots · g go path · . hidden · esc back",
83
+ "↵ open · ⌫ up · s add project · ]/[ agent · 1-7 agent · r roots · g path · esc",
72
84
  id="footer-bar",
73
85
  )
74
86
  yield Footer()
@@ -83,8 +95,11 @@ class DirBrowserScreen(Screen[bool | None]):
83
95
  bar = self.query_one("#path-bar", Static)
84
96
  mark = " · project?" if is_projectish(self._cwd) else ""
85
97
  hidden = " · hidden" if self._show_hidden else ""
98
+ ag = get_agent(self._agent)
99
+ ag_s = f"{ag.ascii_logo} {self._agent}" if ag else self._agent
86
100
  bar.update(
87
- f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden}"
101
+ f"[bold #00ffc8]{self._cwd}[/]{mark}{hidden} · "
102
+ f"agent [bold #c84bff]{ag_s}[/]"
88
103
  + (f" [#6a6a80]{extra}[/]" if extra else "")
89
104
  )
90
105
 
@@ -248,15 +263,57 @@ class DirBrowserScreen(Screen[bool | None]):
248
263
  self.query_one("#filter", Input).value = ""
249
264
  self._render_list()
250
265
 
266
+ def action_agent_prev(self) -> None:
267
+ try:
268
+ i = _AGENT_IDS.index(self._agent)
269
+ except ValueError:
270
+ i = 0
271
+ self._agent = _AGENT_IDS[(i - 1) % len(_AGENT_IDS)]
272
+ self._set_path_bar()
273
+ self.notify(f"agent → {self._agent}", severity="information", timeout=1)
274
+
275
+ def action_agent_next(self) -> None:
276
+ try:
277
+ i = _AGENT_IDS.index(self._agent)
278
+ except ValueError:
279
+ i = 0
280
+ self._agent = _AGENT_IDS[(i + 1) % len(_AGENT_IDS)]
281
+ self._set_path_bar()
282
+ self.notify(f"agent → {self._agent}", severity="information", timeout=1)
283
+
284
+ def _agent_n(self, n: int) -> None:
285
+ if 1 <= n <= len(_AGENT_IDS):
286
+ self._agent = _AGENT_IDS[n - 1]
287
+ self._set_path_bar()
288
+ self.notify(f"agent → {self._agent}", severity="information", timeout=1)
289
+
290
+ def action_agent_n_1(self) -> None:
291
+ self._agent_n(1)
292
+
293
+ def action_agent_n_2(self) -> None:
294
+ self._agent_n(2)
295
+
296
+ def action_agent_n_3(self) -> None:
297
+ self._agent_n(3)
298
+
299
+ def action_agent_n_4(self) -> None:
300
+ self._agent_n(4)
301
+
302
+ def action_agent_n_5(self) -> None:
303
+ self._agent_n(5)
304
+
305
+ def action_agent_n_6(self) -> None:
306
+ self._agent_n(6)
307
+
308
+ def action_agent_n_7(self) -> None:
309
+ self._agent_n(7)
310
+
251
311
  def action_select(self) -> None:
252
312
  """Register highlighted path, or current directory if nothing useful."""
253
313
  path = self._selected_path()
254
314
  # if on .. or empty, use cwd
255
315
  if path is None or (path == self._cwd.parent and self._mode == "browse"):
256
316
  path = self._cwd
257
- if self._mode == "roots" and path:
258
- # selecting a root without entering — still ok as project
259
- pass
260
317
  if not path or not path.is_dir():
261
318
  self.notify("not a directory", severity="error")
262
319
  return
@@ -269,7 +326,12 @@ class DirBrowserScreen(Screen[bool | None]):
269
326
  reg = ProjectRegistry()
270
327
  existing = reg.find_by_path(resolved)
271
328
  if existing:
272
- self.notify(f"already registered as {existing.id}", severity="warning")
329
+ existing.default_agent = self._agent
330
+ reg.save(existing)
331
+ self.notify(
332
+ f"already had {existing.id} — default agent → {self._agent}",
333
+ severity="information",
334
+ )
273
335
  self.dismiss(True)
274
336
  return
275
337
 
@@ -280,7 +342,10 @@ class DirBrowserScreen(Screen[bool | None]):
280
342
  default_agent=self._agent,
281
343
  notes="added via browser",
282
344
  )
283
- self.notify(f"added {p.id} → {resolved}", severity="information")
345
+ self.notify(
346
+ f"added {p.id} · agent {self._agent} → {resolved}",
347
+ severity="information",
348
+ )
284
349
  self.dismiss(True)
285
350
 
286
351
  def action_go_path(self) -> None:
@@ -0,0 +1,452 @@
1
+ """Main project hub screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual import on, work
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 import __version__
16
+ from ccmd.core.agents import all_agents, detect_agents, get_agent
17
+ from ccmd.core.launcher import LaunchError, launch_project
18
+ from ccmd.core.projects import Project, ProjectRegistry
19
+ from ccmd.tui.ascii import BANNER, BANNER_COMPACT
20
+
21
+ # Order for 1–7 hotkeys
22
+ AGENT_ORDER = [a.id for a in all_agents()]
23
+
24
+
25
+ def _agent_bar() -> str:
26
+ parts: list[str] = []
27
+ for i, a in enumerate(detect_agents(), start=1):
28
+ if a["installed"]:
29
+ parts.append(f"[{a['color']}]{i}:{a['logo']}{a['id']}[/]")
30
+ else:
31
+ parts.append(f"[#6a6a80]{i}:{a['id']}[/]")
32
+ return " ".join(parts)
33
+
34
+
35
+ class HubScreen(Screen):
36
+ BINDINGS = [
37
+ Binding("enter", "open_project", "Open", show=True),
38
+ Binding("a", "pick_agent", "Agents", show=True),
39
+ Binding("A", "set_agent_only", "Set agent", show=True),
40
+ Binding("n", "new_project", "New", show=True),
41
+ Binding("b", "browse", "Browse", show=True),
42
+ Binding("f", "browse_mnt", "Disk /mnt", show=True),
43
+ Binding("s", "sessions", "Sessions", show=True),
44
+ Binding("h", "ssh", "SSH", show=True),
45
+ Binding("l", "aliases", "Aliases", show=True),
46
+ Binding("d", "detach", "Detach", show=True),
47
+ Binding("left_square_bracket", "agent_prev", "Agent−", show=True),
48
+ Binding("right_square_bracket", "agent_next", "Agent+", show=True),
49
+ Binding("slash", "focus_filter", "Filter", show=False),
50
+ Binding("/", "focus_filter", "Filter", show=True),
51
+ Binding("r", "refresh", "Refresh", show=True),
52
+ Binding("q", "quit", "Quit", show=True),
53
+ # 1–7 set default agent
54
+ Binding("1", "agent_n_1", show=False),
55
+ Binding("2", "agent_n_2", show=False),
56
+ Binding("3", "agent_n_3", show=False),
57
+ Binding("4", "agent_n_4", show=False),
58
+ Binding("5", "agent_n_5", show=False),
59
+ Binding("6", "agent_n_6", show=False),
60
+ Binding("7", "agent_n_7", show=False),
61
+ ]
62
+
63
+ def __init__(self) -> None:
64
+ super().__init__()
65
+ self.registry = ProjectRegistry()
66
+ self._projects: list[Project] = []
67
+ self._filtered: list[Project] = []
68
+ self._agent_info = detect_agents()
69
+
70
+ def compose(self) -> ComposeResult:
71
+ yield Static(BANNER.strip("\n"), id="banner")
72
+ yield Static(
73
+ f"project hub · v{__version__} · b/f browse disk · ] cycle agent · 1–7 set agent",
74
+ id="subtitle",
75
+ )
76
+ yield Static(_agent_bar(), id="agent-bar")
77
+ yield Input(placeholder="filter projects… (/ to focus)", id="filter")
78
+ with Horizontal(id="main"):
79
+ with Vertical(id="projects-panel"):
80
+ yield Static("projects (registry — not whole disk)", classes="panel-title")
81
+ yield OptionList(id="project-list")
82
+ with Vertical(id="detail-panel"):
83
+ yield Static("detail", classes="panel-title")
84
+ yield Static("Select a project", id="detail")
85
+ yield Static("", id="status")
86
+ yield Static(
87
+ "↵ open · a launch w/ agent · A set default · ]/[ cycle · 1-7 agent · "
88
+ "b browse · f /mnt · d detach · q quit",
89
+ id="footer-bar",
90
+ )
91
+ yield Footer()
92
+
93
+ def on_mount(self) -> None:
94
+ self._load_projects()
95
+ self.query_one("#project-list", OptionList).focus()
96
+ if not self._projects:
97
+ self.notify(
98
+ "no projects — opening disk browser (or press f for /mnt)",
99
+ severity="information",
100
+ timeout=4,
101
+ )
102
+ self.set_timer(0.3, self.action_browse)
103
+ else:
104
+ # hint once if everything is claude
105
+ if all(p.default_agent == "claude" for p in self._projects[:20]):
106
+ self.notify(
107
+ "tip: ] cycles default agent · 1–7 picks agent · f browses /mnt",
108
+ severity="information",
109
+ timeout=5,
110
+ )
111
+
112
+ def _load_projects(self, query: str = "") -> None:
113
+ self._projects = self.registry.list()
114
+ if query.strip():
115
+ self._filtered = self.registry.fuzzy(query)
116
+ else:
117
+ self._filtered = list(self._projects)
118
+ ol = self.query_one("#project-list", OptionList)
119
+ # preserve highlight id
120
+ prev_id = None
121
+ if ol.highlighted is not None:
122
+ try:
123
+ prev_id = ol.get_option_at_index(ol.highlighted).id
124
+ except Exception:
125
+ prev_id = None
126
+ ol.clear_options()
127
+ if not self._filtered:
128
+ ol.add_option(Option(" (no projects — press b or f to browse disk)", id="__empty__"))
129
+ self.query_one("#status", Static).update(
130
+ "empty registry · b browse · f /mnt · n type path"
131
+ )
132
+ return
133
+ restore_idx = 0
134
+ for i, p in enumerate(self._filtered):
135
+ mark = "✓" if p.exists() else "!"
136
+ agent = get_agent(p.default_agent)
137
+ logo = agent.ascii_logo if agent else "?"
138
+ # short root hint so non-vylth stands out
139
+ root_hint = _root_label(p.path)
140
+ label = f" {mark} {logo} {p.name:<18} {p.default_agent:<8} {root_hint} {p.path}"
141
+ ol.add_option(Option(label, id=p.id))
142
+ if prev_id and p.id == prev_id:
143
+ restore_idx = i
144
+ ol.highlighted = restore_idx
145
+ self._update_detail()
146
+ agents_used = sorted({p.default_agent for p in self._projects})
147
+ self.query_one("#status", Static).update(
148
+ f"{len(self._filtered)}/{len(self._projects)} projects · "
149
+ f"defaults: {', '.join(agents_used)} · "
150
+ f"disk: b / f not this list"
151
+ )
152
+
153
+ def _selected(self) -> Project | None:
154
+ ol = self.query_one("#project-list", OptionList)
155
+ if ol.highlighted is None:
156
+ return None
157
+ try:
158
+ opt = ol.get_option_at_index(ol.highlighted)
159
+ except Exception:
160
+ return None
161
+ if opt.id in (None, "__empty__"):
162
+ return None
163
+ return self.registry.get(str(opt.id))
164
+
165
+ def _update_detail(self) -> None:
166
+ p = self._selected()
167
+ detail = self.query_one("#detail", Static)
168
+ if not p:
169
+ detail.update(
170
+ "Select a project\n\n"
171
+ "[#6a6a80]This list is your saved registry.\n"
172
+ "Press [bold]f[/] to browse /mnt and subdirs,\n"
173
+ "or [bold]b[/] for home + all roots.\n"
174
+ "[bold]][/] cycles default agent on selection.[/]"
175
+ )
176
+ return
177
+ agent = get_agent(p.default_agent)
178
+ agent_line = (
179
+ f"{agent.ascii_logo} {agent.display_name}"
180
+ if agent
181
+ else p.default_agent
182
+ )
183
+ exists = "yes" if p.exists() else "MISSING"
184
+ # agent roster
185
+ roster: list[str] = []
186
+ for i, a in enumerate(self._agent_info, start=1):
187
+ mark = "●" if a["id"] == p.default_agent else "·"
188
+ ready = "✓" if a["installed"] else "✗"
189
+ roster.append(f" {mark} {i} {a['logo']} {a['id']:<8} {ready}")
190
+
191
+ lines = [
192
+ f"[bold #00ffc8]{p.name}[/]",
193
+ f"id {p.id}",
194
+ f"path {p.path}",
195
+ f"root {_root_label(p.path)}",
196
+ f"exists {exists}",
197
+ f"agent [bold]{agent_line}[/] (default)",
198
+ f"ssh {p.ssh_host or '—'}",
199
+ f"env {', '.join(p.env_files) or '—'}",
200
+ f"tags {', '.join(p.tags) or '—'}",
201
+ f"opened {p.last_opened_at or 'never'}",
202
+ "",
203
+ "[bold #c84bff]agents[/] ]/[ cycle · 1-7 set · A set only · a launch",
204
+ *roster,
205
+ "",
206
+ "[#6a6a80]↵ open · d detach · b/f add from disk[/]",
207
+ ]
208
+ detail.update("\n".join(lines))
209
+
210
+ def _set_default_agent(self, agent_id: str, *, notify: bool = True) -> None:
211
+ p = self._selected()
212
+ if not p:
213
+ self.notify("select a project first", severity="warning")
214
+ return
215
+ if get_agent(agent_id) is None:
216
+ self.notify(f"unknown agent {agent_id}", severity="error")
217
+ return
218
+ p.default_agent = agent_id
219
+ self.registry.save(p)
220
+ if notify:
221
+ self.notify(f"{p.id} → default agent {agent_id}", severity="information")
222
+ self._load_projects(self.query_one("#filter", Input).value)
223
+
224
+ def _cycle_agent(self, delta: int) -> None:
225
+ p = self._selected()
226
+ if not p:
227
+ self.notify("select a project first", severity="warning")
228
+ return
229
+ ids = AGENT_ORDER
230
+ try:
231
+ idx = ids.index(p.default_agent)
232
+ except ValueError:
233
+ idx = 0
234
+ new_id = ids[(idx + delta) % len(ids)]
235
+ self._set_default_agent(new_id)
236
+
237
+ def _agent_n(self, n: int) -> None:
238
+ if 1 <= n <= len(AGENT_ORDER):
239
+ self._set_default_agent(AGENT_ORDER[n - 1])
240
+
241
+ # ── events ───────────────────────────────────────────────
242
+
243
+ @on(Input.Changed, "#filter")
244
+ def on_filter(self, event: Input.Changed) -> None:
245
+ self._load_projects(event.value)
246
+
247
+ @on(OptionList.OptionHighlighted)
248
+ def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
249
+ self._update_detail()
250
+
251
+ @on(OptionList.OptionSelected)
252
+ def on_select(self, event: OptionList.OptionSelected) -> None:
253
+ self.action_open_project()
254
+
255
+ # ── actions ──────────────────────────────────────────────
256
+
257
+ def action_focus_filter(self) -> None:
258
+ self.query_one("#filter", Input).focus()
259
+
260
+ def action_refresh(self) -> None:
261
+ self._agent_info = detect_agents()
262
+ try:
263
+ self.query_one("#agent-bar", Static).update(_agent_bar())
264
+ except Exception:
265
+ pass
266
+ self._load_projects(self.query_one("#filter", Input).value)
267
+ self.notify("refreshed", severity="information")
268
+
269
+ def action_agent_prev(self) -> None:
270
+ self._cycle_agent(-1)
271
+
272
+ def action_agent_next(self) -> None:
273
+ self._cycle_agent(1)
274
+
275
+ def action_agent_n_1(self) -> None:
276
+ self._agent_n(1)
277
+
278
+ def action_agent_n_2(self) -> None:
279
+ self._agent_n(2)
280
+
281
+ def action_agent_n_3(self) -> None:
282
+ self._agent_n(3)
283
+
284
+ def action_agent_n_4(self) -> None:
285
+ self._agent_n(4)
286
+
287
+ def action_agent_n_5(self) -> None:
288
+ self._agent_n(5)
289
+
290
+ def action_agent_n_6(self) -> None:
291
+ self._agent_n(6)
292
+
293
+ def action_agent_n_7(self) -> None:
294
+ self._agent_n(7)
295
+
296
+ def action_open_project(self) -> None:
297
+ p = self._selected()
298
+ if not p:
299
+ self.notify("no project selected", severity="warning")
300
+ return
301
+ if not p.exists():
302
+ self.notify(f"path missing: {p.path} — press b to re-add", severity="error")
303
+ return
304
+ self._launch(p, p.default_agent, detach=False)
305
+
306
+ def action_detach(self) -> None:
307
+ p = self._selected()
308
+ if not p:
309
+ self.notify("no project selected", severity="warning")
310
+ return
311
+ if not p.exists():
312
+ self.notify(f"path missing: {p.path}", severity="error")
313
+ return
314
+ self._launch(p, p.default_agent, detach=True)
315
+
316
+ def action_pick_agent(self) -> None:
317
+ """Pick agent, save as default, then launch."""
318
+ p = self._selected()
319
+ if not p:
320
+ self.notify("select a project first", severity="warning")
321
+ return
322
+ from ccmd.tui.screens.agents import AgentScreen
323
+
324
+ def _done(agent_id: str | None) -> None:
325
+ if not agent_id:
326
+ return
327
+ p2 = self.registry.get(p.id) or p
328
+ p2.default_agent = agent_id
329
+ self.registry.save(p2)
330
+ self._load_projects(self.query_one("#filter", Input).value)
331
+ self._launch(p2, agent_id, detach=False)
332
+
333
+ self.app.push_screen(AgentScreen(project=p, mode="launch"), _done)
334
+
335
+ def action_set_agent_only(self) -> None:
336
+ """Pick agent and save as default without launching."""
337
+ p = self._selected()
338
+ if not p:
339
+ self.notify("select a project first", severity="warning")
340
+ return
341
+ from ccmd.tui.screens.agents import AgentScreen
342
+
343
+ def _done(agent_id: str | None) -> None:
344
+ if agent_id:
345
+ self._set_default_agent(agent_id)
346
+
347
+ self.app.push_screen(AgentScreen(project=p, mode="set"), _done)
348
+
349
+ def action_new_project(self) -> None:
350
+ from ccmd.tui.screens.project_edit import ProjectEditScreen
351
+
352
+ def _done(ok: bool | None) -> None:
353
+ if ok:
354
+ self._load_projects(self.query_one("#filter", Input).value)
355
+
356
+ self.app.push_screen(ProjectEditScreen(), _done)
357
+
358
+ def action_browse(self) -> None:
359
+ from ccmd.tui.screens.browser import DirBrowserScreen
360
+
361
+ def _done(ok: bool | None) -> None:
362
+ if ok:
363
+ self._load_projects(self.query_one("#filter", Input).value)
364
+
365
+ # start at home; roots list includes /mnt/*
366
+ self.app.push_screen(DirBrowserScreen(start=Path.home()), _done)
367
+
368
+ def action_browse_mnt(self) -> None:
369
+ from ccmd.tui.screens.browser import DirBrowserScreen
370
+
371
+ def _done(ok: bool | None) -> None:
372
+ if ok:
373
+ self._load_projects(self.query_one("#filter", Input).value)
374
+
375
+ start = Path("/mnt")
376
+ if not start.is_dir():
377
+ start = Path.home()
378
+ self.notify("/mnt not found — browsing home", severity="warning")
379
+ self.app.push_screen(DirBrowserScreen(start=start), _done)
380
+
381
+ def action_sessions(self) -> None:
382
+ from ccmd.tui.screens.sessions import SessionsScreen
383
+
384
+ self.app.push_screen(SessionsScreen())
385
+
386
+ def action_ssh(self) -> None:
387
+ from ccmd.tui.screens.ssh import SSHScreen
388
+
389
+ self.app.push_screen(SSHScreen())
390
+
391
+ def action_aliases(self) -> None:
392
+ from ccmd.tui.screens.aliases import AliasesScreen
393
+
394
+ self.app.push_screen(AliasesScreen())
395
+
396
+ def action_quit(self) -> None:
397
+ self.app.exit()
398
+
399
+ @work(thread=True)
400
+ def _launch(self, project: Project, agent_id: str, detach: bool) -> None:
401
+ self.app.call_from_thread(
402
+ self.query_one("#status", Static).update,
403
+ f"handing off to {agent_id}…",
404
+ )
405
+ try:
406
+
407
+ def _do() -> None:
408
+ try:
409
+ code = launch_project(
410
+ project, agent_id, detach=detach, replace=not detach
411
+ )
412
+ if detach:
413
+ self.notify(
414
+ f"launched {agent_id} (detached)", severity="information"
415
+ )
416
+ else:
417
+ self.notify(
418
+ f"{agent_id} exited ({code})", severity="information"
419
+ )
420
+ self._load_projects(self.query_one("#filter", Input).value)
421
+ except LaunchError as e:
422
+ self.notify(str(e), severity="error")
423
+
424
+ self.app.call_from_thread(_do)
425
+ except Exception as e:
426
+ self.app.call_from_thread(self.notify, str(e), severity="error")
427
+
428
+ def on_resize(self) -> None:
429
+ banner = self.query_one("#banner", Static)
430
+ if self.size.width < 50:
431
+ banner.update(BANNER_COMPACT)
432
+ else:
433
+ banner.update(BANNER.strip("\n"))
434
+
435
+
436
+ def _root_label(path: str) -> str:
437
+ """Short badge for where the project lives."""
438
+ p = path.replace("\\", "/")
439
+ if p.startswith("/mnt/vylth"):
440
+ return "[vylth]"
441
+ if p.startswith("/mnt/c"):
442
+ return "[win-c]"
443
+ if p.startswith("/mnt/d"):
444
+ return "[win-d]"
445
+ if p.startswith("/mnt/"):
446
+ part = p.split("/")[2] if len(p.split("/")) > 2 else "mnt"
447
+ return f"[mnt/{part}]"
448
+ if p.startswith(str(Path.home())):
449
+ return "[home]"
450
+ if p.startswith("/opt"):
451
+ return "[opt]"
452
+ return "[disk]"
@@ -15,10 +15,22 @@ Screen {
15
15
 
16
16
  #subtitle {
17
17
  color: #c84bff;
18
+ padding: 0 2 0 2;
19
+ height: 1;
20
+ }
21
+
22
+ #agent-bar {
23
+ color: #e8e8f0;
18
24
  padding: 0 2 1 2;
19
25
  height: 1;
20
26
  }
21
27
 
28
+ #path-bar {
29
+ color: #e8e8f0;
30
+ padding: 0 2 1 2;
31
+ height: auto;
32
+ }
33
+
22
34
  #filter {
23
35
  dock: top;
24
36
  margin: 0 1 1 1;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.1
3
+ Version: 2.0.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ccmd"
7
- version = "2.0.1"
7
+ version = "2.0.2"
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"
@@ -1,238 +0,0 @@
1
- """Main project hub screen."""
2
-
3
- from __future__ import annotations
4
-
5
- from textual import on, work
6
- from textual.app import ComposeResult
7
- from textual.binding import Binding
8
- from textual.containers import Horizontal, Vertical
9
- from textual.screen import Screen
10
- from textual.widgets import Footer, Input, OptionList, Static
11
- from textual.widgets.option_list import Option
12
-
13
- from ccmd import __version__
14
- from ccmd.core.agents import get_agent
15
- from ccmd.core.launcher import LaunchError, launch_project
16
- from ccmd.core.projects import Project, ProjectRegistry
17
- from ccmd.tui.ascii import BANNER, BANNER_COMPACT
18
-
19
-
20
- class HubScreen(Screen):
21
- BINDINGS = [
22
- Binding("enter", "open_project", "Open", show=True),
23
- Binding("a", "pick_agent", "Agents", show=True),
24
- Binding("n", "new_project", "New", show=True),
25
- Binding("b", "browse", "Browse", show=True),
26
- Binding("s", "sessions", "Sessions", show=True),
27
- Binding("h", "ssh", "SSH", show=True),
28
- Binding("l", "aliases", "Aliases", show=True),
29
- Binding("slash", "focus_filter", "Filter", show=False),
30
- Binding("/", "focus_filter", "Filter", show=True),
31
- Binding("r", "refresh", "Refresh", show=True),
32
- Binding("q", "quit", "Quit", show=True),
33
- ]
34
-
35
- def __init__(self) -> None:
36
- super().__init__()
37
- self.registry = ProjectRegistry()
38
- self._projects: list[Project] = []
39
- self._filtered: list[Project] = []
40
-
41
- def compose(self) -> ComposeResult:
42
- yield Static(BANNER.strip("\n"), id="banner")
43
- yield Static(f"project hub · v{__version__} · pick → agent → launch", id="subtitle")
44
- yield Input(placeholder="filter projects… (/ to focus)", id="filter")
45
- with Horizontal(id="main"):
46
- with Vertical(id="projects-panel"):
47
- yield Static("projects", classes="panel-title")
48
- yield OptionList(id="project-list")
49
- with Vertical(id="detail-panel"):
50
- yield Static("detail", classes="panel-title")
51
- yield Static("Select a project", id="detail")
52
- yield Static("", id="status")
53
- yield Static(
54
- "↵ open · a agents · b browse · n new · s sessions · h ssh · l aliases · / filter · q quit",
55
- id="footer-bar",
56
- )
57
- yield Footer()
58
-
59
- def on_mount(self) -> None:
60
- self._load_projects()
61
- self.query_one("#filter", Input).focus()
62
-
63
- def _load_projects(self, query: str = "") -> None:
64
- self._projects = self.registry.list()
65
- if query.strip():
66
- self._filtered = self.registry.fuzzy(query)
67
- else:
68
- self._filtered = list(self._projects)
69
- ol = self.query_one("#project-list", OptionList)
70
- ol.clear_options()
71
- if not self._filtered:
72
- ol.add_option(Option(" (no projects — press n to add)", id="__empty__"))
73
- self.query_one("#status", Static).update(
74
- "no projects yet · n new · or: ccmd migrate --apply"
75
- )
76
- return
77
- for p in self._filtered:
78
- mark = "✓" if p.exists() else "!"
79
- agent = p.default_agent
80
- label = f" {mark} {p.name:<20} {agent:<8} {p.path}"
81
- ol.add_option(Option(label, id=p.id))
82
- ol.highlighted = 0
83
- self._update_detail()
84
- self.query_one("#status", Static).update(
85
- f"{len(self._filtered)}/{len(self._projects)} projects"
86
- )
87
-
88
- def _selected(self) -> Project | None:
89
- ol = self.query_one("#project-list", OptionList)
90
- if ol.highlighted is None:
91
- return None
92
- try:
93
- opt = ol.get_option_at_index(ol.highlighted)
94
- except Exception:
95
- return None
96
- if opt.id in (None, "__empty__"):
97
- return None
98
- return self.registry.get(str(opt.id))
99
-
100
- def _update_detail(self) -> None:
101
- p = self._selected()
102
- detail = self.query_one("#detail", Static)
103
- if not p:
104
- detail.update("Select a project")
105
- return
106
- agent = get_agent(p.default_agent)
107
- agent_line = (
108
- f"{agent.ascii_logo} {agent.display_name}"
109
- if agent
110
- else p.default_agent
111
- )
112
- exists = "yes" if p.exists() else "MISSING"
113
- lines = [
114
- f"[bold #00ffc8]{p.name}[/]",
115
- f"id {p.id}",
116
- f"path {p.path}",
117
- f"exists {exists}",
118
- f"agent {agent_line}",
119
- f"ssh {p.ssh_host or '—'}",
120
- f"env {', '.join(p.env_files) or '—'}",
121
- f"tags {', '.join(p.tags) or '—'}",
122
- f"opened {p.last_opened_at or 'never'}",
123
- "",
124
- "[#6a6a80]enter open · a choose agent · d detach[/]",
125
- ]
126
- detail.update("\n".join(lines))
127
-
128
- @on(Input.Changed, "#filter")
129
- def on_filter(self, event: Input.Changed) -> None:
130
- self._load_projects(event.value)
131
-
132
- @on(OptionList.OptionHighlighted)
133
- def on_highlight(self, event: OptionList.OptionHighlighted) -> None:
134
- self._update_detail()
135
-
136
- @on(OptionList.OptionSelected)
137
- def on_select(self, event: OptionList.OptionSelected) -> None:
138
- self.action_open_project()
139
-
140
- def action_focus_filter(self) -> None:
141
- self.query_one("#filter", Input).focus()
142
-
143
- def action_refresh(self) -> None:
144
- q = self.query_one("#filter", Input).value
145
- self._load_projects(q)
146
- self.notify("refreshed", severity="information")
147
-
148
- def action_open_project(self) -> None:
149
- p = self._selected()
150
- if not p:
151
- self.notify("no project selected", severity="warning")
152
- return
153
- if not p.exists():
154
- self.notify(f"path missing: {p.path}", severity="error")
155
- return
156
- self._launch(p, p.default_agent, detach=False)
157
-
158
- def action_pick_agent(self) -> None:
159
- p = self._selected()
160
- if not p:
161
- self.notify("select a project first", severity="warning")
162
- return
163
- from ccmd.tui.screens.agents import AgentScreen
164
-
165
- def _done(agent_id: str | None) -> None:
166
- if agent_id:
167
- self._launch(p, agent_id, detach=False)
168
-
169
- self.app.push_screen(AgentScreen(project=p), _done)
170
-
171
- def action_new_project(self) -> None:
172
- from ccmd.tui.screens.project_edit import ProjectEditScreen
173
-
174
- def _done(ok: bool | None) -> None:
175
- if ok:
176
- self._load_projects(self.query_one("#filter", Input).value)
177
-
178
- self.app.push_screen(ProjectEditScreen(), _done)
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
-
189
- def action_sessions(self) -> None:
190
- from ccmd.tui.screens.sessions import SessionsScreen
191
-
192
- self.app.push_screen(SessionsScreen())
193
-
194
- def action_ssh(self) -> None:
195
- from ccmd.tui.screens.ssh import SSHScreen
196
-
197
- self.app.push_screen(SSHScreen())
198
-
199
- def action_aliases(self) -> None:
200
- from ccmd.tui.screens.aliases import AliasesScreen
201
-
202
- self.app.push_screen(AliasesScreen())
203
-
204
- def action_quit(self) -> None:
205
- self.app.exit()
206
-
207
- @work(thread=True)
208
- def _launch(self, project: Project, agent_id: str, detach: bool) -> None:
209
- self.app.call_from_thread(
210
- self.query_one("#status", Static).update,
211
- f"handing off to {agent_id}…",
212
- )
213
- try:
214
- # replace process — must run on main for exec; use call_from_thread
215
- def _do() -> None:
216
- try:
217
- code = launch_project(
218
- project, agent_id, detach=detach, replace=not detach
219
- )
220
- if detach:
221
- self.notify(f"launched {agent_id} (detached)", severity="information")
222
- else:
223
- # if returned, agent exited
224
- self.notify(f"{agent_id} exited ({code})", severity="information")
225
- self._load_projects(self.query_one("#filter", Input).value)
226
- except LaunchError as e:
227
- self.notify(str(e), severity="error")
228
-
229
- self.app.call_from_thread(_do)
230
- except Exception as e:
231
- self.app.call_from_thread(self.notify, str(e), severity="error")
232
-
233
- def on_resize(self) -> None:
234
- banner = self.query_one("#banner", Static)
235
- if self.size.width < 50:
236
- banner.update(BANNER_COMPACT)
237
- else:
238
- banner.update(BANNER.strip("\n"))
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
File without changes
File without changes