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/__init__.py +3 -0
- keywright/_transport_worker.py +29 -0
- keywright/classify.py +185 -0
- keywright/cli.py +748 -0
- keywright/config.py +105 -0
- keywright/hazards.py +948 -0
- keywright/hook.py +161 -0
- keywright/patterns.py +153 -0
- keywright/session.py +144 -0
- keywright/setup/__init__.py +2 -0
- keywright/setup/check.py +115 -0
- keywright/setup/claude_code.py +176 -0
- keywright/setup/codex.py +62 -0
- keywright/setup/common.py +146 -0
- keywright/skill/SKILL.md +134 -0
- keywright/transport.py +182 -0
- keywright-0.0.1.dist-info/METADATA +77 -0
- keywright-0.0.1.dist-info/RECORD +23 -0
- keywright-0.0.1.dist-info/WHEEL +4 -0
- keywright-0.0.1.dist-info/entry_points.txt +3 -0
- keywright-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
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())
|