From 4d94a84daaf95445b57c7a5eed491fc80f29e9d2 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Wed, 7 Oct 2026 20:09:41 +0200 Subject: [PATCH 1/2] feat(doctor): check a computer is set up for a brain `km doctor` lists, one line per check, what this computer still lacks to work on the brain, each failure with its fix: git and its name and email, access to the origin and whether the branch lacks changes that are on it, the mounted brains, the km version against the brain's pin, the plugins the brain's .claude/settings.json enables (installed for the user, for a project the brain lies in, or synced by the organization), and whether km can run at all (uv, or a Python with PyYAML). Everything expected is read from the brain or from git's and Claude Code's own files, so km carries no host, organization or plugin names. Network checks run without prompts (GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=never, empty askpass so an editor's password box cannot open, ssh BatchMode), in the C locale so git's messages can be told apart, and with a 15 s limit: a missing login on a private repository fails instead of waiting for a password. Printed URLs drop a user or token. Relative submodule URLs are resolved against origin, and submodule names may hold spaces. A hand-edited JSON file with odd values is read as empty, not a crash. Uncommitted changes, a mounted brain not checked out and a km newer than the pin are notes, not failures. The fix for a branch behind or diverged from origin is a pull with rebase from the named branch, which works without tracking information. `paths.git` returns exit code 127 instead of raising when git is missing, so km doctor (and every other command) says so instead of a traceback; it also takes env and timeout. The check imports no PyYAML, and the launcher lets `doctor` start on a Python without it, trying python3, python and `py -3` (on Windows `python3` is often the Store's placeholder). The launcher also finds uv in ~/.local/bin, where its installer puts it, before an app restart has put that folder on the PATH; and when nothing can run km it says so in plain words with the one-line uv install for Windows and for macOS/Linux, and whether git is missing too. The skill gains a setup-check section: check git first, run km doctor, offer to install uv and the other safe fixes, hand over the rest in plain words. --- .github/workflows/test.yml | 5 + CLAUDE.md | 2 +- README.md | 12 +- skills/km/SKILL.md | 21 ++- skills/km/bin/km | 32 +++- src/km/cli.py | 3 +- src/km/doctor.py | 290 +++++++++++++++++++++++++++++++++++++ src/km/paths.py | 13 +- tests/doctor_smoke.sh | 141 ++++++++++++++++++ 9 files changed, 503 insertions(+), 16 deletions(-) create mode 100644 src/km/doctor.py create mode 100644 tests/doctor_smoke.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 537c2a9..9965d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,7 @@ jobs: KM_REPO_URL="file://$GITHUB_WORKSPACE" /tmp/wheel/bin/km init /tmp/wheel-brain --team --name W --desc w --initials WW --folder d=D - run: bash tests/gate_smoke.sh - run: bash tests/index_smoke.sh + - run: bash tests/doctor_smoke.sh - name: team_smoke.sh, with a Latin-1 locale for its file-name encoding test run: sudo localedef -i en_US -f ISO-8859-1 en_US.ISO-8859-1 && bash tests/team_smoke.sh @@ -80,6 +81,10 @@ jobs: source tests/lib.sh windows_like "" || { echo "::error::the runner's encoding is UTF-8, this job tests nothing"; exit 1; } with_env "" bash tests/encoding_smoke.sh # as checked: UTF-8 mode off + - name: km doctor on Windows + env: + PYTHON: python + run: bash tests/doctor_smoke.sh # Merging a version bump is the release: once main is green, tag v<__version__> if that tag is new. # Brains pin these tags, and `km init` writes the tag of the km that runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 06383b4..78f40b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,5 +17,5 @@ brains. See README.md for the layout. - A release bumps `src/km/__init__.py`, `.claude-plugin/plugin.json` and the README examples together (a test checks it). Merging it to main is the release: CI tags `vX.Y.Z` once main is green. The templates pin the tag of the km that wrote them. -- Run all three smoke tests before pushing; add a case for every behaviour change and check that it +- Run all the smoke tests before pushing; add a case for every behaviour change and check that it fails without the change. diff --git a/README.md b/README.md index 8db7fcf..ee82bf6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ As a Claude Code plugin: /km brain add https://github.com/org/team-brain.git # mount another brain /km @all Zephyr RTOS # search all mounted brains /km upgrade # move the brain to the installed km version +/km check my setup # what this computer still needs for the brain (km doctor) /km help # all commands ``` @@ -49,8 +50,15 @@ km promote ... move a note into the brain as a review doc km approve DOC... --by WHO sign review docs off; ai: only where the doc says approval: ai km init DIR [--team ...] create a brain from the templates km upgrade move a brain's km pins to this version +km doctor [--offline] check this computer is set up for the brain ``` +`km doctor` reads what to expect from the brain alone: its origin, the brains it mounts +(`.gitmodules`), the km version it pins, and the plugins its `.claude/settings.json` enables +(`enabledPlugins`, with the marketplaces in `extraKnownMarketplaces`). A brain lists its plugins there +for Claude Code anyway, which offers to add the marketplace when the folder is trusted. Each check +that fails names its fix; it needs no PyYAML, so it also runs on a computer that is half set up. + Every command takes `--root DIR`; the default is the git work tree around the current directory. Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.2.0` shows it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.2.0`, or run it @@ -83,7 +91,7 @@ a brain from the copy-in days into one that pins km. .claude-plugin/ plugin and marketplace manifests action.yml GitHub Action: installs km at the action's version .pre-commit-hooks.yaml pre-commit hook km-validate -src/km/ the package: cli, validate, gen_index, demote, serve, promote, init, upgrade +src/km/ the package: cli, validate, gen_index, demote, serve, promote, init, upgrade, doctor src/km/templates/ what `km init` writes skills/km/SKILL.md the skill; skills/km/bin/km runs the package from the installed plugin tests/ smoke tests @@ -92,7 +100,7 @@ tests/ smoke tests ## Development The smoke tests need Python 3.11+, PyYAML and git: `bash tests/gate_smoke.sh`, `index_smoke.sh`, -`team_smoke.sh`. CI runs all three plus the GitHub Action on every pull request, and +`team_smoke.sh`, `doctor_smoke.sh`. CI runs all four plus the GitHub Action on every pull request, and `encoding_smoke.sh` on Windows (team_smoke runs it on macOS under non-UTF-8 locales). A release bumps `__version__` in `src/km/__init__.py`, `version` in `.claude-plugin/plugin.json` and the version in this README together; merging that to main is the release, CI tags `vX.Y.Z` once main is green. diff --git a/skills/km/SKILL.md b/skills/km/SKILL.md index e29dca9..91791ac 100644 --- a/skills/km/SKILL.md +++ b/skills/km/SKILL.md @@ -10,7 +10,8 @@ description: >- knowledge base or looked up from it, rather than writing a markdown file by hand. Triggers on: km, knowledge base, brain, save this, was steht im brain, ins brain speichern, dokumentiere das, Entscheidung festhalten, Meeting-Notiz, ADR, lint, - "speicher das sauber ab", "was wissen wir über". + "speicher das sauber ab", "was wissen wir über", check my setup, "prüfe meine + Einrichtung". argument-hint: question, content, brain command, help, etc. --- @@ -30,6 +31,24 @@ it runs that commit through `uvx`, so results match CI (`KM_LOCAL=1` keeps the p offline, for a pin to a movable tag, or when the workflows disagree, it runs the plugin's km and warns; `/km upgrade` the brain then. +## Setup check (`/km doctor`, "check my setup") + +For someone who just set up a computer for the brain, often not a developer. Answer in their +language and in plain words, one line per problem, the fix first; no git jargon they did not use. + +1. Before km: `git --version`. Without git, nothing else works: say so and stop; the brain's own + setup guide (README or CLAUDE.md) says how to install it. +2. `km doctor` in the brain (`--offline` when they say they have no network). Where `km` itself + cannot start, its message names uv and the one-line command that installs it (no administrator + rights needed): offer to run that command for their system, then run `km doctor` again. km finds + uv where the installer puts it, so the app need not be restarted first. +3. Each `FAIL` comes with a fix. Offer to run the ones that change only this computer and are safe + (`git submodule update --init`, the `git pull --rebase` it names on a clean tree, + `git config --global user.name` after asking for the value); hand over the ones they must do + themselves (signing in, installing a plugin with `/plugin`, asking for access). A `note` needs nothing; mention it only if it + explains something they asked about. +4. Run `km doctor` again after fixes and end with what still fails, or that the setup is complete. + ## Init (`/km init`) When the user runs `/km init` (or confirms after "CONVENTIONS.md missing" prompt): diff --git a/skills/km/bin/km b/skills/km/bin/km index d385cac..1cb4488 100755 --- a/skills/km/bin/km +++ b/skills/km/bin/km @@ -4,11 +4,29 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" # -P: follow a symlinked skill install src="$here/../../../src" export PYTHONPATH="$src${PYTHONPATH:+:$PYTHONPATH}" -if command -v uv >/dev/null 2>&1; then - exec uv run --quiet --no-project --python '>=3.11' --with pyyaml==6.0.3 python -m km "$@" +# uv, also where its installer puts it before an app restart has put that folder on the PATH +uv="$(command -v uv || true)" +for u in "$HOME/.local/bin/uv" "$HOME/.local/bin/uv.exe"; do [ -n "$uv" ] || { [ -x "$u" ] && uv="$u"; }; done +if [ -n "$uv" ]; then + exec "$uv" run --quiet --no-project --python '>=3.11' --with pyyaml==6.0.3 python -m km "$@" fi -if ! python3 -c 'import sys, yaml; sys.exit(sys.version_info < (3, 11))' 2>/dev/null; then - echo "km needs uv, or python3 3.11+ with PyYAML" >&2 - exit 1 -fi -exec python3 -m km "$@" +# Without uv: a Python 3.11+ under any of its usual names (on Windows `python3` is often only the +# Store's placeholder). `km doctor` needs no PyYAML, so it can say what is missing. +cmd=""; skip="" +for a in "$@"; do + if [ -n "$skip" ]; then skip=""; elif [ "$a" = --root ]; then skip=1; elif [ "${a#--root=}" = "$a" ]; then cmd="$a"; break; fi +done +need='import sys; sys.exit(sys.version_info < (3, 11))' +[ "$cmd" = doctor ] || need="import yaml; $need" +for py in python3 python "py -3"; do + if $py -c "$need" >/dev/null 2>&1; then exec $py -m km "$@"; fi +done +# Nothing to run km with: say so in words someone who never used a terminal can act on. +printf '%s\n' >&2 \ + "km cannot start on this computer: it needs uv (or Python 3.11+ with PyYAML). Install uv, no" \ + "administrator rights needed:" \ + ' Windows (PowerShell): powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"' \ + " macOS and Linux: curl -LsSf https://astral.sh/uv/install.sh | sh" \ + "then run the km command again. More: https://docs.astral.sh/uv/getting-started/installation/" +command -v git >/dev/null 2>&1 || echo "git is missing as well: https://git-scm.com/downloads" >&2 +exit 1 diff --git a/src/km/cli.py b/src/km/cli.py index f0ec772..31f5f2a 100644 --- a/src/km/cli.py +++ b/src/km/cli.py @@ -32,8 +32,9 @@ "approve": ("km.approve", "sign review docs off as accepted (--by WHO)"), "init": ("km.init", "create a brain: `init DIR --initials XX`, `--team ...` for a team brain"), "upgrade": ("km.upgrade", "move a brain's km pins to this version; drop km files copied in earlier"), + "doctor": ("km.doctor", "check this computer is set up for the brain: git, access, plugins, km version"), } -NO_PIN_CHECK = {"init", "upgrade"} # they write the pins, with the km that is running +NO_PIN_CHECK = {"init", "upgrade", "doctor"} # they write the pins, or check the km that is running RUNNER = os.environ.get("KM_PINNED_RUNNER", "uvx") diff --git a/src/km/doctor.py b/src/km/doctor.py new file mode 100644 index 0000000..21fcc09 --- /dev/null +++ b/src/km/doctor.py @@ -0,0 +1,290 @@ +"""Check that this computer is set up to work on a brain: `km doctor [--root DIR] [--offline]`. + +Every expected value comes from the brain itself or from git's and Claude Code's own files, never +from km: the origin and the mounted brains (.gitmodules) it must reach, the km version it pins, and +the plugins its `.claude/settings.json` enables. One line per check; a failed one says how to fix +it. Network checks run without prompts and with a time limit, so a missing login fails instead of +hanging; --offline skips them. Exit 1 when a check failed. + +Runs without PyYAML, so it works on a computer that is only half set up. +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from km import KM_REF +from km.paths import git, repo_root +from km.pins import read_pins, version_key + +TIMEOUT = 15 # seconds per network check +OFFLINE = ("could not resolve host", "failed to connect", "connection timed out", "network is unreachable", + "could not connect", "operation timed out", "connection refused", "no answer within") +UV_DOCS = "https://docs.astral.sh/uv/getting-started/installation/" +results: list[tuple[str, str, str]] = [] + + +def report(status: str, what: str, fix: str = "") -> None: + results.append((status, what, fix)) + + +def as_dict(value) -> dict: + """A JSON object, or {} for anything else a hand-edited file may hold there.""" + return value if isinstance(value, dict) else {} + + +def shown(url: str) -> str: + """A URL fit to print: without a user or token in it (https://user:token@host/...).""" + return re.sub(r"(?<=://)[^/@]+@", "", url) + + +def remote(*args: str, cwd: Path) -> tuple[int, str, str]: + """git against a remote, without prompts: a missing login fails at once instead of waiting for + a password, a browser window or an editor's askpass box. English messages, so they can be told + apart. (code, stdout, stderr); code -1 when it did not answer in time.""" + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never", "GIT_ASKPASS": "", + "SSH_ASKPASS": "", "SSH_ASKPASS_REQUIRE": "never", "LC_ALL": "C"} + if "GIT_SSH_COMMAND" not in env and git("config", "--get", "core.sshCommand", cwd=cwd).returncode: + env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes -o ConnectTimeout=10" + try: + res = git("-c", "core.askPass=", "-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", + *args, cwd=cwd, env=env, timeout=TIMEOUT) + except subprocess.TimeoutExpired: + return -1, "", f"no answer within {TIMEOUT} s" + return res.returncode, res.stdout, res.stderr + + +def unreachable(name: str, url: str, err: str) -> None: + """Report a remote that did not answer: offline, an unknown ssh host key, or no login / no access.""" + first = next((ln.strip() for ln in err.splitlines() if ln.strip()), "no message") + if any(s in err.lower() for s in OFFLINE): + report("FAIL", f"{name}: {shown(url)} cannot be reached ({first})", + "check the network or VPN; --offline skips the network checks") + elif "host key verification failed" in err.lower(): + report("FAIL", f"{name}: this computer does not know the host key of {shown(url)}", + "connect once in a terminal (`git ls-remote` with that URL) and confirm the host key") + else: + report("FAIL", f"{name}: no access to {shown(url)} ({first})", + "sign in to the host once (open the URL in a browser, or `git clone` it in a terminal " + "and log in), and ask the owner for read access if that does not help") + + +def check_identity(root: Path) -> None: + missing = [k for k in ("user.name", "user.email") if not git("config", "--get", k, cwd=root).stdout.strip()] + if missing: + report("FAIL", f"git does not know your {' and '.join(k.split('.')[1] for k in missing)}", + " and ".join(f'`git config --global {k} "..."`' for k in missing) + + "; use the email address of your account on the git host") + else: + report("ok", "git knows your name and email") + + +def check_uv() -> None: + """km runs through uv; without it, on a Python 3.11+ with PyYAML. Neither: no km command but this + one starts.""" + if shutil.which("uv"): + report("ok", "uv is installed") + return + local = Path.home() / ".local" / "bin" / ("uv.exe" if os.name == "nt" else "uv") + if local.is_file(): + report("FAIL", f"uv is installed in {local.parent} but that folder is not on the PATH", + "close and reopen the app or terminal; if that does not help, add the folder to the PATH") + elif importlib.util.find_spec("yaml") is None: + report("FAIL", "uv is not installed, and this Python has no PyYAML: km cannot run", f"install uv: {UV_DOCS}") + else: + report("note", "uv is not installed; km runs on this Python and cannot run the km version a " + "brain pins when it differs", f"install uv: {UV_DOCS}") + + +def check_origin(root: Path, offline: bool) -> str: + """Returns the origin URL, "" when there is none.""" + url = git("remote", "get-url", "origin", cwd=root).stdout.strip() + if not url: + report("FAIL", "the brain has no origin to pull from and push to", + "clone the brain from its git host instead of copying the folder") + return "" + branch = git("symbolic-ref", "-q", "--short", "HEAD", cwd=root).stdout.strip() + if offline: + report("skip", f"origin {shown(url)} (offline)") + return url + code, out, err = remote("ls-remote", "origin", "HEAD", *([f"refs/heads/{branch}"] if branch else []), cwd=root) + if code != 0: + unreachable("origin", url, err) + return url + report("ok", f"origin {shown(url)} answers") + if not branch: + report("skip", "up to date: no branch checked out") + return url + theirs = next((sha for sha, _, ref in (ln.partition("\t") for ln in out.splitlines()) + if ref == f"refs/heads/{branch}"), None) + if theirs is None: + report("skip", f"up to date: branch {branch} exists only here") + elif git("merge-base", "--is-ancestor", theirs, "HEAD", cwd=root).returncode == 0: + report("ok", f"branch {branch} has everything origin has") + else: # behind, or diverged: a rebase onto origin's branch fixes both + report("FAIL", f"branch {branch} lacks changes that are on origin", + f"ask Claude to update the brain, or `git pull --rebase origin {branch}`") + return url + + +def check_worktree(root: Path) -> None: + changed = git("status", "--porcelain", "--ignore-submodules=all", cwd=root).stdout.splitlines() + if changed: + report("note", f"{len(changed)} changed or new file(s) not committed yet", + "commit them, contribute them, or discard them; `git status` lists them") + else: + report("ok", "no uncommitted changes") + + +def submodule_config(root: Path, key: str) -> dict[str, str]: + """{name: value} of submodule.. in .gitmodules; names may hold spaces and dots.""" + res = git("config", "-f", ".gitmodules", "-z", "--get-regexp", rf"^submodule\..*\.{key}$", cwd=root) + pairs = (rec.partition("\n") for rec in res.stdout.split("\0") if rec) + return {k[len("submodule."):-len(key) - 1]: v for k, _, v in pairs} + + +def resolve_url(url: str, origin: str) -> str: + """A relative submodule URL (./x, ../x) resolved against origin, as git does; others unchanged.""" + if not url.startswith(("./", "../")) or not origin: + return url + base = origin.rstrip("/") + for part in url.split("/"): + if part == "..": + base = base.rsplit("/", 1)[0] if "/" in base.split("://")[-1] else base.rsplit(":", 1)[0] + elif part not in (".", ""): + base = f"{base}/{part}" + return base + + +def check_submodules(root: Path, origin: str, offline: bool) -> None: + if not (root / ".gitmodules").is_file(): + return + urls, paths = submodule_config(root, "url"), submodule_config(root, "path") + if not urls: + report("note", ".gitmodules lists no mounted brain git can read", "`git config -f .gitmodules --list` shows why") + for name, url in urls.items(): + path = paths.get(name, name) + # after `git submodule init` the brain's own config holds the URL in use, relative ones resolved + url = git("config", "--get", f"submodule.{name}.url", cwd=root).stdout.strip() or resolve_url(url, origin) + code, err = None, "" + if not offline and not url.startswith(("./", "../")): + code, _, err = remote("ls-remote", url, "HEAD", cwd=root) + if code not in (None, 0): + unreachable(f"mounted {path}", url, err) + elif not (root / path / ".git").exists(): + report("note", f"mounted {path} is not checked out", f"`git submodule update --init \"{path}\"`") + elif code == 0: + report("ok", f"mounted {path} answers") + else: + report("skip", f"mounted {path}" + (" (offline)" if offline else " (not checked: no origin to resolve its URL)")) + + +def config_dir() -> Path: + env = os.environ.get("CLAUDE_CONFIG_DIR") + return Path(env).expanduser() if env else Path.home() / ".claude" + + +def load_json(path: Path) -> dict: + try: + return as_dict(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError): + return {} + + +def install_records(cfg: Path) -> dict: + return as_dict(load_json(cfg / "plugins" / "installed_plugins.json").get("plugins")) + + +def check_km_version(root: Path) -> None: + pins = read_pins(root) + hook = pins.pop(".pre-commit-config.yaml", set()) + pinned = {p.version or str(p) for p in (set().union(*pins.values()) if pins else hook)} + if not pinned: + report("skip", f"km {KM_REF}: the brain pins no km version") + elif pinned == {KM_REF}: + report("ok", f"km {KM_REF} is the version the brain pins") + elif all((version_key(v) or ()) > (version_key(KM_REF) or ()) for v in pinned): + ids = sorted(p for p in install_records(config_dir()) if p.split("@")[0] == "km") + how = f"`claude plugin update {ids[0]}`" if ids else "`/plugin` in Claude Code" + report("FAIL", f"km {KM_REF} is older than the brain's {', '.join(sorted(map(str, pinned)))}", + f"update the km plugin ({how}), then restart Claude; if no update is offered, " + "ask the brain's maintainer") + elif shutil.which("uv"): + report("note", f"km {KM_REF}; the brain pins {', '.join(sorted(map(str, pinned)))}", + "km runs the pinned version through uv; a maintainer moves the pins with `km upgrade`") + else: + report("note", f"km {KM_REF}; the brain pins {', '.join(sorted(map(str, pinned)))}, which km cannot " + "run without uv, so its results may differ from CI", f"install uv: {UV_DOCS}") + + +def installed(plugin: str, root: Path, cfg: Path) -> bool: + """Installed for this brain: for the user, or for a project the brain lies in; or synced to the + account by its organization (`/plugins/synced//[~gN]/`).""" + entries = install_records(cfg).get(plugin, []) + for e in entries if isinstance(entries, list) else [entries]: + e = as_dict(e) + project = e.get("projectPath") + if e.get("scope", "user") == "user" or (isinstance(project, str) and project + and Path(project).resolve() in (root, *root.parents)): + return True + name = plugin.split("@")[0] + synced = cfg / "plugins" / "synced" + return synced.is_dir() and any(d.is_dir() and d.name.split("~")[0] == name for d in synced.glob("*/*")) + + +def check_plugins(root: Path) -> None: + settings = load_json(root / ".claude" / "settings.json") + wanted = [p for p, on in as_dict(settings.get("enabledPlugins")).items() if on is True] + if not wanted: + return + cfg = config_dir() + known = load_json(cfg / "plugins" / "known_marketplaces.json") + extra = as_dict(settings.get("extraKnownMarketplaces")) + for plugin in wanted: + if installed(plugin, root, cfg): + report("ok", f"plugin {plugin} is installed") + continue + market = plugin.partition("@")[2] + src = as_dict(as_dict(extra.get(market)).get("source")) + where = src.get("repo") or src.get("url") # not a "directory" source: a path on its author's computer + add = f"`/plugin marketplace add {shown(where)}`, then " if market not in known and isinstance(where, str) else "" + report("FAIL", f"plugin {plugin} is not installed", f"in Claude Code: {add}`/plugin install {plugin}`, " + "or add it in the Claude app under Customize, Plugins; then restart Claude") + + +def main() -> int: + ap = argparse.ArgumentParser(prog="km doctor", description="check that this computer is set up to work on a brain") + ap.add_argument("--offline", action="store_true", help="skip the checks that need the network") + args = ap.parse_args() + root = repo_root() + print(f"km doctor: {root}") + if not shutil.which("git"): + report("FAIL", "git is not installed", "install git: https://git-scm.com/downloads") + elif git("rev-parse", "--is-inside-work-tree", cwd=root).stdout.strip() != "true": + report("FAIL", f"{root} is not a git repository", "clone the brain with git and run km doctor inside it") + else: + check_identity(root) + origin = check_origin(root, args.offline) + check_worktree(root) + check_submodules(root, origin, args.offline) + check_km_version(root) + check_plugins(root) + check_uv() + for status, what, fix in results: + print(f" {status:<5} {what}") + if fix and status != "ok": + print(f" fix: {fix}") + failed = sum(status == "FAIL" for status, _, _ in results) + print(f"{failed} problem(s) to fix." if failed else "Everything needed is set up.") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/km/paths.py b/src/km/paths.py index 57ce1d2..4b72106 100644 --- a/src/km/paths.py +++ b/src/km/paths.py @@ -11,13 +11,18 @@ TEMPLATES = PACKAGE / "templates" -def git(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: +def git(*args: str, cwd: Path | None = None, env: dict[str, str] | None = None, + timeout: float | None = None) -> subprocess.CompletedProcess[str]: """git, with its output decoded the way Python decodes file names, so each name it lists opens the file it names: UTF-8 on Windows and macOS, the locale's encoding on Linux. With the system's code page instead (cp1252 on Windows), a name with an umlaut named no file. Bytes that do not - decode (a git message in another encoding, an odd name) are kept, not a crash.""" - return subprocess.run(["git", *args], cwd=cwd, capture_output=True, - encoding=sys.getfilesystemencoding(), errors="surrogateescape") + decode (a git message in another encoding, an odd name) are kept, not a crash. Without git + installed it fails like a missing command (127) instead of raising.""" + try: + return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, stdin=subprocess.DEVNULL, + encoding=sys.getfilesystemencoding(), errors="surrogateescape", timeout=timeout) + except FileNotFoundError: + return subprocess.CompletedProcess(["git", *args], 127, "", "git is not installed") def write_text(path: Path, text: str) -> None: diff --git a/tests/doctor_smoke.sh b/tests/doctor_smoke.sh new file mode 100644 index 0000000..cc95867 --- /dev/null +++ b/tests/doctor_smoke.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Smoke test for `km doctor`: on a team brain with a local origin, every check must pass, and each +# thing that is missing (git identity, access, a newer km, a plugin, uv on the PATH) must fail with +# a fix, without hanging and in ASCII. +# Run: bash tests/doctor_smoke.sh +set -u +source "$(dirname "$0")/lib.sh" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +fake_km_remote "$T/kmremote"; export KM_REPO_URL="file://$T/kmremote" +KM_REF="v$(km --version)"; KM_SHA="$(git -C "$T/kmremote" rev-parse "$KM_REF^{commit}")" +S99="$(printf '9%.0s' {1..40})" +# git and Claude Code read only what this test writes +printf '[user]\n\tname = t\n\temail = t@t\n[protocol "file"]\n\tallow = always\n' > "$T/gitconfig" +export GIT_CONFIG_GLOBAL="$T/gitconfig" GIT_CONFIG_NOSYSTEM=1 CLAUDE_CONFIG_DIR="$T/cfg" +mkdir -p "$T/home" && export HOME="$T/home" +B="$T/brain" +km init "$B" --team --name D --desc d --initials DD --folder docs=Docs >/dev/null +git -C "$B" init -q -b main && git -C "$B" add -A && git -C "$B" commit -q -m init +git init -q --bare -b main "$T/origin.git" && git -C "$B" remote add origin "$T/origin.git" && git -C "$B" push -q origin main +doctor(){ out="$(cd "$B" && km doctor "$@" 2>&1)"; rc=$?; } + +doctor +{ [ "$rc" = 0 ] && out_has "Everything needed is set up" && out_has "ok origin" && out_has "has everything origin has" \ + && out_has "ok km $KM_REF is the version the brain pins"; } && ok "a set-up brain passes" || no "a set-up brain passes ($out)" +LC_ALL=C grep -q '[^ -~]' <<< "$out" && no "the output is ASCII" || ok "the output is ASCII" +out="$(cd "$B" && PYTHONPATH="$REPO/src" "$PY" -c 'import sys, runpy +sys.modules["yaml"] = None # PyYAML missing +sys.argv = ["km", "doctor", "--offline"]; runpy.run_module("km", run_name="__main__")' 2>&1)" +out_has "km doctor:" && ! out_has "Traceback" && ok "runs without PyYAML" || no "runs without PyYAML ($out)" + +printf '[user]\n\tname = t\n' > "$T/noemail" +out="$(cd "$B" && GIT_CONFIG_GLOBAL="$T/noemail" km doctor 2>&1)"; rc=$? +{ [ "$rc" = 1 ] && out_has "FAIL git does not know your email" && out_has 'git config --global user.email'; } \ + && ok "a missing git email fails with the command that sets it" || no "missing git email ($out)" + +echo x > "$B/scratch.md"; doctor +{ [ "$rc" = 0 ] && out_has "note 1 changed or new file"; } && ok "uncommitted changes are a note, not a failure" || no "uncommitted changes ($out)" +rm "$B/scratch.md" + +git clone -q "$T/origin.git" "$T/other" && git -C "$T/other" commit -q --allow-empty -m newer && git -C "$T/other" push -q origin main +doctor +{ [ "$rc" = 1 ] && out_has "FAIL branch main lacks changes that are on origin"; } && ok "a brain behind origin fails" || no "behind origin ($out)" +git -C "$B" commit -q --allow-empty -m local; doctor +fix="$(sed -n 's/.*`\(git pull --rebase origin main\)`.*/\1/p' <<< "$out")" +{ [ "$rc" = 1 ] && [ -n "$fix" ] && ( cd "$B" && $fix -q ) && doctor && [ "$rc" = 0 ]; } \ + && ok "a diverged brain fails with a pull that works" || no "diverged brain ($out)" +doctor --offline +{ [ "$rc" = 0 ] && out_has "skip origin .* (offline)"; } && ok "--offline skips the origin" || no "--offline ($out)" + +git -C "$B" remote set-url origin "file://$T/nope.git" +start=$SECONDS; doctor +{ [ "$rc" = 1 ] && out_has "FAIL origin: no access to" && out_has "fix: sign in" && [ $((SECONDS-start)) -lt 20 ]; } \ + && ok "an origin that cannot be read fails at once, with a fix" || no "unreadable origin ($out)" +git -C "$B" remote set-url origin "$T/origin.git" +git -C "$B" remote set-url origin "https://someone:s3cret@127.0.0.1:9/brain.git"; doctor +{ [ "$rc" = 1 ] && out_has "FAIL origin: https://127.0.0.1:9/brain.git cannot be reached" && ! out_has "s3cret"; } \ + && ok "an origin that does not answer is a network problem, its token not shown" || no "token in URL ($out)" +git -C "$B" remote set-url origin "$T/origin.git" + +git init -q "$T/peer" && git -C "$T/peer" commit -q --allow-empty -m peer +git -C "$B" submodule add -q "$T/peer" brains/peer >/dev/null 2>&1 && git -C "$B" commit -q -m mount && git -C "$B" push -q origin main +doctor; out_has "ok mounted brains/peer answers" && ok "a mounted brain that answers passes" || no "mounted brain ($out)" +git -C "$B" submodule deinit -q -f brains/peer; doctor +{ [ "$rc" = 0 ] && out_has "note mounted brains/peer is not checked out" && out_has "submodule update --init \"brains/peer\""; } \ + && ok "a mounted brain not checked out is a note with the command" || no "mounted brain not checked out ($out)" +git -C "$B" config -f .gitmodules submodule.brains/peer.url "file://$T/gone"; doctor +{ [ "$rc" = 1 ] && out_has "FAIL mounted brains/peer: no access"; } && ok "a mounted brain that cannot be read fails" || no "unreadable mounted brain ($out)" +git -C "$B" checkout -q .gitmodules +git init -q "$T/my peer" && git -C "$T/my peer" commit -q --allow-empty -m peer +git -C "$B" submodule add -q "$T/my peer" "brains/my peer" >/dev/null 2>&1 && git -C "$B" commit -q -m spaces; doctor +{ out_has "ok mounted brains/my peer answers" && ! out_has "FAIL mounted"; } && ok "a mounted brain with a space in its path" || no "space in path ($out)" +git -C "$B" submodule deinit -q -f "brains/my peer"; git -C "$B" config -f .gitmodules "submodule.brains/my peer.url" ../gone.git; doctor +{ [ "$rc" = 1 ] && out_has "FAIL mounted brains/my peer: no access to .*/gone.git"; } \ + && ok "a relative URL is resolved against origin and checked" || no "relative submodule URL ($out)" +git -C "$B" checkout -q .gitmodules + +repin "$KM_SHA" "$KM_REF" "$S99" v99.0.0 "$B"/.github/workflows/*.yml "$B/.pre-commit-config.yaml"; doctor +{ [ "$rc" = 1 ] && out_has "FAIL km $KM_REF is older than the brain's v99.0.0" && out_has "ask the brain.s maintainer"; } \ + && ok "a km older than the brain's pin fails" || no "older km ($out)" +repin "$S99" v99.0.0 "$(git -C "$T/kmremote" rev-parse v0.9.0)" v0.9.0 "$B"/.github/workflows/*.yml "$B/.pre-commit-config.yaml"; doctor +{ [ "$rc" = 0 ] && out_has "note km $KM_REF; the brain pins v0.9.0"; } && ok "a km newer than the pin is a note" || no "newer km ($out)" +git -C "$B" checkout -q . + +mkdir -p "$B/.claude" "$T/cfg/plugins" +cat > "$B/.claude/settings.json" <<'JSON' +{"extraKnownMarketplaces": {"team": {"source": {"source": "github", "repo": "owner/team-plugins"}}}, + "enabledPlugins": {"km@team": true, "docs@team": true, "off@team": false}} +JSON +cat > "$T/cfg/plugins/installed_plugins.json" < "$T/cfg/plugins/known_marketplaces.json"; doctor +out_has "fix: in Claude Code: ./plugin install docs@team" && ok "a known marketplace is not added again" || no "known marketplace ($out)" +printf '{"enabledPlugins": ["x@team"], "extraKnownMarketplaces": "team"}' > "$B/.claude/settings.json" +printf '{"plugins": ["x"]}' > "$T/cfg/plugins/installed_plugins.json"; doctor +! out_has "Traceback" && ok "odd values in the JSON files are no crash" || no "odd JSON values ($out)" +printf '{"enabledPlugins": {"docs@team": true}, "extraKnownMarketplaces": {"team": {"source": "github"}}}' > "$B/.claude/settings.json" +printf '{"plugins": {"docs@team": [{"scope": "project", "projectPath": "%s"}]}}' "$(cd "$B" && { pwd -W 2>/dev/null || pwd; })" > "$T/cfg/plugins/installed_plugins.json"; doctor +{ ! out_has "Traceback" && out_has "ok plugin docs@team is installed"; } && ok "a project install in the brain counts" || no "project install ($out)" +printf '{}' > "$T/cfg/plugins/installed_plugins.json" +mkdir -p "$T/cfg/plugins/synced/bucket/docs~g2"; doctor +{ [ "$rc" = 0 ] && out_has "ok plugin docs@team is installed"; } && ok "a plugin synced by the organization counts" || no "synced plugin ($out)" + +if [ "$SEP" = ":" ]; then # PATH games need a POSIX layout + mkdir -p "$T/bin" "$T/home/.local/bin" && : > "$T/home/.local/bin/uv" + ln -s "$(command -v git)" "$T/bin/git" && ln -s "$(command -v "$PY")" "$T/bin/py" + out="$(cd "$B" && HOME="$T/home" PATH="$T/bin" PYTHONPATH="$REPO/src" "$T/bin/py" -m km doctor --offline 2>&1)" + out_has "FAIL uv is installed in .* but that folder is not on the PATH" && ok "uv installed but not on the PATH fails" || no "uv off the PATH ($out)" + rm "$T/home/.local/bin/uv" + repin "$KM_SHA" "$KM_REF" "$(git -C "$T/kmremote" rev-parse v0.9.0)" v0.9.0 "$B"/.github/workflows/*.yml "$B/.pre-commit-config.yaml" + out="$(cd "$B" && PATH="$T/bin" PYTHONPATH="$REPO/src" "$T/bin/py" -m km doctor --offline 2>&1)" + out_has "the brain pins v0.9.0, which km cannot run without uv" && ok "without uv a differing pin says km cannot run it" || no "pin without uv ($out)" + git -C "$B" checkout -q . + out="$(cd "$B" && PATH="$T/bin" PYTHONPATH="$REPO/src" "$T/bin/py" -c 'import sys, runpy +sys.modules["yaml"] = None +sys.argv = ["km", "doctor", "--offline"]; runpy.run_module("km", run_name="__main__")' 2>&1)" + out_has "FAIL uv is not installed, and this Python has no PyYAML" && ok "no uv and no PyYAML fails: km cannot run" || no "no uv, no PyYAML ($out)" + # the launcher: nothing to run km with, then uv only where its installer put it + mkdir -p "$T/bare" && ln -s "$(command -v dirname)" "$T/bare/dirname" + out="$(cd "$B" && PATH="$T/bare" "$BASH" "$REPO/skills/km/bin/km" doctor 2>&1)"; rc=$? + { [ "$rc" = 1 ] && out_has "km cannot start on this computer" && out_has "install.ps1" && out_has "git is missing as well"; } \ + && ok "the launcher says what to install when nothing can run km" || no "launcher without uv or Python ($out)" + printf '#!/bin/sh\necho "fake uv $*"\n' > "$T/home/.local/bin/uv" && chmod +x "$T/home/.local/bin/uv" + out="$(cd "$B" && PATH="$T/bare" "$BASH" "$REPO/skills/km/bin/km" doctor 2>&1)" + out_has "fake uv run .* -m km doctor" && ok "the launcher finds uv off the PATH, before a restart" || no "launcher uv off PATH ($out)" + rm "$T/home/.local/bin/uv" + rm "$T/bin/git" + out="$(cd "$B" && PATH="$T/bin" PYTHONPATH="$REPO/src" "$T/bin/py" -m km doctor 2>&1)"; rc=$? + { [ "$rc" = 1 ] && out_has "FAIL git is not installed" && ! out_has "Traceback"; } && ok "without git it says so" || no "no git ($out)" +fi + +out="$(cd "$T" && km doctor 2>&1)"; rc=$? +{ [ "$rc" = 1 ] && out_has "is not a git repository"; } && ok "outside a git repository it says so" || no "outside a repository ($out)" +summary doctor_smoke From 2339b8401ebb6e34579a5b9fc604be087ab1bd17 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Wed, 7 Oct 2026 20:09:52 +0200 Subject: [PATCH 2/2] chore(release): km 1.3.0 --- .claude-plugin/plugin.json | 2 +- README.md | 10 +++++----- src/km/__init__.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ab77331..d16af5e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "km", - "version": "1.2.0", + "version": "1.3.0", "description": "Knowledge management for markdown knowledge bases: answer from it with sources, save notes, decisions and transcripts with validated frontmatter, lint, mount peer brains, contribute to a team brain by PR, and set up a shared team brain with CI (/km init --team).", "author": { "name": "Roman Passler" diff --git a/README.md b/README.md index ee82bf6..fb983b4 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ for Claude Code anyway, which offers to add the marketplace when the folder is t that fails names its fix; it needs no PyYAML, so it also runs on a computer that is half set up. Every command takes `--root DIR`; the default is the git work tree around the current directory. -Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.2.0` shows -it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.2.0`, or run it -without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.2.0`. +Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.3.0` shows +it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.3.0`, or run it +without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.3.0`. ## What a brain gets @@ -71,13 +71,13 @@ Brains carry no km code. `km init` writes `CONVENTIONS.md`, `CLAUDE.md`, `schema generated indexes, a README, and CI that installs km through this repository's GitHub Action: ```yaml -- uses: pasrom/km@ # v1.2.0, installs the km CLI at that commit +- uses: pasrom/km@ # v1.3.0, installs the km CLI at that commit - run: km validate - run: km gen-index --check ``` The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`, -`rev: # frozen: v1.2.0`, `id: km-validate`). Every pin names a release by its commit, so a +`rev: # frozen: v1.3.0`, `id: km-validate`). Every pin names a release by its commit, so a tag moved later cannot change what a brain runs; the version rides along as a comment, and `km upgrade` checks that the commit is that release's. km's own dependencies are pinned to exact versions. `km init` and `km upgrade` look a release's commit up by its tag once, so a release tag here diff --git a/src/km/__init__.py b/src/km/__init__.py index eb724bc..dd151be 100644 --- a/src/km/__init__.py +++ b/src/km/__init__.py @@ -1,4 +1,4 @@ """km: knowledge management for markdown knowledge bases.""" -__version__ = "1.2.0" +__version__ = "1.3.0" KM_REF = f"v{__version__}" # this km's release tag; brains pin the commit it points at