nekocat 0.1.0__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.
Files changed (52) hide show
  1. nekocat-0.1.0/.gitignore +5 -0
  2. nekocat-0.1.0/PKG-INFO +102 -0
  3. nekocat-0.1.0/README.md +73 -0
  4. nekocat-0.1.0/nekocat/__init__.py +5 -0
  5. nekocat-0.1.0/nekocat/__version__.py +7 -0
  6. nekocat-0.1.0/nekocat/cli/__init__.py +9 -0
  7. nekocat-0.1.0/nekocat/cli/cmd_catch.py +132 -0
  8. nekocat-0.1.0/nekocat/cli/cmd_chat.py +85 -0
  9. nekocat-0.1.0/nekocat/cli/cmd_disable.py +48 -0
  10. nekocat-0.1.0/nekocat/cli/cmd_enable.py +23 -0
  11. nekocat-0.1.0/nekocat/cli/cmd_logs.py +86 -0
  12. nekocat-0.1.0/nekocat/cli/cmd_look.py +81 -0
  13. nekocat-0.1.0/nekocat/cli/cmd_meow.py +48 -0
  14. nekocat-0.1.0/nekocat/cli/cmd_open.py +43 -0
  15. nekocat-0.1.0/nekocat/cli/cmd_pair.py +74 -0
  16. nekocat-0.1.0/nekocat/cli/cmd_setup.py +222 -0
  17. nekocat-0.1.0/nekocat/cli/cmd_share.py +102 -0
  18. nekocat-0.1.0/nekocat/cli/cmd_shell.py +97 -0
  19. nekocat-0.1.0/nekocat/cli/cmd_status.py +55 -0
  20. nekocat-0.1.0/nekocat/cli/cmd_throw.py +102 -0
  21. nekocat-0.1.0/nekocat/cli/cmd_uwu.py +38 -0
  22. nekocat-0.1.0/nekocat/cli/main.py +73 -0
  23. nekocat-0.1.0/nekocat/core/__init__.py +1 -0
  24. nekocat-0.1.0/nekocat/core/config.py +240 -0
  25. nekocat-0.1.0/nekocat/core/identity.py +241 -0
  26. nekocat-0.1.0/nekocat/core/logger.py +193 -0
  27. nekocat-0.1.0/nekocat/core/models.py +328 -0
  28. nekocat-0.1.0/nekocat/core/peer_cache.py +225 -0
  29. nekocat-0.1.0/nekocat/daemon/__init__.py +7 -0
  30. nekocat-0.1.0/nekocat/daemon/auth.py +348 -0
  31. nekocat-0.1.0/nekocat/daemon/chat.py +189 -0
  32. nekocat-0.1.0/nekocat/daemon/crypto.py +260 -0
  33. nekocat-0.1.0/nekocat/daemon/discovery.py +294 -0
  34. nekocat-0.1.0/nekocat/daemon/lan_protocol.py +247 -0
  35. nekocat-0.1.0/nekocat/daemon/main.py +300 -0
  36. nekocat-0.1.0/nekocat/daemon/notification.py +160 -0
  37. nekocat-0.1.0/nekocat/daemon/pairing.py +217 -0
  38. nekocat-0.1.0/nekocat/daemon/router.py +490 -0
  39. nekocat-0.1.0/nekocat/daemon/shell.py +332 -0
  40. nekocat-0.1.0/nekocat/daemon/transfer.py +403 -0
  41. nekocat-0.1.0/nekocat/ipc/__init__.py +1 -0
  42. nekocat-0.1.0/nekocat/ipc/client.py +280 -0
  43. nekocat-0.1.0/nekocat/ipc/protocol.py +161 -0
  44. nekocat-0.1.0/nekocat/ipc/server.py +152 -0
  45. nekocat-0.1.0/pypi-AgEIcHlwaS5vcmcCJDJmZTE3MzAwLTdiMjYtNDM5NS1iNDNjLTVjMjU0OTE4NzA3OQACKlszLCJiMzE5YmM3Zi1mODExLTRlMGUtYmVlMy04MDg0NDQ0YTIyZDMiXQAABiA5ylHSJqkL-nGc6nxd7BarCQb1RtUD4oAwNpIzKyKQXQ.txt +0 -0
  46. nekocat-0.1.0/pyproject.toml +52 -0
  47. nekocat-0.1.0/tests/__init__.py +0 -0
  48. nekocat-0.1.0/tests/test_config.py +53 -0
  49. nekocat-0.1.0/tests/test_crypto.py +65 -0
  50. nekocat-0.1.0/tests/test_identity.py +37 -0
  51. nekocat-0.1.0/tests/test_ipc_protocol.py +50 -0
  52. nekocat-0.1.0/tests/test_models.py +64 -0
@@ -0,0 +1,5 @@
1
+ # Gitignore settings for ESPHome
2
+ # This is an example and may include too much for your use-case.
3
+ # You can modify this file to suit your needs.
4
+ /.esphome/
5
+ /secrets.yaml
nekocat-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: nekocat
3
+ Version: 0.1.0
4
+ Summary: A LAN communication platform — chat, files, shell, and more over your local network
5
+ Project-URL: Homepage, https://github.com/nekocat/nekocat
6
+ Project-URL: Issues, https://github.com/nekocat/nekocat/issues
7
+ License: MIT
8
+ Keywords: chat,communication,daemon,file-transfer,lan,network
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications
17
+ Classifier: Topic :: System :: Networking
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: aiofiles>=23.0
20
+ Requires-Dist: click>=8.1
21
+ Requires-Dist: cryptography>=41.0
22
+ Requires-Dist: plyer>=2.1
23
+ Requires-Dist: rich>=13.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
27
+ Requires-Dist: pytest>=7.4; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # NekoCat 🐱
31
+
32
+ > A LAN communication platform. Chat, share files, send notifications, open remote shells — all over your local network with a single `pip install`.
33
+
34
+ ```
35
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
36
+ XX /\_/\ XX nekocat XX
37
+ XX ( o.o ) XX a lan communication XX
38
+ XX > < XX package XX
39
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```bash
45
+ pip install nekocat
46
+ neko setup
47
+ ```
48
+
49
+ That's it. The daemon starts automatically and will restart on every boot.
50
+
51
+ ## Commands
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `neko setup` | First-time setup: generate keys, register autostart, start daemon |
56
+ | `neko look` | Discover NekoCat devices on your LAN |
57
+ | `neko throw file.txt Laptop` | Send a file (or folder) to a device |
58
+ | `neko catch` | Wait for incoming transfers |
59
+ | `neko chat` | Open LAN broadcast chat |
60
+ | `neko meow "Hello!"` | Send a desktop notification |
61
+ | `neko shell Laptop` | Open a remote terminal |
62
+ | `neko share ~/Projects` | Share a folder read-only |
63
+ | `neko pair Laptop` | Pair with a new device |
64
+ | `neko open` | Open the received-files directory |
65
+ | `neko logs` | View transfer, chat, and error history |
66
+ | `neko status` | Show daemon status |
67
+ | `neko enable` | Enable autostart on boot |
68
+ | `neko disable` | Disable autostart on boot |
69
+ | `neko uwu` | ✨ |
70
+
71
+ ## Architecture
72
+
73
+ ```
74
+ neko (CLI) ──IPC─► nekod (Daemon) ──LAN─► peers
75
+ localhost:54320 port:54322
76
+ ```
77
+
78
+ The CLI never touches the network directly. Every LAN operation is handled by `nekod`, which runs as a background service.
79
+
80
+ ## Security
81
+
82
+ - Each installation generates a unique **Node ID** and an **Ed25519 identity key pair**.
83
+ - All sessions use **X25519 ephemeral key exchange** so traffic is encrypted with a fresh key each time.
84
+ - First-time connections show a **6-digit pairing code** that must match on both devices.
85
+ - Once paired, devices are trusted automatically.
86
+
87
+ ## Configuration
88
+
89
+ | OS | Path |
90
+ |----|------|
91
+ | Windows | `%APPDATA%\NekoCat\` |
92
+ | Linux | `~/.config/nekocat/` |
93
+ | macOS | `~/Library/Application Support/NekoCat/` |
94
+
95
+ Config files:
96
+ - `config.json` — general settings
97
+ - `trusted_peers.json` — paired devices and their public keys
98
+ - `nekocat.db` — SQLite database for peer cache and all logs
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,73 @@
1
+ # NekoCat 🐱
2
+
3
+ > A LAN communication platform. Chat, share files, send notifications, open remote shells — all over your local network with a single `pip install`.
4
+
5
+ ```
6
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
7
+ XX /\_/\ XX nekocat XX
8
+ XX ( o.o ) XX a lan communication XX
9
+ XX > < XX package XX
10
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ pip install nekocat
17
+ neko setup
18
+ ```
19
+
20
+ That's it. The daemon starts automatically and will restart on every boot.
21
+
22
+ ## Commands
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | `neko setup` | First-time setup: generate keys, register autostart, start daemon |
27
+ | `neko look` | Discover NekoCat devices on your LAN |
28
+ | `neko throw file.txt Laptop` | Send a file (or folder) to a device |
29
+ | `neko catch` | Wait for incoming transfers |
30
+ | `neko chat` | Open LAN broadcast chat |
31
+ | `neko meow "Hello!"` | Send a desktop notification |
32
+ | `neko shell Laptop` | Open a remote terminal |
33
+ | `neko share ~/Projects` | Share a folder read-only |
34
+ | `neko pair Laptop` | Pair with a new device |
35
+ | `neko open` | Open the received-files directory |
36
+ | `neko logs` | View transfer, chat, and error history |
37
+ | `neko status` | Show daemon status |
38
+ | `neko enable` | Enable autostart on boot |
39
+ | `neko disable` | Disable autostart on boot |
40
+ | `neko uwu` | ✨ |
41
+
42
+ ## Architecture
43
+
44
+ ```
45
+ neko (CLI) ──IPC─► nekod (Daemon) ──LAN─► peers
46
+ localhost:54320 port:54322
47
+ ```
48
+
49
+ The CLI never touches the network directly. Every LAN operation is handled by `nekod`, which runs as a background service.
50
+
51
+ ## Security
52
+
53
+ - Each installation generates a unique **Node ID** and an **Ed25519 identity key pair**.
54
+ - All sessions use **X25519 ephemeral key exchange** so traffic is encrypted with a fresh key each time.
55
+ - First-time connections show a **6-digit pairing code** that must match on both devices.
56
+ - Once paired, devices are trusted automatically.
57
+
58
+ ## Configuration
59
+
60
+ | OS | Path |
61
+ |----|------|
62
+ | Windows | `%APPDATA%\NekoCat\` |
63
+ | Linux | `~/.config/nekocat/` |
64
+ | macOS | `~/Library/Application Support/NekoCat/` |
65
+
66
+ Config files:
67
+ - `config.json` — general settings
68
+ - `trusted_peers.json` — paired devices and their public keys
69
+ - `nekocat.db` — SQLite database for peer cache and all logs
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,5 @@
1
+ """NekoCat — a LAN communication platform."""
2
+
3
+ from nekocat.__version__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ """Version information for NekoCat."""
2
+
3
+ __version__ = "0.1.0"
4
+ __version_info__ = (0, 1, 0)
5
+
6
+ #: LAN wire-protocol version negotiated between peers.
7
+ PROTOCOL_VERSION: int = 1
@@ -0,0 +1,9 @@
1
+ """
2
+ NekoCat CLI package.
3
+
4
+ Every command imports the shared IPC client and the banner printer from here.
5
+ """
6
+
7
+ from nekocat.cli.main import BANNER
8
+
9
+ __all__ = ["BANNER"]
@@ -0,0 +1,132 @@
1
+ """
2
+ ``nekocat catch`` — wait for and accept incoming file transfers.
3
+
4
+ Listens for push events from the daemon and prompts the user to accept
5
+ or reject each incoming transfer. Displays a progress bar while receiving.
6
+
7
+ Usage
8
+ -----
9
+ nekocat catch
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.progress import (
19
+ BarColumn,
20
+ DownloadColumn,
21
+ Progress,
22
+ TransferSpeedColumn,
23
+ TimeRemainingColumn,
24
+ TextColumn,
25
+ )
26
+
27
+ from nekocat.ipc.client import IPCClient
28
+ from nekocat.ipc.protocol import CMD, IPCRequest
29
+
30
+ console = Console()
31
+
32
+
33
+ @click.command()
34
+ def catch() -> None:
35
+ """Wait for incoming files from peers."""
36
+ asyncio.run(_catch())
37
+
38
+
39
+ async def _catch() -> None:
40
+ console.print("[cyan]Waiting for incoming files… (Ctrl+C to stop)[/cyan]")
41
+
42
+ async with IPCClient() as client:
43
+ await client.send(IPCRequest(cmd=CMD.RECEIVE_START))
44
+
45
+ active_tasks: dict[str, object] = {}
46
+
47
+ with Progress(
48
+ TextColumn("[bold blue]{task.fields[filename]}"),
49
+ BarColumn(),
50
+ DownloadColumn(),
51
+ TransferSpeedColumn(),
52
+ TimeRemainingColumn(),
53
+ console=console,
54
+ ) as progress:
55
+ try:
56
+ async for push in client.stream_push():
57
+ if push.cmd == CMD.PUSH_INCOMING_TRANSFER:
58
+ d = push.data
59
+ from_name = d.get("from", "Unknown")
60
+ file_name = d.get("file_name", "?")
61
+ file_size = int(d.get("file_size", 0))
62
+ job_id = d.get("job_id", "")
63
+
64
+ console.print(
65
+ f"\n[bold]{from_name}[/bold] wants to send "
66
+ f"[cyan]{file_name}[/cyan] "
67
+ f"({_human_size(file_size)})"
68
+ )
69
+ answer = click.prompt(
70
+ "Accept? [Y/n]",
71
+ default="Y",
72
+ show_default=False,
73
+ ).strip().lower()
74
+
75
+ if answer in ("", "y", "yes"):
76
+ await client.send(
77
+ IPCRequest(
78
+ cmd=CMD.ACCEPT_TRANSFER,
79
+ payload={"job_id": job_id},
80
+ )
81
+ )
82
+ task_id = progress.add_task(
83
+ "Receiving…",
84
+ total=file_size,
85
+ filename=file_name,
86
+ )
87
+ active_tasks[job_id] = task_id
88
+ else:
89
+ await client.send(
90
+ IPCRequest(
91
+ cmd=CMD.REJECT_TRANSFER,
92
+ payload={"job_id": job_id},
93
+ )
94
+ )
95
+ console.print("[yellow]Rejected.[/yellow]")
96
+
97
+ elif push.cmd == CMD.PUSH_TRANSFER_PROGRESS:
98
+ d = push.data
99
+ job_id = d.get("job_id", "")
100
+ task_id = active_tasks.get(job_id)
101
+ if task_id is not None:
102
+ progress.update(
103
+ task_id, # type: ignore[arg-type]
104
+ completed=d.get("transferred_bytes", 0),
105
+ )
106
+
107
+ elif push.cmd == CMD.PUSH_TRANSFER_COMPLETE:
108
+ d = push.data
109
+ job_id = d.get("job_id", "")
110
+ task_id = active_tasks.pop(job_id, None)
111
+ if task_id is not None:
112
+ progress.update(task_id, completed=progress.tasks[0].total) # type: ignore
113
+ console.print(
114
+ f"[green]✓ Saved to:[/green] {d.get('save_path', '(unknown)')}"
115
+ )
116
+
117
+ elif push.cmd == CMD.PUSH_TRANSFER_ERROR:
118
+ d = push.data
119
+ job_id = d.get("job_id", "")
120
+ active_tasks.pop(job_id, None)
121
+ console.print(f"[red]✗ Transfer error:[/red] {d.get('error')}")
122
+
123
+ except (KeyboardInterrupt, asyncio.CancelledError):
124
+ pass
125
+
126
+
127
+ def _human_size(n: int) -> str:
128
+ for unit in ("B", "KB", "MB", "GB"):
129
+ if n < 1024:
130
+ return f"{n:.1f} {unit}"
131
+ n //= 1024
132
+ return f"{n:.1f} TB"
@@ -0,0 +1,85 @@
1
+ """
2
+ ``nekocat chat`` — interactive LAN group chat.
3
+
4
+ Opens a live chat session. Messages from other peers arrive in real time
5
+ via daemon push events. Ctrl+C exits.
6
+
7
+ Usage
8
+ -----
9
+ nekocat chat
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import datetime
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.markup import escape
20
+
21
+ from nekocat.ipc.client import IPCClient
22
+ from nekocat.ipc.protocol import CMD, IPCRequest
23
+
24
+ console = Console()
25
+
26
+
27
+ @click.command()
28
+ @click.option("--history", "show_history", is_flag=True, default=True,
29
+ help="Show recent chat history on startup.")
30
+ def chat(show_history: bool) -> None:
31
+ """Open an interactive LAN chat session."""
32
+ asyncio.run(_chat(show_history))
33
+
34
+
35
+ async def _chat(show_history: bool) -> None:
36
+ async with IPCClient() as client:
37
+ if show_history:
38
+ resp = await client.send(IPCRequest(cmd=CMD.CHAT_HISTORY, payload={"limit": 50}))
39
+ if resp.status == "ok":
40
+ msgs = resp.data.get("messages", [])
41
+ if msgs:
42
+ console.print("[dim]── recent history ──[/dim]")
43
+ for m in msgs:
44
+ _print_message(m)
45
+ console.print("[dim]────────────────────[/dim]")
46
+
47
+ console.print("[cyan]LAN chat — type and press Enter. Ctrl+C to exit.[/cyan]\n")
48
+
49
+ # Register as subscriber for push events
50
+ await client.send(IPCRequest(cmd=CMD.CHAT_SEND, payload={"content": ""}))
51
+
52
+ # Run input and receive loops concurrently
53
+ input_task = asyncio.create_task(_input_loop(client))
54
+ recv_task = asyncio.create_task(_recv_loop(client))
55
+
56
+ try:
57
+ await asyncio.gather(input_task, recv_task)
58
+ except (KeyboardInterrupt, asyncio.CancelledError):
59
+ input_task.cancel()
60
+ recv_task.cancel()
61
+
62
+
63
+ async def _input_loop(client: IPCClient) -> None:
64
+ """Read lines from stdin and send them as chat messages."""
65
+ loop = asyncio.get_running_loop()
66
+ while True:
67
+ line = await loop.run_in_executor(None, input)
68
+ line = line.strip()
69
+ if not line:
70
+ continue
71
+ await client.send(IPCRequest(cmd=CMD.CHAT_SEND, payload={"content": line}))
72
+
73
+
74
+ async def _recv_loop(client: IPCClient) -> None:
75
+ """Listen for push events and print incoming chat messages."""
76
+ async for push in client.stream_push():
77
+ if push.cmd == CMD.PUSH_CHAT_MESSAGE:
78
+ _print_message(push.data)
79
+
80
+
81
+ def _print_message(m: dict) -> None:
82
+ ts = datetime.datetime.fromtimestamp(m.get("timestamp", 0)).strftime("%H:%M")
83
+ user = escape(f"{m.get('from_username', '?')}@{m.get('from_hostname', '?')}")
84
+ content = escape(m.get("content", ""))
85
+ console.print(f"[dim]{ts}[/dim] [bold cyan]{user}[/bold cyan]: {content}")
@@ -0,0 +1,48 @@
1
+ """
2
+ ``nekocat disable`` — disable daemon autostart on boot.
3
+
4
+ Usage
5
+ -----
6
+ nekocat disable
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import subprocess
13
+ import sys
14
+
15
+ import click
16
+ from rich.console import Console
17
+
18
+ console = Console()
19
+
20
+
21
+ @click.command()
22
+ def disable() -> None:
23
+ """Disable the daemon autostart on boot."""
24
+ system = platform.system()
25
+ try:
26
+ if system == "Windows":
27
+ subprocess.run(
28
+ "schtasks /Delete /F /TN \"NekoCat Daemon\"",
29
+ shell=True, check=True, capture_output=True,
30
+ )
31
+ elif system == "Linux":
32
+ subprocess.run(["systemctl", "--user", "disable", "nekod"], check=False)
33
+ from pathlib import Path
34
+ svc = Path.home() / ".config" / "systemd" / "user" / "nekod.service"
35
+ if svc.exists():
36
+ svc.unlink()
37
+ elif system == "Darwin":
38
+ from pathlib import Path
39
+ plist = Path.home() / "Library" / "LaunchAgents" / "com.nekocat.daemon.plist"
40
+ if plist.exists():
41
+ subprocess.run(["launchctl", "unload", str(plist)], check=False)
42
+ plist.unlink()
43
+ else:
44
+ console.print(f"[yellow]Autostart not supported on {system}.[/yellow]")
45
+ return
46
+ console.print("[green]✓[/green] Autostart disabled.")
47
+ except Exception as exc:
48
+ console.print(f"[red]Error disabling autostart:[/red] {exc}")
@@ -0,0 +1,23 @@
1
+ """
2
+ ``nekocat enable`` — enable daemon autostart on boot.
3
+
4
+ Usage
5
+ -----
6
+ nekocat enable
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import click
12
+ from rich.console import Console
13
+
14
+ from nekocat.cli.cmd_setup import _register_autostart
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.command()
20
+ def enable() -> None:
21
+ """Enable the daemon to start automatically on boot."""
22
+ _register_autostart()
23
+ console.print("[green]✓[/green] Autostart enabled.")
@@ -0,0 +1,86 @@
1
+ """
2
+ ``nekocat logs`` — view transfer, chat, and error history.
3
+
4
+ Usage
5
+ -----
6
+ nekocat logs
7
+ nekocat logs --limit 20
8
+ nekocat logs --type transfer
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import datetime
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from nekocat.ipc.client import IPCClient
21
+ from nekocat.ipc.protocol import CMD, IPCRequest
22
+
23
+ console = Console()
24
+
25
+
26
+ @click.command()
27
+ @click.option("--limit", default=50, help="Number of log entries to show (default 50).")
28
+ @click.option(
29
+ "--type", "event_type", default=None,
30
+ help="Filter by type: transfer, chat, notification, shell_session, pair, error.",
31
+ )
32
+ def logs(limit: int, event_type: str | None) -> None:
33
+ """View activity history (transfers, chat, notifications, errors)."""
34
+ asyncio.run(_logs(limit, event_type))
35
+
36
+
37
+ async def _logs(limit: int, event_type: str | None) -> None:
38
+ payload: dict = {"limit": limit}
39
+ if event_type:
40
+ payload["event_type"] = event_type
41
+
42
+ async with IPCClient() as client:
43
+ resp = await client.send(IPCRequest(cmd=CMD.LOGS_QUERY, payload=payload))
44
+
45
+ if resp.status == "error":
46
+ console.print(f"[red]Error:[/red] {resp.error}")
47
+ return
48
+
49
+ data = resp.data
50
+ entries = data.get("entries", [])
51
+
52
+ if not entries:
53
+ console.print("[yellow]No log entries found.[/yellow]")
54
+ return
55
+
56
+ table = Table(show_header=True, header_style="bold cyan")
57
+ table.add_column("Time", style="dim")
58
+ table.add_column("Type")
59
+ table.add_column("Peer")
60
+ table.add_column("Summary")
61
+
62
+ for e in entries:
63
+ ts = e.get("timestamp", 0)
64
+ time_str = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
65
+ etype = e.get("event_type", "?")
66
+ peer = e.get("display_name") or e.get("node_id", "") or ""
67
+ summary = e.get("summary", "")
68
+
69
+ type_colors = {
70
+ "transfer": "blue",
71
+ "chat": "cyan",
72
+ "notification": "magenta",
73
+ "shell_session": "green",
74
+ "pair": "yellow",
75
+ "error": "red",
76
+ }
77
+ color = type_colors.get(etype, "white")
78
+
79
+ table.add_row(
80
+ time_str,
81
+ f"[{color}]{etype}[/{color}]",
82
+ f"[dim]{peer}[/dim]",
83
+ summary,
84
+ )
85
+
86
+ console.print(table)
@@ -0,0 +1,81 @@
1
+ """
2
+ ``nekocat look`` — discover and list peers on the LAN.
3
+
4
+ Shows online peers discovered by the daemon and, if --all is passed,
5
+ also shows cached offline peers with their last-seen timestamp.
6
+
7
+ Usage
8
+ -----
9
+ nekocat look
10
+ nekocat look --all
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from nekocat.ipc.client import IPCClient
22
+ from nekocat.ipc.protocol import CMD, IPCRequest
23
+
24
+ console = Console()
25
+
26
+
27
+ @click.command()
28
+ @click.option("--all", "show_all", is_flag=True, help="Include offline cached peers.")
29
+ def look(show_all: bool) -> None:
30
+ """List peers visible on the LAN."""
31
+ asyncio.run(_look(show_all))
32
+
33
+
34
+ async def _look(show_all: bool) -> None:
35
+ async with IPCClient() as client:
36
+ resp = await client.send(IPCRequest(cmd=CMD.LOOK, payload={"all": show_all}))
37
+
38
+ if resp.status == "error":
39
+ console.print(f"[red]Error:[/red] {resp.error}")
40
+ return
41
+
42
+ peers: list[dict] = resp.data.get("peers", [])
43
+
44
+ if not peers:
45
+ console.print("[yellow]No peers found on the LAN.[/yellow]")
46
+ return
47
+
48
+ table = Table(title="LAN Peers", show_header=True, header_style="bold cyan")
49
+ table.add_column("Name", style="bold")
50
+ table.add_column("Hostname")
51
+ table.add_column("IP Address")
52
+ table.add_column("Status")
53
+ table.add_column("Trusted", justify="center")
54
+ table.add_column("Last Seen")
55
+
56
+ for p in peers:
57
+ status = p.get("status", "unknown")
58
+ status_style = "green" if status == "online" else "dim"
59
+ trusted = "✓" if p.get("trusted") else ""
60
+ name = f"{p.get('username', '?')}@{p.get('hostname', '?')}"
61
+
62
+ import time
63
+ last_seen = p.get("last_seen", 0.0)
64
+ delta = time.time() - last_seen
65
+ if delta < 60:
66
+ seen = f"{int(delta)}s ago"
67
+ elif delta < 3600:
68
+ seen = f"{int(delta/60)}m ago"
69
+ else:
70
+ seen = f"{int(delta/3600)}h ago"
71
+
72
+ table.add_row(
73
+ name,
74
+ p.get("hostname", "?"),
75
+ p.get("ip", "?"),
76
+ f"[{status_style}]{status}[/{status_style}]",
77
+ f"[green]{trusted}[/green]",
78
+ seen,
79
+ )
80
+
81
+ console.print(table)