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.
Files changed (57) hide show
  1. kitden/__init__.py +3 -0
  2. kitden/__main__.py +7 -0
  3. kitden/changes/__init__.py +0 -0
  4. kitden/changes/actions.py +91 -0
  5. kitden/changes/atomic.py +60 -0
  6. kitden/changes/backup.py +113 -0
  7. kitden/changes/client_cli.py +55 -0
  8. kitden/changes/engine.py +407 -0
  9. kitden/changes/json_splice.py +236 -0
  10. kitden/changes/toml_edit.py +69 -0
  11. kitden/cli.py +228 -0
  12. kitden/clients/__init__.py +0 -0
  13. kitden/clients/base.py +306 -0
  14. kitden/clients/claude_code.py +397 -0
  15. kitden/clients/codex.py +347 -0
  16. kitden/clients/cursor.py +419 -0
  17. kitden/clients/versions.py +58 -0
  18. kitden/command.py +17 -0
  19. kitden/gateways.py +134 -0
  20. kitden/mcp_server.py +148 -0
  21. kitden/model.py +273 -0
  22. kitden/page.py +157 -0
  23. kitden/redact.py +134 -0
  24. kitden/views/__init__.py +0 -0
  25. kitden/views/after.py +40 -0
  26. kitden/views/commands.py +131 -0
  27. kitden/views/effective.py +123 -0
  28. kitden/views/explain.py +95 -0
  29. kitden/views/findings.py +215 -0
  30. kitden/views/gateway_view.py +76 -0
  31. kitden/views/inventory.py +101 -0
  32. kitden/views/load_map.py +227 -0
  33. kitden/views/overview.py +179 -0
  34. kitden/views/setup_server.py +88 -0
  35. kitden/watch.py +96 -0
  36. kitden/web/__init__.py +0 -0
  37. kitden/web/app.py +514 -0
  38. kitden/web/security.py +112 -0
  39. kitden/web/static/index.html +48 -0
  40. kitden/web/static/js/agents.js +51 -0
  41. kitden/web/static/js/api.js +26 -0
  42. kitden/web/static/js/app.js +210 -0
  43. kitden/web/static/js/changes.js +93 -0
  44. kitden/web/static/js/dom.js +47 -0
  45. kitden/web/static/js/gateway.js +285 -0
  46. kitden/web/static/js/map.js +215 -0
  47. kitden/web/static/js/motion.js +51 -0
  48. kitden/web/static/js/overview.js +107 -0
  49. kitden/web/static/js/panel.js +604 -0
  50. kitden/web/static/js/parts.js +105 -0
  51. kitden/web/static/js/store.js +129 -0
  52. kitden/web/static/style.css +341 -0
  53. kitden-0.1.1.dist-info/METADATA +129 -0
  54. kitden-0.1.1.dist-info/RECORD +57 -0
  55. kitden-0.1.1.dist-info/WHEEL +4 -0
  56. kitden-0.1.1.dist-info/entry_points.txt +2 -0
  57. kitden-0.1.1.dist-info/licenses/LICENSE +202 -0
kitden/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """See and safely change what AI coding clients load on this computer."""
2
+
3
+ __version__ = "0.1.1"
kitden/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m kitden`, used to start the page in the background."""
2
+
3
+ import sys
4
+
5
+ from kitden.cli import main
6
+
7
+ sys.exit(main())
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
@@ -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
@@ -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()))