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/.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..fb983b4 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,12 +50,19 @@ 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 -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 @@ -63,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 @@ -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/__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 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