ccmd 2.0.5__tar.gz → 2.0.7__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.5/ccmd.egg-info → ccmd-2.0.7}/PKG-INFO +1 -1
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/__init__.py +1 -1
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/cli.py +62 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/projects.py +17 -2
- ccmd-2.0.7/ccmd/core/shell_cmds.py +352 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/hub.py +4 -0
- {ccmd-2.0.5 → ccmd-2.0.7/ccmd.egg-info}/PKG-INFO +1 -1
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd.egg-info/SOURCES.txt +1 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/pyproject.toml +1 -1
- {ccmd-2.0.5 → ccmd-2.0.7}/LICENSE +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/MANIFEST.in +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/README.md +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/__main__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/config/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/config/paths.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/config/schema.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/aider.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/base.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/chatgpt.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/claude.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/codex.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/cursor.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/goose.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/agents/grok.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/fs.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/launcher.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/migrate.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/profiles.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/scan.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/sessions.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/core/ssh.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/mcp/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/mcp/server.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/app.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/ascii.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/agents.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/aliases.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/browser.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/project_edit.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/sessions.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/screens/ssh.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/theme.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/widgets/__init__.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd/tui/widgets/spinner.py +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd.egg-info/dependency_links.txt +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd.egg-info/entry_points.txt +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd.egg-info/requires.txt +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/ccmd.egg-info/top_level.txt +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/commands.yaml +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/requirements.txt +0 -0
- {ccmd-2.0.5 → ccmd-2.0.7}/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,352 @@
|
|
|
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
|
+
|
|
133
|
+
Prefer snappy 3–4 letter names (ana, flo) before the full slug.
|
|
134
|
+
"""
|
|
135
|
+
raw = re.sub(r"[^a-z0-9]+", "", (project_id or name).lower())
|
|
136
|
+
if not raw:
|
|
137
|
+
raw = "proj"
|
|
138
|
+
cands: list[str] = []
|
|
139
|
+
# progressive prefixes first: ana, anas, anast… (user wants ana not anastasis)
|
|
140
|
+
for n in (3, 4, 5, 6, 7, 8):
|
|
141
|
+
if n <= len(raw):
|
|
142
|
+
cands.append(raw[:n])
|
|
143
|
+
# first letters of hyphen/underscore parts (build-your-own → byo)
|
|
144
|
+
parts = re.split(r"[-_\s]+", project_id or name)
|
|
145
|
+
initials = "".join(p[0] for p in parts if p)[:6].lower()
|
|
146
|
+
initials = re.sub(r"[^a-z0-9]", "", initials)
|
|
147
|
+
if len(initials) >= 2:
|
|
148
|
+
cands.append(initials)
|
|
149
|
+
# consonants-only compact form
|
|
150
|
+
cons = re.sub(r"[aeiou]", "", raw)
|
|
151
|
+
if len(cons) >= 3:
|
|
152
|
+
cands.append(cons[:4])
|
|
153
|
+
# full slug last (only if reasonably short)
|
|
154
|
+
if 2 <= len(raw) <= 10:
|
|
155
|
+
cands.append(raw)
|
|
156
|
+
# dedupe preserve order
|
|
157
|
+
seen: set[str] = set()
|
|
158
|
+
out: list[str] = []
|
|
159
|
+
for c in cands:
|
|
160
|
+
if c and c not in seen and c[0].isalpha():
|
|
161
|
+
seen.add(c)
|
|
162
|
+
out.append(c)
|
|
163
|
+
return out or [f"p{raw[:6]}"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def allocate_shorts(projects: list) -> dict[str, str]:
|
|
167
|
+
"""Map project.id → unique short name."""
|
|
168
|
+
used: set[str] = set(RESERVED)
|
|
169
|
+
# honor existing project.short if set and unique
|
|
170
|
+
mapping: dict[str, str] = {}
|
|
171
|
+
for p in projects:
|
|
172
|
+
existing = getattr(p, "short", None) or ""
|
|
173
|
+
existing = re.sub(r"[^a-z0-9]", "", existing.lower())
|
|
174
|
+
if existing and existing not in used and existing[0].isalpha():
|
|
175
|
+
mapping[p.id] = existing
|
|
176
|
+
used.add(existing)
|
|
177
|
+
# also reserve agent variants
|
|
178
|
+
for suf in AGENT_SUFFIX.values():
|
|
179
|
+
used.add(existing + suf)
|
|
180
|
+
|
|
181
|
+
for p in projects:
|
|
182
|
+
if p.id in mapping:
|
|
183
|
+
continue
|
|
184
|
+
chosen = None
|
|
185
|
+
for cand in short_candidates(p.id, p.name):
|
|
186
|
+
if cand in used:
|
|
187
|
+
continue
|
|
188
|
+
# agent variants must also be free
|
|
189
|
+
if any((cand + s) in used for s in AGENT_SUFFIX.values()):
|
|
190
|
+
continue
|
|
191
|
+
chosen = cand
|
|
192
|
+
break
|
|
193
|
+
if not chosen:
|
|
194
|
+
# fallback numbered
|
|
195
|
+
base = short_candidates(p.id, p.name)[0][:4]
|
|
196
|
+
n = 2
|
|
197
|
+
while True:
|
|
198
|
+
cand = f"{base}{n}"
|
|
199
|
+
if cand not in used and all(
|
|
200
|
+
(cand + s) not in used for s in AGENT_SUFFIX.values()
|
|
201
|
+
):
|
|
202
|
+
chosen = cand
|
|
203
|
+
break
|
|
204
|
+
n += 1
|
|
205
|
+
mapping[p.id] = chosen
|
|
206
|
+
used.add(chosen)
|
|
207
|
+
for suf in AGENT_SUFFIX.values():
|
|
208
|
+
used.add(chosen + suf)
|
|
209
|
+
return mapping
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def render_shell_cmds(projects: list, shorts: dict[str, str]) -> str:
|
|
213
|
+
"""Bash/zsh-compatible function definitions."""
|
|
214
|
+
lines = [
|
|
215
|
+
"#!/usr/bin/env bash",
|
|
216
|
+
"# Auto-generated by ccmd — do not edit by hand.",
|
|
217
|
+
"# Regenerate: ccmd shell regen",
|
|
218
|
+
"#",
|
|
219
|
+
"# Usage examples:",
|
|
220
|
+
"# ana open project with its default agent",
|
|
221
|
+
"# anac same project + claude",
|
|
222
|
+
"# anag + grok anax + codex anau + cursor",
|
|
223
|
+
"# anao + goose anaa + aider anap + chatgpt",
|
|
224
|
+
"#",
|
|
225
|
+
"",
|
|
226
|
+
]
|
|
227
|
+
# stable order by short name
|
|
228
|
+
rows = sorted(
|
|
229
|
+
((shorts[p.id], p) for p in projects if p.id in shorts),
|
|
230
|
+
key=lambda t: t[0],
|
|
231
|
+
)
|
|
232
|
+
for short, p in rows:
|
|
233
|
+
pid = p.id
|
|
234
|
+
agent = p.default_agent or "claude"
|
|
235
|
+
lines.append(f"# {p.name} → {p.path} (default: {agent})")
|
|
236
|
+
lines.append(
|
|
237
|
+
f"{short}() {{ command ccmd open {pid} \"$@\"; }}"
|
|
238
|
+
)
|
|
239
|
+
for agent_id, suf in AGENT_SUFFIX.items():
|
|
240
|
+
fn = f"{short}{suf}"
|
|
241
|
+
lines.append(
|
|
242
|
+
f"{fn}() {{ command ccmd open {pid} --agent {agent_id} \"$@\"; }}"
|
|
243
|
+
)
|
|
244
|
+
lines.append("")
|
|
245
|
+
|
|
246
|
+
lines.append("# end ccmd shell cmds")
|
|
247
|
+
lines.append("")
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def write_shell_cmds(projects: list | None = None) -> Path:
|
|
252
|
+
"""Write ~/.ccmd/shell_cmds.sh and persist short names on projects."""
|
|
253
|
+
from ccmd.core.projects import ProjectRegistry
|
|
254
|
+
|
|
255
|
+
reg = ProjectRegistry()
|
|
256
|
+
projects = list(projects) if projects is not None else reg.list()
|
|
257
|
+
shorts = allocate_shorts(projects)
|
|
258
|
+
|
|
259
|
+
# persist short onto each project yaml
|
|
260
|
+
for p in projects:
|
|
261
|
+
short = shorts.get(p.id)
|
|
262
|
+
if short and getattr(p, "short", None) != short:
|
|
263
|
+
p.short = short
|
|
264
|
+
reg.save(p, regen_shell=False)
|
|
265
|
+
|
|
266
|
+
path = shell_cmds_path()
|
|
267
|
+
path.write_text(render_shell_cmds(projects, shorts), encoding="utf-8")
|
|
268
|
+
path.chmod(0o644)
|
|
269
|
+
|
|
270
|
+
# also drop a cheat-sheet
|
|
271
|
+
sheet = generated_dir() / "cmds.txt"
|
|
272
|
+
lines = ["# ccmd short commands\n"]
|
|
273
|
+
for p in sorted(projects, key=lambda x: shorts.get(x.id, x.id)):
|
|
274
|
+
s = shorts.get(p.id, "?")
|
|
275
|
+
lines.append(
|
|
276
|
+
f"{s:<10} {p.id:<20} default={p.default_agent:<8} "
|
|
277
|
+
f"+c claude +g grok +x codex +u cursor +o goose +a aider +p chatgpt\n"
|
|
278
|
+
)
|
|
279
|
+
lines.append(f"{'':10} {p.path}\n")
|
|
280
|
+
sheet.write_text("".join(lines), encoding="utf-8")
|
|
281
|
+
return path
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def install_shell_hook(rc_file: Path | None = None) -> list[Path]:
|
|
285
|
+
"""Ensure bashrc/zshrc sources shell_cmds.sh (managed block)."""
|
|
286
|
+
ensure_dirs()
|
|
287
|
+
write_shell_cmds()
|
|
288
|
+
block = (
|
|
289
|
+
f"\n{MARKER_BEGIN}\n"
|
|
290
|
+
f"# managed by ccmd — short project commands (ana/anac/anag…)\n"
|
|
291
|
+
f'[ -f "$HOME/.ccmd/shell_cmds.sh" ] && . "$HOME/.ccmd/shell_cmds.sh"\n'
|
|
292
|
+
f"{MARKER_END}\n"
|
|
293
|
+
)
|
|
294
|
+
updated: list[Path] = []
|
|
295
|
+
candidates = []
|
|
296
|
+
if rc_file:
|
|
297
|
+
candidates = [rc_file]
|
|
298
|
+
else:
|
|
299
|
+
home_dir = Path.home()
|
|
300
|
+
for name in (".bashrc", ".zshrc", ".bash_profile"):
|
|
301
|
+
p = home_dir / name
|
|
302
|
+
if p.exists() or name == ".bashrc":
|
|
303
|
+
candidates.append(p)
|
|
304
|
+
|
|
305
|
+
for rc in candidates:
|
|
306
|
+
try:
|
|
307
|
+
text = rc.read_text(encoding="utf-8") if rc.exists() else ""
|
|
308
|
+
except OSError:
|
|
309
|
+
continue
|
|
310
|
+
if MARKER_BEGIN in text:
|
|
311
|
+
# replace existing block
|
|
312
|
+
pattern = re.compile(
|
|
313
|
+
re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END),
|
|
314
|
+
re.DOTALL,
|
|
315
|
+
)
|
|
316
|
+
new_text = pattern.sub(
|
|
317
|
+
block.strip(),
|
|
318
|
+
text,
|
|
319
|
+
)
|
|
320
|
+
if not new_text.endswith("\n"):
|
|
321
|
+
new_text += "\n"
|
|
322
|
+
else:
|
|
323
|
+
new_text = text.rstrip() + "\n" + block
|
|
324
|
+
# backup once
|
|
325
|
+
bak = home() / "backups" / f"{rc.name}.bak"
|
|
326
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
if rc.exists():
|
|
328
|
+
shutil.copy2(rc, bak)
|
|
329
|
+
rc.write_text(new_text, encoding="utf-8")
|
|
330
|
+
updated.append(rc)
|
|
331
|
+
return updated
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def list_cmd_rows(projects: list | None = None) -> list[dict]:
|
|
335
|
+
from ccmd.core.projects import ProjectRegistry
|
|
336
|
+
|
|
337
|
+
projects = list(projects) if projects is not None else ProjectRegistry().list()
|
|
338
|
+
shorts = allocate_shorts(projects)
|
|
339
|
+
rows = []
|
|
340
|
+
for p in projects:
|
|
341
|
+
s = shorts.get(p.id, "")
|
|
342
|
+
rows.append(
|
|
343
|
+
{
|
|
344
|
+
"short": s,
|
|
345
|
+
"project": p.id,
|
|
346
|
+
"default_agent": p.default_agent,
|
|
347
|
+
"path": p.path,
|
|
348
|
+
"variants": {a: f"{s}{suf}" for a, suf in AGENT_SUFFIX.items()},
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
rows.sort(key=lambda r: r["short"])
|
|
352
|
+
return rows
|
|
@@ -193,9 +193,12 @@ class HubScreen(Screen):
|
|
|
193
193
|
ready = "ok" if a["installed"] else "no"
|
|
194
194
|
roster.append(f" {mark} {i} {a['logo']} {a['id']:<8} {ready}")
|
|
195
195
|
|
|
196
|
+
short = p.short or p.id[:4]
|
|
196
197
|
lines = [
|
|
197
198
|
p.name,
|
|
198
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",
|
|
199
202
|
f"path {p.path}",
|
|
200
203
|
f"root {_root_label(p.path)}",
|
|
201
204
|
f"exists {exists}",
|
|
@@ -209,6 +212,7 @@ class HubScreen(Screen):
|
|
|
209
212
|
*roster,
|
|
210
213
|
"",
|
|
211
214
|
"enter open · d detach · b/f add from disk",
|
|
215
|
+
"shell: source ~/.ccmd/shell_cmds.sh (or: ccmd shell install)",
|
|
212
216
|
]
|
|
213
217
|
detail.update("\n".join(lines))
|
|
214
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
|
|
File without changes
|