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.
- {ccmd-2.0.4/ccmd.egg-info → ccmd-2.0.6}/PKG-INFO +1 -1
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/__init__.py +1 -1
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/cli.py +62 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/projects.py +17 -2
- ccmd-2.0.6/ccmd/core/shell_cmds.py +347 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/browser.py +15 -17
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/hub.py +30 -30
- {ccmd-2.0.4 → ccmd-2.0.6/ccmd.egg-info}/PKG-INFO +1 -1
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/SOURCES.txt +1 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/pyproject.toml +1 -1
- {ccmd-2.0.4 → ccmd-2.0.6}/LICENSE +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/MANIFEST.in +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/README.md +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/__main__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/paths.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/config/schema.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/aider.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/base.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/chatgpt.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/claude.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/codex.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/cursor.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/goose.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/agents/grok.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/fs.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/launcher.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/migrate.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/profiles.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/scan.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/sessions.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/core/ssh.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/mcp/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/mcp/server.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/app.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/ascii.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/agents.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/aliases.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/project_edit.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/sessions.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/screens/ssh.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/theme.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/widgets/__init__.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd/tui/widgets/spinner.py +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/dependency_links.txt +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/entry_points.txt +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/requires.txt +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/ccmd.egg-info/top_level.txt +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/commands.yaml +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/requirements.txt +0 -0
- {ccmd-2.0.4 → ccmd-2.0.6}/setup.cfg +0 -0
|
@@ -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
|
-
"
|
|
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"
|
|
104
|
-
f"
|
|
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
|
-
"
|
|
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
|
-
|
|
190
|
-
f"path {
|
|
191
|
-
f"project {'yes
|
|
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
|
-
"
|
|
192
|
+
"children",
|
|
195
193
|
]
|
|
196
194
|
for name, _, _ in kids[:12]:
|
|
197
|
-
lines.append(f"
|
|
195
|
+
lines.append(f" - {name}/")
|
|
198
196
|
if len(kids) > 12:
|
|
199
|
-
lines.append(f"
|
|
197
|
+
lines.append(f" ... +{len(kids) - 12} more")
|
|
200
198
|
lines.extend(
|
|
201
199
|
[
|
|
202
200
|
"",
|
|
203
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
178
|
-
"Press
|
|
179
|
-
"or
|
|
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 = "
|
|
193
|
-
ready = "
|
|
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
|
-
|
|
196
|
+
short = p.short or p.id[:4]
|
|
200
197
|
lines = [
|
|
201
|
-
|
|
202
|
-
f"id {
|
|
203
|
-
f"
|
|
204
|
-
f"
|
|
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
|
|
207
|
-
f"ssh {
|
|
208
|
-
f"env {
|
|
209
|
-
f"tags {
|
|
210
|
-
f"opened {
|
|
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
|
-
"
|
|
211
|
+
"agents ]/[ cycle · 1-7 set · A set · a launch",
|
|
213
212
|
*roster,
|
|
214
213
|
"",
|
|
215
|
-
"
|
|
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
|
|
|
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
|
|
File without changes
|
|
File without changes
|