keywright 0.0.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.
keywright/hook.py ADDED
@@ -0,0 +1,161 @@
1
+ """Claude Code hooks, stdlib only.
2
+
3
+ PreToolUse on Bash: the hazard tripwire over the whole line, with one refinement for lines that
4
+ invoke `kw` itself. The part of a `kw run/put/get/capture` line that is the remote payload is
5
+ the server's to judge (it is flagged there, prompted or refused there), so here it only earns a
6
+ note. Everything local on the same line (other segments, substitutions, redirections, `get -o`
7
+ destinations) is enforced as usual. And `kw setup|login|logout|session-start` always asks,
8
+ whatever options precede the subcommand: an agent must not rewrite its own gate.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shlex
15
+ from typing import Any
16
+
17
+ from .hazards import Hazard, blocks, describe, scan_command, scan_path, worst
18
+
19
+ CLI_NAMES = {"kw", "keywright"}
20
+ GUARDED = {"setup", "login", "logout", "session-start"}
21
+ REMOTE_COMMAND = {"run", "capture"}
22
+ GLOBAL_VALUE_OPTS = {"--profile", "-p"}
23
+ _SEPARATORS = {";", "|", "||", "&&", "&", "(", ")"}
24
+
25
+
26
+ def _tokens(command: str) -> list[str] | None:
27
+ try:
28
+ lex = shlex.shlex(command, posix=True, punctuation_chars=";|&()<>")
29
+ lex.whitespace_split = True
30
+ return list(lex)
31
+ except ValueError:
32
+ return None
33
+
34
+
35
+ def _segments(tokens: list[str]) -> list[list[str]]:
36
+ segs: list[list[str]] = [[]]
37
+ for tok in tokens:
38
+ if tok in _SEPARATORS:
39
+ if segs[-1]:
40
+ segs.append([])
41
+ else:
42
+ segs[-1].append(tok)
43
+ return [s for s in segs if s]
44
+
45
+
46
+ def parse_invocation(words: list[str]) -> dict[str, Any] | None:
47
+ """For a segment that invokes kw: {sub, host, remote, local_writes, local_reads}. None otherwise."""
48
+ if not words or words[0].rsplit("/", 1)[-1] not in CLI_NAMES:
49
+ return None
50
+ i = 1
51
+ while i < len(words) and words[i].startswith("-"):
52
+ if words[i] in GLOBAL_VALUE_OPTS and "=" not in words[i]:
53
+ i += 2
54
+ else:
55
+ i += 1
56
+ if i >= len(words):
57
+ return {"sub": None, "remote": [], "local_writes": [], "local_reads": []}
58
+ sub, rest = words[i], words[i + 1:]
59
+ out: dict[str, Any] = {"sub": sub, "remote": [], "local_writes": [], "local_reads": []}
60
+ if sub in REMOTE_COMMAND:
61
+ if "--" in rest:
62
+ k = rest.index("--")
63
+ out["remote"].append(shlex.join(rest[k + 1:]))
64
+ rest = rest[:k]
65
+ for j, w in enumerate(rest):
66
+ if w in ("-c", "--command") and j + 1 < len(rest):
67
+ out["remote"].append(rest[j + 1])
68
+ if w.startswith(("-c=", "--command=")):
69
+ out["remote"].append(w.split("=", 1)[1])
70
+ positional = [w for w in rest if not w.startswith("-")]
71
+ if sub == "put" and len(positional) >= 2:
72
+ out["remote_write"] = positional[1]
73
+ for j, w in enumerate(rest):
74
+ if w in ("--file", "-f") and j + 1 < len(rest):
75
+ out["local_reads"].append(rest[j + 1])
76
+ if sub == "get":
77
+ if len(positional) >= 2:
78
+ out["remote_read"] = positional[1]
79
+ for j, w in enumerate(rest):
80
+ if w in ("-o", "--output") and j + 1 < len(rest):
81
+ out["local_writes"].append(rest[j + 1])
82
+ return out
83
+
84
+
85
+ def analyze(command: str) -> tuple[list[Hazard], list[Hazard], bool]:
86
+ """(local hazards, server-gated hazards, guarded-subcommand present)."""
87
+ tokens = _tokens(command)
88
+ if tokens is None:
89
+ return scan_command(command), [], False
90
+ segments = _segments(tokens)
91
+ if not any(s[0].rsplit("/", 1)[-1] in CLI_NAMES for s in segments):
92
+ return scan_command(command), [], False
93
+ local_parts: list[str] = []
94
+ remote: list[Hazard] = []
95
+ local_extra: list[Hazard] = []
96
+ guarded = False
97
+ for seg in segments:
98
+ inv = parse_invocation(seg)
99
+ if inv is None:
100
+ local_parts.append(shlex.join(seg))
101
+ continue
102
+ if inv["sub"] in GUARDED:
103
+ guarded = True
104
+ for r in inv["remote"]:
105
+ remote.extend(scan_command(r))
106
+ if inv.get("remote_write"):
107
+ remote.extend(scan_path(inv["remote_write"], "write"))
108
+ if inv.get("remote_read"):
109
+ remote.extend(scan_path(inv["remote_read"], "read"))
110
+ for p in inv["local_writes"]:
111
+ local_extra.extend(scan_path(p, "write"))
112
+ for p in inv["local_reads"]:
113
+ local_extra.extend(scan_path(p, "read"))
114
+ # the invocation minus its payload still takes part in the local scan (cd, pipes around it)
115
+ local_parts.append(shlex.join([w for w in seg if w not in inv["remote"]]).split(" -- ")[0])
116
+ local = scan_command(" ; ".join(local_parts)) + [h for h in local_extra if h not in local_extra[:0]]
117
+ seen: list[Hazard] = []
118
+ for h in local:
119
+ if h not in seen:
120
+ seen.append(h)
121
+ return seen, remote, guarded
122
+
123
+
124
+ def pre_tool_use(payload: dict[str, Any]) -> dict[str, Any] | None:
125
+ if payload.get("tool_name") != "Bash":
126
+ return None
127
+ command = str((payload.get("tool_input") or {}).get("command") or "")
128
+ if not command.strip():
129
+ return None
130
+ local, remote, guarded = analyze(command)
131
+ notes = []
132
+ if local:
133
+ notes.append("local " + describe(local))
134
+ if remote:
135
+ notes.append("server-gated " + describe(remote))
136
+ text = "Keywright hazard check: " + "; ".join(notes) if notes else ""
137
+ if guarded:
138
+ reason = "kw setup/login/logout change the agent's own gate; the human runs them."
139
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask",
140
+ "permissionDecisionReason": reason},
141
+ "systemMessage": reason + (" " + text if text else "")}
142
+ level = worst(local)
143
+ if level is None:
144
+ return {"systemMessage": text} if text else None
145
+ if not blocks(level):
146
+ return {"systemMessage": text}
147
+ decision = "deny" if level == "critical" else "ask"
148
+ reason = text + (". Refused: if this must happen, the user runs it by hand." if level == "critical"
149
+ else ". Flagged as destructive; an existing allow rule does not cover it.")
150
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
151
+ "permissionDecisionReason": reason},
152
+ "systemMessage": text}
153
+
154
+
155
+ def run_pre_tool_use(stdin_text: str) -> str:
156
+ try:
157
+ payload = json.loads(stdin_text)
158
+ except ValueError:
159
+ return ""
160
+ out = pre_tool_use(payload) if isinstance(payload, dict) else None
161
+ return json.dumps(out) if out else ""
keywright/patterns.py ADDED
@@ -0,0 +1,153 @@
1
+ """Permission paths and glob patterns.
2
+
3
+ Paths are slash-separated: ssh/<host>/exec/<bin>[/<sub>...] mcp/<server>/tools/<tool>
4
+ llm/<gateway>/model/<model>
5
+
6
+ Pattern rules:
7
+ * matches exactly one segment
8
+ ** matches zero or more segments (only meaningful as the last segment)
9
+ a pattern without wildcards also matches any path it is a segment-prefix of,
10
+ so granting `ssh/nvr/exec/docker` covers `ssh/nvr/exec/docker/ps`.
11
+ a segment may end in * to match a prefix: `entity_id=light.*` matches `entity_id=light.kitchen`.
12
+
13
+ MCP calls are qualified by argument values when the resource names argument keys:
14
+ mcp/<server>/tools/<tool>/<key>=<value> one path per configured key present in the call
15
+ so `mcp/ha/tools/call_service` (a literal prefix) still covers every call, while a deny on
16
+ `mcp/ha/tools/call_service/service=automation.turn_off` blocks only that service.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ PATTERN_HELP = (
24
+ "Pattern syntax: slash-separated segments, ssh/<host>/exec/<command>[/<sub>], "
25
+ "mcp/<server>/tools/<tool>[/<key>=<value>] or llm/<gateway>/model/<model>. `*` alone matches one whole segment; `**` matches "
26
+ "everything below and must be last; a segment may END in `*` to match a prefix (tools/ha_get_*, "
27
+ "entity_id=light.*) but `*` cannot appear elsewhere in a segment. A literal pattern also covers "
28
+ "everything under it. Allowed characters: letters, digits and _ . : @ + = , - (no spaces). Each pattern "
29
+ "must name one concrete resource: the first two segments cannot be wildcards."
30
+ )
31
+
32
+ KINDS = ("ssh", "mcp", "llm")
33
+
34
+ _SEGMENT = re.compile(r"^[A-Za-z0-9_.:@+=,-]+\*?$|^\*$|^\*\*$")
35
+ _VALUE_CHARS = re.compile(r"[^A-Za-z0-9_.:@+=,-]")
36
+
37
+
38
+ def validate(pattern: str) -> str:
39
+ pattern = pattern.strip().strip("/")
40
+ if not pattern:
41
+ raise ValueError("empty pattern")
42
+ parts = pattern.split("/")
43
+ for i, seg in enumerate(parts):
44
+ if not _SEGMENT.match(seg):
45
+ raise ValueError(f"invalid segment {seg!r} in {pattern!r}")
46
+ if seg == "**" and i != len(parts) - 1:
47
+ raise ValueError("** is only allowed as the last segment")
48
+ if parts[0] not in KINDS and parts[0] not in ("*", "**"):
49
+ raise ValueError("pattern must start with ssh/, mcp/ or llm/")
50
+ return pattern
51
+
52
+
53
+ def matches(pattern: str, path: str) -> bool:
54
+ p = pattern.strip("/").split("/")
55
+ t = path.strip("/").split("/")
56
+ if p and p[-1] == "**":
57
+ head = p[:-1]
58
+ return len(t) >= len(head) and all(_seg(a, b) for a, b in zip(head, t, strict=False))
59
+ if len(t) == len(p):
60
+ return all(_seg(a, b) for a, b in zip(p, t, strict=True))
61
+ # implicit prefix rule for literal (and value-glob) patterns
62
+ if len(t) > len(p) and not any(s in ("*", "**") for s in p):
63
+ return all(_seg(a, b) for a, b in zip(p, t, strict=False))
64
+ return False
65
+
66
+
67
+ def _seg(pat: str, seg: str) -> bool:
68
+ if pat == "*" or pat == seg:
69
+ return True
70
+ return pat.endswith("*") and len(pat) > 1 and seg.startswith(pat[:-1])
71
+
72
+
73
+ def _is_glob(seg: str) -> bool:
74
+ return seg.endswith("*") and len(seg) > 1 and seg != "**"
75
+
76
+
77
+ def _seg_covers(allowed: str, required: str) -> bool:
78
+ """Every concrete segment matched by `required` is matched by `allowed`."""
79
+ if allowed == "*" or allowed == required:
80
+ return True
81
+ if _is_glob(allowed):
82
+ base = allowed[:-1]
83
+ return required.startswith(base) if not _is_glob(required) else required[:-1].startswith(base)
84
+ return False
85
+
86
+
87
+ def _seg_overlaps(a: str, b: str) -> bool:
88
+ if a == "*" or b == "*" or a == b:
89
+ return True
90
+ pa, pb = (a[:-1] if _is_glob(a) else None), (b[:-1] if _is_glob(b) else None)
91
+ if pa is not None and pb is not None:
92
+ return pa.startswith(pb) or pb.startswith(pa)
93
+ if pa is not None:
94
+ return b.startswith(pa)
95
+ if pb is not None:
96
+ return a.startswith(pb)
97
+ return False
98
+
99
+
100
+ def qualifier(key: str, value: object) -> str:
101
+ """`key=value` segment for an MCP argument. Characters outside the segment alphabet become `_`,
102
+ so a value can never smuggle a wildcard or a path separator into a required path."""
103
+ text = _VALUE_CHARS.sub("_", str(value))[:120] or "_"
104
+ return f"{key}={text}"
105
+
106
+
107
+ def resource_of(path: str) -> tuple[str, str] | None:
108
+ """('ssh', 'nvr') for 'ssh/nvr/exec/ls'. None if the path is too short."""
109
+ parts = path.strip("/").split("/")
110
+ if len(parts) < 2 or parts[0] not in KINDS:
111
+ return None
112
+ return parts[0], parts[1]
113
+
114
+
115
+ def model_segment(model: str) -> str:
116
+ """The path segment for an LLM model name. Provider prefixes such as `openrouter/anthropic/x`
117
+ contain slashes, which are path separators here, so every character outside the segment
118
+ alphabet becomes `_`: `llm/<gateway>/model/openrouter_anthropic_x`."""
119
+ return _VALUE_CHARS.sub("_", model)[:120] or "_"
120
+
121
+
122
+ def specificity(pattern: str) -> int:
123
+ """Higher = more specific. Used to prefer narrow grants when several match."""
124
+ parts = pattern.strip("/").split("/")
125
+ score = 0
126
+ for s in parts:
127
+ score += 0 if s == "**" else 1 if s == "*" else 2 if _is_glob(s) else 3
128
+ return score
129
+
130
+
131
+ def _shape(pattern: str) -> tuple[list[str], bool]:
132
+ parts = pattern.strip("/").split("/")
133
+ if parts[-1] == "**":
134
+ return parts[:-1], True
135
+ return parts, not any(p == "*" for p in parts)
136
+
137
+
138
+ def overlaps(left: str, right: str) -> bool:
139
+ """Whether two scopes share any concrete path (including implicit literal prefixes)."""
140
+ a, a_tail = _shape(left)
141
+ b, b_tail = _shape(right)
142
+ if (len(a) < len(b) and not a_tail) or (len(b) < len(a) and not b_tail):
143
+ return False
144
+ return all(_seg_overlaps(x, y) for x, y in zip(a, b, strict=False))
145
+
146
+
147
+ def covers(allowed: str, required: str) -> bool:
148
+ """Whether the entire required scope is contained in the allowed scope."""
149
+ a, a_tail = _shape(allowed)
150
+ b, b_tail = _shape(required)
151
+ if len(a) > len(b) or (not a_tail and (b_tail or len(a) != len(b))):
152
+ return False
153
+ return all(_seg_covers(x, y) for x, y in zip(a, b, strict=False))
keywright/session.py ADDED
@@ -0,0 +1,144 @@
1
+ """Which conversation this call belongs to.
2
+
3
+ The server scopes "This session" approvals to whatever the client names in X-Keywright-Session,
4
+ and Claude Code's stateless MCP transport names nothing. The CLI names it, in this order:
5
+
6
+ 1. KW_SESSION in the environment: automation can set `job:<id>`, Claude Code gets
7
+ `claude:<session_id>` from the SessionStart hook (`kw session-start --hook`) through
8
+ CLAUDE_ENV_FILE. This is the preferred path because it is per conversation.
9
+ 2. CODEX_THREAD_ID when supplied by Codex: a conversation identity that survives hidden
10
+ process ancestry in a sandbox. Only a bounded, header-safe value is accepted.
11
+ 3. A per-agent-process fallback: a file named by the nearest non-shell ancestor's
12
+ (pid, start time, boot id), created with O_EXCL and holding a random id. PID reuse cannot
13
+ revive an old identity because the start time differs; stale files are swept on every run.
14
+ 4. None: the server then offers no "This session" scope, as before the CLI existed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ import secrets
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ SHELLS = {"bash", "sh", "zsh", "dash", "fish", "ksh", "sudo", "env", "timeout", "nohup", "script", "python3",
26
+ "python"}
27
+ AGENT_HINTS = {"claude": "claude", "codex": "codex", "cephloom": "cephloom"}
28
+ MAX_LEN = 128
29
+
30
+
31
+ def runtime_dir() -> Path:
32
+ base = os.environ.get("XDG_RUNTIME_DIR")
33
+ if base and os.access(base, os.W_OK):
34
+ return Path(base) / "keywright"
35
+ return Path(os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")) / "keywright" / "run"
36
+
37
+
38
+ def resolve() -> tuple[str | None, str]:
39
+ """(session value or None, how it was found)."""
40
+ env = os.environ.get("KW_SESSION")
41
+ if env:
42
+ return env[:MAX_LEN], "KW_SESSION"
43
+ thread = os.environ.get("CODEX_THREAD_ID", "")
44
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,121}", thread):
45
+ return f"codex:{thread}", "CODEX_THREAD_ID"
46
+ if not sys.platform.startswith("linux"):
47
+ return None, "none (no /proc on this platform)"
48
+ anchor = _agent_ancestor()
49
+ if anchor is None:
50
+ return None, "none (no agent process found above this shell)"
51
+ pid, start, kind = anchor
52
+ try:
53
+ value = _per_process(pid, start, kind)
54
+ except OSError as e:
55
+ return None, f"none ({e})"
56
+ return value, f"process {pid} ({kind})"
57
+
58
+
59
+ def _proc_field(pid: int) -> tuple[int, str, str] | None:
60
+ """(ppid, comm, starttime) from /proc/<pid>/stat, or None when the process is gone."""
61
+ try:
62
+ stat = Path(f"/proc/{pid}/stat").read_text()
63
+ except OSError:
64
+ return None
65
+ # comm may contain spaces; it is bracketed
66
+ comm = stat[stat.index("(") + 1:stat.rindex(")")]
67
+ rest = stat[stat.rindex(")") + 2:].split()
68
+ return int(rest[1]), comm, rest[19]
69
+
70
+
71
+ def _cmdline(pid: int) -> str:
72
+ try:
73
+ return Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
74
+ except OSError:
75
+ return ""
76
+
77
+
78
+ def _agent_ancestor() -> tuple[int, str, str] | None:
79
+ pid = os.getppid()
80
+ for _ in range(64):
81
+ if pid <= 1:
82
+ return None
83
+ info = _proc_field(pid)
84
+ if info is None:
85
+ return None
86
+ ppid, comm, start = info
87
+ if comm not in SHELLS:
88
+ line = _cmdline(pid).lower()
89
+ kind = next((k for k, needle in AGENT_HINTS.items() if needle in comm.lower() or needle in line), "shell")
90
+ return pid, start, kind
91
+ pid = ppid
92
+ return None
93
+
94
+
95
+ def _boot_id() -> str:
96
+ try:
97
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()[:8]
98
+ except OSError:
99
+ return "noboot"
100
+
101
+
102
+ def _per_process(pid: int, start: str, kind: str) -> str:
103
+ d = runtime_dir()
104
+ d.mkdir(parents=True, exist_ok=True)
105
+ os.chmod(d, 0o700)
106
+ _sweep(d)
107
+ path = d / f"{pid}-{start}-{_boot_id()}"
108
+ try:
109
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
110
+ except FileExistsError:
111
+ return path.read_text().strip()
112
+ value = f"{kind}:{secrets.token_urlsafe(12)}"
113
+ with os.fdopen(fd, "w") as f:
114
+ f.write(value)
115
+ return value
116
+
117
+
118
+ def _sweep(d: Path) -> None:
119
+ boot = _boot_id()
120
+ for f in d.iterdir():
121
+ parts = f.name.split("-")
122
+ if len(parts) != 3:
123
+ continue
124
+ pid, start, b = parts
125
+ info = _proc_field(int(pid)) if pid.isdigit() else None
126
+ if b != boot or info is None or info[2] != start:
127
+ try:
128
+ f.unlink()
129
+ except OSError:
130
+ pass
131
+
132
+
133
+ def session_start_hook(payload: dict) -> str | None:
134
+ """Claude Code SessionStart hook: persist the conversation id for later Bash calls.
135
+ Returns the line written, or None when there was nowhere to write it."""
136
+ sid = str(payload.get("session_id") or "").strip()
137
+ target = os.environ.get("CLAUDE_ENV_FILE")
138
+ if not sid or not target:
139
+ return None
140
+ safe = "".join(c for c in sid if c.isalnum() or c in "-_.")[:MAX_LEN - 7]
141
+ line = f"export KW_SESSION=claude:{safe}\n"
142
+ with open(target, "a") as f:
143
+ f.write(line)
144
+ return line.strip()
@@ -0,0 +1,2 @@
1
+ """`kw setup ...`: owns the agent-machine side of Keywright (hooks, permission rules, skill, MCP
2
+ registration) with read-merge-write edits, backups, idempotence and --dry-run."""
@@ -0,0 +1,115 @@
1
+ """`kw setup check`: the doctor. Every line is a fact with a verdict; nonzero exit on any failure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from .. import __version__, config, session
13
+ from .claude_code import LEGACY_HOOK_FILE, mcp_registrations, skill_version
14
+ from .codex import BEGIN, END, codex_home
15
+ from .common import binary_path, claude_dir, find_hooks, read_json
16
+
17
+
18
+ def _line(ok: bool | None, what: str, detail: str = "") -> tuple[bool | None, str]:
19
+ mark = {True: "ok ", False: "FAIL", None: "info"}[ok]
20
+ return ok, f"{mark} {what}" + (f": {detail}" if detail else "")
21
+
22
+
23
+ def integration_checks() -> list[tuple[bool | None, str]]:
24
+ """Check integrations present on this machine, including Codex-only installs."""
25
+ lines = []
26
+ home = claude_dir()
27
+ kw = binary_path()
28
+ has_claude = home.exists() or shutil.which("claude") is not None
29
+ codex_dir = codex_home()
30
+ has_codex = codex_dir.exists() or shutil.which("codex") is not None
31
+ if has_claude:
32
+ settings = read_json(home / "settings.json")
33
+ hooks = find_hooks(settings)
34
+ if hooks:
35
+ stale = [h for h in hooks if kw not in h and "gatekeeper-hazards.py" in h]
36
+ lines.append(_line(not stale, "claude code hooks", "; ".join(hooks)))
37
+ if stale:
38
+ lines.append(_line(False, "legacy hook", "points at a copied file; run `kw setup claude-code`"))
39
+ else:
40
+ lines.append(_line(False, "claude code hooks", "none; run `kw setup claude-code`"))
41
+ if (home / LEGACY_HOOK_FILE).exists():
42
+ lines.append(_line(False, "legacy hook file", f"{home / LEGACY_HOOK_FILE} still present"))
43
+ sv = skill_version(home / "skills" / "keywright" / "SKILL.md")
44
+ lines.append(_line(sv == __version__, "claude code skill", sv or "not installed"))
45
+ else:
46
+ lines.append(_line(None, "claude code", "not installed; skipped"))
47
+ if has_codex:
48
+ # Match Codex's global instruction-file precedence.
49
+ candidates = [codex_dir / "AGENTS.override.md", codex_dir / "AGENTS.md"]
50
+ active = next((p for p in candidates if p.is_file() and p.read_text().strip()), None)
51
+ text = active.read_text() if active else ""
52
+ installed = BEGIN in text and END in text and text.index(BEGIN) < text.index(END)
53
+ detail = str(active) if installed else "Keywright instructions missing from the active global file; run `kw setup codex`"
54
+ lines.append(_line(installed, "codex instructions", detail))
55
+ if not has_claude and not has_codex:
56
+ lines.append(_line(False, "agent integration", "none; run `kw setup codex` or `kw setup claude-code`"))
57
+ regs = mcp_registrations() if has_claude else []
58
+ lines.append(_line(None, "mcp registrations", ", ".join(regs) if regs else "none (CLI only)"))
59
+ return lines
60
+
61
+
62
+ def run(*, profile_name: str | None = None, out=None) -> int:
63
+ out = out or sys.stdout
64
+ lines: list[tuple[bool | None, str]] = []
65
+ lines.append(_line(None, "client", f"keywright {__version__} at {binary_path()}"))
66
+
67
+ profile = None
68
+ try:
69
+ profile = config.load(profile_name)
70
+ lines.append(_line(True, "profile", f"{profile.name} -> {profile.mcp_url}"))
71
+ except config.ConfigError as e:
72
+ lines.append(_line(False, "profile", str(e)))
73
+ mode = config.file_mode_ok()
74
+ if mode is not None:
75
+ lines.append(_line(mode, "config file mode", "0600" if mode else f"{config.config_file()} is readable by others"))
76
+
77
+ if profile:
78
+ base = profile.mcp_url[: -len("/mcp")]
79
+ try:
80
+ with urllib.request.urlopen(base + "/healthz", timeout=10) as r:
81
+ health = json.loads(r.read().decode())
82
+ lines.append(_line(bool(health.get("ok")), "server", f"{base} version {health.get('version', '?')} "
83
+ f"notifier {health.get('notifier', '?')}"))
84
+ sv = str(health.get("version") or "")
85
+ if sv and sv.split(".")[:2] != __version__.split(".")[:2]:
86
+ lines.append(_line(False, "version match", f"server {sv} vs client {__version__}"))
87
+ except (urllib.error.URLError, ValueError, OSError) as e:
88
+ lines.append(_line(False, "server", f"{base}/healthz unreachable ({e})"))
89
+ try:
90
+ from ..transport import AuthError, TransportError, call, payload
91
+
92
+ listing = payload(call(profile, None, "access_list_resources", {}, read_timeout=20))
93
+ lines.append(_line(True, "token", f"agent {listing.get('agent')} sees {len(listing.get('resources', []))} resource(s)"))
94
+ except AuthError:
95
+ lines.append(_line(False, "token", "rejected (401); run `kw login` with a current token"))
96
+ except TransportError as e:
97
+ lines.append(_line(False, "token", f"could not call the server ({e})"))
98
+
99
+ value, source = session.resolve()
100
+ lines.append(_line(None, "session", f"{value or 'none'} ({source})"))
101
+
102
+ lines.extend(integration_checks())
103
+ for tool in ("claude", "codex"):
104
+ path = shutil.which(tool)
105
+ if path:
106
+ try:
107
+ ver = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10, check=False).stdout.strip()
108
+ except (OSError, subprocess.TimeoutExpired):
109
+ ver = "?"
110
+ lines.append(_line(None, tool, ver.splitlines()[0] if ver else path))
111
+
112
+ failed = any(ok is False for ok, _ in lines)
113
+ for _, text in lines:
114
+ out.write(text + "\n")
115
+ return 1 if failed else 0