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 ADDED
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """cc-hub: a hub directory convention and CLI for chat-launched coding agents."""
3
+
4
+ __version__ = "0.2.1"
cc_hub/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Allow `python -m cc_hub`."""
3
+
4
+ from cc_hub.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
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'}")