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
|
@@ -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)
|
keywright/setup/codex.py
ADDED
|
@@ -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"))
|
keywright/skill/SKILL.md
ADDED
|
@@ -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.
|