cc-hub 0.2.1__py3-none-any.whl
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.
- cc_hub/__init__.py +4 -0
- cc_hub/__main__.py +7 -0
- cc_hub/cli.py +79 -0
- cc_hub/commands/__init__.py +1 -0
- cc_hub/commands/doctor.py +373 -0
- cc_hub/commands/env.py +218 -0
- cc_hub/commands/hosts.py +44 -0
- cc_hub/commands/init.py +110 -0
- cc_hub/commands/link.py +33 -0
- cc_hub/commands/memory.py +172 -0
- cc_hub/commands/projects.py +196 -0
- cc_hub/commands/update.py +203 -0
- cc_hub/console.py +34 -0
- cc_hub/encoding.py +74 -0
- cc_hub/hub.py +67 -0
- cc_hub/manifest.py +69 -0
- cc_hub/platform.py +70 -0
- cc_hub/templates/AGENTS.local.md +3 -0
- cc_hub/templates/AGENTS.md +80 -0
- cc_hub/templates/BOOTSTRAP.md +31 -0
- cc_hub/templates/IDENTITY.md +3 -0
- cc_hub/templates/MEMORY.md +12 -0
- cc_hub/templates/SOUL.md +26 -0
- cc_hub/templates/USER.md +9 -0
- cc_hub/templates/_gitignore +16 -0
- cc_hub/templates/cron/README.md +19 -0
- cc_hub/templates/cron/memory-consolidate.md +7 -0
- cc_hub/templates/hosts.toml +9 -0
- cc_hub/templates/hub.toml +11 -0
- cc_hub/templates/projects.toml +8 -0
- cc_hub/templates/skills/hub-adapt/SKILL.md +42 -0
- cc_hub/templates/skills/hub-cc-connect/SKILL.md +51 -0
- cc_hub/templates/skills/hub-hosts/SKILL.md +48 -0
- cc_hub/templates/skills/hub-memory/SKILL.md +43 -0
- cc_hub/templates/skills/hub-projects/SKILL.md +34 -0
- cc_hub-0.2.1.dist-info/METADATA +86 -0
- cc_hub-0.2.1.dist-info/RECORD +40 -0
- cc_hub-0.2.1.dist-info/WHEEL +4 -0
- cc_hub-0.2.1.dist-info/entry_points.txt +2 -0
- cc_hub-0.2.1.dist-info/licenses/LICENSE +21 -0
cc_hub/__init__.py
ADDED
cc_hub/__main__.py
ADDED
cc_hub/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Command-line entry point."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from cc_hub import __version__
|
|
11
|
+
from cc_hub.encoding import configure_stdio
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
add_completion=False,
|
|
16
|
+
help="Hub directory tool for agents launched by cc-connect.",
|
|
17
|
+
# A traceback from a hub command must not print hub contents or secrets.
|
|
18
|
+
pretty_exceptions_show_locals=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback()
|
|
23
|
+
def _root(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
hub: Path | None = typer.Option(
|
|
26
|
+
None,
|
|
27
|
+
"--hub",
|
|
28
|
+
envvar="CC_HUB_DIR",
|
|
29
|
+
help="Hub directory (default: search upward from cwd, then ~/.cc-hub).",
|
|
30
|
+
),
|
|
31
|
+
) -> None:
|
|
32
|
+
ctx.obj = {"hub": hub}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def version() -> None:
|
|
37
|
+
"""Print the cc-hub version."""
|
|
38
|
+
typer.echo(__version__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def where(ctx: typer.Context) -> None:
|
|
43
|
+
"""Print the hub directory and the package location."""
|
|
44
|
+
import cc_hub
|
|
45
|
+
from cc_hub.hub import HubNotFound, find_hub
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
hub: Path | None = find_hub((ctx.obj or {}).get("hub"))
|
|
49
|
+
except HubNotFound as exc:
|
|
50
|
+
hub = None
|
|
51
|
+
typer.echo(str(exc), err=True)
|
|
52
|
+
typer.echo(f"hub: {hub or 'not found'}")
|
|
53
|
+
typer.echo(f"package: {Path(cc_hub.__file__).parent}")
|
|
54
|
+
if hub is None:
|
|
55
|
+
raise typer.Exit(code=1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
from cc_hub.commands import doctor as _doctor # noqa: E402
|
|
59
|
+
from cc_hub.commands import env as _env # noqa: E402
|
|
60
|
+
from cc_hub.commands import hosts as _hosts # noqa: E402
|
|
61
|
+
from cc_hub.commands import init as _init # noqa: E402
|
|
62
|
+
from cc_hub.commands import link as _link # noqa: E402
|
|
63
|
+
from cc_hub.commands import memory as _memory # noqa: E402
|
|
64
|
+
from cc_hub.commands import projects as _projects # noqa: E402
|
|
65
|
+
from cc_hub.commands import update as _update # noqa: E402
|
|
66
|
+
|
|
67
|
+
_init.register(app)
|
|
68
|
+
_doctor.register(app)
|
|
69
|
+
_update.register(app)
|
|
70
|
+
_env.register(app)
|
|
71
|
+
_memory.register(app)
|
|
72
|
+
_projects.register(app)
|
|
73
|
+
_link.register(app)
|
|
74
|
+
_hosts.register(app)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None:
|
|
78
|
+
configure_stdio()
|
|
79
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""cc-hub doctor: health checks for a hub directory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
9
|
+
import tomllib
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from cc_hub import __version__, manifest
|
|
19
|
+
from cc_hub.commands.init import enclosing_git_repo
|
|
20
|
+
from cc_hub.encoding import read_text, write_text
|
|
21
|
+
from cc_hub.hub import load_config
|
|
22
|
+
from cc_hub.platform import COPY_MARKER
|
|
23
|
+
|
|
24
|
+
Which = Callable[[str], "str | None"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Check:
|
|
29
|
+
id: str
|
|
30
|
+
status: str # ok | warn | fail
|
|
31
|
+
message: str
|
|
32
|
+
fix: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DATE_NAME = re.compile(r"^\d{4}-\d{2}-\d{2}\.md$")
|
|
36
|
+
ENV_MAX_AGE_DAYS = 7
|
|
37
|
+
VALID_SHELLS = ("bash", "pwsh", "powershell")
|
|
38
|
+
AGENT_MARKERS = {"claude": ("claude", ".claude"), "codex": ("codex", ".codex")}
|
|
39
|
+
# agent -> [(relative path, "exists" | "if-present-contains", needle)]
|
|
40
|
+
# A CLAUDE.md is optional (Claude Code reads AGENTS.md when there is none), but
|
|
41
|
+
# one without the import would shadow AGENTS.md.
|
|
42
|
+
POINTER_RULES: dict[str, list[tuple[str, str, str]]] = {
|
|
43
|
+
"claude": [
|
|
44
|
+
(".claude/skills", "exists", ""),
|
|
45
|
+
("CLAUDE.md", "if-present-contains", "@AGENTS.md"),
|
|
46
|
+
],
|
|
47
|
+
"codex": [
|
|
48
|
+
(".agents/skills", "exists", ""),
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
# Hook configs that cc-hub 0.1.0's hub-adapt wrote into; `cc-hub hook` no longer exists.
|
|
52
|
+
HOOK_CONFIGS = (
|
|
53
|
+
".claude/settings.json",
|
|
54
|
+
".claude/settings.local.json",
|
|
55
|
+
".codex/hooks.json",
|
|
56
|
+
".codex/config.toml",
|
|
57
|
+
)
|
|
58
|
+
CC_HUB_HOOK = re.compile(r"\bhook (?:pre|post)-tool-use\b")
|
|
59
|
+
_READ_ERRORS = (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect_agents(*, home: Path, which: Which) -> list[str]:
|
|
63
|
+
return [a for a, (exe, dot) in AGENT_MARKERS.items() if which(exe) or (home / dot).exists()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ok(id_: str, message: str) -> Check:
|
|
67
|
+
return Check(id_, "ok", message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_table(path: Path, key: str) -> list[dict]:
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return []
|
|
73
|
+
return list(tomllib.loads(read_text(path)).get(key, []))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_checks(
|
|
77
|
+
hub: Path,
|
|
78
|
+
*,
|
|
79
|
+
fix: bool = False,
|
|
80
|
+
home: Path | None = None,
|
|
81
|
+
which: Which = shutil.which,
|
|
82
|
+
now: float | None = None,
|
|
83
|
+
package_version: str = __version__,
|
|
84
|
+
) -> list[Check]:
|
|
85
|
+
hub = Path(hub)
|
|
86
|
+
home = home or Path.home()
|
|
87
|
+
now = time.time() if now is None else now
|
|
88
|
+
checks: list[Check] = []
|
|
89
|
+
|
|
90
|
+
# hub-toml
|
|
91
|
+
cfg: dict = {}
|
|
92
|
+
try:
|
|
93
|
+
cfg = load_config(hub)
|
|
94
|
+
tv = str(cfg.get("hub", {}).get("template_version", "0"))
|
|
95
|
+
if tv == package_version:
|
|
96
|
+
checks.append(_ok("hub-toml", f"template_version {tv}"))
|
|
97
|
+
else:
|
|
98
|
+
checks.append(
|
|
99
|
+
Check(
|
|
100
|
+
"hub-toml",
|
|
101
|
+
"warn",
|
|
102
|
+
f"template_version {tv}, package is {package_version}",
|
|
103
|
+
"run `cc-hub update` (read the migration notes in CHANGELOG.md first)",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
except _READ_ERRORS as exc:
|
|
107
|
+
checks.append(
|
|
108
|
+
Check("hub-toml", "fail", f"hub.toml unreadable: {exc}", "fix the TOML syntax")
|
|
109
|
+
)
|
|
110
|
+
name = str(cfg.get("hub", {}).get("name", hub.name))
|
|
111
|
+
|
|
112
|
+
# required-files (fix: user files and .gitignore only)
|
|
113
|
+
if fix:
|
|
114
|
+
write_text(
|
|
115
|
+
hub / ".gitignore", manifest.render(".gitignore", name=name, version=package_version)
|
|
116
|
+
)
|
|
117
|
+
for d in manifest.USER_DIRS:
|
|
118
|
+
(hub / d).mkdir(exist_ok=True)
|
|
119
|
+
for rel in manifest.USER_OWNED:
|
|
120
|
+
if not (hub / rel).is_file():
|
|
121
|
+
write_text(hub / rel, manifest.render(rel, name=name, version=package_version))
|
|
122
|
+
missing = [
|
|
123
|
+
rel
|
|
124
|
+
for rel in manifest.FRAMEWORK_OWNED + manifest.USER_OWNED
|
|
125
|
+
if rel not in manifest.ONE_TIME and not (hub / rel).is_file()
|
|
126
|
+
]
|
|
127
|
+
if missing:
|
|
128
|
+
checks.append(
|
|
129
|
+
Check(
|
|
130
|
+
"required-files",
|
|
131
|
+
"fail",
|
|
132
|
+
"missing: " + ", ".join(missing),
|
|
133
|
+
"`cc-hub doctor --fix` restores user files; "
|
|
134
|
+
"`cc-hub update` restores framework files",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
checks.append(_ok("required-files", "all present"))
|
|
139
|
+
|
|
140
|
+
# bootstrap-pending
|
|
141
|
+
if (hub / "BOOTSTRAP.md").exists():
|
|
142
|
+
checks.append(
|
|
143
|
+
Check(
|
|
144
|
+
"bootstrap-pending",
|
|
145
|
+
"warn",
|
|
146
|
+
"BOOTSTRAP.md still present: first-run ritual not completed",
|
|
147
|
+
"send the agent its first message",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
checks.append(_ok("bootstrap-pending", "bootstrap completed"))
|
|
152
|
+
|
|
153
|
+
# memory-budget
|
|
154
|
+
max_lines = int(cfg.get("memory", {}).get("max_lines", 200))
|
|
155
|
+
for rel, limit in (("MEMORY.md", max_lines), ("USER.md", 100)):
|
|
156
|
+
p = hub / rel
|
|
157
|
+
if not p.is_file():
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
n = len(read_text(p).splitlines())
|
|
161
|
+
except _READ_ERRORS as exc:
|
|
162
|
+
checks.append(
|
|
163
|
+
Check(
|
|
164
|
+
f"memory-budget:{rel}",
|
|
165
|
+
"fail",
|
|
166
|
+
f"unreadable: {exc}",
|
|
167
|
+
"fix the file's permissions or re-save it as UTF-8",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
if n <= limit:
|
|
172
|
+
checks.append(_ok(f"memory-budget:{rel}", f"{n}/{limit} lines"))
|
|
173
|
+
else:
|
|
174
|
+
checks.append(
|
|
175
|
+
Check(
|
|
176
|
+
f"memory-budget:{rel}",
|
|
177
|
+
"fail",
|
|
178
|
+
f"{n}/{limit} lines",
|
|
179
|
+
"run the consolidation procedure from the hub-memory skill",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# memory-files
|
|
184
|
+
mem = hub / "memory"
|
|
185
|
+
bad = [p.name for p in mem.glob("*.md") if not DATE_NAME.match(p.name)] if mem.is_dir() else []
|
|
186
|
+
if bad:
|
|
187
|
+
checks.append(
|
|
188
|
+
Check(
|
|
189
|
+
"memory-files",
|
|
190
|
+
"warn",
|
|
191
|
+
"unexpected names: " + ", ".join(bad),
|
|
192
|
+
"rename to YYYY-MM-DD.md",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
checks.append(_ok("memory-files", "names ok"))
|
|
197
|
+
|
|
198
|
+
# projects
|
|
199
|
+
try:
|
|
200
|
+
projects = _load_table(hub / "projects.toml", "project")
|
|
201
|
+
gone = [
|
|
202
|
+
str(p.get("name", "?"))
|
|
203
|
+
for p in projects
|
|
204
|
+
if not Path(str(p.get("path", ""))).expanduser().is_dir()
|
|
205
|
+
]
|
|
206
|
+
if gone:
|
|
207
|
+
checks.append(
|
|
208
|
+
Check(
|
|
209
|
+
"projects",
|
|
210
|
+
"warn",
|
|
211
|
+
"paths missing: " + ", ".join(gone),
|
|
212
|
+
"cc-hub projects remove <name>",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
checks.append(_ok("projects", f"{len(projects)} registered"))
|
|
217
|
+
except _READ_ERRORS as exc:
|
|
218
|
+
checks.append(Check("projects", "fail", f"projects.toml: {exc}", "fix the TOML"))
|
|
219
|
+
|
|
220
|
+
# hosts
|
|
221
|
+
try:
|
|
222
|
+
hosts = _load_table(hub / "hosts.toml", "host")
|
|
223
|
+
bad_shell = [
|
|
224
|
+
str(h.get("name", "?")) for h in hosts if h.get("shell", "bash") not in VALID_SHELLS
|
|
225
|
+
]
|
|
226
|
+
if bad_shell:
|
|
227
|
+
checks.append(
|
|
228
|
+
Check(
|
|
229
|
+
"hosts",
|
|
230
|
+
"fail",
|
|
231
|
+
"invalid shell for: " + ", ".join(bad_shell),
|
|
232
|
+
"shell must be bash | pwsh | powershell",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
checks.append(_ok("hosts", f"{len(hosts)} registered"))
|
|
237
|
+
except _READ_ERRORS as exc:
|
|
238
|
+
checks.append(Check("hosts", "fail", f"hosts.toml: {exc}", "fix the TOML"))
|
|
239
|
+
|
|
240
|
+
# env-stale
|
|
241
|
+
env_md = hub / "ENV.md"
|
|
242
|
+
if not env_md.is_file():
|
|
243
|
+
checks.append(Check("env-stale", "warn", "ENV.md missing", "run `cc-hub env`"))
|
|
244
|
+
else:
|
|
245
|
+
age = (now - env_md.stat().st_mtime) / 86400
|
|
246
|
+
if age <= ENV_MAX_AGE_DAYS:
|
|
247
|
+
checks.append(_ok("env-stale", f"ENV.md is {age:.0f} days old"))
|
|
248
|
+
else:
|
|
249
|
+
checks.append(
|
|
250
|
+
Check("env-stale", "warn", f"ENV.md is {age:.0f} days old", "run `cc-hub env`")
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# pointers
|
|
254
|
+
agents = detect_agents(home=home, which=which)
|
|
255
|
+
if not agents:
|
|
256
|
+
checks.append(_ok("pointers", "no agent runtimes detected on this machine"))
|
|
257
|
+
for agent in agents:
|
|
258
|
+
rules = POINTER_RULES[agent]
|
|
259
|
+
if not any((hub / rel).exists() for rel, _, _ in rules):
|
|
260
|
+
checks.append(_ok(
|
|
261
|
+
f"pointers:{agent}",
|
|
262
|
+
"not set up here; the hub-adapt skill does it when this runtime runs here",
|
|
263
|
+
))
|
|
264
|
+
continue
|
|
265
|
+
problems = []
|
|
266
|
+
for rel, kind, needle in rules:
|
|
267
|
+
p = hub / rel
|
|
268
|
+
if not p.exists():
|
|
269
|
+
if kind == "exists":
|
|
270
|
+
problems.append(f"{rel} missing")
|
|
271
|
+
continue
|
|
272
|
+
if kind == "exists":
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
found = needle in read_text(p)
|
|
276
|
+
except _READ_ERRORS as exc:
|
|
277
|
+
problems.append(f"{rel} unreadable: {exc}")
|
|
278
|
+
continue
|
|
279
|
+
if not found:
|
|
280
|
+
problems.append(f"{rel} lacks '{needle}' and hides AGENTS.md")
|
|
281
|
+
if problems:
|
|
282
|
+
checks.append(
|
|
283
|
+
Check(
|
|
284
|
+
f"pointers:{agent}",
|
|
285
|
+
"warn",
|
|
286
|
+
"; ".join(problems),
|
|
287
|
+
"run the hub-adapt skill from the agent",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
checks.append(_ok(f"pointers:{agent}", "pointers present"))
|
|
292
|
+
|
|
293
|
+
# stale-hooks
|
|
294
|
+
leftover = []
|
|
295
|
+
for rel in HOOK_CONFIGS:
|
|
296
|
+
p = hub / rel
|
|
297
|
+
try:
|
|
298
|
+
if p.is_file() and CC_HUB_HOOK.search(read_text(p)):
|
|
299
|
+
leftover.append(rel)
|
|
300
|
+
except _READ_ERRORS:
|
|
301
|
+
continue
|
|
302
|
+
if leftover:
|
|
303
|
+
checks.append(
|
|
304
|
+
Check(
|
|
305
|
+
"stale-hooks",
|
|
306
|
+
"warn",
|
|
307
|
+
"cc-hub hook entries left in: " + ", ".join(leftover),
|
|
308
|
+
"remove them (the command no longer exists); the hub-adapt skill does this",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
checks.append(_ok("stale-hooks", "no leftover cc-hub hooks"))
|
|
313
|
+
|
|
314
|
+
# link-stale
|
|
315
|
+
skills = hub / "skills"
|
|
316
|
+
src_mtime = max((p.stat().st_mtime for p in skills.rglob("*") if p.is_file()), default=0.0)
|
|
317
|
+
stale = [
|
|
318
|
+
str(m.parent.relative_to(hub))
|
|
319
|
+
for m in hub.glob(f".*/skills/{COPY_MARKER}")
|
|
320
|
+
if m.stat().st_mtime < src_mtime
|
|
321
|
+
]
|
|
322
|
+
if stale:
|
|
323
|
+
checks.append(
|
|
324
|
+
Check(
|
|
325
|
+
"link-stale",
|
|
326
|
+
"warn",
|
|
327
|
+
"stale copies: " + ", ".join(stale),
|
|
328
|
+
"re-run the hub-adapt skill (cc-hub link)",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
checks.append(_ok("link-stale", "no stale copies"))
|
|
333
|
+
|
|
334
|
+
# nested-git
|
|
335
|
+
repo = enclosing_git_repo(hub.parent)
|
|
336
|
+
if repo is None:
|
|
337
|
+
checks.append(_ok("nested-git", "hub is outside other repositories"))
|
|
338
|
+
else:
|
|
339
|
+
checks.append(
|
|
340
|
+
Check(
|
|
341
|
+
"nested-git",
|
|
342
|
+
"fail",
|
|
343
|
+
f"hub is inside {repo}",
|
|
344
|
+
"move the hub outside that repository",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return checks
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def register(app: typer.Typer) -> None:
|
|
352
|
+
@app.command("doctor")
|
|
353
|
+
def doctor_cmd(
|
|
354
|
+
ctx: typer.Context,
|
|
355
|
+
fix: bool = typer.Option(False, "--fix", help="Apply safe fixes."),
|
|
356
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Check the hub's health."""
|
|
359
|
+
from cc_hub.console import console, echo_json, resolve_hub
|
|
360
|
+
|
|
361
|
+
hub = resolve_hub(ctx)
|
|
362
|
+
checks = run_checks(hub, fix=fix)
|
|
363
|
+
if as_json:
|
|
364
|
+
echo_json([asdict(c) for c in checks])
|
|
365
|
+
else:
|
|
366
|
+
table = Table(title=f"cc-hub doctor: {hub}")
|
|
367
|
+
for col in ("check", "status", "message", "fix"):
|
|
368
|
+
table.add_column(col)
|
|
369
|
+
for c in checks:
|
|
370
|
+
table.add_row(escape(c.id), escape(c.status), escape(c.message), escape(c.fix))
|
|
371
|
+
console.print(table)
|
|
372
|
+
if any(c.status == "fail" for c in checks):
|
|
373
|
+
raise typer.Exit(code=1)
|
cc_hub/commands/env.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""cc-hub env: probe this machine and write ENV.md / env.json for the agent."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import locale
|
|
8
|
+
import os
|
|
9
|
+
import platform as pyplatform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import psutil
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from cc_hub.encoding import decode_bytes, write_text
|
|
21
|
+
from cc_hub.hub import load_config
|
|
22
|
+
from cc_hub.platform import is_windows, is_wsl
|
|
23
|
+
|
|
24
|
+
PROBE_BINARIES = ("cc-hub", "uv", "python", "cc-connect", "claude", "codex")
|
|
25
|
+
SHELLS = ("bash", "zsh", "pwsh", "powershell", "cmd")
|
|
26
|
+
CC_CONNECT_CONFIGS = (".cc-connect/config.toml",)
|
|
27
|
+
# Systems that only ship python3 must not be reported as "python: not installed".
|
|
28
|
+
ALIASES: dict[str, tuple[str, ...]] = {"python": ("python3", "python")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_alias(which: Callable[[str], str | None], name: str) -> tuple[str | None, str]:
|
|
32
|
+
"""The first alias of `name` (itself, when it has none) that resolves on PATH.
|
|
33
|
+
|
|
34
|
+
Returns the resolved path and the alias that produced it, so a caller can
|
|
35
|
+
probe further (e.g. its version) under the name that actually exists.
|
|
36
|
+
"""
|
|
37
|
+
for candidate in ALIASES.get(name, (name,)):
|
|
38
|
+
found = which(candidate)
|
|
39
|
+
if found:
|
|
40
|
+
return found, candidate
|
|
41
|
+
return None, name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _probe_tools(
|
|
45
|
+
names: Iterable[str],
|
|
46
|
+
which: Callable[[str], str | None],
|
|
47
|
+
version_of: Callable[[str], str],
|
|
48
|
+
) -> dict[str, dict]:
|
|
49
|
+
entries = {}
|
|
50
|
+
for name in names:
|
|
51
|
+
path, resolved = _resolve_alias(which, name)
|
|
52
|
+
entries[name] = {"path": path, "version": version_of(resolved) if path else ""}
|
|
53
|
+
return entries
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _probe_paths(
|
|
57
|
+
names: Iterable[str],
|
|
58
|
+
which: Callable[[str], str | None],
|
|
59
|
+
) -> dict[str, dict]:
|
|
60
|
+
entries = {}
|
|
61
|
+
for name in names:
|
|
62
|
+
path, _ = _resolve_alias(which, name)
|
|
63
|
+
entries[name] = {"path": path}
|
|
64
|
+
return entries
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def version_of(exe: str, *, run: Callable = subprocess.run) -> str:
|
|
68
|
+
try:
|
|
69
|
+
proc = run([exe, "--version"], capture_output=True, timeout=5)
|
|
70
|
+
except (OSError, subprocess.SubprocessError):
|
|
71
|
+
return ""
|
|
72
|
+
lines = decode_bytes(proc.stdout or proc.stderr or b"").strip().splitlines()
|
|
73
|
+
return lines[0][:80] if lines else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def probe(
|
|
77
|
+
hub: Path,
|
|
78
|
+
*,
|
|
79
|
+
tools: list[str],
|
|
80
|
+
home: Path | None = None,
|
|
81
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
82
|
+
version_of: Callable[[str], str] = version_of,
|
|
83
|
+
now: datetime | None = None,
|
|
84
|
+
) -> dict:
|
|
85
|
+
hub = Path(hub)
|
|
86
|
+
home = home or Path.home()
|
|
87
|
+
now = now or datetime.now().astimezone()
|
|
88
|
+
shells = {name: which(name) for name in SHELLS}
|
|
89
|
+
shells["default"] = os.environ.get("SHELL") or (
|
|
90
|
+
"powershell" if is_windows() else "sh"
|
|
91
|
+
)
|
|
92
|
+
cc_cfg = next(
|
|
93
|
+
(str(home / rel) for rel in CC_CONNECT_CONFIGS if (home / rel).is_file()),
|
|
94
|
+
None,
|
|
95
|
+
)
|
|
96
|
+
return {
|
|
97
|
+
"generated_at": now.isoformat(timespec="seconds"),
|
|
98
|
+
"hub": str(hub),
|
|
99
|
+
"system": {
|
|
100
|
+
"os": pyplatform.system(),
|
|
101
|
+
"release": pyplatform.release(),
|
|
102
|
+
"arch": pyplatform.machine(),
|
|
103
|
+
"wsl": is_wsl(),
|
|
104
|
+
"home": str(home),
|
|
105
|
+
"timezone": time.tzname[0],
|
|
106
|
+
"locale_encoding": locale.getpreferredencoding(False),
|
|
107
|
+
"pythonutf8": os.environ.get("PYTHONUTF8", ""),
|
|
108
|
+
},
|
|
109
|
+
"shells": shells,
|
|
110
|
+
"tools": _probe_tools(tools, which, version_of),
|
|
111
|
+
"agents": {
|
|
112
|
+
"claude_home": (home / ".claude").is_dir(),
|
|
113
|
+
"codex_home": (home / ".codex").is_dir(),
|
|
114
|
+
"cc_connect_config": cc_cfg,
|
|
115
|
+
},
|
|
116
|
+
"paths": _probe_paths(PROBE_BINARIES, which),
|
|
117
|
+
"resources": {
|
|
118
|
+
"cpus": os.cpu_count() or 1,
|
|
119
|
+
"memory_gb": round(psutil.virtual_memory().total / 2**30, 1),
|
|
120
|
+
"disk_free_gb": round(shutil.disk_usage(hub).free / 2**30, 1),
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_env_md(data: dict) -> str:
|
|
126
|
+
s = data["system"]
|
|
127
|
+
lines = [
|
|
128
|
+
"# Environment",
|
|
129
|
+
"",
|
|
130
|
+
f"Generated by `cc-hub env` at {data['generated_at']}. "
|
|
131
|
+
"Re-run when in doubt.",
|
|
132
|
+
"",
|
|
133
|
+
"## System",
|
|
134
|
+
"",
|
|
135
|
+
f"- OS: {s['os']} {s['release']} ({s['arch']})"
|
|
136
|
+
f"{' under WSL' if s['wsl'] else ''}",
|
|
137
|
+
f"- Home: {s['home']}",
|
|
138
|
+
f"- Hub: {data['hub']}",
|
|
139
|
+
f"- Timezone: {s['timezone']}; locale encoding: "
|
|
140
|
+
f"{s['locale_encoding']}; PYTHONUTF8="
|
|
141
|
+
f"{s['pythonutf8'] or 'unset'}",
|
|
142
|
+
"",
|
|
143
|
+
"## Shell",
|
|
144
|
+
"",
|
|
145
|
+
f"- Default shell: {data['shells']['default']}",
|
|
146
|
+
]
|
|
147
|
+
for name in SHELLS:
|
|
148
|
+
path = data["shells"].get(name)
|
|
149
|
+
lines.append(f"- {name}: {path or 'not installed'}")
|
|
150
|
+
lines += ["", "## Tools", ""]
|
|
151
|
+
for name, info in data["tools"].items():
|
|
152
|
+
if info["path"]:
|
|
153
|
+
lines.append(f"- {name}: {info['path']} {info['version']}".rstrip())
|
|
154
|
+
else:
|
|
155
|
+
lines.append(f"- {name}: not installed")
|
|
156
|
+
a = data["agents"]
|
|
157
|
+
lines += [
|
|
158
|
+
"",
|
|
159
|
+
"## Agents & bridge",
|
|
160
|
+
"",
|
|
161
|
+
f"- ~/.claude present: {a['claude_home']}",
|
|
162
|
+
f"- ~/.codex present: {a['codex_home']}",
|
|
163
|
+
f"- cc-connect config: {a['cc_connect_config'] or 'not found'}",
|
|
164
|
+
"",
|
|
165
|
+
"## Paths",
|
|
166
|
+
"",
|
|
167
|
+
"Absolute paths to use when a command is not found on PATH:",
|
|
168
|
+
"",
|
|
169
|
+
]
|
|
170
|
+
for name, info in data["paths"].items():
|
|
171
|
+
if not info["path"]:
|
|
172
|
+
lines.append(f"- {name}: not installed")
|
|
173
|
+
else:
|
|
174
|
+
lines.append(f"- {name}: {info['path']}")
|
|
175
|
+
r = data["resources"]
|
|
176
|
+
lines += [
|
|
177
|
+
"",
|
|
178
|
+
"## Resources",
|
|
179
|
+
"",
|
|
180
|
+
f"- CPUs: {r['cpus']}; memory: {r['memory_gb']} GB; "
|
|
181
|
+
f"free disk at hub: {r['disk_free_gb']} GB",
|
|
182
|
+
"",
|
|
183
|
+
]
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_env(hub: Path, **probe_kwargs) -> dict:
|
|
188
|
+
hub = Path(hub)
|
|
189
|
+
if "tools" not in probe_kwargs:
|
|
190
|
+
probe_kwargs["tools"] = list(
|
|
191
|
+
load_config(hub).get("env", {}).get("tools", [])
|
|
192
|
+
)
|
|
193
|
+
data = probe(hub, **probe_kwargs)
|
|
194
|
+
write_text(
|
|
195
|
+
hub / "env.json",
|
|
196
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
197
|
+
)
|
|
198
|
+
write_text(hub / "ENV.md", render_env_md(data))
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def register(app: typer.Typer) -> None:
|
|
203
|
+
@app.command("env")
|
|
204
|
+
def env_cmd(
|
|
205
|
+
ctx: typer.Context,
|
|
206
|
+
as_json: bool = typer.Option(
|
|
207
|
+
False, "--json", help="Print env.json to stdout."
|
|
208
|
+
),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Probe this machine and write ENV.md and env.json."""
|
|
211
|
+
from cc_hub.console import echo_json, resolve_hub
|
|
212
|
+
|
|
213
|
+
hub = resolve_hub(ctx)
|
|
214
|
+
data = write_env(hub)
|
|
215
|
+
if as_json:
|
|
216
|
+
echo_json(data)
|
|
217
|
+
else:
|
|
218
|
+
typer.echo(f"wrote {hub / 'ENV.md'} and {hub / 'env.json'}")
|