ai-comm 0.2.4__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.
ai_comm/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """ai-comm CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ai_comm import __version__
10
+ from ai_comm.commands import response, send, window
11
+
12
+
13
+ def _version_callback(value: bool) -> None:
14
+ if value:
15
+ typer.echo(f"ai-comm {__version__}")
16
+ raise typer.Exit()
17
+
18
+
19
+ HELP_TEXT = """\
20
+ Cross-AI CLI communication tool for Kitty terminal.
21
+
22
+ Enables AI assistants (Claude, Codex, Gemini, Aider, Cursor, OpenCode)
23
+ running in separate Kitty windows to communicate with each other.
24
+
25
+ IMPORTANT: Only use parameters documented here. Hidden/internal
26
+ parameters exist for debugging only and must never be used.
27
+
28
+ Workflow:
29
+ 1. ai-comm list-ai-windows Find available AI windows
30
+ 2. ai-comm send <MESSAGE> -w <ID> Send message and get response
31
+
32
+ Protocol:
33
+ - Request-response only. Receiver must NOT send new messages back
34
+ immediately after responding—this causes deadlock.
35
+ - If you need the sender to act, include the request in your response.
36
+
37
+ Notes:
38
+ - AI assistants MUST use ONLY the two Workflow commands without
39
+ any additional parameters. Other parameters are forbidden for AI use.
40
+ - For long responses, ask AI to write to a file in project directory
41
+ (/tmp requires manual approval on target AI — avoid).
42
+ - Blocking is intentional: AI cannot perceive time, only acts on user
43
+ input. Non-blocking = lost messages. Timed-out commands auto-move
44
+ to background; results return when ready.
45
+ """
46
+
47
+ app = typer.Typer(
48
+ name="ai-comm",
49
+ help=HELP_TEXT,
50
+ no_args_is_help=True,
51
+ add_completion=False,
52
+ )
53
+
54
+
55
+ @app.callback()
56
+ def _app_callback(
57
+ version: Annotated[
58
+ bool,
59
+ typer.Option("--version", "-V", callback=_version_callback, is_eager=True),
60
+ ] = False,
61
+ ) -> None:
62
+ pass
63
+
64
+
65
+ WORKFLOW_PANEL = "Workflow Commands (for AI agents)"
66
+ DEBUG_CMD_PANEL = "Debug Commands (HUMAN ONLY - AI agents must not use)"
67
+
68
+ # Workflow commands - for AI agents
69
+ app.command(name="send", help=send.SEND_HELP, rich_help_panel=WORKFLOW_PANEL)(send.send)
70
+ app.command(
71
+ name="list-ai-windows", help=window.LIST_HELP, rich_help_panel=WORKFLOW_PANEL
72
+ )(window.list_ai_windows)
73
+
74
+ # Debug commands - for human use only
75
+ app.command(
76
+ name="get-response",
77
+ help=response.GET_RESPONSE_HELP,
78
+ rich_help_panel=DEBUG_CMD_PANEL,
79
+ )(response.get_response)
80
+ app.command(
81
+ name="wait-idle", help=response.WAIT_IDLE_HELP, rich_help_panel=DEBUG_CMD_PANEL
82
+ )(response.wait_idle)
83
+ app.command(
84
+ name="get-text", help=response.GET_TEXT_HELP, rich_help_panel=DEBUG_CMD_PANEL
85
+ )(response.get_text)
86
+
87
+
88
+ def main() -> None:
89
+ """Main entry point."""
90
+ app()
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
@@ -0,0 +1,11 @@
1
+ """CLI commands for ai-comm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+
8
+ def handle_error(error: Exception, exit_code: int = 1) -> None:
9
+ """Handle error with consistent formatting."""
10
+ typer.echo(f"Error: {error}", err=True)
11
+ raise typer.Exit(exit_code) from None
@@ -0,0 +1,122 @@
1
+ """Response and idle waiting commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ai_comm.commands import handle_error
11
+ from ai_comm.kitten_client import KittenClient, KittenError
12
+ from ai_comm.polling import PollingTimeoutError
13
+ from ai_comm.services import InteractionService
14
+
15
+
16
+ def wait_idle(
17
+ window: Annotated[int, typer.Option("--window", "-w", help="Target window ID")],
18
+ timeout: Annotated[
19
+ int, typer.Option("--timeout", "-t", help="Timeout in seconds")
20
+ ] = 1800,
21
+ idle_time: Annotated[
22
+ int, typer.Option("--idle-time", help="Idle time in seconds")
23
+ ] = 3,
24
+ ) -> None:
25
+ """Wait for window content to stabilize."""
26
+ try:
27
+ client = KittenClient()
28
+ service = InteractionService(client)
29
+
30
+ elapsed = service.wait_for_response(window, idle_time, float(timeout))
31
+ typer.echo(f"Window {window} idle after {elapsed:.1f}s")
32
+
33
+ except PollingTimeoutError as e:
34
+ typer.echo(f"Timeout: {e}", err=True)
35
+ raise typer.Exit(1) from None
36
+ except KittenError as e:
37
+ handle_error(e)
38
+
39
+
40
+ def get_text(
41
+ window: Annotated[int, typer.Option("--window", "-w", help="Target window ID")],
42
+ extent: Annotated[str, typer.Option("--extent", help="screen or all")] = "all",
43
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
44
+ ) -> None:
45
+ """Get raw text content from window."""
46
+ try:
47
+ client = KittenClient()
48
+ text = client.get_text(window, extent)
49
+ except KittenError as e:
50
+ handle_error(e)
51
+ return
52
+
53
+ if as_json:
54
+ typer.echo(json.dumps({"text": text}))
55
+ else:
56
+ typer.echo(text)
57
+
58
+
59
+ HUMAN_ONLY_WARNING = """\
60
+ Debug command for human troubleshooting.
61
+ AI agents: run 'ai-comm --help' for usage instructions.
62
+
63
+ """
64
+
65
+ WAIT_IDLE_HELP = (
66
+ HUMAN_ONLY_WARNING
67
+ + """\
68
+ Wait for window content to stabilize.
69
+ """
70
+ )
71
+
72
+ GET_TEXT_HELP = (
73
+ HUMAN_ONLY_WARNING
74
+ + """\
75
+ Get raw text content from window.
76
+ """
77
+ )
78
+
79
+ GET_RESPONSE_HELP = (
80
+ HUMAN_ONLY_WARNING
81
+ + """\
82
+ Get parsed response from an AI window.
83
+
84
+ Examples:
85
+ ai-comm get-response -w 5
86
+ ai-comm get-response -w 8 --json
87
+ """
88
+ )
89
+
90
+
91
+ def get_response(
92
+ window: Annotated[
93
+ int,
94
+ typer.Option("--window", "-w", help="Target window ID (from list-ai-windows)"),
95
+ ],
96
+ parser: Annotated[
97
+ str,
98
+ typer.Option("--parser", "-p", help="Response parser (auto-detected)"),
99
+ ] = "auto",
100
+ extent: Annotated[str, typer.Option("--extent", help="screen or all")] = "all",
101
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
102
+ ) -> None:
103
+ """Get parsed response from window."""
104
+ try:
105
+ client = KittenClient()
106
+ service = InteractionService(client)
107
+
108
+ response, effective_parser = service.get_response(
109
+ window, parser=parser, extent=extent
110
+ )
111
+
112
+ if not response:
113
+ typer.echo("No content", err=True)
114
+ raise typer.Exit(1)
115
+
116
+ if as_json:
117
+ typer.echo(json.dumps({"response": response, "parser": effective_parser}))
118
+ else:
119
+ typer.echo(response)
120
+
121
+ except KittenError as e:
122
+ handle_error(e)
@@ -0,0 +1,120 @@
1
+ """Send message commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ai_comm.commands import handle_error
11
+ from ai_comm.kitten_client import KittenClient, KittenError
12
+ from ai_comm.polling import PollingTimeoutError
13
+ from ai_comm.services import InteractionService
14
+
15
+ SEND_HELP = """\
16
+ Send message to AI window and wait for response.
17
+
18
+ The message is automatically wrapped with sender metadata. For Aider, /ask is
19
+ prepended to prevent automatic file edits.
20
+
21
+ Notes:
22
+ - For long responses, ask AI to write to a file in the project directory
23
+ (/tmp and other external paths require manual approval on target AI — avoid).
24
+ - Timed-out commands auto-move to background.
25
+
26
+ Examples:
27
+ ai-comm send "review this function" -w 5
28
+ ai-comm send "write to out_$(date +%Y%m%d_%H%M%S).md" -w 8
29
+ """
30
+
31
+
32
+ DEBUG_PANEL = "Debug Options (HUMAN ONLY - AI agents must not use)"
33
+
34
+
35
+ def send(
36
+ message: Annotated[str, typer.Argument(help="Message to send to the AI")],
37
+ window: Annotated[
38
+ int,
39
+ typer.Option("--window", "-w", help="Target window ID (from list-ai-windows)"),
40
+ ],
41
+ timeout: Annotated[
42
+ int,
43
+ typer.Option(
44
+ "--timeout", "-t", help="Timeout in seconds", rich_help_panel=DEBUG_PANEL
45
+ ),
46
+ ] = 1800,
47
+ idle_time: Annotated[
48
+ int,
49
+ typer.Option(
50
+ "--idle-time", help="Idle time in seconds", rich_help_panel=DEBUG_PANEL
51
+ ),
52
+ ] = 3,
53
+ parser: Annotated[
54
+ str,
55
+ typer.Option(
56
+ "--parser",
57
+ "-p",
58
+ help="Response parser (auto, claude, codex, gemini, aider...)",
59
+ rich_help_panel=DEBUG_PANEL,
60
+ ),
61
+ ] = "auto",
62
+ raw: Annotated[
63
+ bool,
64
+ typer.Option(
65
+ "--raw",
66
+ help="Return raw terminal text instead of parsed response",
67
+ rich_help_panel=DEBUG_PANEL,
68
+ ),
69
+ ] = False,
70
+ no_wait: Annotated[
71
+ bool,
72
+ typer.Option(
73
+ "--no-wait",
74
+ help="Send without waiting for response",
75
+ rich_help_panel=DEBUG_PANEL,
76
+ ),
77
+ ] = False,
78
+ as_json: Annotated[
79
+ bool,
80
+ typer.Option("--json", help="Output as JSON", rich_help_panel=DEBUG_PANEL),
81
+ ] = False,
82
+ ) -> None:
83
+ """Send message to AI window and wait for response."""
84
+ try:
85
+ client = KittenClient()
86
+ service = InteractionService(client)
87
+
88
+ service.send_message(window, message)
89
+
90
+ if no_wait:
91
+ if as_json:
92
+ typer.echo(json.dumps({"status": "sent", "window": window}))
93
+ else:
94
+ typer.echo(f"Message sent to window {window}")
95
+ return
96
+
97
+ elapsed = service.wait_for_response(window, idle_time, float(timeout))
98
+
99
+ response, effective_parser = service.get_response(
100
+ window, parser=parser, raw=raw
101
+ )
102
+
103
+ if as_json:
104
+ typer.echo(
105
+ json.dumps(
106
+ {
107
+ "response": response,
108
+ "elapsed": elapsed,
109
+ "parser": effective_parser if not raw else None,
110
+ }
111
+ )
112
+ )
113
+ else:
114
+ typer.echo(response)
115
+
116
+ except PollingTimeoutError as e:
117
+ typer.echo(f"Timeout: {e}", err=True)
118
+ raise typer.Exit(1) from None
119
+ except KittenError as e:
120
+ handle_error(e)
@@ -0,0 +1,42 @@
1
+ """Window listing commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ai_comm.kitten_client import KittenClient, KittenError
11
+
12
+ LIST_HELP = """\
13
+ List Kitty windows running AI CLIs.
14
+
15
+ Output columns: ID (window ID for -w option), CLI (detected AI type), CWD.
16
+
17
+ Examples:
18
+ ai-comm list-ai-windows
19
+ ai-comm list-ai-windows --json
20
+ """
21
+
22
+
23
+ def list_ai_windows(
24
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
25
+ ) -> None:
26
+ """List windows running AI CLIs."""
27
+ try:
28
+ client = KittenClient()
29
+ ai_windows = client.list_ai_windows()
30
+ except KittenError as e:
31
+ typer.echo(f"Error: {e}", err=True)
32
+ raise typer.Exit(1) from None
33
+
34
+ if as_json:
35
+ typer.echo(json.dumps(ai_windows, indent=2))
36
+ else:
37
+ if not ai_windows:
38
+ typer.echo("No AI CLI windows found")
39
+ return
40
+ typer.echo(f"{'ID':>4} {'CLI':10s} CWD")
41
+ for w in ai_windows:
42
+ typer.echo(f"{w['id']:4d} {w['cli']:10s} {w.get('cwd', '')}")
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simplified ai-comm kitten providing atomic operations via Boss API.
4
+
5
+ This kitten is called by the ai-comm CLI tool. It provides low-level
6
+ operations that require access to kitty's Boss API.
7
+
8
+ Commands:
9
+ get-text --window ID [--extent screen|all]
10
+ send-text --window ID TEXT
11
+ send-key --window ID KEY
12
+ check-idle --window ID --last-hash HASH
13
+ list-ai-windows
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import hashlib
20
+ import json
21
+ import subprocess
22
+ from pathlib import PurePath
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from kittens.tui.handler import result_handler # type: ignore[import-not-found]
26
+
27
+ if TYPE_CHECKING:
28
+ from kitty.boss import Boss # type: ignore[import-not-found]
29
+ from kitty.window import Window # type: ignore[import-not-found]
30
+
31
+ # Known AI CLI names and their canonical form
32
+ AI_CLI_NAMES: dict[str, str] = {
33
+ "claude": "claude",
34
+ "codex": "codex",
35
+ "gemini": "gemini",
36
+ "aider": "aider",
37
+ "cursor": "cursor",
38
+ "cursor-cli": "cursor",
39
+ "cursor-agent": "cursor",
40
+ "opencode": "opencode",
41
+ }
42
+
43
+ # Keywords that indicate non-CLI processes (status lines, themes, etc.)
44
+ EXCLUDE_KEYWORDS: list[str] = ["powerline", "statusline", "prompt", "theme"]
45
+
46
+
47
+ def parse_args(args: list[str]) -> argparse.Namespace:
48
+ """Parse command line arguments."""
49
+ parser = argparse.ArgumentParser(prog="ai_comm_kitten")
50
+ subparsers = parser.add_subparsers(dest="command", required=True)
51
+
52
+ # get-text
53
+ get_text = subparsers.add_parser("get-text")
54
+ get_text.add_argument("--window", "-w", type=int, required=True)
55
+ get_text.add_argument("--extent", default="all", choices=["screen", "all"])
56
+
57
+ # send-text
58
+ send_text = subparsers.add_parser("send-text")
59
+ send_text.add_argument("--window", "-w", type=int, required=True)
60
+ send_text.add_argument("text", nargs="+")
61
+
62
+ # send-key
63
+ send_key = subparsers.add_parser("send-key")
64
+ send_key.add_argument("--window", "-w", type=int, required=True)
65
+ send_key.add_argument("key")
66
+
67
+ # check-idle
68
+ check_idle = subparsers.add_parser("check-idle")
69
+ check_idle.add_argument("--window", "-w", type=int, required=True)
70
+ check_idle.add_argument("--last-hash", default="")
71
+
72
+ # list-ai-windows
73
+ subparsers.add_parser("list-ai-windows")
74
+
75
+ return parser.parse_args(args)
76
+
77
+
78
+ def compute_hash(text: str) -> str:
79
+ """Compute short hash of text content."""
80
+ return hashlib.md5(text.encode()).hexdigest()[:16]
81
+
82
+
83
+ def get_window(boss: Boss, window_id: int) -> Window | None:
84
+ """Get window by ID."""
85
+ return boss.window_id_map.get(window_id)
86
+
87
+
88
+ def get_text(boss: Boss, window_id: int, extent: str) -> dict[str, Any]:
89
+ """Get text content from window."""
90
+ window = get_window(boss, window_id)
91
+ if window is None:
92
+ return {"status": "error", "message": f"Window {window_id} not found"}
93
+
94
+ result = boss.call_remote_control(
95
+ window, ("get-text", "-m", f"id:{window_id}", "--extent", extent)
96
+ )
97
+ text = result if isinstance(result, str) else ""
98
+ return {"status": "ok", "text": text, "hash": compute_hash(text)}
99
+
100
+
101
+ def send_text(boss: Boss, window_id: int, text: str) -> dict[str, Any]:
102
+ """Send text to window using multiple methods."""
103
+ window = get_window(boss, window_id)
104
+ if window is None:
105
+ return {"status": "error", "message": f"Window {window_id} not found"}
106
+
107
+ # Try paste_text first (fastest)
108
+ paste = getattr(window, "paste_text", None)
109
+ if callable(paste):
110
+ paste(text)
111
+ return {"status": "ok", "method": "paste"}
112
+
113
+ # Try write_to_child
114
+ writer = getattr(window, "write_to_child", None)
115
+ if callable(writer):
116
+ try:
117
+ writer(text.encode())
118
+ except TypeError:
119
+ writer(text)
120
+ return {"status": "ok", "method": "write_to_child"}
121
+
122
+ # Fallback to subprocess
123
+ socket = getattr(boss, "listening_on", "") or ""
124
+ cmd = ["kitty", "@"]
125
+ if socket:
126
+ cmd.extend(["--to", socket])
127
+ cmd.extend(["send-text", "-m", f"id:{window_id}", "--", text])
128
+ subprocess.run(cmd, check=False, capture_output=True)
129
+ return {"status": "ok", "method": "subprocess"}
130
+
131
+
132
+ def send_key(boss: Boss, window_id: int, key: str) -> dict[str, Any]:
133
+ """Send key press to window."""
134
+ window = get_window(boss, window_id)
135
+ if window is None:
136
+ return {"status": "error", "message": f"Window {window_id} not found"}
137
+
138
+ socket = getattr(boss, "listening_on", "") or ""
139
+ cmd = ["kitty", "@"]
140
+ if socket:
141
+ cmd.extend(["--to", socket])
142
+ cmd.extend(["send-key", "-m", f"id:{window_id}", key])
143
+ subprocess.run(cmd, check=False, capture_output=True)
144
+ return {"status": "ok"}
145
+
146
+
147
+ def check_idle(boss: Boss, window_id: int, last_hash: str) -> dict[str, Any]:
148
+ """Check if window content has changed."""
149
+ result = get_text(boss, window_id, "all")
150
+ if result["status"] != "ok":
151
+ return result
152
+
153
+ current_hash = result["hash"]
154
+ is_idle = current_hash == last_hash if last_hash else False
155
+ return {"status": "ok", "idle": is_idle, "hash": current_hash}
156
+
157
+
158
+ def detect_ai_cli(cmdline_args: list[str]) -> str | None:
159
+ """Detect AI CLI from cmdline arguments (for parser selection only)."""
160
+ # Check exclusions first
161
+ for arg in cmdline_args:
162
+ arg_lower = arg.lower()
163
+ for exclude in EXCLUDE_KEYWORDS:
164
+ if exclude in arg_lower:
165
+ return None
166
+
167
+ for arg in cmdline_args:
168
+ # Check basename (without extension)
169
+ path = PurePath(arg)
170
+ name = path.stem.lower()
171
+ if name in AI_CLI_NAMES:
172
+ return AI_CLI_NAMES[name]
173
+
174
+ # Check path components (for wrappers like /path/to/cursor-cli/index.js)
175
+ for part in path.parts:
176
+ part_name = PurePath(part).stem.lower()
177
+ if part_name in AI_CLI_NAMES:
178
+ return AI_CLI_NAMES[part_name]
179
+
180
+ return None
181
+
182
+
183
+ def list_ai_windows(boss: Boss) -> dict[str, Any]:
184
+ """List windows running AI CLIs."""
185
+ ai_windows: list[dict[str, Any]] = []
186
+
187
+ for os_window in boss.os_window_map.values():
188
+ for tab in os_window.tabs:
189
+ for window in tab.windows:
190
+ child = getattr(window, "child", None)
191
+ if not child:
192
+ continue
193
+
194
+ fg_processes = getattr(child, "foreground_processes", None)
195
+ if callable(fg_processes):
196
+ fg_processes = fg_processes()
197
+ if not fg_processes:
198
+ continue
199
+
200
+ detected_cli: str | None = None
201
+ detected_cwd: str = ""
202
+ for proc in fg_processes:
203
+ cmdline = proc.get("cmdline", [])
204
+ cli = detect_ai_cli(cmdline)
205
+ if cli:
206
+ detected_cli = cli
207
+ detected_cwd = proc.get("cwd", "")
208
+ break
209
+
210
+ if detected_cli:
211
+ ai_windows.append(
212
+ {"id": window.id, "cli": detected_cli, "cwd": detected_cwd}
213
+ )
214
+
215
+ return {"status": "ok", "ai_windows": ai_windows}
216
+
217
+
218
+ def main(args: list[str]) -> str:
219
+ """Main entry point - not used for no_ui kitten."""
220
+ return ""
221
+
222
+
223
+ @result_handler(no_ui=True)
224
+ def handle_result(
225
+ args: list[str], answer: str, target_window_id: int, boss: Boss
226
+ ) -> str:
227
+ """Handle kitten result, runs in kitty process."""
228
+ try:
229
+ parsed = parse_args(args[1:])
230
+ except SystemExit:
231
+ return json.dumps({"status": "error", "message": "Invalid arguments"})
232
+
233
+ result: dict[str, Any]
234
+
235
+ if parsed.command == "get-text":
236
+ result = get_text(boss, parsed.window, parsed.extent)
237
+
238
+ elif parsed.command == "send-text":
239
+ text = " ".join(parsed.text)
240
+ result = send_text(boss, parsed.window, text)
241
+
242
+ elif parsed.command == "send-key":
243
+ result = send_key(boss, parsed.window, parsed.key)
244
+
245
+ elif parsed.command == "check-idle":
246
+ result = check_idle(boss, parsed.window, parsed.last_hash)
247
+
248
+ elif parsed.command == "list-ai-windows":
249
+ result = list_ai_windows(boss)
250
+
251
+ else:
252
+ result = {"status": "error", "message": f"Unknown command: {parsed.command}"}
253
+
254
+ return json.dumps(result, ensure_ascii=False)