kitden 0.1.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.
- kitden/__init__.py +3 -0
- kitden/__main__.py +7 -0
- kitden/changes/__init__.py +0 -0
- kitden/changes/actions.py +91 -0
- kitden/changes/atomic.py +60 -0
- kitden/changes/backup.py +113 -0
- kitden/changes/client_cli.py +55 -0
- kitden/changes/engine.py +407 -0
- kitden/changes/json_splice.py +236 -0
- kitden/changes/toml_edit.py +69 -0
- kitden/cli.py +228 -0
- kitden/clients/__init__.py +0 -0
- kitden/clients/base.py +306 -0
- kitden/clients/claude_code.py +397 -0
- kitden/clients/codex.py +347 -0
- kitden/clients/cursor.py +419 -0
- kitden/clients/versions.py +58 -0
- kitden/command.py +17 -0
- kitden/gateways.py +134 -0
- kitden/mcp_server.py +148 -0
- kitden/model.py +273 -0
- kitden/page.py +157 -0
- kitden/redact.py +134 -0
- kitden/views/__init__.py +0 -0
- kitden/views/after.py +40 -0
- kitden/views/commands.py +131 -0
- kitden/views/effective.py +123 -0
- kitden/views/explain.py +95 -0
- kitden/views/findings.py +215 -0
- kitden/views/gateway_view.py +76 -0
- kitden/views/inventory.py +101 -0
- kitden/views/load_map.py +227 -0
- kitden/views/overview.py +179 -0
- kitden/views/setup_server.py +88 -0
- kitden/watch.py +96 -0
- kitden/web/__init__.py +0 -0
- kitden/web/app.py +514 -0
- kitden/web/security.py +112 -0
- kitden/web/static/index.html +48 -0
- kitden/web/static/js/agents.js +51 -0
- kitden/web/static/js/api.js +26 -0
- kitden/web/static/js/app.js +210 -0
- kitden/web/static/js/changes.js +93 -0
- kitden/web/static/js/dom.js +47 -0
- kitden/web/static/js/gateway.js +285 -0
- kitden/web/static/js/map.js +215 -0
- kitden/web/static/js/motion.js +51 -0
- kitden/web/static/js/overview.js +107 -0
- kitden/web/static/js/panel.js +604 -0
- kitden/web/static/js/parts.js +105 -0
- kitden/web/static/js/store.js +129 -0
- kitden/web/static/style.css +341 -0
- kitden-0.1.1.dist-info/METADATA +129 -0
- kitden-0.1.1.dist-info/RECORD +57 -0
- kitden-0.1.1.dist-info/WHEEL +4 -0
- kitden-0.1.1.dist-info/entry_points.txt +2 -0
- kitden-0.1.1.dist-info/licenses/LICENSE +202 -0
kitden/__init__.py
ADDED
kitden/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Turn a requested action into steps for the change engine (write-matrix.md)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from kitden.clients.base import PLUGIN_DISABLE, PLUGIN_ENABLE, REMOVE_DIRECT, ClientAdapter
|
|
7
|
+
from kitden.model import Gateway, Item, Step
|
|
8
|
+
|
|
9
|
+
GATEWAY_CONNECT = "gateway.connect"
|
|
10
|
+
SETUP_SERVER_UPDATE = "setup_server.update"
|
|
11
|
+
ENTRY_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def connect_steps(
|
|
15
|
+
adapters: dict[str, ClientAdapter], clients: list[str], gateway: Gateway, name: str
|
|
16
|
+
) -> list[Step]:
|
|
17
|
+
"""One gateway entry per chosen client, in the user scope (FR-024, FR-033)."""
|
|
18
|
+
if not ENTRY_NAME.fullmatch(name):
|
|
19
|
+
raise ValueError("The entry name may use letters, digits, '-' and '_' only")
|
|
20
|
+
if not clients:
|
|
21
|
+
raise ValueError("Choose at least one client")
|
|
22
|
+
steps = []
|
|
23
|
+
for client in dict.fromkeys(clients):
|
|
24
|
+
adapter = adapters.get(client)
|
|
25
|
+
if adapter is None:
|
|
26
|
+
raise ValueError(f"{client} cannot be connected to a gateway")
|
|
27
|
+
steps.append(adapter.gateway_step(gateway, name))
|
|
28
|
+
return steps
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def remove_steps(adapters: dict[str, ClientAdapter], item: Item) -> list[Step]:
|
|
32
|
+
"""Remove one direct MCP entry (FR-026)."""
|
|
33
|
+
adapter = adapters.get(item.client)
|
|
34
|
+
if adapter is None or REMOVE_DIRECT not in item.actions:
|
|
35
|
+
raise ValueError(f"{item.name} cannot be removed here")
|
|
36
|
+
return [adapter.remove_step(item)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plugin_steps(adapters: dict[str, ClientAdapter], item: Item, enable: bool) -> list[Step]:
|
|
40
|
+
"""Turn one plugin on or off (FR-027). Cursor and account plugins have no control."""
|
|
41
|
+
action = PLUGIN_ENABLE if enable else PLUGIN_DISABLE
|
|
42
|
+
adapter = adapters.get(item.client)
|
|
43
|
+
if adapter is None or action not in item.actions:
|
|
44
|
+
raise ValueError(f"{item.name} cannot be turned {'on' if enable else 'off'} here")
|
|
45
|
+
return [adapter.plugin_step(item, enable)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def setup_server_steps(
|
|
49
|
+
adapters: dict[str, ClientAdapter], clients: list[str], command: str
|
|
50
|
+
) -> list[Step]:
|
|
51
|
+
"""Add the read-only setup server to the chosen clients (FR-032).
|
|
52
|
+
|
|
53
|
+
GUI clients may not see the shell PATH, so the entry uses an absolute path (research R9).
|
|
54
|
+
"""
|
|
55
|
+
if not Path(command).is_absolute():
|
|
56
|
+
raise ValueError("The setup server needs the absolute path of the kitden command")
|
|
57
|
+
if not clients:
|
|
58
|
+
raise ValueError("Choose at least one client")
|
|
59
|
+
steps = []
|
|
60
|
+
for client in dict.fromkeys(clients):
|
|
61
|
+
adapter = adapters.get(client)
|
|
62
|
+
if adapter is None:
|
|
63
|
+
raise ValueError(f"The setup server cannot be added to {client}")
|
|
64
|
+
steps.append(adapter.setup_server_step(command, ["mcp"]))
|
|
65
|
+
return steps
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update_steps(
|
|
69
|
+
adapters: dict[str, ClientAdapter],
|
|
70
|
+
items: list[Item],
|
|
71
|
+
added_clients: set[str],
|
|
72
|
+
command: str | None,
|
|
73
|
+
) -> list[Step]:
|
|
74
|
+
"""Replace old `agentsetup` entries with `kitden` (feature 002, FR-006).
|
|
75
|
+
|
|
76
|
+
Per client: remove the old entry, then add `kitden` unless that client already has it. Both
|
|
77
|
+
steps edit one settings file; the engine applies them in order.
|
|
78
|
+
"""
|
|
79
|
+
if not items:
|
|
80
|
+
raise ValueError("Choose an old Kitden entry.")
|
|
81
|
+
steps = []
|
|
82
|
+
for item in items:
|
|
83
|
+
adapter = adapters.get(item.client)
|
|
84
|
+
if adapter is None:
|
|
85
|
+
raise ValueError("Choose an old Kitden entry.")
|
|
86
|
+
steps.append(adapter.remove_step(item))
|
|
87
|
+
if item.client not in added_clients:
|
|
88
|
+
if command is None or not Path(command).is_absolute():
|
|
89
|
+
raise ValueError("The setup server needs the absolute path of the kitden command")
|
|
90
|
+
steps.append(adapter.setup_server_step(command, ["mcp"]))
|
|
91
|
+
return steps
|
kitden/changes/atomic.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Write a file so that readers see either the old bytes or the new bytes, never a mix.
|
|
2
|
+
|
|
3
|
+
Research R7: resolve symlinks, refuse hard links, temp file in the same folder, keep the mode,
|
|
4
|
+
full fsync on macOS, then rename over the target.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import fcntl
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
NEW_FILE_MODE = 0o600
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RefusedWriteError(Exception):
|
|
18
|
+
"""The file must not be written this way."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _full_fsync(fd: int) -> None:
|
|
22
|
+
# On macOS plain fsync does not flush the drive cache; F_FULLFSYNC does.
|
|
23
|
+
try:
|
|
24
|
+
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
|
|
25
|
+
except (AttributeError, OSError):
|
|
26
|
+
os.fsync(fd)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_atomic(path: Path | str, data: bytes) -> Path:
|
|
30
|
+
"""Replace the file at `path` (or the file it links to) with `data`. Returns the real path."""
|
|
31
|
+
target = Path(os.path.realpath(path))
|
|
32
|
+
try:
|
|
33
|
+
info = target.stat()
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
mode = NEW_FILE_MODE
|
|
36
|
+
else:
|
|
37
|
+
if info.st_nlink > 1:
|
|
38
|
+
raise RefusedWriteError(f"{target} has a hard link; renaming would break it")
|
|
39
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
40
|
+
|
|
41
|
+
temp = target.with_name(f".{target.name}.{secrets.token_hex(4)}.tmp")
|
|
42
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
43
|
+
try:
|
|
44
|
+
with os.fdopen(fd, "wb") as handle:
|
|
45
|
+
handle.write(data)
|
|
46
|
+
handle.flush()
|
|
47
|
+
os.fchmod(handle.fileno(), mode)
|
|
48
|
+
_full_fsync(handle.fileno())
|
|
49
|
+
os.replace(temp, target)
|
|
50
|
+
except BaseException:
|
|
51
|
+
with contextlib.suppress(FileNotFoundError):
|
|
52
|
+
temp.unlink()
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
folder = os.open(target.parent, os.O_RDONLY)
|
|
56
|
+
try:
|
|
57
|
+
os.fsync(folder)
|
|
58
|
+
finally:
|
|
59
|
+
os.close(folder)
|
|
60
|
+
return target
|
kitden/changes/backup.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Backups and the change log (research R7, FR-017, FR-022).
|
|
2
|
+
|
|
3
|
+
Folders are 0700 and files 0600. A backup holds the original bytes, which may contain secrets;
|
|
4
|
+
the manifest and the log never do. A backup is removed only when it is older than 30 days and not
|
|
5
|
+
one of the newest 100 changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from kitden.redact import redact, redact_text
|
|
17
|
+
|
|
18
|
+
KEEP_DAYS = 30
|
|
19
|
+
KEEP_COUNT = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _private_folder(path: Path) -> None:
|
|
23
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
path.chmod(0o700)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_private(path: Path, data: bytes, append: bool = False) -> None:
|
|
28
|
+
flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC)
|
|
29
|
+
fd = os.open(path, flags, 0o600)
|
|
30
|
+
try:
|
|
31
|
+
os.fchmod(fd, 0o600)
|
|
32
|
+
os.write(fd, data)
|
|
33
|
+
finally:
|
|
34
|
+
os.close(fd)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _hide_text(value: Any) -> Any:
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
return redact_text(value)
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
return {k: _hide_text(v) for k, v in value.items()}
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
return [_hide_text(v) for v in value]
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChangeStore:
|
|
48
|
+
def __init__(self, root: Path) -> None:
|
|
49
|
+
self.root = Path(root)
|
|
50
|
+
self.folder = self.root / "changes"
|
|
51
|
+
self.log_path = self.root / "changes.log"
|
|
52
|
+
|
|
53
|
+
def _change_folder(self, change_id: str) -> Path:
|
|
54
|
+
for path in (self.root, self.folder, self.folder / change_id):
|
|
55
|
+
_private_folder(path)
|
|
56
|
+
return self.folder / change_id
|
|
57
|
+
|
|
58
|
+
def backup(self, change_id: str, files: dict[str, bytes | None]) -> dict[str, Path | None]:
|
|
59
|
+
"""Save the original bytes of each file (None = the file did not exist)."""
|
|
60
|
+
folder = self._change_folder(change_id)
|
|
61
|
+
saved: dict[str, Path | None] = {}
|
|
62
|
+
manifest = []
|
|
63
|
+
for index, (path, data) in enumerate(files.items()):
|
|
64
|
+
entry: dict[str, Any] = {"path": path, "existed": data is not None}
|
|
65
|
+
saved[path] = None
|
|
66
|
+
if data is not None:
|
|
67
|
+
copy = folder / f"{index}-{Path(path).name}"
|
|
68
|
+
_write_private(copy, data)
|
|
69
|
+
saved[path] = copy
|
|
70
|
+
entry["sha256"] = hashlib.sha256(data).hexdigest()
|
|
71
|
+
manifest.append(entry)
|
|
72
|
+
record = {"change": change_id, "files": manifest}
|
|
73
|
+
_write_private(folder / "manifest.json", json.dumps(record, indent=2).encode())
|
|
74
|
+
return saved
|
|
75
|
+
|
|
76
|
+
def save(self, change_id: str, record: dict) -> None:
|
|
77
|
+
"""Keep what undo needs. Lives next to the backup and is just as private."""
|
|
78
|
+
folder = self._change_folder(change_id)
|
|
79
|
+
_write_private(folder / "change.json", json.dumps(record, indent=2).encode())
|
|
80
|
+
|
|
81
|
+
def load(self) -> list[dict]:
|
|
82
|
+
records = []
|
|
83
|
+
for path in sorted(self.folder.glob("*/change.json")) if self.folder.is_dir() else []:
|
|
84
|
+
try:
|
|
85
|
+
records.append(json.loads(path.read_text()))
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
continue
|
|
88
|
+
return records
|
|
89
|
+
|
|
90
|
+
def log(self, entry: dict) -> None:
|
|
91
|
+
_private_folder(self.root)
|
|
92
|
+
line = json.dumps(_hide_text(redact(entry)[0]), ensure_ascii=False)
|
|
93
|
+
_write_private(self.log_path, (line + "\n").encode(), append=True)
|
|
94
|
+
|
|
95
|
+
def entries(self) -> list[dict]:
|
|
96
|
+
"""Change log entries, newest first."""
|
|
97
|
+
if not self.log_path.is_file():
|
|
98
|
+
return []
|
|
99
|
+
lines = self.log_path.read_text().splitlines()
|
|
100
|
+
return [json.loads(line) for line in reversed(lines) if line.strip()]
|
|
101
|
+
|
|
102
|
+
def prune(self, now: datetime | None = None) -> None:
|
|
103
|
+
if not self.folder.is_dir():
|
|
104
|
+
return
|
|
105
|
+
cutoff = (now or datetime.now(UTC)) - timedelta(days=KEEP_DAYS)
|
|
106
|
+
folders = sorted(p for p in self.folder.iterdir() if p.is_dir())
|
|
107
|
+
for folder in folders[:-KEEP_COUNT]:
|
|
108
|
+
try:
|
|
109
|
+
made = datetime.strptime(folder.name[:16], "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
|
110
|
+
except ValueError:
|
|
111
|
+
continue
|
|
112
|
+
if made < cutoff:
|
|
113
|
+
shutil.rmtree(folder)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Run a client's own command (FR-021), for example `claude mcp add-json`. Output is redacted."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess # noqa: S404 - runs fixed client commands without a shell
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kitden.redact import redact_text
|
|
10
|
+
|
|
11
|
+
TIMEOUT_SECONDS = 60.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CommandResult:
|
|
16
|
+
returncode: int
|
|
17
|
+
output: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_command(name: str, home: Path) -> str | None:
|
|
21
|
+
"""The client binary on PATH, or in the places its installer uses."""
|
|
22
|
+
found = shutil.which(name)
|
|
23
|
+
if found:
|
|
24
|
+
return found
|
|
25
|
+
for candidate in (home / ".local/bin" / name, home / ".claude/local" / name):
|
|
26
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
27
|
+
return str(candidate)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandRunner:
|
|
32
|
+
"""Runs commands with HOME set to the home folder the page shows."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, home: Path, timeout: float = TIMEOUT_SECONDS) -> None:
|
|
35
|
+
self.home = Path(home)
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def __call__(self, command: list[str], cwd: str | None) -> CommandResult:
|
|
39
|
+
binary = find_command(command[0], self.home)
|
|
40
|
+
if binary is None:
|
|
41
|
+
return CommandResult(127, f"{command[0]} was not found")
|
|
42
|
+
env = {**os.environ, "HOME": str(self.home)}
|
|
43
|
+
try:
|
|
44
|
+
done = subprocess.run( # noqa: S603 - fixed client command, no shell
|
|
45
|
+
[binary, *command[1:]],
|
|
46
|
+
cwd=cwd,
|
|
47
|
+
env=env,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=self.timeout,
|
|
51
|
+
check=False,
|
|
52
|
+
)
|
|
53
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
54
|
+
return CommandResult(1, redact_text(str(error)))
|
|
55
|
+
return CommandResult(done.returncode, redact_text((done.stdout + done.stderr).strip()))
|