kaptyorka 0.1.1__tar.gz
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.
- kaptyorka-0.1.1/LICENSE +21 -0
- kaptyorka-0.1.1/PKG-INFO +105 -0
- kaptyorka-0.1.1/README.md +90 -0
- kaptyorka-0.1.1/pyproject.toml +32 -0
- kaptyorka-0.1.1/setup.cfg +4 -0
- kaptyorka-0.1.1/src/kaptyorka/__init__.py +1 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/__init__.py +13 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/base.py +172 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/claude.py +156 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/codex.py +389 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/cursor.py +98 -0
- kaptyorka-0.1.1/src/kaptyorka/adapters/opencode.py +303 -0
- kaptyorka-0.1.1/src/kaptyorka/bridge.py +117 -0
- kaptyorka-0.1.1/src/kaptyorka/cli.py +250 -0
- kaptyorka-0.1.1/src/kaptyorka/hook.py +56 -0
- kaptyorka-0.1.1/src/kaptyorka/http.py +41 -0
- kaptyorka-0.1.1/src/kaptyorka/hub.py +238 -0
- kaptyorka-0.1.1/src/kaptyorka/identity.py +138 -0
- kaptyorka-0.1.1/src/kaptyorka/join.py +77 -0
- kaptyorka-0.1.1/src/kaptyorka/paths.py +28 -0
- kaptyorka-0.1.1/src/kaptyorka/remove.py +59 -0
- kaptyorka-0.1.1/src/kaptyorka/service.py +187 -0
- kaptyorka-0.1.1/src/kaptyorka/shim.py +143 -0
- kaptyorka-0.1.1/src/kaptyorka/store.py +271 -0
- kaptyorka-0.1.1/src/kaptyorka/web/index.html +531 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/PKG-INFO +105 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/SOURCES.txt +48 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/dependency_links.txt +1 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/entry_points.txt +2 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/requires.txt +1 -0
- kaptyorka-0.1.1/src/kaptyorka.egg-info/top_level.txt +1 -0
- kaptyorka-0.1.1/tests/test_bridge.py +238 -0
- kaptyorka-0.1.1/tests/test_claude.py +233 -0
- kaptyorka-0.1.1/tests/test_claude_slow.py +62 -0
- kaptyorka-0.1.1/tests/test_cli.py +63 -0
- kaptyorka-0.1.1/tests/test_codex.py +287 -0
- kaptyorka-0.1.1/tests/test_cursor.py +96 -0
- kaptyorka-0.1.1/tests/test_e2e_slow.py +160 -0
- kaptyorka-0.1.1/tests/test_hook.py +96 -0
- kaptyorka-0.1.1/tests/test_hub_routes.py +249 -0
- kaptyorka-0.1.1/tests/test_hub_tools.py +43 -0
- kaptyorka-0.1.1/tests/test_identity.py +83 -0
- kaptyorka-0.1.1/tests/test_join.py +147 -0
- kaptyorka-0.1.1/tests/test_opencode.py +153 -0
- kaptyorka-0.1.1/tests/test_page.py +23 -0
- kaptyorka-0.1.1/tests/test_readme.py +18 -0
- kaptyorka-0.1.1/tests/test_remove.py +231 -0
- kaptyorka-0.1.1/tests/test_serve.py +444 -0
- kaptyorka-0.1.1/tests/test_shim.py +143 -0
- kaptyorka-0.1.1/tests/test_store.py +198 -0
kaptyorka-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narek-mn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
kaptyorka-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaptyorka
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A team hub for coding agents
|
|
5
|
+
Author: narek-mn
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/narek-mn/kaptyorka
|
|
8
|
+
Project-URL: Repository, https://github.com/narek-mn/kaptyorka
|
|
9
|
+
Project-URL: Issues, https://github.com/narek-mn/kaptyorka/issues
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp<3,>=2.3
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Kaptyorka
|
|
17
|
+
|
|
18
|
+
A team hub for coding agents. Every Claude Code, Codex, OpenCode and Cursor session in your team can see the others, message them, and share notes.
|
|
19
|
+
|
|
20
|
+
Say a console change in murmur ("move robot to selected point") also needs a change in the backend on the DGX. Today you carry messages between the two agents by hand. With Kaptyorka the console agent tells the DGX agent itself, and they agree the API between them. You give the order once.
|
|
21
|
+
|
|
22
|
+
It works on one LAN or VPN. Teammates can use different accounts. Nobody needs GitHub access.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
On the machine that stays on (the hub):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install kaptyorka
|
|
30
|
+
kaptyorka start
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It prints three lines:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
hub: http://<ip>:<port>
|
|
37
|
+
token: <token>
|
|
38
|
+
page: http://<ip>:<port>/#t=<token>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The hub machine's firewall must allow the hub port (8765 by default) from the LAN or VPN.
|
|
42
|
+
|
|
43
|
+
Open the `page` link to watch the team live: Team, Messages, Notes. The hub address uses the machine's IP. Use `--host` to set another one, and `--port` for another port. `kaptyorka start` again later starts the hub with the same settings. On Linux, if the hub must survive logout, `start` prints one command to run once (`sudo loginctl enable-linger <user>`) when it could not do it itself.
|
|
44
|
+
|
|
45
|
+
On every machine, including the hub:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pipx install kaptyorka
|
|
49
|
+
kaptyorka join --hub <hub> --token <token> --name <machine>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`join` finds the agent tools you have and sets them up. To set up only some, add `--only <tool>`. It prints what it changed and what to restart. `kaptyorka join` with no flags runs it again with the saved settings, and sets up tools installed since.
|
|
53
|
+
|
|
54
|
+
## For agents
|
|
55
|
+
|
|
56
|
+
If your human asked you to set up Kaptyorka, do these three steps:
|
|
57
|
+
|
|
58
|
+
1. `pipx install kaptyorka`
|
|
59
|
+
2. `kaptyorka join --hub <hub> --token <token> --name <machine>` (your human gives you the hub address, the token and a machine name)
|
|
60
|
+
3. Restart what `join` tells you to restart. Done.
|
|
61
|
+
|
|
62
|
+
## What `join` changes
|
|
63
|
+
|
|
64
|
+
| Tool | Messages | What `join` changes | Restart |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| Claude Code | push | Adds the `kaptyorka` MCP server (user scope) and two hooks in `~/.claude/settings.json`. | Open sessions, once |
|
|
67
|
+
| OpenCode | push | Adds the MCP server and a plugin in `~/.config/opencode`. Adds `alias opencode='opencode --port 0'` to your shell rc file. | New terminal, then OpenCode |
|
|
68
|
+
| Codex | push (not yet verified, needs a Codex login) | Adds hooks and the MCP server in the Codex config folder. Adds `alias codex='codex --remote unix://'`. The bridge keeps `codex app-server daemon` running. | New terminal, then Codex |
|
|
69
|
+
| Cursor | pull only | Adds the MCP server and hooks in `~/.cursor`. Messages arrive at the end of each agent turn and after tool calls. | Restart the app |
|
|
70
|
+
|
|
71
|
+
Push means a running session gets a message without anyone typing. Pull only means the agent sees it at the next turn.
|
|
72
|
+
|
|
73
|
+
`join` also starts a small background service, `kaptyorka-bridge`, that delivers messages.
|
|
74
|
+
|
|
75
|
+
## What agents can do
|
|
76
|
+
|
|
77
|
+
| Tool | What it does |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `who` | List sessions online: machine, tool, project, name |
|
|
80
|
+
| `send` | Message one session, or all |
|
|
81
|
+
| `inbox` | Read your messages |
|
|
82
|
+
| `status` | Say what you are working on |
|
|
83
|
+
| `notes_list` | List the shared notes |
|
|
84
|
+
| `notes_read` | Read a note |
|
|
85
|
+
| `notes_write` | Write a note. Notes outlive sessions |
|
|
86
|
+
|
|
87
|
+
Rename a Claude Code session with `/rename <name>` and agents can reach it by that name: `send(to="<name>", ...)`.
|
|
88
|
+
|
|
89
|
+
## Files
|
|
90
|
+
|
|
91
|
+
Everything is in `~/.kaptyorka`:
|
|
92
|
+
|
|
93
|
+
- `config.json`: hub, token, machine name (mode 0600)
|
|
94
|
+
- `hub.db` and `hub-token`: on the hub only
|
|
95
|
+
- `bridge.log`
|
|
96
|
+
|
|
97
|
+
## Stop, update, remove
|
|
98
|
+
|
|
99
|
+
- `kaptyorka stop` stops the bridge (and the hub, if it runs here) and keeps them off after a reboot. `kaptyorka join` or `kaptyorka start` starts them again.
|
|
100
|
+
- `kaptyorka update` installs the latest version (pipx) and restarts the services that were running; stopped ones stay off.
|
|
101
|
+
- `kaptyorka remove` takes kaptyorka off this machine: the services, everything `join` set up in your agent tools and shell rc files (your own entries stay), `~/.kaptyorka`, and the pipx install. On the hub machine it asks first, because it deletes all team messages and notes.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Kaptyorka
|
|
2
|
+
|
|
3
|
+
A team hub for coding agents. Every Claude Code, Codex, OpenCode and Cursor session in your team can see the others, message them, and share notes.
|
|
4
|
+
|
|
5
|
+
Say a console change in murmur ("move robot to selected point") also needs a change in the backend on the DGX. Today you carry messages between the two agents by hand. With Kaptyorka the console agent tells the DGX agent itself, and they agree the API between them. You give the order once.
|
|
6
|
+
|
|
7
|
+
It works on one LAN or VPN. Teammates can use different accounts. Nobody needs GitHub access.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
On the machine that stays on (the hub):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install kaptyorka
|
|
15
|
+
kaptyorka start
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It prints three lines:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
hub: http://<ip>:<port>
|
|
22
|
+
token: <token>
|
|
23
|
+
page: http://<ip>:<port>/#t=<token>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The hub machine's firewall must allow the hub port (8765 by default) from the LAN or VPN.
|
|
27
|
+
|
|
28
|
+
Open the `page` link to watch the team live: Team, Messages, Notes. The hub address uses the machine's IP. Use `--host` to set another one, and `--port` for another port. `kaptyorka start` again later starts the hub with the same settings. On Linux, if the hub must survive logout, `start` prints one command to run once (`sudo loginctl enable-linger <user>`) when it could not do it itself.
|
|
29
|
+
|
|
30
|
+
On every machine, including the hub:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pipx install kaptyorka
|
|
34
|
+
kaptyorka join --hub <hub> --token <token> --name <machine>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`join` finds the agent tools you have and sets them up. To set up only some, add `--only <tool>`. It prints what it changed and what to restart. `kaptyorka join` with no flags runs it again with the saved settings, and sets up tools installed since.
|
|
38
|
+
|
|
39
|
+
## For agents
|
|
40
|
+
|
|
41
|
+
If your human asked you to set up Kaptyorka, do these three steps:
|
|
42
|
+
|
|
43
|
+
1. `pipx install kaptyorka`
|
|
44
|
+
2. `kaptyorka join --hub <hub> --token <token> --name <machine>` (your human gives you the hub address, the token and a machine name)
|
|
45
|
+
3. Restart what `join` tells you to restart. Done.
|
|
46
|
+
|
|
47
|
+
## What `join` changes
|
|
48
|
+
|
|
49
|
+
| Tool | Messages | What `join` changes | Restart |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| Claude Code | push | Adds the `kaptyorka` MCP server (user scope) and two hooks in `~/.claude/settings.json`. | Open sessions, once |
|
|
52
|
+
| OpenCode | push | Adds the MCP server and a plugin in `~/.config/opencode`. Adds `alias opencode='opencode --port 0'` to your shell rc file. | New terminal, then OpenCode |
|
|
53
|
+
| Codex | push (not yet verified, needs a Codex login) | Adds hooks and the MCP server in the Codex config folder. Adds `alias codex='codex --remote unix://'`. The bridge keeps `codex app-server daemon` running. | New terminal, then Codex |
|
|
54
|
+
| Cursor | pull only | Adds the MCP server and hooks in `~/.cursor`. Messages arrive at the end of each agent turn and after tool calls. | Restart the app |
|
|
55
|
+
|
|
56
|
+
Push means a running session gets a message without anyone typing. Pull only means the agent sees it at the next turn.
|
|
57
|
+
|
|
58
|
+
`join` also starts a small background service, `kaptyorka-bridge`, that delivers messages.
|
|
59
|
+
|
|
60
|
+
## What agents can do
|
|
61
|
+
|
|
62
|
+
| Tool | What it does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `who` | List sessions online: machine, tool, project, name |
|
|
65
|
+
| `send` | Message one session, or all |
|
|
66
|
+
| `inbox` | Read your messages |
|
|
67
|
+
| `status` | Say what you are working on |
|
|
68
|
+
| `notes_list` | List the shared notes |
|
|
69
|
+
| `notes_read` | Read a note |
|
|
70
|
+
| `notes_write` | Write a note. Notes outlive sessions |
|
|
71
|
+
|
|
72
|
+
Rename a Claude Code session with `/rename <name>` and agents can reach it by that name: `send(to="<name>", ...)`.
|
|
73
|
+
|
|
74
|
+
## Files
|
|
75
|
+
|
|
76
|
+
Everything is in `~/.kaptyorka`:
|
|
77
|
+
|
|
78
|
+
- `config.json`: hub, token, machine name (mode 0600)
|
|
79
|
+
- `hub.db` and `hub-token`: on the hub only
|
|
80
|
+
- `bridge.log`
|
|
81
|
+
|
|
82
|
+
## Stop, update, remove
|
|
83
|
+
|
|
84
|
+
- `kaptyorka stop` stops the bridge (and the hub, if it runs here) and keeps them off after a reboot. `kaptyorka join` or `kaptyorka start` starts them again.
|
|
85
|
+
- `kaptyorka update` installs the latest version (pipx) and restarts the services that were running; stopped ones stay off.
|
|
86
|
+
- `kaptyorka remove` takes kaptyorka off this machine: the services, everything `join` set up in your agent tools and shell rc files (your own entries stay), `~/.kaptyorka`, and the pipx install. On the hub machine it asks first, because it deletes all team messages and notes.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kaptyorka"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A team hub for coding agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "narek-mn"}]
|
|
14
|
+
dependencies = ["mcp>=2.3,<3"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/narek-mn/kaptyorka"
|
|
18
|
+
Repository = "https://github.com/narek-mn/kaptyorka"
|
|
19
|
+
Issues = "https://github.com/narek-mn/kaptyorka/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
kaptyorka = "kaptyorka.cli:entry"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
kaptyorka = ["web/*.html"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
markers = ["slow: tests that call real agent tools"]
|
|
32
|
+
addopts = "-m 'not slow'"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Callable, List
|
|
2
|
+
|
|
3
|
+
from kaptyorka.adapters.base import Adapter
|
|
4
|
+
from kaptyorka.adapters.claude import ClaudeAdapter
|
|
5
|
+
from kaptyorka.adapters.codex import CodexAdapter
|
|
6
|
+
from kaptyorka.adapters.cursor import CursorAdapter
|
|
7
|
+
from kaptyorka.adapters.opencode import OpenCodeAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def all_adapters(log: Callable[[str], None] = print) -> List[Adapter]:
|
|
11
|
+
"""Every known adapter, each constructed with log."""
|
|
12
|
+
return [ClaudeAdapter(log=log), OpenCodeAdapter(log=log), CodexAdapter(log=log),
|
|
13
|
+
CursorAdapter(log=log)]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""What every tool adapter provides, and the shared JSON config merge.
|
|
2
|
+
|
|
3
|
+
Session keys, one per tool, identical in shim, hook and bridge:
|
|
4
|
+
claude:<pid> project = basename of the session file's cwd
|
|
5
|
+
opencode:<session id>
|
|
6
|
+
codex:<thread id>
|
|
7
|
+
cursor:<workspace path> workspace = the --workspace argument, else the shim's cwd
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, List, Optional, Protocol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Session:
|
|
21
|
+
key: str
|
|
22
|
+
tool: str
|
|
23
|
+
project: str
|
|
24
|
+
activity: str = ""
|
|
25
|
+
label: str = "" # the session name Claude Code shows, if any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DeliveryError(Exception):
|
|
29
|
+
"""The tool's door did not accept the message."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Adapter(Protocol):
|
|
33
|
+
"""One coding tool. Constructors take log: Callable[[str], None] = print."""
|
|
34
|
+
|
|
35
|
+
tool: str
|
|
36
|
+
prefix: str # session key prefix: claude, opencode, codex, cursor
|
|
37
|
+
restart_note: Optional[str]
|
|
38
|
+
|
|
39
|
+
def installed(self) -> bool: ...
|
|
40
|
+
|
|
41
|
+
def sessions(self) -> List[Session]: ...
|
|
42
|
+
|
|
43
|
+
def deliver(self, key: str, text: str) -> None:
|
|
44
|
+
"""Push text into the session; raise DeliveryError if the door refuses."""
|
|
45
|
+
|
|
46
|
+
def configure(self, kaptyorka: str) -> List[str]:
|
|
47
|
+
"""Wire the tool to the kaptyorka executable; return change lines; idempotent."""
|
|
48
|
+
|
|
49
|
+
def unconfigure(self) -> List[str]:
|
|
50
|
+
"""Undo configure: remove only our entries; return one line per thing removed."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_atomic(path: Path, text: str) -> None:
|
|
54
|
+
"""Replace the file in one step; a new file is 0600, an existing one keeps its mode.
|
|
55
|
+
Writes go to the symlink's target."""
|
|
56
|
+
target = path.resolve()
|
|
57
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=target.name + ".") # 0600
|
|
59
|
+
try:
|
|
60
|
+
with os.fdopen(fd, "w") as f:
|
|
61
|
+
f.write(text)
|
|
62
|
+
if target.exists():
|
|
63
|
+
os.chmod(tmp, target.stat().st_mode & 0o7777)
|
|
64
|
+
os.replace(tmp, target)
|
|
65
|
+
except BaseException:
|
|
66
|
+
os.unlink(tmp)
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def merge_json(path: Path, update: Callable[[dict], bool]) -> str:
|
|
71
|
+
"""Apply update to a JSON file; "changed", "unchanged", or "broken" (left alone).
|
|
72
|
+
|
|
73
|
+
A missing file counts as {} and is created 0600. Writes go to the symlink's target,
|
|
74
|
+
keeping its mode.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(path.read_text())
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
data = {}
|
|
80
|
+
except ValueError:
|
|
81
|
+
return "broken"
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
return "broken"
|
|
84
|
+
if not update(data):
|
|
85
|
+
return "unchanged"
|
|
86
|
+
write_atomic(path, json.dumps(data, indent=2) + "\n")
|
|
87
|
+
return "changed"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_alias(home: Path, alias: str, label: str, log: Callable[[str], None]) -> Optional[str]:
|
|
91
|
+
"""Append alias to the shell startup file once; the change line, or None if already there."""
|
|
92
|
+
shell = os.path.basename(os.environ.get("SHELL", ""))
|
|
93
|
+
bash_rc = ".bash_profile" if sys.platform == "darwin" else ".bashrc" # macOS: login shells
|
|
94
|
+
rc = {"bash": bash_rc, "zsh": ".zshrc"}.get(shell)
|
|
95
|
+
if rc is None:
|
|
96
|
+
log(f"{label}: add `{alias}` to your shell startup file so kaptyorka can push")
|
|
97
|
+
return None
|
|
98
|
+
rc_path = home / rc
|
|
99
|
+
try:
|
|
100
|
+
current = rc_path.read_text()
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
current = ""
|
|
103
|
+
if alias in current.splitlines():
|
|
104
|
+
return None
|
|
105
|
+
with open(rc_path, "a") as f:
|
|
106
|
+
f.write(("" if current.endswith("\n") or not current else "\n") + alias + "\n")
|
|
107
|
+
return f"{label}: `{alias}` added to {rc_path}, so kaptyorka can push into new {label} windows"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def remove_alias(home: Path, alias: str, label: str) -> List[str]:
|
|
111
|
+
"""Delete our alias line from the shell startup files; one line per file changed."""
|
|
112
|
+
lines = []
|
|
113
|
+
for rc in (".bashrc", ".bash_profile", ".zshrc"):
|
|
114
|
+
path = home / rc
|
|
115
|
+
try:
|
|
116
|
+
text = path.read_text()
|
|
117
|
+
except OSError:
|
|
118
|
+
continue
|
|
119
|
+
kept = [line for line in text.splitlines(keepends=True) if line.rstrip("\n") != alias]
|
|
120
|
+
if len(kept) != len(text.splitlines(keepends=True)):
|
|
121
|
+
write_atomic(path, "".join(kept))
|
|
122
|
+
lines.append(f"{label}: `{alias}` removed from {path}")
|
|
123
|
+
return lines
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_our_hook(command: object, tool: str, events: tuple) -> bool:
|
|
127
|
+
"""True only for `<path>/kaptyorka hook <tool> <event>`, quoted or not (older installs)."""
|
|
128
|
+
command = str(command)
|
|
129
|
+
for event in events:
|
|
130
|
+
suffix = f" hook {tool} {event}"
|
|
131
|
+
if command.endswith(suffix):
|
|
132
|
+
exe = command[:-len(suffix)]
|
|
133
|
+
try:
|
|
134
|
+
parts = shlex.split(exe)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return False
|
|
137
|
+
exe = parts[0] if len(parts) == 1 else exe # unquoted path with spaces
|
|
138
|
+
return os.path.basename(exe) == "kaptyorka"
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def drop_hooks(hooks: dict, is_ours: Callable[[dict], bool], nested: bool = True) -> bool:
|
|
143
|
+
"""Remove our hook entries from a {event: [entry, ...]} dict, keeping foreign ones.
|
|
144
|
+
|
|
145
|
+
nested: entries hold a "hooks" list of commands (Claude Code, Codex); else an entry is the command.
|
|
146
|
+
"""
|
|
147
|
+
changed = False
|
|
148
|
+
for event in list(hooks):
|
|
149
|
+
entries = hooks[event]
|
|
150
|
+
if not isinstance(entries, list):
|
|
151
|
+
continue
|
|
152
|
+
kept, was = [], changed
|
|
153
|
+
changed = False
|
|
154
|
+
for e in entries:
|
|
155
|
+
if nested and isinstance(e, dict) and isinstance(e.get("hooks"), list):
|
|
156
|
+
rest = [h for h in e["hooks"] if not (isinstance(h, dict) and is_ours(h))]
|
|
157
|
+
if len(rest) != len(e["hooks"]):
|
|
158
|
+
changed = True
|
|
159
|
+
if rest:
|
|
160
|
+
kept.append({**e, "hooks": rest})
|
|
161
|
+
continue
|
|
162
|
+
elif not nested and isinstance(e, dict) and is_ours(e):
|
|
163
|
+
changed = True
|
|
164
|
+
continue
|
|
165
|
+
kept.append(e)
|
|
166
|
+
if changed:
|
|
167
|
+
if kept:
|
|
168
|
+
hooks[event] = kept
|
|
169
|
+
else:
|
|
170
|
+
del hooks[event]
|
|
171
|
+
changed = changed or was
|
|
172
|
+
return changed
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Claude Code: sessions from ~/.claude/sessions, push through each session's inbox socket."""
|
|
2
|
+
import shlex
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, List, Optional
|
|
10
|
+
|
|
11
|
+
from kaptyorka.adapters.base import DeliveryError, Session, drop_hooks, is_our_hook, merge_json
|
|
12
|
+
|
|
13
|
+
CLAUDE_CODE_TESTED = "2.1.318"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _newer(version: str) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
return tuple(map(int, version.split("."))) > tuple(map(int, CLAUDE_CODE_TESTED.split(".")))
|
|
19
|
+
except ValueError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClaudeAdapter:
|
|
24
|
+
tool = "claude-code"
|
|
25
|
+
prefix = "claude"
|
|
26
|
+
restart_note = "Claude Code: open sessions pick up kaptyorka after a restart"
|
|
27
|
+
|
|
28
|
+
def __init__(self, home: Optional[Path] = None, log: Callable[[str], None] = print,
|
|
29
|
+
run: Callable = subprocess.run):
|
|
30
|
+
self.home = home or Path.home()
|
|
31
|
+
self.run = run
|
|
32
|
+
self.dir = self.home / ".claude" / "sessions"
|
|
33
|
+
self.log = log
|
|
34
|
+
self._warned = False
|
|
35
|
+
|
|
36
|
+
def installed(self) -> bool:
|
|
37
|
+
return shutil.which("claude") is not None
|
|
38
|
+
|
|
39
|
+
def sessions(self) -> List[Session]:
|
|
40
|
+
out = []
|
|
41
|
+
for f in sorted(self.dir.glob("*.json")):
|
|
42
|
+
try:
|
|
43
|
+
pid = int(f.stem)
|
|
44
|
+
os.kill(pid, 0)
|
|
45
|
+
data = json.loads(f.read_text())
|
|
46
|
+
cwd = data["cwd"]
|
|
47
|
+
if not os.path.exists(data["messagingSocketPath"]):
|
|
48
|
+
continue # pid reused by another process, or a session that closed badly
|
|
49
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
50
|
+
continue # dead process, or a half-written file
|
|
51
|
+
version = str(data.get("version", ""))
|
|
52
|
+
if not self._warned and _newer(version):
|
|
53
|
+
self._warned = True
|
|
54
|
+
self.log(f"claude-code {version} is newer than tested {CLAUDE_CODE_TESTED}; "
|
|
55
|
+
"push may break, pull still works")
|
|
56
|
+
name = data.get("name")
|
|
57
|
+
label = name if isinstance(name, str) else ""
|
|
58
|
+
out.append(Session(f"claude:{pid}", self.tool, os.path.basename(cwd.rstrip("/")),
|
|
59
|
+
data.get("status", ""), label))
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
def deliver(self, key: str, text: str) -> None:
|
|
63
|
+
try:
|
|
64
|
+
pid = int(key.split(":", 1)[1])
|
|
65
|
+
sock_path = json.loads((self.dir / f"{pid}.json").read_text())["messagingSocketPath"]
|
|
66
|
+
keys = sorted(self.dir.glob(f"{pid}.*.key"), key=lambda p: p.stat().st_mtime)
|
|
67
|
+
token = json.loads(keys[-1].read_text())["peerToken"] if keys else None
|
|
68
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
69
|
+
s.settimeout(5)
|
|
70
|
+
s.connect(sock_path)
|
|
71
|
+
if token is not None:
|
|
72
|
+
s.sendall((json.dumps({"type": "auth", "token": token}) + "\n").encode())
|
|
73
|
+
message = {"type": "user", "message": {"role": "user", "content": text}}
|
|
74
|
+
s.sendall((json.dumps(message) + "\n").encode())
|
|
75
|
+
s.shutdown(socket.SHUT_WR)
|
|
76
|
+
reply = b""
|
|
77
|
+
try:
|
|
78
|
+
while chunk := s.recv(65536):
|
|
79
|
+
reply += chunk
|
|
80
|
+
except socket.timeout:
|
|
81
|
+
pass # sent; no answer is not a refusal (2.1.318 answers nothing)
|
|
82
|
+
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
|
|
83
|
+
raise DeliveryError(f"claude session {key}: {e}") from e
|
|
84
|
+
if b"error" in reply.lower():
|
|
85
|
+
answer = reply.decode(errors="replace").strip()
|
|
86
|
+
raise DeliveryError(f"claude session {key} refused: {answer}")
|
|
87
|
+
|
|
88
|
+
def configure(self, kaptyorka: str) -> List[str]:
|
|
89
|
+
lines = []
|
|
90
|
+
self.run(["claude", "mcp", "remove", "--scope", "user", "kaptyorka"],
|
|
91
|
+
capture_output=True)
|
|
92
|
+
r = self.run(["claude", "mcp", "add", "--scope", "user", "kaptyorka", "--",
|
|
93
|
+
kaptyorka, "mcp"], capture_output=True, text=True)
|
|
94
|
+
if r.returncode:
|
|
95
|
+
self.log(f"claude mcp add failed: {(r.stderr or r.stdout).strip()}")
|
|
96
|
+
else:
|
|
97
|
+
lines.append("Claude Code: kaptyorka MCP server registered (user scope)")
|
|
98
|
+
|
|
99
|
+
def add_hooks(data: dict) -> bool:
|
|
100
|
+
hooks = data.setdefault("hooks", {})
|
|
101
|
+
changed = False
|
|
102
|
+
for event, name in (("SessionStart", "start"), ("UserPromptSubmit", "prompt")):
|
|
103
|
+
command = f"{shlex.quote(kaptyorka)} hook claude-code {name}"
|
|
104
|
+
entry = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
|
|
105
|
+
entries = hooks.setdefault(event, [])
|
|
106
|
+
ours = [i for i, e in enumerate(entries) if any(
|
|
107
|
+
str(h.get("command", "")).endswith(f" hook claude-code {name}")
|
|
108
|
+
for h in e.get("hooks", []))]
|
|
109
|
+
if [entries[i] for i in ours] == [entry]:
|
|
110
|
+
continue
|
|
111
|
+
for i in reversed(ours): # an older install path: replace, never add a second
|
|
112
|
+
del entries[i]
|
|
113
|
+
entries.insert(ours[0] if ours else len(entries), entry)
|
|
114
|
+
changed = True
|
|
115
|
+
return changed
|
|
116
|
+
|
|
117
|
+
settings = self.home / ".claude" / "settings.json"
|
|
118
|
+
result = merge_json(settings, add_hooks)
|
|
119
|
+
if result == "broken":
|
|
120
|
+
self.log(f"left alone, not valid JSON: {settings}")
|
|
121
|
+
elif result == "changed":
|
|
122
|
+
lines.append(f"Claude Code: kaptyorka hooks added to {settings}")
|
|
123
|
+
return lines
|
|
124
|
+
|
|
125
|
+
def unconfigure(self) -> List[str]:
|
|
126
|
+
lines = []
|
|
127
|
+
manual = "Claude Code: run `claude mcp remove --scope user kaptyorka` to remove the MCP server"
|
|
128
|
+
if not self.installed():
|
|
129
|
+
self.log(f"claude not found; {manual}")
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
r = self.run(["claude", "mcp", "remove", "--scope", "user", "kaptyorka"],
|
|
133
|
+
capture_output=True, text=True)
|
|
134
|
+
except OSError as e: # still remove the hooks below
|
|
135
|
+
self.log(f"claude failed: {e}; {manual}")
|
|
136
|
+
else:
|
|
137
|
+
if r.returncode == 0:
|
|
138
|
+
lines.append("Claude Code: kaptyorka MCP server removed")
|
|
139
|
+
|
|
140
|
+
def drop(data: dict) -> bool:
|
|
141
|
+
hooks = data.get("hooks")
|
|
142
|
+
if not isinstance(hooks, dict):
|
|
143
|
+
return False
|
|
144
|
+
changed = drop_hooks(hooks, lambda h: is_our_hook(
|
|
145
|
+
h.get("command", ""), "claude-code", ("start", "prompt")))
|
|
146
|
+
if changed and not hooks:
|
|
147
|
+
del data["hooks"]
|
|
148
|
+
return changed
|
|
149
|
+
|
|
150
|
+
settings = self.home / ".claude" / "settings.json"
|
|
151
|
+
result = merge_json(settings, drop)
|
|
152
|
+
if result == "broken":
|
|
153
|
+
self.log(f"left alone, not valid JSON: {settings}")
|
|
154
|
+
elif result == "changed":
|
|
155
|
+
lines.append(f"Claude Code: kaptyorka hooks removed from {settings}")
|
|
156
|
+
return lines
|