ccmd 2.0.4__tar.gz → 2.0.6__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.4/ccmd.egg-info → ccmd-2.0.6}/PKG-INFO +1 -1
  2. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/__init__.py +1 -1
  3. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/cli.py +62 -0
  4. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/projects.py +17 -2
  5. ccmd-2.0.6/ccmd/core/shell_cmds.py +347 -0
  6. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/browser.py +15 -17
  7. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/hub.py +30 -30
  8. {ccmd-2.0.4 → ccmd-2.0.6/ccmd.egg-info}/PKG-INFO +1 -1
  9. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/SOURCES.txt +1 -0
  10. {ccmd-2.0.4 → ccmd-2.0.6}/pyproject.toml +1 -1
  11. {ccmd-2.0.4 → ccmd-2.0.6}/LICENSE +0 -0
  12. {ccmd-2.0.4 → ccmd-2.0.6}/MANIFEST.in +0 -0
  13. {ccmd-2.0.4 → ccmd-2.0.6}/README.md +0 -0
  14. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/__main__.py +0 -0
  15. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/__init__.py +0 -0
  16. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/paths.py +0 -0
  17. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/schema.py +0 -0
  18. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/__init__.py +0 -0
  19. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/__init__.py +0 -0
  20. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/aider.py +0 -0
  21. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/base.py +0 -0
  22. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/chatgpt.py +0 -0
  23. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/claude.py +0 -0
  24. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/codex.py +0 -0
  25. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/cursor.py +0 -0
  26. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/goose.py +0 -0
  27. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/grok.py +0 -0
  28. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/fs.py +0 -0
  29. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/launcher.py +0 -0
  30. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/migrate.py +0 -0
  31. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/profiles.py +0 -0
  32. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/scan.py +0 -0
  33. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/sessions.py +0 -0
  34. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/ssh.py +0 -0
  35. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/mcp/__init__.py +0 -0
  36. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/mcp/server.py +0 -0
  37. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/__init__.py +0 -0
  38. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/app.py +0 -0
  39. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/ascii.py +0 -0
  40. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/__init__.py +0 -0
  41. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/agents.py +0 -0
  42. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/aliases.py +0 -0
  43. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/project_edit.py +0 -0
  44. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/sessions.py +0 -0
  45. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/ssh.py +0 -0
  46. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/theme.py +0 -0
  47. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/widgets/__init__.py +0 -0
  48. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/widgets/spinner.py +0 -0
  49. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/dependency_links.txt +0 -0
  50. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/entry_points.txt +0 -0
  51. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/requires.txt +0 -0
  52. {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/top_level.txt +0 -0
  53. {ccmd-2.0.4 → ccmd-2.0.6}/commands.yaml +0 -0
  54. {ccmd-2.0.4 → ccmd-2.0.6}/requirements.txt +0 -0
  55. {ccmd-2.0.4 → ccmd-2.0.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.4
3
+ Version: 2.0.6
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.4"
3
+ __version__ = "2.0.6"
4
4
  __all__ = ["__version__"]
@@ -20,6 +20,55 @@ from ccmd.core.sessions import SessionStore
20
20
  from ccmd.core.ssh import SSHStore
21
21
 
22
22
 
23
+ def _cmd_shell(args: argparse.Namespace) -> int:
24
+ from ccmd.core.shell_cmds import (
25
+ install_shell_hook,
26
+ list_cmd_rows,
27
+ shell_cmds_path,
28
+ write_shell_cmds,
29
+ )
30
+
31
+ sub = args.shell_cmd or "regen"
32
+ if sub == "install":
33
+ path = write_shell_cmds()
34
+ rcs = install_shell_hook()
35
+ print(f"wrote {path}")
36
+ for rc in rcs:
37
+ print(f"hooked {rc}")
38
+ print("restart shell or: source ~/.ccmd/shell_cmds.sh")
39
+ print("then: ana / anac / anag …")
40
+ return 0
41
+ if sub == "regen":
42
+ path = write_shell_cmds()
43
+ print(f"regenerated {path}")
44
+ rows = list_cmd_rows()
45
+ for r in rows[:30]:
46
+ print(
47
+ f" {r['short']:<10} {r['project']:<18} "
48
+ f"default={r['default_agent']:<8} "
49
+ f"{r['short']}c/g/x/u/o/a/p"
50
+ )
51
+ if len(rows) > 30:
52
+ print(f" … +{len(rows) - 30} more (see {path})")
53
+ return 0
54
+ if sub == "list":
55
+ for r in list_cmd_rows():
56
+ vars_ = " ".join(
57
+ f"{k[0]}={v}" for k, v in r["variants"].items()
58
+ )
59
+ # show compact
60
+ v = r["variants"]
61
+ print(
62
+ f"{r['short']:<10} {r['project']:<20} "
63
+ f"{v.get('claude','')} {v.get('grok','')} {v.get('codex','')} "
64
+ f"{v.get('cursor','')} → {r['path']}"
65
+ )
66
+ print(f"\nfile: {shell_cmds_path()}")
67
+ return 0
68
+ print("usage: ccmd shell install|regen|list", file=sys.stderr)
69
+ return 1
70
+
71
+
23
72
  def _cmd_open(args: argparse.Namespace) -> int:
24
73
  reg = ProjectRegistry()
25
74
  project = reg.get(args.project)
@@ -245,6 +294,18 @@ def build_parser() -> argparse.ArgumentParser:
245
294
  sub.add_parser("mcp", help="Run MCP stdio server")
246
295
  sub.add_parser("tui", help="Force open TUI")
247
296
 
297
+ sh = sub.add_parser(
298
+ "shell",
299
+ help="Short project commands (ana/anac/anag…) for bash/zsh",
300
+ )
301
+ sh.add_argument(
302
+ "shell_cmd",
303
+ nargs="?",
304
+ choices=["install", "regen", "list"],
305
+ default="regen",
306
+ help="install=write cmds + hook bashrc; regen=rewrite cmds; list=show",
307
+ )
308
+
248
309
  return p
249
310
 
250
311
 
@@ -274,6 +335,7 @@ def main(argv: list[str] | None = None) -> int:
274
335
  "aliases": _cmd_aliases,
275
336
  "doctor": _cmd_doctor,
276
337
  "mcp": _cmd_mcp,
338
+ "shell": _cmd_shell,
277
339
  }
278
340
  handler = handlers.get(args.cmd)
279
341
  if not handler:
@@ -30,6 +30,7 @@ class Project:
30
30
  name: str
31
31
  path: str
32
32
  default_agent: str = "claude"
33
+ short: str = "" # shell shortcut base, e.g. "ana" → ana/anac/anag…
33
34
  tags: list[str] = field(default_factory=list)
34
35
  env_files: list[str] = field(default_factory=lambda: [".env"])
35
36
  path_prepend: list[str] = field(default_factory=list)
@@ -60,6 +61,7 @@ class Project:
60
61
  name=str(data.get("name") or data["id"]),
61
62
  path=str(data["path"]),
62
63
  default_agent=str(data.get("default_agent") or "claude"),
64
+ short=str(data.get("short") or ""),
63
65
  tags=list(data.get("tags") or []),
64
66
  env_files=list(data.get("env_files") or [".env"]),
65
67
  path_prepend=list(data.get("path_prepend") or []),
@@ -103,7 +105,7 @@ class ProjectRegistry:
103
105
  return None
104
106
  return Project.from_dict(data)
105
107
 
106
- def save(self, project: Project) -> Project:
108
+ def save(self, project: Project, *, regen_shell: bool = True) -> Project:
107
109
  project.updated_at = _now()
108
110
  path = self._file(project.id)
109
111
  path.write_text(
@@ -111,12 +113,25 @@ class ProjectRegistry:
111
113
  encoding="utf-8",
112
114
  )
113
115
  path.chmod(0o600)
116
+ if regen_shell:
117
+ try:
118
+ from ccmd.core.shell_cmds import write_shell_cmds
119
+
120
+ write_shell_cmds()
121
+ except Exception:
122
+ pass
114
123
  return project
115
124
 
116
125
  def delete(self, project_id: str) -> bool:
117
126
  path = self._file(project_id)
118
127
  if path.exists():
119
128
  path.unlink()
129
+ try:
130
+ from ccmd.core.shell_cmds import write_shell_cmds
131
+
132
+ write_shell_cmds()
133
+ except Exception:
134
+ pass
120
135
  return True
121
136
  return False
122
137
 
@@ -150,7 +165,7 @@ class ProjectRegistry:
150
165
  if not p:
151
166
  return
152
167
  p.last_opened_at = _now()
153
- self.save(p)
168
+ self.save(p, regen_shell=False)
154
169
 
155
170
  def fuzzy(self, query: str, limit: int = 50) -> list[Project]:
156
171
  projects = self.list()
@@ -0,0 +1,347 @@
1
+ """Generate per-project shell shortcuts (ana, anac, anag, …).
2
+
3
+ When a project is registered, users get short commands:
4
+
5
+ ana → ccmd open anastasis (default agent)
6
+ anac → ccmd open anastasis --agent claude
7
+ anag → grok
8
+ anax → codex
9
+ anau → cursor
10
+ anao → goose
11
+ anaa → aider
12
+ anap → chatgpt
13
+
14
+ Functions live in ~/.ccmd/shell_cmds.sh and are sourced from a managed
15
+ block in ~/.bashrc / ~/.zshrc. They call `ccmd open` (no eval-cd).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import shutil
22
+ from pathlib import Path
23
+
24
+ from ccmd.config.paths import ensure_dirs, generated_dir, home
25
+
26
+ # Single-letter agent suffixes (c = claude, so cursor uses u)
27
+ AGENT_SUFFIX: dict[str, str] = {
28
+ "claude": "c",
29
+ "grok": "g",
30
+ "codex": "x",
31
+ "cursor": "u",
32
+ "goose": "o",
33
+ "aider": "a",
34
+ "chatgpt": "p",
35
+ }
36
+
37
+ # Names we must never generate (shell builtins / common tools)
38
+ RESERVED = frozenset(
39
+ {
40
+ "cd",
41
+ "ls",
42
+ "ll",
43
+ "la",
44
+ "pwd",
45
+ "rm",
46
+ "cp",
47
+ "mv",
48
+ "cat",
49
+ "less",
50
+ "more",
51
+ "head",
52
+ "tail",
53
+ "grep",
54
+ "find",
55
+ "which",
56
+ "type",
57
+ "echo",
58
+ "printf",
59
+ "test",
60
+ "true",
61
+ "false",
62
+ "exit",
63
+ "export",
64
+ "source",
65
+ "alias",
66
+ "unalias",
67
+ "bg",
68
+ "fg",
69
+ "jobs",
70
+ "kill",
71
+ "ps",
72
+ "top",
73
+ "htop",
74
+ "ssh",
75
+ "scp",
76
+ "git",
77
+ "vim",
78
+ "nvim",
79
+ "nano",
80
+ "code",
81
+ "cursor",
82
+ "claude",
83
+ "grok",
84
+ "codex",
85
+ "go",
86
+ "node",
87
+ "npm",
88
+ "npx",
89
+ "pip",
90
+ "python",
91
+ "python3",
92
+ "ccmd",
93
+ "sudo",
94
+ "su",
95
+ "man",
96
+ "help",
97
+ "history",
98
+ "clear",
99
+ "reset",
100
+ "time",
101
+ "date",
102
+ "whoami",
103
+ "env",
104
+ "set",
105
+ "unset",
106
+ "pushd",
107
+ "popd",
108
+ "dirs",
109
+ "tmux",
110
+ "screen",
111
+ "docker",
112
+ "make",
113
+ "curl",
114
+ "wget",
115
+ "tar",
116
+ "zip",
117
+ "unzip",
118
+ }
119
+ )
120
+
121
+ MARKER_BEGIN = "# >>> ccmd shell cmds >>>"
122
+ MARKER_END = "# <<< ccmd shell cmds <<<"
123
+
124
+
125
+ def shell_cmds_path() -> Path:
126
+ ensure_dirs()
127
+ return home() / "shell_cmds.sh"
128
+
129
+
130
+ def short_candidates(project_id: str, name: str) -> list[str]:
131
+ """Generate preferred short-name candidates for a project."""
132
+ raw = re.sub(r"[^a-z0-9]+", "", (project_id or name).lower())
133
+ if not raw:
134
+ raw = "proj"
135
+ cands: list[str] = []
136
+ # full slug if short enough
137
+ if 2 <= len(raw) <= 12:
138
+ cands.append(raw)
139
+ # progressive prefixes: ana, anas, anast…
140
+ for n in range(3, min(8, len(raw) + 1)):
141
+ cands.append(raw[:n])
142
+ # consonants-only compact form
143
+ cons = re.sub(r"[aeiou]", "", raw)
144
+ if len(cons) >= 3:
145
+ cands.append(cons[:4])
146
+ # first letters of hyphen/underscore parts
147
+ parts = re.split(r"[-_\s]+", project_id or name)
148
+ initials = "".join(p[0] for p in parts if p)[:6].lower()
149
+ if len(initials) >= 2:
150
+ cands.append(re.sub(r"[^a-z0-9]", "", initials))
151
+ # dedupe preserve order
152
+ seen: set[str] = set()
153
+ out: list[str] = []
154
+ for c in cands:
155
+ if c and c not in seen and c[0].isalpha():
156
+ seen.add(c)
157
+ out.append(c)
158
+ return out or [f"p{raw[:6]}"]
159
+
160
+
161
+ def allocate_shorts(projects: list) -> dict[str, str]:
162
+ """Map project.id → unique short name."""
163
+ used: set[str] = set(RESERVED)
164
+ # honor existing project.short if set and unique
165
+ mapping: dict[str, str] = {}
166
+ for p in projects:
167
+ existing = getattr(p, "short", None) or ""
168
+ existing = re.sub(r"[^a-z0-9]", "", existing.lower())
169
+ if existing and existing not in used and existing[0].isalpha():
170
+ mapping[p.id] = existing
171
+ used.add(existing)
172
+ # also reserve agent variants
173
+ for suf in AGENT_SUFFIX.values():
174
+ used.add(existing + suf)
175
+
176
+ for p in projects:
177
+ if p.id in mapping:
178
+ continue
179
+ chosen = None
180
+ for cand in short_candidates(p.id, p.name):
181
+ if cand in used:
182
+ continue
183
+ # agent variants must also be free
184
+ if any((cand + s) in used for s in AGENT_SUFFIX.values()):
185
+ continue
186
+ chosen = cand
187
+ break
188
+ if not chosen:
189
+ # fallback numbered
190
+ base = short_candidates(p.id, p.name)[0][:4]
191
+ n = 2
192
+ while True:
193
+ cand = f"{base}{n}"
194
+ if cand not in used and all(
195
+ (cand + s) not in used for s in AGENT_SUFFIX.values()
196
+ ):
197
+ chosen = cand
198
+ break
199
+ n += 1
200
+ mapping[p.id] = chosen
201
+ used.add(chosen)
202
+ for suf in AGENT_SUFFIX.values():
203
+ used.add(chosen + suf)
204
+ return mapping
205
+
206
+
207
+ def render_shell_cmds(projects: list, shorts: dict[str, str]) -> str:
208
+ """Bash/zsh-compatible function definitions."""
209
+ lines = [
210
+ "#!/usr/bin/env bash",
211
+ "# Auto-generated by ccmd — do not edit by hand.",
212
+ "# Regenerate: ccmd shell regen",
213
+ "#",
214
+ "# Usage examples:",
215
+ "# ana open project with its default agent",
216
+ "# anac same project + claude",
217
+ "# anag + grok anax + codex anau + cursor",
218
+ "# anao + goose anaa + aider anap + chatgpt",
219
+ "#",
220
+ "",
221
+ ]
222
+ # stable order by short name
223
+ rows = sorted(
224
+ ((shorts[p.id], p) for p in projects if p.id in shorts),
225
+ key=lambda t: t[0],
226
+ )
227
+ for short, p in rows:
228
+ pid = p.id
229
+ agent = p.default_agent or "claude"
230
+ lines.append(f"# {p.name} → {p.path} (default: {agent})")
231
+ lines.append(
232
+ f"{short}() {{ command ccmd open {pid} \"$@\"; }}"
233
+ )
234
+ for agent_id, suf in AGENT_SUFFIX.items():
235
+ fn = f"{short}{suf}"
236
+ lines.append(
237
+ f"{fn}() {{ command ccmd open {pid} --agent {agent_id} \"$@\"; }}"
238
+ )
239
+ lines.append("")
240
+
241
+ lines.append("# end ccmd shell cmds")
242
+ lines.append("")
243
+ return "\n".join(lines)
244
+
245
+
246
+ def write_shell_cmds(projects: list | None = None) -> Path:
247
+ """Write ~/.ccmd/shell_cmds.sh and persist short names on projects."""
248
+ from ccmd.core.projects import ProjectRegistry
249
+
250
+ reg = ProjectRegistry()
251
+ projects = list(projects) if projects is not None else reg.list()
252
+ shorts = allocate_shorts(projects)
253
+
254
+ # persist short onto each project yaml
255
+ for p in projects:
256
+ short = shorts.get(p.id)
257
+ if short and getattr(p, "short", None) != short:
258
+ p.short = short
259
+ reg.save(p, regen_shell=False)
260
+
261
+ path = shell_cmds_path()
262
+ path.write_text(render_shell_cmds(projects, shorts), encoding="utf-8")
263
+ path.chmod(0o644)
264
+
265
+ # also drop a cheat-sheet
266
+ sheet = generated_dir() / "cmds.txt"
267
+ lines = ["# ccmd short commands\n"]
268
+ for p in sorted(projects, key=lambda x: shorts.get(x.id, x.id)):
269
+ s = shorts.get(p.id, "?")
270
+ lines.append(
271
+ f"{s:<10} {p.id:<20} default={p.default_agent:<8} "
272
+ f"+c claude +g grok +x codex +u cursor +o goose +a aider +p chatgpt\n"
273
+ )
274
+ lines.append(f"{'':10} {p.path}\n")
275
+ sheet.write_text("".join(lines), encoding="utf-8")
276
+ return path
277
+
278
+
279
+ def install_shell_hook(rc_file: Path | None = None) -> list[Path]:
280
+ """Ensure bashrc/zshrc sources shell_cmds.sh (managed block)."""
281
+ ensure_dirs()
282
+ write_shell_cmds()
283
+ block = (
284
+ f"\n{MARKER_BEGIN}\n"
285
+ f"# managed by ccmd — short project commands (ana/anac/anag…)\n"
286
+ f'[ -f "$HOME/.ccmd/shell_cmds.sh" ] && . "$HOME/.ccmd/shell_cmds.sh"\n'
287
+ f"{MARKER_END}\n"
288
+ )
289
+ updated: list[Path] = []
290
+ candidates = []
291
+ if rc_file:
292
+ candidates = [rc_file]
293
+ else:
294
+ home_dir = Path.home()
295
+ for name in (".bashrc", ".zshrc", ".bash_profile"):
296
+ p = home_dir / name
297
+ if p.exists() or name == ".bashrc":
298
+ candidates.append(p)
299
+
300
+ for rc in candidates:
301
+ try:
302
+ text = rc.read_text(encoding="utf-8") if rc.exists() else ""
303
+ except OSError:
304
+ continue
305
+ if MARKER_BEGIN in text:
306
+ # replace existing block
307
+ pattern = re.compile(
308
+ re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END),
309
+ re.DOTALL,
310
+ )
311
+ new_text = pattern.sub(
312
+ block.strip(),
313
+ text,
314
+ )
315
+ if not new_text.endswith("\n"):
316
+ new_text += "\n"
317
+ else:
318
+ new_text = text.rstrip() + "\n" + block
319
+ # backup once
320
+ bak = home() / "backups" / f"{rc.name}.bak"
321
+ bak.parent.mkdir(parents=True, exist_ok=True)
322
+ if rc.exists():
323
+ shutil.copy2(rc, bak)
324
+ rc.write_text(new_text, encoding="utf-8")
325
+ updated.append(rc)
326
+ return updated
327
+
328
+
329
+ def list_cmd_rows(projects: list | None = None) -> list[dict]:
330
+ from ccmd.core.projects import ProjectRegistry
331
+
332
+ projects = list(projects) if projects is not None else ProjectRegistry().list()
333
+ shorts = allocate_shorts(projects)
334
+ rows = []
335
+ for p in projects:
336
+ s = shorts.get(p.id, "")
337
+ rows.append(
338
+ {
339
+ "short": s,
340
+ "project": p.id,
341
+ "default_agent": p.default_agent,
342
+ "path": p.path,
343
+ "variants": {a: f"{s}{suf}" for a, suf in AGENT_SUFFIX.items()},
344
+ }
345
+ )
346
+ rows.sort(key=lambda r: r["short"])
347
+ return rows
@@ -12,8 +12,6 @@ from textual.screen import Screen
12
12
  from textual.widgets import Footer, Input, OptionList, Static
13
13
  from textual.widgets.option_list import Option
14
14
 
15
- from rich.markup import escape
16
-
17
15
  from ccmd.config.schema import load_config
18
16
  from ccmd.core.agents import all_agents, get_agent
19
17
  from ccmd.core.fs import default_browse_roots, is_projectish, list_dir_entries
@@ -71,8 +69,8 @@ class DirBrowserScreen(Screen[bool | None]):
71
69
  self._agent = load_config().default_agent or "claude"
72
70
 
73
71
  def compose(self) -> ComposeResult:
74
- yield Static("directory browser", id="subtitle")
75
- yield Static("", id="path-bar")
72
+ yield Static("directory browser", id="subtitle", markup=False)
73
+ yield Static("", id="path-bar", markup=False)
76
74
  yield Input(placeholder="filter this folder…", id="filter")
77
75
  with Horizontal(id="main"):
78
76
  with Vertical(id="projects-panel"):
@@ -80,10 +78,11 @@ class DirBrowserScreen(Screen[bool | None]):
80
78
  yield OptionList(id="dir-list")
81
79
  with Vertical(id="detail-panel"):
82
80
  yield Static("preview", classes="panel-title")
83
- yield Static("", id="preview")
81
+ yield Static("", id="preview", markup=False)
84
82
  yield Static(
85
- " open · up · s add project · ]/[ agent · 1-7 agent · r roots · g path · esc",
83
+ "enter open · backspace up · s add · ]/[ agent · 1-7 agent · r roots · g path · esc",
86
84
  id="footer-bar",
85
+ markup=False,
87
86
  )
88
87
  yield Footer()
89
88
 
@@ -100,9 +99,8 @@ class DirBrowserScreen(Screen[bool | None]):
100
99
  ag = get_agent(self._agent)
101
100
  ag_s = f"{ag.ascii_logo} {self._agent}" if ag else self._agent
102
101
  bar.update(
103
- f"[bold #00ffc8]{escape(str(self._cwd))}[/]{mark}{hidden} · "
104
- f"agent [bold #c84bff]{escape(ag_s)}[/]"
105
- + (f" [#6a6a80]{escape(extra)}[/]" if extra else "")
102
+ f"{self._cwd}{mark}{hidden} · agent {ag_s}"
103
+ + (f" {extra}" if extra else "")
106
104
  )
107
105
 
108
106
  def _render_list(self) -> None:
@@ -144,7 +142,7 @@ class DirBrowserScreen(Screen[bool | None]):
144
142
  def _render_roots(self) -> None:
145
143
  self._mode = "roots"
146
144
  self.query_one("#path-bar", Static).update(
147
- "[bold #c84bff]browse roots[/] · pick a starting point"
145
+ "browse roots · pick a starting point"
148
146
  )
149
147
  self.query_one("#list-title", Static).update("roots")
150
148
  ol = self.query_one("#dir-list", OptionList)
@@ -186,21 +184,21 @@ class DirBrowserScreen(Screen[bool | None]):
186
184
  kids = list_dir_entries(resolved, show_hidden=self._show_hidden, dirs_only=True)
187
185
  project = is_projectish(resolved)
188
186
  lines = [
189
- f"[bold #00ffc8]{escape(str(resolved.name or resolved))}[/]",
190
- f"path {escape(str(resolved))}",
191
- f"project {'yes' if project else 'no (still selectable)'}",
187
+ str(resolved.name or resolved),
188
+ f"path {resolved}",
189
+ f"project {'yes' if project else 'no (still selectable)'}",
192
190
  f"subdirs {len(kids)}",
193
191
  "",
194
- "[#6a6a80]children[/]",
192
+ "children",
195
193
  ]
196
194
  for name, _, _ in kids[:12]:
197
- lines.append(f" · {escape(name)}/")
195
+ lines.append(f" - {name}/")
198
196
  if len(kids) > 12:
199
- lines.append(f" +{len(kids) - 12} more")
197
+ lines.append(f" ... +{len(kids) - 12} more")
200
198
  lines.extend(
201
199
  [
202
200
  "",
203
- "[#6a6a80]enter open folder · s select as project[/]",
201
+ "enter open folder · s select as project",
204
202
  ]
205
203
  )
206
204
  prev.update("\n".join(lines))
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- from rich.markup import escape
8
7
  from textual import on, work
9
8
  from textual.app import ComposeResult
10
9
  from textual.binding import Binding
@@ -24,12 +23,11 @@ AGENT_ORDER = [a.id for a in all_agents()]
24
23
 
25
24
 
26
25
  def _agent_bar() -> str:
26
+ """Plain text — no Rich tags (logos/paths break markup easily)."""
27
27
  parts: list[str] = []
28
28
  for i, a in enumerate(detect_agents(), start=1):
29
- if a["installed"]:
30
- parts.append(f"[{a['color']}]{i}:{a['logo']}{a['id']}[/]")
31
- else:
32
- parts.append(f"[#6a6a80]{i}:{a['id']}[/]")
29
+ flag = "+" if a["installed"] else "-"
30
+ parts.append(f"{i}:{a['logo']}{a['id']}{flag}")
33
31
  return " ".join(parts)
34
32
 
35
33
 
@@ -87,11 +85,13 @@ class HubScreen(Screen):
87
85
  yield OptionList(id="project-list")
88
86
  with Vertical(id="detail-panel"):
89
87
  yield Static("detail", classes="panel-title")
90
- yield Static("Select a project", id="detail")
91
- yield Static("", id="status")
88
+ # markup=False: paths, timestamps, and key hints must never hit Rich
89
+ yield Static("Select a project", id="detail", markup=False)
90
+ yield Static("", id="status", markup=False)
92
91
  yield Static(
93
- " open · f /mnt · b browse · / filter · esc list · a agent · ] cycle · q quit",
92
+ "enter open · f /mnt · b browse · / filter · esc list · a agent · ] cycle · q quit",
94
93
  id="footer-bar",
94
+ markup=False,
95
95
  )
96
96
  yield Footer()
97
97
 
@@ -174,10 +174,10 @@ class HubScreen(Screen):
174
174
  if not p:
175
175
  detail.update(
176
176
  "Select a project\n\n"
177
- "[#6a6a80]This list is your saved registry.\n"
178
- "Press [bold]f[/] to browse /mnt and subdirs,\n"
179
- "or [bold]b[/] for home + all roots.\n"
180
- "Press ] to cycle default agent.[/]"
177
+ "This list is your saved registry.\n"
178
+ "Press f to browse /mnt and subdirs,\n"
179
+ "or b for home + all roots.\n"
180
+ "Press ] to cycle default agent."
181
181
  )
182
182
  return
183
183
  agent = get_agent(p.default_agent)
@@ -189,30 +189,30 @@ class HubScreen(Screen):
189
189
  exists = "yes" if p.exists() else "MISSING"
190
190
  roster: list[str] = []
191
191
  for i, a in enumerate(self._agent_info, start=1):
192
- mark = "" if a["id"] == p.default_agent else "·"
193
- ready = "" if a["installed"] else ""
194
- roster.append(
195
- f" {mark} {i} {escape(str(a['logo']))} "
196
- f"{escape(a['id']):<8} {ready}"
197
- )
192
+ mark = "*" if a["id"] == p.default_agent else " "
193
+ ready = "ok" if a["installed"] else "no"
194
+ roster.append(f" {mark} {i} {a['logo']} {a['id']:<8} {ready}")
198
195
 
199
- # Escape anything that can contain [ ] so Rich markup doesn't explode
196
+ short = p.short or p.id[:4]
200
197
  lines = [
201
- f"[bold #00ffc8]{escape(p.name)}[/]",
202
- f"id {escape(p.id)}",
203
- f"path {escape(p.path)}",
204
- f"root {escape(_root_label(p.path))}",
198
+ p.name,
199
+ f"id {p.id}",
200
+ f"short {short} → {short}c claude · {short}g grok · {short}x codex · {short}u cursor",
201
+ f" {short}o goose · {short}a aider · {short}p chatgpt · bare {short} = default",
202
+ f"path {p.path}",
203
+ f"root {_root_label(p.path)}",
205
204
  f"exists {exists}",
206
- f"agent [bold]{escape(agent_line)}[/] (default)",
207
- f"ssh {escape(p.ssh_host or '')}",
208
- f"env {escape(', '.join(p.env_files) or '')}",
209
- f"tags {escape(', '.join(p.tags) or '')}",
210
- f"opened {escape(p.last_opened_at or 'never')}",
205
+ f"agent {agent_line} (default)",
206
+ f"ssh {p.ssh_host or '-'}",
207
+ f"env {', '.join(p.env_files) or '-'}",
208
+ f"tags {', '.join(p.tags) or '-'}",
209
+ f"opened {p.last_opened_at or 'never'}",
211
210
  "",
212
- "[bold #c84bff]agents[/] keys: ] [ cycle · 1-7 set · A set · a launch",
211
+ "agents ]/[ cycle · 1-7 set · A set · a launch",
213
212
  *roster,
214
213
  "",
215
- "[#6a6a80]enter open · d detach · b/f add from disk[/]",
214
+ "enter open · d detach · b/f add from disk",
215
+ "shell: source ~/.ccmd/shell_cmds.sh (or: ccmd shell install)",
216
216
  ]
217
217
  detail.update("\n".join(lines))
218
218
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccmd
3
- Version: 2.0.4
3
+ Version: 2.0.6
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
@@ -24,6 +24,7 @@ ccmd/core/profiles.py
24
24
  ccmd/core/projects.py
25
25
  ccmd/core/scan.py
26
26
  ccmd/core/sessions.py
27
+ ccmd/core/shell_cmds.py
27
28
  ccmd/core/ssh.py
28
29
  ccmd/core/agents/__init__.py
29
30
  ccmd/core/agents/aider.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ccmd"
7
- version = "2.0.4"
7
+ version = "2.0.6"
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
File without changes