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/cli.py ADDED
@@ -0,0 +1,748 @@
1
+ """`kw` / `keywright`: the command agents use.
2
+
3
+ Subcommands import lazily so the hook path (`kw hazards --hook`, run before every Bash call)
4
+ stays stdlib-only and fast. Exit codes are unambiguous by default:
5
+
6
+ 0 ok 1 remote command nonzero 2 usage 3 approval pending 4 denied/refused/locked/rate-limited
7
+ 5 unknown resource/tool/request 6 transport, auth, no config, or outcome unknown
8
+ 7 the broker could not run it (ssh_failed, put_failed, get_failed, capture_failed, upstream_failed)
9
+
10
+ A gated call is three phases: one call with wait_seconds=0 (which executes at once when access
11
+ already exists), a poll on the request id while the human decides, then one execute call. A
12
+ transport failure on either executing call leaves the outcome unknown; it is reported as such
13
+ and never retried here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import shlex
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ EXIT_OK, EXIT_REMOTE, EXIT_USAGE, EXIT_PENDING, EXIT_DENIED, EXIT_UNKNOWN, EXIT_TRANSPORT, EXIT_FAILED = range(8)
28
+ ERROR_EXIT = {
29
+ "approval_required": EXIT_PENDING, "pending": EXIT_PENDING, "expired": EXIT_PENDING,
30
+ "denied": EXIT_DENIED, "refused": EXIT_DENIED, "locked": EXIT_DENIED, "rate_limited": EXIT_DENIED,
31
+ "unknown_resource": EXIT_UNKNOWN, "not_found": EXIT_UNKNOWN, "unknown_tool": EXIT_UNKNOWN,
32
+ "unauthorized": EXIT_TRANSPORT, "invalid_arguments": EXIT_USAGE,
33
+ "ssh_failed": EXIT_FAILED, "put_failed": EXIT_FAILED, "get_failed": EXIT_FAILED, "capture_failed": EXIT_FAILED,
34
+ "upstream_failed": EXIT_FAILED, "internal": EXIT_FAILED,
35
+ }
36
+ # Approvals arrive on a phone; 300 s produced timeouts in normal use (F3 in the 2026-09-20 field
37
+ # report). KW_WAIT sets the default per machine or per profile without repeating --wait everywhere.
38
+ DEFAULT_WAIT = int(os.environ.get("KW_WAIT") or 900)
39
+ POLL_CHUNK = 60
40
+ PROXY_SEP = "__" # `<server>__<tool>` names a tool on a brokered MCP server
41
+ # Tools that only ask or look: after an approval there is nothing to run again. Everything else,
42
+ # including every proxied `<server>__<tool>`, executes, so the default is "execute" and this set
43
+ # names the exceptions; a name the client has never seen still behaves correctly.
44
+ NON_EXECUTING_TOOLS = {"access_request", "access_status", "access_list_resources", "request_attention"}
45
+
46
+
47
+ def err(text: str) -> None:
48
+ sys.stderr.write(f"kw: {text}\n")
49
+ sys.stderr.flush()
50
+
51
+
52
+ # ---- parser -------------------------------------------------------------------------------------
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ p = argparse.ArgumentParser(prog="kw", description="Keywright client: brokered hosts and tools from any shell.")
55
+ p.add_argument("--profile", "-p", help="profile name from the config file (default: KW_PROFILE or 'default')")
56
+ p.add_argument("--version", action="store_true", help="print the client version and exit")
57
+ sub = p.add_subparsers(dest="cmd")
58
+
59
+ def gated(sp: argparse.ArgumentParser, reason: bool = True) -> None:
60
+ if reason:
61
+ sp.add_argument("-r", "--reason", default="", help="why; the human reads this on their phone")
62
+ sp.add_argument("--wait", type=int, default=DEFAULT_WAIT, help=f"seconds to wait for approval (default {DEFAULT_WAIT})")
63
+ sp.add_argument("--no-wait", action="store_true", help="return at once with the request id when approval is needed")
64
+ sp.add_argument("--json", action="store_true", help="print the server's payload as one JSON object")
65
+ sp.add_argument("-q", "--quiet", action="store_true", help="no kw: handle line on stderr")
66
+
67
+ sp = sub.add_parser("resources", help="brokered resources and your standing on each")
68
+ sp.add_argument("--json", action="store_true")
69
+
70
+ sp = sub.add_parser("run", help="run a command on a brokered host: kw run <host> [opts] -- <cmd...>")
71
+ sp.add_argument("host")
72
+ sp.add_argument("-c", "--command", help="a verbatim shell line (pipes, loops); otherwise use -- <cmd...>")
73
+ sp.add_argument("-u", "--user", help="remote account (subject to policy)")
74
+ sp.add_argument("-t", "--timeout", type=int, default=300, help="remote command timeout in seconds")
75
+ sp.add_argument("--exit-passthrough", action="store_true", help="exit with the remote command's own code")
76
+ gated(sp)
77
+
78
+ sp = sub.add_parser("put", help="write a file on a brokered host (content from --file or stdin)")
79
+ sp.add_argument("host")
80
+ sp.add_argument("path")
81
+ sp.add_argument("-f", "--file", help="local file to send (default: stdin)")
82
+ sp.add_argument("--mode", default="0644")
83
+ sp.add_argument("-u", "--user")
84
+ gated(sp)
85
+
86
+ sp = sub.add_parser("get", help="read a file from a brokered host")
87
+ sp.add_argument("host")
88
+ sp.add_argument("path")
89
+ sp.add_argument("-o", "--output", help="save to this local file instead of stdout")
90
+ sp.add_argument("--allow-truncated", action="store_true", help="save even when the server truncated the content")
91
+ sp.add_argument("-u", "--user")
92
+ gated(sp)
93
+
94
+ sp = sub.add_parser("capture", help="run a command on one host, deliver its output to a file on another")
95
+ sp.add_argument("host")
96
+ sp.add_argument("--to", required=True, metavar="HOST:PATH")
97
+ sp.add_argument("-c", "--command")
98
+ sp.add_argument("--append", action="store_true")
99
+ sp.add_argument("--mode", default="0600")
100
+ sp.add_argument("-t", "--timeout", type=int, default=300)
101
+ sp.add_argument("-u", "--user")
102
+ sp.add_argument("--to-user")
103
+ gated(sp)
104
+
105
+ sp = sub.add_parser("request", help="ask for access ahead of time: patterns, or exact operations from a file")
106
+ g = sp.add_mutually_exclusive_group(required=True)
107
+ g.add_argument("--pattern", action="append", help="permission pattern; repeatable")
108
+ g.add_argument("--ops", help="JSON file (or - for stdin) holding a list of exact operations")
109
+ gated(sp)
110
+
111
+ sp = sub.add_parser("status", help="approval state of a request (never resumes a command)")
112
+ sp.add_argument("request_id")
113
+ sp.add_argument("--wait", type=int, default=0)
114
+ sp.add_argument("--json", action="store_true")
115
+
116
+ sp = sub.add_parser("ping", help="ask the human to come back to the conversation (their phone, and the dashboard)")
117
+ sp.add_argument("message", nargs="+", help="what you need from them, in a sentence or two")
118
+ sp.add_argument("--where", default="", help="where the conversation is (default: this host and directory)")
119
+ sp.add_argument("--json", action="store_true")
120
+
121
+ sp = sub.add_parser("llm", help="print the environment that routes LLM calls through a Keywright gateway")
122
+ sp.add_argument("gateway", help="the llm resource name (see kw resources)")
123
+ sp.add_argument("--shell", choices=["sh", "fish"], default="sh")
124
+ sp = sub.add_parser("tools", help="brokered MCP tools available to you (names)")
125
+ sp.add_argument("--json", action="store_true")
126
+
127
+ sp = sub.add_parser("tool", help="call a brokered MCP tool: kw tool <server> <tool> [k=v ...|--args '{}']")
128
+ sp.add_argument("server")
129
+ sp.add_argument("tool", nargs="?")
130
+ sp.add_argument("--list", action="store_true", help="show this server's tools and schemas")
131
+ sp.add_argument("--args", "-a", dest="args_json", help="arguments as a JSON object")
132
+ sp.add_argument("kv", nargs="*", help="arguments as key=value (JSON values accepted)")
133
+ sp.add_argument("--raw", action="store_true", help="print the raw result JSON")
134
+ gated(sp)
135
+
136
+ sp = sub.add_parser("hazards", help="flag destructive shell commands (also the Claude Code PreToolUse hook)")
137
+ sp.add_argument("command", nargs="?")
138
+ sp.add_argument("--hook", action="store_true")
139
+ sp.add_argument("--json", action="store_true")
140
+
141
+ sp = sub.add_parser("session-start", help="Claude Code SessionStart hook: records the conversation id")
142
+ sp.add_argument("--hook", action="store_true")
143
+
144
+ sub.add_parser("guide", aliases=["guides"], help="print the agent guide")
145
+ sub.add_parser("session", help="print the session id kw sends and where it came from")
146
+ sub.add_parser("version", help="print the client version")
147
+
148
+ sp = sub.add_parser("login", help="store the server URL and an agent token")
149
+ sp.add_argument("url")
150
+ sp.add_argument("--token", help="the token (prefer --paste to keep it out of shell history)")
151
+ sp.add_argument("--paste", action="store_true", help="read the token from stdin")
152
+ sp.add_argument("--name", default="default", help="profile name")
153
+ sp = sub.add_parser("logout", help="forget a stored profile")
154
+ sp.add_argument("--name", default="default")
155
+
156
+ sp = sub.add_parser("setup", help="configure this machine: claude-code | codex | check | uninstall")
157
+ ss = sp.add_subparsers(dest="target", required=True)
158
+ cc = ss.add_parser("claude-code")
159
+ cc.add_argument("--mcp", choices=("keep", "user", "remove"), default="keep")
160
+ cc.add_argument("--no-bash-timeout", action="store_true", help="do not set BASH_DEFAULT_TIMEOUT_MS")
161
+ cc.add_argument("--dry-run", action="store_true")
162
+ cx = ss.add_parser("codex")
163
+ cx.add_argument("--mcp", choices=("none", "keep"), default="none")
164
+ cx.add_argument("--dry-run", action="store_true")
165
+ ss.add_parser("check")
166
+ un = ss.add_parser("uninstall")
167
+ un.add_argument("--keep-config", action="store_true")
168
+ un.add_argument("--dry-run", action="store_true")
169
+ return p
170
+
171
+
172
+ # ---- gated calls --------------------------------------------------------------------------------
173
+ class Gate:
174
+ """Runs one tool call through approval, execution and recovery, with the exit-code contract."""
175
+
176
+ def __init__(self, args: argparse.Namespace):
177
+ from . import config, session
178
+
179
+ try:
180
+ self.profile = config.load(getattr(args, "profile", None))
181
+ except config.ConfigError as e:
182
+ raise SystemExit(_fail(str(e), EXIT_TRANSPORT)) from e
183
+ self.session, _ = session.resolve()
184
+ self.wait = 0 if getattr(args, "no_wait", False) else int(getattr(args, "wait", 0) or 0)
185
+ self.quiet = bool(getattr(args, "quiet", False))
186
+ self.as_json = bool(getattr(args, "json", False))
187
+
188
+ def call(self, tool: str, arguments: dict[str, Any], read_timeout: float = 60.0):
189
+ from .transport import call
190
+
191
+ return call(self.profile, self.session, tool, arguments, read_timeout=read_timeout)
192
+
193
+ def gated(self, tool: str, arguments: dict[str, Any], remote_timeout: int = 60) -> tuple[dict[str, Any], int]:
194
+ """(payload, exit code). Exit 0 means the payload is a success result."""
195
+ from .transport import AuthError, TransportError, payload
196
+
197
+ # Proxied tools carry the control key underscored so it cannot collide with an upstream
198
+ # parameter; the server strips it before forwarding (F1 in the 2026-09-20 field report).
199
+ args = {**arguments, ("_wait_seconds" if PROXY_SEP in tool else "wait_seconds"): 0}
200
+ try:
201
+ first = self.call(tool, args, read_timeout=remote_timeout + 30)
202
+ except AuthError as e:
203
+ return _fail(f"token rejected ({e}); run `kw login`", EXIT_TRANSPORT)
204
+ except TransportError as e:
205
+ if tool not in NON_EXECUTING_TOOLS:
206
+ return _fail(f"outcome unknown: the call may have run before the connection failed ({e}); "
207
+ "check with the user before repeating it", EXIT_TRANSPORT)
208
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)
209
+ data = payload(first)
210
+ pending_request = tool == "access_request" and data.get("status") == "pending"
211
+ if not first.is_error and not pending_request:
212
+ return data, EXIT_OK
213
+ kind = "approval_required" if pending_request else str(data.get("error") or "error")
214
+ if kind != "approval_required":
215
+ return self._error(data)
216
+ rid = str(data.get("request_id") or "")
217
+ _say_pending(data, self.quiet)
218
+ if self.wait <= 0 or not rid:
219
+ return data, EXIT_PENDING
220
+ decided, status = self._poll(rid, data.get("notified"))
221
+ if not decided:
222
+ return status, EXIT_PENDING
223
+ if status.get("status") != "approved":
224
+ return self._error({**status, "error": "denied" if status.get("status") == "denied" else "expired"})
225
+ if tool in NON_EXECUTING_TOOLS:
226
+ return status, EXIT_OK
227
+ try:
228
+ second = self.call(tool, args, read_timeout=remote_timeout + 30)
229
+ except TransportError as e:
230
+ return _fail(f"approved, but the outcome is unknown: the connection failed during execution ({e}); "
231
+ "check with the user before repeating it", EXIT_TRANSPORT)
232
+ data = payload(second)
233
+ return (data, EXIT_OK) if not second.is_error else self._error(data)
234
+
235
+ def _poll(self, rid: str, notified: Any) -> tuple[bool, dict[str, Any]]:
236
+ from .transport import TransportError, payload
237
+
238
+ deadline = time.monotonic() + self.wait
239
+ last: dict[str, Any] = {"status": "pending", "request_id": rid}
240
+ while True:
241
+ remaining = deadline - time.monotonic()
242
+ if remaining <= 0:
243
+ return False, last
244
+ chunk = int(min(POLL_CHUNK, remaining))
245
+ try:
246
+ res = self.call("access_status", {"request_id": rid, "wait_seconds": chunk}, read_timeout=chunk + 30)
247
+ except TransportError as e:
248
+ err(f"waiting {rid}: broker unreachable ({e}); retrying")
249
+ time.sleep(min(5, max(1, remaining)))
250
+ continue
251
+ last = payload(res)
252
+ if last.get("status") != "pending":
253
+ return True, last
254
+ waited = int(self.wait - (deadline - time.monotonic()))
255
+ if not self.quiet:
256
+ err(f"waiting for approval {rid} {waited}/{self.wait}s notified={notified or 'unknown'}")
257
+
258
+ def _error(self, data: dict[str, Any]) -> tuple[dict[str, Any], int]:
259
+ kind = str(data.get("error") or data.get("status") or "error")
260
+ code = ERROR_EXIT.get(kind, EXIT_FAILED)
261
+ bits = [kind]
262
+ for k in ("request_id", "stage", "hazard", "retry_after", "deny_reason", "reason"):
263
+ if data.get(k):
264
+ bits.append(f"{k}={data[k]}")
265
+ if data.get("hazards"):
266
+ bits.append("flagged: " + "; ".join(f"{h.get('severity')}: {h.get('reason')}" for h in data["hazards"]))
267
+ if data.get("hint"):
268
+ bits.append(str(data["hint"]))
269
+ err(" | ".join(bits))
270
+ return data, code
271
+
272
+
273
+ def _reissue_hint(data: dict[str, Any]) -> str:
274
+ """What the approved request was for, so whoever reads the terminal knows which command to repeat."""
275
+ op = data.get("operation") or {}
276
+ if op.get("command") and op.get("host"):
277
+ return f"kw run {op['host']} -- {op['command']}"
278
+ if op.get("path") and op.get("host"):
279
+ return f"kw put/get {op['host']} {op['path']}"
280
+ if op.get("tool") and op.get("server"):
281
+ return f"kw tool {op['server']} {op['tool']} ..."
282
+ if data.get("operations"):
283
+ return f"the {data['operations']} operations of the batch"
284
+ return "patterns " + ", ".join(data.get("patterns") or []) if data.get("patterns") else "see the admin UI"
285
+
286
+
287
+ def _fail(text: str, code: int) -> tuple[dict[str, Any], int]:
288
+ err(text)
289
+ return {"error": "transport", "message": text}, code
290
+
291
+
292
+ def _say_pending(data: dict[str, Any], quiet: bool) -> None:
293
+ bits = [f"approval requested {data.get('request_id')} notified={data.get('notified', 'unknown')}"]
294
+ if data.get("hazard"):
295
+ bits.append(f"flagged {data['hazard']}")
296
+ if data.get("patterns"):
297
+ bits.append("patterns " + ", ".join(data["patterns"]))
298
+ err(" | ".join(bits))
299
+ if any(str(p).endswith("/exec/**") for p in data.get("patterns") or []):
300
+ err("kw: host-wide ** because this line has shell syntax the classifier cannot split; it is approved "
301
+ "exactly, one line at a time. Several plain steps in one prompt: kw request --ops ops.json (kw guide)")
302
+
303
+
304
+ def handle_line(data: dict[str, Any], extra: str = "") -> str:
305
+ h = data.get("keywright") or {}
306
+ parts = [f"audit={h.get('audit_id', '?')}"]
307
+ if extra:
308
+ parts.append(extra)
309
+ for pat, src in (h.get("matched") or {}).items():
310
+ bound = (h.get("bound") or {}).get(pat)
311
+ parts.append(f"{pat}<-{src}" + (f" ({bound})" if bound else ""))
312
+ if h.get("consumed_grants"):
313
+ parts.append(f"consumed={len(h['consumed_grants'])}")
314
+ if h.get("hazard"):
315
+ parts.append(f"flagged={h['hazard']}")
316
+ return " ".join(parts)
317
+
318
+
319
+ def emit(data: dict[str, Any], code: int, as_json: bool) -> int:
320
+ if as_json:
321
+ sys.stdout.write(json.dumps(data, indent=1) + "\n")
322
+ return code
323
+
324
+
325
+ # ---- commands -----------------------------------------------------------------------------------
326
+ def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
327
+ if args.command and rest:
328
+ return _usage("give -c or -- <cmd...>, not both")
329
+ command = args.command or (shlex.join(rest) if rest else "")
330
+ if not command:
331
+ return _usage("nothing to run: kw run <host> -- <cmd...> or -c '<line>'")
332
+ gate = Gate(args)
333
+ payload_args = {"host": args.host, "command": command, "reason": args.reason, "timeout": args.timeout}
334
+ if args.user:
335
+ payload_args["user"] = args.user
336
+ data, code = gate.gated("ssh_run", payload_args, remote_timeout=args.timeout)
337
+ if code != EXIT_OK:
338
+ return emit(data, code, gate.as_json)
339
+ if gate.as_json:
340
+ return emit(data, EXIT_OK if data.get("exit_code") == 0 or args.exit_passthrough else EXIT_REMOTE, True)
341
+ sys.stdout.write(data.get("stdout") or "")
342
+ sys.stdout.flush()
343
+ if data.get("stderr"):
344
+ sys.stderr.write(data["stderr"])
345
+ rc = int(data.get("exit_code") or 0)
346
+ if not gate.quiet:
347
+ err(handle_line(data, f"exit={rc} truncated={'yes' if data.get('truncated') else 'no'}"))
348
+ if args.exit_passthrough:
349
+ return rc
350
+ return EXIT_OK if rc == 0 else EXIT_REMOTE
351
+
352
+
353
+ def cmd_put(args: argparse.Namespace) -> int:
354
+ content = Path(args.file).read_text() if args.file else sys.stdin.read()
355
+ gate = Gate(args)
356
+ payload_args = {"host": args.host, "path": args.path, "content": content, "mode": args.mode, "reason": args.reason}
357
+ if args.user:
358
+ payload_args["user"] = args.user
359
+ data, code = gate.gated("ssh_put", payload_args, remote_timeout=120)
360
+ if code != EXIT_OK:
361
+ return emit(data, code, gate.as_json)
362
+ if gate.as_json:
363
+ return emit(data, EXIT_OK, True)
364
+ if not gate.quiet:
365
+ err(handle_line(data, f"wrote {data.get('bytes')} bytes to {data.get('written')}"))
366
+ return EXIT_OK
367
+
368
+
369
+ def cmd_get(args: argparse.Namespace) -> int:
370
+ gate = Gate(args)
371
+ payload_args = {"host": args.host, "path": args.path, "reason": args.reason}
372
+ if args.user:
373
+ payload_args["user"] = args.user
374
+ data, code = gate.gated("ssh_get", payload_args, remote_timeout=120)
375
+ if code != EXIT_OK:
376
+ return emit(data, code, gate.as_json)
377
+ if gate.as_json:
378
+ return emit(data, EXIT_OK, True)
379
+ truncated = bool(data.get("truncated"))
380
+ if args.output:
381
+ if truncated and not args.allow_truncated:
382
+ err(f"refusing to write {args.output}: the server truncated the content at 1 MiB "
383
+ "(pass --allow-truncated to keep the partial file)")
384
+ return EXIT_FAILED
385
+ Path(args.output).write_text(data.get("content") or "")
386
+ if not gate.quiet:
387
+ err(handle_line(data, f"saved {args.output} truncated={'yes' if truncated else 'no'}"))
388
+ return EXIT_OK
389
+ sys.stdout.write(data.get("content") or "")
390
+ sys.stdout.flush()
391
+ if not gate.quiet:
392
+ err(handle_line(data, f"truncated={'yes' if truncated else 'no'}"))
393
+ return EXIT_OK
394
+
395
+
396
+ def cmd_capture(args: argparse.Namespace, rest: list[str]) -> int:
397
+ if args.command and rest:
398
+ return _usage("give -c or -- <cmd...>, not both")
399
+ command = args.command or (shlex.join(rest) if rest else "")
400
+ if not command or ":" not in args.to:
401
+ return _usage("kw capture <host> --to <host2>:<path> -- <cmd...>")
402
+ to_host, to_path = args.to.split(":", 1)
403
+ gate = Gate(args)
404
+ payload_args = {"host": args.host, "command": command, "to_host": to_host, "to_path": to_path,
405
+ "append": args.append, "mode": args.mode, "reason": args.reason, "timeout": args.timeout}
406
+ if args.user:
407
+ payload_args["user"] = args.user
408
+ if args.to_user:
409
+ payload_args["to_user"] = args.to_user
410
+ data, code = gate.gated("ssh_capture", payload_args, remote_timeout=args.timeout)
411
+ if code != EXIT_OK:
412
+ return emit(data, code, gate.as_json)
413
+ if gate.as_json:
414
+ return emit(data, EXIT_OK, True)
415
+ if not gate.quiet:
416
+ err(handle_line(data, f"{'appended' if data.get('append') else 'wrote'} {data.get('bytes')} bytes to "
417
+ f"{data.get('to_host')}:{data.get('written')}"))
418
+ return EXIT_OK
419
+
420
+
421
+ def cmd_request(args: argparse.Namespace) -> int:
422
+ gate = Gate(args)
423
+ if args.ops:
424
+ text = sys.stdin.read() if args.ops == "-" else Path(args.ops).read_text()
425
+ try:
426
+ ops = json.loads(text)
427
+ except ValueError as e:
428
+ return _usage(f"--ops is not JSON: {e}")
429
+ if not isinstance(ops, list):
430
+ return _usage("--ops must hold a JSON list of operations")
431
+ payload_args: dict[str, Any] = {"operations": ops, "reason": args.reason}
432
+ else:
433
+ payload_args = {"patterns": args.pattern, "reason": args.reason}
434
+ data, code = gate.gated("access_request", payload_args)
435
+ if code == EXIT_OK and not gate.quiet:
436
+ err(f"{data.get('status', 'approved')} {data.get('request_id', '')}".strip())
437
+ return emit(data, code, gate.as_json)
438
+
439
+
440
+ def cmd_ping(args: argparse.Namespace) -> int:
441
+ """Ask for the human. Nothing comes back but an acknowledgement: their answer belongs in the
442
+ conversation, so the only sane thing to do afterwards is ask there and stop."""
443
+ from .transport import TransportError, payload
444
+
445
+ gate = Gate(args)
446
+ message = " ".join(args.message).strip()
447
+ if not message:
448
+ return _usage("say what you need: kw ping 'which of these two should I do?'")
449
+ try:
450
+ res = gate.call("request_attention", {"message": message, "context": args.where or _where()},
451
+ read_timeout=30)
452
+ except TransportError as e:
453
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)[1]
454
+ data = payload(res)
455
+ if res.is_error:
456
+ return emit(*gate._error(data), args.json)
457
+ if not args.json:
458
+ err(f"{data.get('status', 'sent')} {data.get('nudge_id', '')} notified={data.get('notified', 'unknown')}")
459
+ return emit(data, EXIT_OK, args.json)
460
+
461
+
462
+ def _where() -> str:
463
+ """Where this conversation is, as a human would name it: host and directory, home as ~."""
464
+ import os
465
+ import socket
466
+
467
+ cwd = os.getcwd()
468
+ home = os.path.expanduser("~")
469
+ if cwd == home or cwd.startswith(home + os.sep):
470
+ cwd = "~" + cwd[len(home):]
471
+ return f"{socket.gethostname()}:{cwd}"[:200]
472
+
473
+
474
+ def cmd_status(args: argparse.Namespace) -> int:
475
+ from .transport import TransportError, payload
476
+
477
+ gate = Gate(args)
478
+ try:
479
+ data = payload(gate.call("access_status", {"request_id": args.request_id, "wait_seconds": 0}, read_timeout=30))
480
+ except TransportError as e:
481
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)[1]
482
+ if data.get("status") == "pending" and args.wait > 0:
483
+ gate.wait = args.wait
484
+ _, data = gate._poll(args.request_id, data.get("notified"))
485
+ status = str(data.get("status") or data.get("error") or "unknown")
486
+ if not args.json:
487
+ err(f"{args.request_id} {status}" + (f" ({data['deny_reason']})" if data.get("deny_reason") else ""))
488
+ if status == "approved":
489
+ err(f"approved, not run: re-issue the same command to execute it ({_reissue_hint(data)})")
490
+ code = {"approved": EXIT_OK, "pending": EXIT_PENDING, "expired": EXIT_PENDING, "denied": EXIT_DENIED,
491
+ "not_found": EXIT_UNKNOWN}.get(status, EXIT_FAILED)
492
+ return emit(data, code, args.json)
493
+
494
+
495
+ def cmd_resources(args: argparse.Namespace) -> int:
496
+ from .transport import TransportError, payload
497
+
498
+ gate = Gate(args)
499
+ try:
500
+ data = payload(gate.call("access_list_resources", {}, read_timeout=30))
501
+ except TransportError as e:
502
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)[1]
503
+ if args.json:
504
+ return emit(data, EXIT_OK, True)
505
+ sys.stdout.write(f"agent {data.get('agent')} session {data.get('session') or 'none'}\n")
506
+ for r in data.get("resources", []):
507
+ grants = len(r.get("grants") or [])
508
+ rules = len(r.get("allow_rules") or []) + len(r.get("constrained_rules") or [])
509
+ sys.stdout.write(f" {r['resource']:<24} {r['kind']:<4} {r['default']:<9} "
510
+ f"grants={grants} rules={rules}"
511
+ + (f" denies={len(r['deny_rules'])}" if r.get("deny_rules") else "")
512
+ + (f" {r['description']}" if r.get("description") else "") + "\n")
513
+ if r.get("kind") == "llm":
514
+ limits = r.get("limits_per_lease") or {}
515
+ sys.stdout.write(f"{'':26}models: {', '.join(r.get('models') or {}) or 'none listed'}\n"
516
+ f"{'':26}per lease: ${limits.get('max_budget')} · budgets "
517
+ f"{'enforced' if r.get('budgets_enforced') else 'NOT enforced (no database)'}\n")
518
+ if data.get("session_note"):
519
+ sys.stdout.write(f" note: {data['session_note']}\n")
520
+ return EXIT_OK
521
+
522
+
523
+ CORE_TOOLS = {"access_list_resources", "access_request", "access_status", "request_attention",
524
+ "ssh_run", "ssh_put", "ssh_get", "ssh_capture"}
525
+
526
+
527
+ def cmd_llm(args: argparse.Namespace) -> int:
528
+ """The four lines that make an SDK or an agent call models through Keywright: the gateway URL
529
+ as the base URL and the agent token as the API key. No provider key is involved."""
530
+ from .config import ConfigError, load
531
+
532
+ try:
533
+ profile = load(getattr(args, "profile", None))
534
+ except ConfigError as e:
535
+ return _fail(str(e), EXIT_USAGE)[1]
536
+ base = profile.url.rstrip("/")
537
+ base = base.removesuffix("/mcp")
538
+ gw = args.gateway.strip("/")
539
+ pairs = [("ANTHROPIC_BASE_URL", f"{base}/llm/{gw}"), ("ANTHROPIC_AUTH_TOKEN", profile.token),
540
+ ("OPENAI_BASE_URL", f"{base}/llm/{gw}/v1"), ("OPENAI_API_KEY", profile.token)]
541
+ for name, value in pairs:
542
+ if args.shell == "fish":
543
+ sys.stdout.write(f"set -gx {name} {shlex.quote(value)}\n")
544
+ else:
545
+ sys.stdout.write(f"export {name}={shlex.quote(value)}\n")
546
+ sys.stdout.write("# unset the real ANTHROPIC_API_KEY / OPENAI_API_KEY here; a model is what you get approved for\n")
547
+ return EXIT_OK
548
+
549
+
550
+ def cmd_tools(args: argparse.Namespace) -> int:
551
+ from .transport import TransportError, list_tools
552
+
553
+ gate = Gate(args)
554
+ try:
555
+ tools = list_tools(gate.profile, gate.session)
556
+ except TransportError as e:
557
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)[1]
558
+ proxied = [t for t in tools if t.name not in CORE_TOOLS]
559
+ if args.json:
560
+ return emit({"tools": [{"name": t.name, "description": t.description} for t in proxied]}, EXIT_OK, True)
561
+ for t in proxied:
562
+ sys.stdout.write(f"{t.name} {(t.description or '').splitlines()[0][:100]}\n")
563
+ return EXIT_OK
564
+
565
+
566
+ def cmd_tool(args: argparse.Namespace) -> int:
567
+ from .transport import TransportError, list_tools
568
+
569
+ gate = Gate(args)
570
+ prefix = f"{args.server}{PROXY_SEP}"
571
+ if args.list or not args.tool:
572
+ try:
573
+ tools = [t for t in list_tools(gate.profile, gate.session) if t.name.startswith(prefix)]
574
+ except TransportError as e:
575
+ return _fail(f"could not reach the broker ({e})", EXIT_TRANSPORT)[1]
576
+ if not tools:
577
+ err(f"no tools for server {args.server!r}; see `kw tools`")
578
+ return EXIT_UNKNOWN
579
+ for t in tools:
580
+ schema = getattr(t, "input_schema", None) or getattr(t, "inputSchema", None) or {}
581
+ sys.stdout.write(f"{t.name[len(prefix):]}: {t.description or ''}\n {json.dumps(schema)}\n")
582
+ return EXIT_OK
583
+ arguments: dict[str, Any] = {}
584
+ if args.args_json:
585
+ try:
586
+ arguments = json.loads(args.args_json)
587
+ except ValueError as e:
588
+ return _usage(f"--json is not JSON: {e}")
589
+ for kv in args.kv:
590
+ if "=" not in kv:
591
+ return _usage(f"expected key=value, got {kv!r}")
592
+ k, v = kv.split("=", 1)
593
+ try:
594
+ arguments[k] = json.loads(v)
595
+ except ValueError:
596
+ arguments[k] = v
597
+ if args.reason:
598
+ arguments["_reason"] = args.reason
599
+ data, code = gate.gated(prefix + args.tool, arguments)
600
+ if code != EXIT_OK:
601
+ return emit(data, code, gate.as_json)
602
+ if gate.as_json or args.raw:
603
+ return emit(data, EXIT_OK, True)
604
+ text = data.get("text")
605
+ sys.stdout.write((text if isinstance(text, str) else json.dumps(data, indent=1)) + "\n")
606
+ return EXIT_OK
607
+
608
+
609
+ def cmd_login(args: argparse.Namespace) -> int:
610
+ from . import config
611
+
612
+ token = args.token
613
+ if args.paste or not token:
614
+ token = sys.stdin.readline().strip() if not sys.stdin.isatty() else input("agent token: ").strip()
615
+ if not token:
616
+ return _usage("no token given")
617
+ path = config.save(args.name, args.url, token)
618
+ err(f"stored profile {args.name!r} in {path}")
619
+ return EXIT_OK
620
+
621
+
622
+ def cmd_logout(args: argparse.Namespace) -> int:
623
+ from . import config
624
+
625
+ err(f"{'removed' if config.remove(args.name) else 'no such'} profile {args.name!r}")
626
+ return EXIT_OK
627
+
628
+
629
+ def cmd_setup(args: argparse.Namespace) -> int:
630
+ from .setup import check, claude_code, codex
631
+
632
+ if args.target == "check":
633
+ return check.run(profile_name=args.profile)
634
+ if args.target == "claude-code":
635
+ url = token = None
636
+ if args.mcp == "user":
637
+ from . import config
638
+
639
+ prof = config.load(args.profile)
640
+ url, token = prof.mcp_url, prof.token
641
+ return claude_code.apply(mcp=args.mcp, url=url, token=token, dry_run=args.dry_run,
642
+ bash_timeout=not args.no_bash_timeout)
643
+ if args.target == "codex":
644
+ url = None
645
+ if args.mcp == "keep":
646
+ from . import config
647
+
648
+ url = config.load(args.profile).mcp_url
649
+ return codex.apply(mcp=args.mcp, url=url, dry_run=args.dry_run)
650
+ if args.target == "uninstall":
651
+ claude_code.uninstall(keep_config=args.keep_config, dry_run=args.dry_run)
652
+ codex.uninstall(dry_run=args.dry_run)
653
+ if not args.keep_config and not args.dry_run:
654
+ from . import config
655
+
656
+ config.remove("default")
657
+ return EXIT_OK
658
+ return EXIT_USAGE
659
+
660
+
661
+ def _usage(text: str) -> int:
662
+ err(text)
663
+ return EXIT_USAGE
664
+
665
+
666
+ # ---- entry --------------------------------------------------------------------------------------
667
+ def main(argv: list[str] | None = None) -> int:
668
+ argv = list(sys.argv[1:] if argv is None else argv)
669
+ rest: list[str] = []
670
+ if "--" in argv:
671
+ i = argv.index("--")
672
+ argv, rest = argv[:i], argv[i + 1:]
673
+ parser = build_parser()
674
+ args = parser.parse_args(argv)
675
+ if args.version or args.cmd == "version":
676
+ from . import __version__
677
+
678
+ print(f"keywright {__version__}")
679
+ return EXIT_OK
680
+ if args.cmd == "hazards":
681
+ if args.hook:
682
+ from .hook import run_pre_tool_use
683
+
684
+ out = run_pre_tool_use(sys.stdin.read())
685
+ if out:
686
+ print(out)
687
+ return EXIT_OK
688
+ from .hazards import main as hazards_main
689
+
690
+ return hazards_main([*(["--json"] if args.json else []), *([args.command] if args.command is not None else [])])
691
+ if args.cmd == "session-start":
692
+ from .session import session_start_hook
693
+
694
+ try:
695
+ payload = json.load(sys.stdin)
696
+ except ValueError:
697
+ return EXIT_OK
698
+ session_start_hook(payload if isinstance(payload, dict) else {})
699
+ return EXIT_OK
700
+ if args.cmd in {"guide", "guides"}:
701
+ print((Path(__file__).resolve().parent / "skill" / "SKILL.md").read_text())
702
+ return EXIT_OK
703
+ if args.cmd == "session":
704
+ from .session import resolve
705
+
706
+ value, source = resolve()
707
+ print(f"{value or 'none'} ({source})")
708
+ return EXIT_OK
709
+ if args.cmd == "login":
710
+ return cmd_login(args)
711
+ if args.cmd == "logout":
712
+ return cmd_logout(args)
713
+ if args.cmd == "setup":
714
+ return cmd_setup(args)
715
+ if rest and args.cmd not in ("run", "capture"):
716
+ return _usage("`--` is only meaningful for run and capture")
717
+ try:
718
+ if args.cmd == "run":
719
+ return cmd_run(args, rest)
720
+ if args.cmd == "put":
721
+ return cmd_put(args)
722
+ if args.cmd == "get":
723
+ return cmd_get(args)
724
+ if args.cmd == "capture":
725
+ return cmd_capture(args, rest)
726
+ if args.cmd == "request":
727
+ return cmd_request(args)
728
+ if args.cmd == "status":
729
+ return cmd_status(args)
730
+ if args.cmd == "ping":
731
+ return cmd_ping(args)
732
+ if args.cmd == "resources":
733
+ return cmd_resources(args)
734
+ if args.cmd == "llm":
735
+ return cmd_llm(args)
736
+ if args.cmd == "tools":
737
+ return cmd_tools(args)
738
+ if args.cmd == "tool":
739
+ return cmd_tool(args)
740
+ except SystemExit as e: # Gate() raised with an exit code tuple
741
+ code = e.code
742
+ return code[1] if isinstance(code, tuple) else int(code or 0)
743
+ parser.print_help()
744
+ return EXIT_USAGE
745
+
746
+
747
+ if __name__ == "__main__":
748
+ sys.exit(main())