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.
@@ -0,0 +1,176 @@
1
+ """`kw setup claude-code`: the hazard hook, the session hook, per-subcommand permission rules,
2
+ the skill, and (optionally) the MCP registration, in ~/.claude/settings.json and ~/.claude.json."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shlex
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .. import __version__
13
+ from .common import (
14
+ Changes,
15
+ binary_path,
16
+ claude_dir,
17
+ drop_rules,
18
+ ensure_rules,
19
+ read_json,
20
+ remove_hooks,
21
+ set_hook,
22
+ )
23
+
24
+ # Subcommands an agent may run unprompted. `ping` belongs here precisely because it is what an
25
+ # agent reaches for when nobody is at the keyboard: a permission prompt on it would block on the
26
+ # person it exists to go and fetch. It authorizes nothing and the broker rate-limits it.
27
+ OPERATIONAL = ("run", "put", "get", "capture", "request", "status", "resources", "tools", "tool", "guide",
28
+ "guides", "ping", "session", "version", "hazards")
29
+ MCP_NAMES = ("gatekeeper", "keywright")
30
+ LEGACY_HOOK_FILE = "hooks/gatekeeper-hazards.py"
31
+
32
+
33
+ def allow_rules() -> list[str]:
34
+ return [f"Bash({name} {sub} *)" for name in ("kw", "keywright") for sub in OPERATIONAL] + \
35
+ [f"Bash({name} {sub})" for name in ("kw", "keywright") for sub in OPERATIONAL]
36
+
37
+
38
+ def _is_our_rule(rule: str) -> bool:
39
+ return rule.startswith(("Bash(kw ", "Bash(keywright ")) or rule in (f"mcp__{n}__*" for n in MCP_NAMES)
40
+
41
+
42
+ def skill_text() -> str:
43
+ return (Path(__file__).resolve().parent.parent / "skill" / "SKILL.md").read_text()
44
+
45
+
46
+ def apply(*, mcp: str = "keep", url: str | None = None, token: str | None = None, dry_run: bool = False,
47
+ bash_timeout: bool = True, out=None) -> int:
48
+ """mcp: keep (leave any MCP registration alone) | user (register at user scope via `claude mcp add`)
49
+ | remove (drop every gatekeeper/keywright MCP entry and its blanket allow rule)."""
50
+ import sys
51
+
52
+ out = out or sys.stdout
53
+ ch = Changes(dry_run)
54
+ home = claude_dir()
55
+ settings_path = home / "settings.json"
56
+ settings = read_json(settings_path)
57
+ kw = binary_path()
58
+
59
+ set_hook(settings, "PreToolUse", "Bash", f"{shlex.quote(kw)} hazards --hook", timeout=10,
60
+ status="Keywright hazard check")
61
+ set_hook(settings, "SessionStart", None, f"{shlex.quote(kw)} session-start --hook", timeout=10)
62
+ ensure_rules(settings, "allow", allow_rules())
63
+ if bash_timeout:
64
+ env = settings.setdefault("env", {})
65
+ if "BASH_DEFAULT_TIMEOUT_MS" not in env:
66
+ env["BASH_DEFAULT_TIMEOUT_MS"] = "600000"
67
+ # Claude Code caps a Bash call at 10 minutes; a longer approval wait would push the call into
68
+ # the background. Keep kw's default wait inside that cap, leaving room for the command itself.
69
+ if "KW_WAIT" not in env:
70
+ env["KW_WAIT"] = "540"
71
+ if mcp == "remove":
72
+ drop_rules(settings, "allow", lambda r: r in (f"mcp__{n}__*" for n in MCP_NAMES))
73
+ ch.edit_json(settings_path, settings)
74
+
75
+ legacy = home / LEGACY_HOOK_FILE
76
+ if legacy.exists():
77
+ ch.note(f"remove the copied hook file {legacy} (the hook now runs `{kw} hazards --hook`)")
78
+ if not dry_run:
79
+ legacy.unlink()
80
+
81
+ skill_path = home / "skills" / "keywright" / "SKILL.md"
82
+ ch.edit_text(skill_path, skill_text())
83
+
84
+ claude_json = Path.home() / ".claude.json"
85
+ if mcp == "remove":
86
+ data = read_json(claude_json)
87
+ removed = 0
88
+ for proj in (data.get("projects") or {}).values():
89
+ servers = proj.get("mcpServers") if isinstance(proj, dict) else None
90
+ for name in MCP_NAMES:
91
+ if servers and name in servers:
92
+ del servers[name]
93
+ removed += 1
94
+ top = data.get("mcpServers") or {}
95
+ for name in MCP_NAMES:
96
+ if name in top:
97
+ del top[name]
98
+ removed += 1
99
+ if removed:
100
+ ch.note(f"drop {removed} MCP registration(s) from {claude_json}")
101
+ ch.edit_json(claude_json, data)
102
+ elif mcp == "user":
103
+ if not (url and token):
104
+ raise SystemExit("--mcp user needs a profile (run `kw login` first)")
105
+ cmd = ["claude", "mcp", "add", "--scope", "user", "--transport", "http", "keywright", url,
106
+ "--header", f"Authorization: Bearer {token}"]
107
+ ch.note("run: claude mcp add --scope user --transport http keywright <url> --header 'Authorization: Bearer …'")
108
+ if not dry_run:
109
+ subprocess.run(cmd, check=False)
110
+ n = ch.apply(out)
111
+ out.write(f"keywright {__version__}: Claude Code setup {'previewed' if dry_run else 'applied'} "
112
+ f"({n} file(s) changed). Open /hooks once or start a new session to load it.\n")
113
+ return 0
114
+
115
+
116
+ def uninstall(*, keep_config: bool = True, dry_run: bool = False, out=None) -> int:
117
+ import sys
118
+
119
+ out = out or sys.stdout
120
+ ch = Changes(dry_run)
121
+ home = claude_dir()
122
+ settings_path = home / "settings.json"
123
+ settings = read_json(settings_path)
124
+ remove_hooks(settings)
125
+ drop_rules(settings, "allow", _is_our_rule)
126
+ env = settings.get("env") or {}
127
+ env.pop("BASH_DEFAULT_TIMEOUT_MS", None)
128
+ env.pop("KW_WAIT", None)
129
+ if not env and "env" in settings:
130
+ del settings["env"]
131
+ ch.edit_json(settings_path, settings)
132
+ skill = home / "skills" / "keywright" / "SKILL.md"
133
+ if skill.exists():
134
+ ch.note(f"remove {skill}")
135
+ if not dry_run:
136
+ skill.unlink()
137
+ ch.apply(out)
138
+ return 0
139
+
140
+
141
+ def describe(settings: dict[str, Any]) -> dict[str, Any]:
142
+ """What `kw setup check` reports about a settings document."""
143
+ from .common import find_hooks
144
+
145
+ allow = (settings.get("permissions") or {}).get("allow") or []
146
+ return {
147
+ "hooks": find_hooks(settings),
148
+ "allow_rules": [r for r in allow if _is_our_rule(r)],
149
+ "bash_timeout": (settings.get("env") or {}).get("BASH_DEFAULT_TIMEOUT_MS"),
150
+ "kw_wait": (settings.get("env") or {}).get("KW_WAIT"),
151
+ }
152
+
153
+
154
+ def skill_version(path: Path) -> str | None:
155
+ if not path.exists():
156
+ return None
157
+ for line in path.read_text().splitlines()[:8]:
158
+ if line.startswith("keywright-version:"):
159
+ return line.split(":", 1)[1].strip()
160
+ return "unknown"
161
+
162
+
163
+ def mcp_registrations() -> list[str]:
164
+ data = read_json(Path.home() / ".claude.json")
165
+ found = []
166
+ for name in MCP_NAMES:
167
+ if name in (data.get("mcpServers") or {}):
168
+ found.append(f"user:{name}")
169
+ for proj, cfg in (data.get("projects") or {}).items():
170
+ if isinstance(cfg, dict) and name in (cfg.get("mcpServers") or {}):
171
+ found.append(f"{proj}:{name}")
172
+ return found
173
+
174
+
175
+ def as_json(data: Any) -> str:
176
+ return json.dumps(data, indent=2)
@@ -0,0 +1,62 @@
1
+ """`kw setup codex`: teach Codex the CLI through a marked section in ~/.codex/AGENTS.md; MCP
2
+ registration only on request (Codex has MCP, the CLI is simply the better fit for long approvals)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .claude_code import skill_text
12
+ from .common import Changes
13
+
14
+ BEGIN = "<!-- keywright:begin -->"
15
+ END = "<!-- keywright:end -->"
16
+
17
+
18
+ def codex_home() -> Path:
19
+ return Path(os.environ.get("CODEX_HOME") or str(Path.home() / ".codex"))
20
+
21
+
22
+ def _section() -> str:
23
+ body = skill_text().split("---", 2)[-1].strip()
24
+ return f"{BEGIN}\n{body}\n{END}\n"
25
+
26
+
27
+ def apply(*, mcp: str = "none", url: str | None = None, dry_run: bool = False, out=None) -> int:
28
+ out = out or sys.stdout
29
+ ch = Changes(dry_run)
30
+ path = codex_home() / "AGENTS.md"
31
+ text = path.read_text() if path.exists() else ""
32
+ if BEGIN in text and END in text:
33
+ head, _, tail = text.partition(BEGIN)
34
+ _, _, tail = tail.partition(END)
35
+ text = head + _section() + tail.lstrip("\n")
36
+ else:
37
+ text = (text.rstrip("\n") + "\n\n" if text.strip() else "") + _section()
38
+ ch.edit_text(path, text)
39
+ if mcp == "keep":
40
+ if not url:
41
+ raise SystemExit("--mcp keep needs a profile (run `kw login` first)")
42
+ ch.note("run: codex mcp add keywright --url <url> --bearer-token-env-var KEYWRIGHT_TOKEN "
43
+ "(export KEYWRIGHT_TOKEN in the shell that starts Codex)")
44
+ if not dry_run:
45
+ subprocess.run(["codex", "mcp", "add", "keywright", "--url", url, "--bearer-token-env-var",
46
+ "KEYWRIGHT_TOKEN"], check=False)
47
+ ch.apply(out)
48
+ return 0
49
+
50
+
51
+ def uninstall(*, dry_run: bool = False, out=None) -> int:
52
+ out = out or sys.stdout
53
+ ch = Changes(dry_run)
54
+ path = codex_home() / "AGENTS.md"
55
+ if path.exists():
56
+ text = path.read_text()
57
+ if BEGIN in text:
58
+ head, _, tail = text.partition(BEGIN)
59
+ _, _, tail = tail.partition(END)
60
+ ch.edit_text(path, head.rstrip("\n") + ("\n" + tail if tail.strip() else "\n"))
61
+ ch.apply(out)
62
+ return 0
@@ -0,0 +1,146 @@
1
+ """Shared edit machinery: JSON read/merge/write with a timestamped backup, a diff for --dry-run,
2
+ and the markers by which our own hook entries and rules are recognized on later runs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import difflib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ OUR_HOOK_MARKERS = ("kw hazards --hook", "keywright hazards --hook", "gatekeeper-hazards.py",
16
+ "kw session-start --hook", "keywright session-start --hook")
17
+
18
+
19
+ class Changes:
20
+ """Collects file edits; applies them (with backups) or prints them (dry run)."""
21
+
22
+ def __init__(self, dry_run: bool):
23
+ self.dry_run = dry_run
24
+ self.edits: list[tuple[Path, str, str]] = [] # path, before, after
25
+ self.notes: list[str] = []
26
+
27
+ def note(self, text: str) -> None:
28
+ self.notes.append(text)
29
+
30
+ def edit_json(self, path: Path, data: Any) -> None:
31
+ before = path.read_text() if path.exists() else ""
32
+ after = json.dumps(data, indent=2) + "\n"
33
+ if before != after:
34
+ self.edits.append((path, before, after))
35
+
36
+ def edit_text(self, path: Path, after: str) -> None:
37
+ before = path.read_text() if path.exists() else ""
38
+ if before != after:
39
+ self.edits.append((path, before, after))
40
+
41
+ def apply(self, out=sys.stdout) -> int:
42
+ for path, before, after in self.edits:
43
+ if self.dry_run:
44
+ diff = difflib.unified_diff(before.splitlines(keepends=True), after.splitlines(keepends=True),
45
+ fromfile=str(path), tofile=str(path))
46
+ out.write("".join(diff) or f"(create {path})\n")
47
+ continue
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ if path.exists():
50
+ shutil.copy2(path, path.with_name(f"{path.name}.bak-{int(time.time())}"))
51
+ tmp = path.with_name(path.name + ".tmp")
52
+ tmp.write_text(after)
53
+ os.replace(tmp, path)
54
+ out.write(f"wrote {path}\n")
55
+ for n in self.notes:
56
+ out.write(("would " if self.dry_run else "") + n + "\n")
57
+ return len(self.edits)
58
+
59
+
60
+ def read_json(path: Path) -> dict[str, Any]:
61
+ if not path.exists():
62
+ return {}
63
+ try:
64
+ data = json.loads(path.read_text() or "{}")
65
+ except ValueError as e:
66
+ raise SystemExit(f"{path} is not valid JSON ({e}); fix it before running setup") from e
67
+ if not isinstance(data, dict):
68
+ raise SystemExit(f"{path} does not hold a JSON object")
69
+ return data
70
+
71
+
72
+ def binary_path() -> str:
73
+ """Absolute path of the `kw` script, so hooks work even when ~/.local/bin is not on PATH."""
74
+ for name in ("kw", "keywright"):
75
+ p = shutil.which(name)
76
+ if p:
77
+ return p
78
+ return "kw"
79
+
80
+
81
+ def set_hook(settings: dict[str, Any], event: str, matcher: str | None, command: str, *, timeout: int = 10,
82
+ status: str | None = None) -> None:
83
+ """Add or replace our hook entry for `event`; other people's hooks are untouched."""
84
+ hooks = settings.setdefault("hooks", {})
85
+ entries: list[dict[str, Any]] = hooks.setdefault(event, [])
86
+ ours = {"type": "command", "command": command, "timeout": timeout}
87
+ if status:
88
+ ours["statusMessage"] = status
89
+ for entry in entries:
90
+ if (entry.get("matcher") or None) != matcher:
91
+ continue
92
+ inner = entry.setdefault("hooks", [])
93
+ inner[:] = [h for h in inner if not _is_ours(h)]
94
+ inner.append(ours)
95
+ return
96
+ entry = {"hooks": [ours]}
97
+ if matcher:
98
+ entry["matcher"] = matcher
99
+ entries.append(entry)
100
+
101
+
102
+ def remove_hooks(settings: dict[str, Any]) -> None:
103
+ hooks = settings.get("hooks") or {}
104
+ for event, entries in list(hooks.items()):
105
+ for entry in entries:
106
+ entry["hooks"] = [h for h in entry.get("hooks", []) if not _is_ours(h)]
107
+ hooks[event] = [e for e in entries if e.get("hooks")]
108
+ if not hooks[event]:
109
+ del hooks[event]
110
+ if not hooks and "hooks" in settings:
111
+ del settings["hooks"]
112
+
113
+
114
+ def _is_ours(h: dict[str, Any]) -> bool:
115
+ cmd = str(h.get("command") or "")
116
+ return any(m in cmd for m in OUR_HOOK_MARKERS)
117
+
118
+
119
+ def find_hooks(settings: dict[str, Any]) -> list[str]:
120
+ out = []
121
+ for entries in (settings.get("hooks") or {}).values():
122
+ for entry in entries:
123
+ for h in entry.get("hooks", []):
124
+ if _is_ours(h):
125
+ out.append(str(h.get("command")))
126
+ return out
127
+
128
+
129
+ def ensure_rules(settings: dict[str, Any], key: str, rules: list[str]) -> None:
130
+ perms = settings.setdefault("permissions", {})
131
+ have: list[str] = perms.setdefault(key, [])
132
+ for r in rules:
133
+ if r not in have:
134
+ have.append(r)
135
+
136
+
137
+ def drop_rules(settings: dict[str, Any], key: str, predicate) -> None:
138
+ perms = settings.get("permissions") or {}
139
+ if key in perms:
140
+ perms[key] = [r for r in perms[key] if not predicate(r)]
141
+ if not perms[key]:
142
+ del perms[key]
143
+
144
+
145
+ def claude_dir() -> Path:
146
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR") or str(Path.home() / ".claude"))
@@ -0,0 +1,134 @@
1
+ ---
2
+ name: keywright
3
+ description: Use `kw` to run commands and move files on brokered SSH hosts, and to call brokered MCP tools, through Keywright. Every call is checked against the human's rules; some prompt the human on their phone. Read this before the first `kw` call in a session.
4
+ keywright-version: 0.0.1
5
+ ---
6
+
7
+ # Keywright: reaching hosts and tools through `kw`
8
+
9
+ Keywright brokers access to SSH hosts and MCP servers. The credentials live on the broker, never on
10
+ this machine. `kw` is the command you use; the human approves what needs approving.
11
+
12
+ ## First call
13
+
14
+ `kw guide` prints this guide; `kw guides` is an alias.
15
+
16
+ ```bash
17
+ kw resources # every brokered host and MCP server, and your standing on each
18
+ kw session # which conversation your calls are attributed to
19
+ ```
20
+
21
+ Under Codex, there can be two approval layers. The local sandbox must permit `kw` to reach the
22
+ broker before Keywright can request approval on the phone. If sandbox network access is blocked,
23
+ request execution permission through the agent's normal approval mechanism, scoped to the needed
24
+ subcommand or resource (for example `kw resources` or `kw run dev-host`). Do not disable the sandbox
25
+ or grant blanket shell access. A local execution approval does not grant access on the broker.
26
+
27
+ Broker connection/handshake attempts stop after 15 seconds, plus up to one second for worker cleanup.
28
+ After connecting, each response gets its operation timeout plus 30 seconds; approval polling and
29
+ remote commands retain their longer waits. A timeout does not prove a sandbox problem: check the
30
+ broker URL, DNS, proxy and network reachability. Do not blindly retry a command whose outcome is unknown.
31
+
32
+ `kw session` prefers `KW_SESSION`, then a valid `CODEX_THREAD_ID` when Codex supplies it, then a
33
+ per-process identity. Thread metadata keeps identity stable across sandbox boundaries. Availability
34
+ depends on the Codex host; process identity may span conversations, and hidden ancestry without
35
+ metadata yields `none` (no session-scoped approvals). Existing process-scoped approvals do not carry
36
+ over when a conversation starts using a thread identity.
37
+
38
+ ## Running commands
39
+
40
+ ```bash
41
+ kw run <host> -r "why" -- journalctl -u myapp -n 50
42
+ kw run <host> -r "why" -c 'docker ps | grep api' # a pipeline or loop; approved one line at a time
43
+ kw put <host> /etc/app/app.env --file app.env -r "why" # or content on stdin
44
+ kw get <host> /etc/app/app.env # content on stdout; -o FILE to save
45
+ kw capture <host> --to <host2>:/root/.env -- cat /run/secret # output goes host to host, never to you
46
+ ```
47
+
48
+ - Remote stdout is your stdout, remote stderr your stderr, so `kw run h -- cat f | grep x` works.
49
+ - One `kw:` line on stderr afterwards names the audit row, what allowed the call, and whether output
50
+ was truncated (output is capped at 1 MiB and decoded as UTF-8).
51
+ - Prefer the `--` form: a plain command classifies narrowly (`ssh/<host>/exec/journalctl`). `-c` and
52
+ anything with pipes, substitution or loops needs the host-wide `**` pattern and is approved exactly,
53
+ one command line at a time.
54
+ - Prefer `kw put`/`kw get` over shell redirection: they classify by path and never put file contents
55
+ in the human's prompt.
56
+ - Quoting through `kw run -c` → ssh → `pct exec`/`docker exec` → a shell mangles inner quotes. For
57
+ anything non-trivial `kw put` a script to the host and run it by path.
58
+ - Always pass `-r`: it is what the human reads on their phone.
59
+
60
+ ## Approvals
61
+
62
+ When a call needs approval, `kw` asks the human and waits (default 900 s; `KW_WAIT` changes it), printing a heartbeat on
63
+ stderr with whether the notification was delivered. Give the Bash tool a timeout of at least the
64
+ wait plus the command's own runtime. Under Claude Code the setup sets `KW_WAIT=540` because a Bash call
65
+ is capped at 10 minutes; a longer wait would push the call into the background and out of your control.
66
+ When a call exits 3, `kw status <id> --wait 540` keeps waiting and names the command to re-issue.
67
+
68
+ Exit codes tell you what happened:
69
+
70
+ | exit | meaning |
71
+ |---|---|
72
+ | 0 | ran; remote command exited 0 |
73
+ | 1 | ran; remote command exited nonzero (code in the `kw:` line, or `--json`) |
74
+ | 3 | approval still pending: the request id is printed; `kw status <id> --wait 900` later; when it reports approved it names the command to re-issue. `--no-wait` returns this at once |
75
+ | 4 | denied, refused, locked, recently denied, or rate-limited. Do not retry without new instructions from the user |
76
+ | 5 | unknown host, tool or request |
77
+ | 6 | could not reach the broker, bad token, or the call's outcome is unknown: check with the user before repeating anything |
78
+ | 7 | the broker could not run it (SSH failed, write refused): the `kw:` line names the stage |
79
+
80
+ Approval is once and exact by default: the same command line, once. Ask for several exact steps in
81
+ one prompt when you know them: `kw request -r "why" --ops ops.json` where the file is a JSON list of
82
+ `{"host","command"}`, `{"host","put","content"}`, `{"host","get"}` or capture objects. Patterns
83
+ (`kw request --pattern ssh/nvr/exec/journalctl`) ask for standing scope instead.
84
+
85
+ Destructive operations are flagged whatever access exists: recursive deletes of system or home
86
+ directories, disk formatting, destroying guests, volumes or pools, force pushes, powering off, and
87
+ touching key material or login configuration. The worst are refused outright (exit 4); the rest
88
+ always prompt and are approved once. Never reword a command to get around a flag; tell the user.
89
+
90
+ `kw status <id>` reports approval only; it never resumes a command. If a call returned 6 after the
91
+ human approved it, the command may or may not have run: ask the user, or check the audit log through
92
+ them, before running it again.
93
+
94
+ ## When you need the human, not a host
95
+
96
+ ```bash
97
+ kw ping "Traefik cert: ACME DNS-01 or the existing wildcard? Both work, you have a preference."
98
+ ```
99
+
100
+ This reaches the same phone as an approval prompt and stays on the Keywright dashboard until they
101
+ clear it. It does **not** bring back an answer and does not wait. So: send it, ask the question in
102
+ the conversation, and stop. They come to you.
103
+
104
+ Use it when you would otherwise sit idle while they are away from the keyboard: a choice only they
105
+ can make, a question you cannot answer from the repo, or work finished and waiting on their eyes.
106
+
107
+ Do not use it to narrate progress, and do not use it to chase a pending approval - that request is
108
+ already on their phone. One ping per thing you need: sending the same message twice folds into the
109
+ one already waiting, and a second different ping inside a minute exits 4. `--where` overrides the
110
+ host and directory it reports; by default it sends both so they know which conversation to open.
111
+
112
+ ## Brokered MCP tools
113
+
114
+ ```bash
115
+ kw tools # names, e.g. ha__ha_get_state
116
+ kw tool ha --list # schemas for one server, on demand
117
+ kw tool ha ha_get_state entity_id=light.kitchen -r "why"
118
+ kw tool ha call_service --args '{"service":"light.turn_on","entity_id":"light.kitchen"}' -r "why"
119
+ ```
120
+
121
+ ## Models through Keywright
122
+
123
+ A gateway of kind `llm` in `kw resources` brokers model calls: the provider keys stay on the broker.
124
+ `kw llm <gateway>` prints the `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` lines that route an SDK
125
+ through it with your Keywright token. A call names a model; a model you have no lease on asks the
126
+ human and waits (`X-Keywright-Wait` seconds, `X-Keywright-Reason` for the prompt), then answers
127
+ `403 approval_required` with a request id if they have not decided yet. Every lease has a spend
128
+ ceiling; a 4xx from the gateway saying budget exceeded means the ceiling, not a bug. Ask with
129
+ `kw request --pattern llm/<gateway>/model/<model>` ahead of time when you know what you need.
130
+
131
+ ## What `kw` will not do for you
132
+
133
+ `kw setup`, `kw login` and `kw logout` change this machine's own gate and are for the human. If the
134
+ setup looks wrong, say so instead of fixing it.