automud 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.
- automud-0.1.0/LICENSE +21 -0
- automud-0.1.0/PKG-INFO +80 -0
- automud-0.1.0/README.md +60 -0
- automud-0.1.0/automud.egg-info/PKG-INFO +80 -0
- automud-0.1.0/automud.egg-info/SOURCES.txt +9 -0
- automud-0.1.0/automud.egg-info/dependency_links.txt +1 -0
- automud-0.1.0/automud.egg-info/entry_points.txt +2 -0
- automud-0.1.0/automud.egg-info/top_level.txt +1 -0
- automud-0.1.0/automud.py +605 -0
- automud-0.1.0/pyproject.toml +31 -0
- automud-0.1.0/setup.cfg +4 -0
automud-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charles Norton
|
|
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.
|
automud-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: automud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key.
|
|
5
|
+
Author: Charles Norton
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/CharlesCNorton/automud
|
|
8
|
+
Project-URL: Source, https://github.com/CharlesCNorton/automud
|
|
9
|
+
Keywords: mud,telnet,gmcp,agent,cli
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Games/Entertainment
|
|
15
|
+
Classifier: Topic :: Communications
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# AutoMUD
|
|
22
|
+
|
|
23
|
+
A persistent telnet/MUD session you drive with small, discrete commands. There is no
|
|
24
|
+
language model and no API key inside it: the intelligence is whoever runs it, a person, a
|
|
25
|
+
script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
|
|
26
|
+
blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
|
|
27
|
+
connection alive in a small background daemon and exposes simple verbs against it.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pipx install git+https://github.com/CharlesCNorton/automud
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or from a clone:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Standard library only, Python 3.8+.
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
automud connect --demo achaea # or: automud connect <host> <port>
|
|
47
|
+
automud send 2 # send a line, print the reply
|
|
48
|
+
automud send Maelvorn
|
|
49
|
+
automud recv # drain any new output
|
|
50
|
+
automud state # structured game state (GMCP) as JSON
|
|
51
|
+
automud status
|
|
52
|
+
automud close
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| verb | what it does |
|
|
56
|
+
|------|--------------|
|
|
57
|
+
| `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
|
|
58
|
+
| `send TEXT` | send one line, print what comes back |
|
|
59
|
+
| `recv` | print any new output |
|
|
60
|
+
| `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
|
|
61
|
+
| `status` | connection and vitals summary |
|
|
62
|
+
| `log [--tail N]` | full session transcript |
|
|
63
|
+
| `close` | end the session and stop the daemon |
|
|
64
|
+
|
|
65
|
+
Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
|
|
66
|
+
|
|
67
|
+
## Behaviour
|
|
68
|
+
|
|
69
|
+
- **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
|
|
70
|
+
telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
|
|
71
|
+
`--max` caps the wait and `--quiet` sets the idle threshold.
|
|
72
|
+
- **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
|
|
73
|
+
room, exits, skills) into JSON for `state`. Options it does not implement (compression,
|
|
74
|
+
MSDP, MXP) are refused rather than mishandled.
|
|
75
|
+
- **One session at a time**, held by a background daemon; a new `connect` replaces it.
|
|
76
|
+
Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT. See [LICENSE](LICENSE).
|
automud-0.1.0/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# AutoMUD
|
|
2
|
+
|
|
3
|
+
A persistent telnet/MUD session you drive with small, discrete commands. There is no
|
|
4
|
+
language model and no API key inside it: the intelligence is whoever runs it, a person, a
|
|
5
|
+
script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
|
|
6
|
+
blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
|
|
7
|
+
connection alive in a small background daemon and exposes simple verbs against it.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pipx install git+https://github.com/CharlesCNorton/automud
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or from a clone:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pip install .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Standard library only, Python 3.8+.
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
automud connect --demo achaea # or: automud connect <host> <port>
|
|
27
|
+
automud send 2 # send a line, print the reply
|
|
28
|
+
automud send Maelvorn
|
|
29
|
+
automud recv # drain any new output
|
|
30
|
+
automud state # structured game state (GMCP) as JSON
|
|
31
|
+
automud status
|
|
32
|
+
automud close
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| verb | what it does |
|
|
36
|
+
|------|--------------|
|
|
37
|
+
| `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
|
|
38
|
+
| `send TEXT` | send one line, print what comes back |
|
|
39
|
+
| `recv` | print any new output |
|
|
40
|
+
| `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
|
|
41
|
+
| `status` | connection and vitals summary |
|
|
42
|
+
| `log [--tail N]` | full session transcript |
|
|
43
|
+
| `close` | end the session and stop the daemon |
|
|
44
|
+
|
|
45
|
+
Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
|
|
46
|
+
|
|
47
|
+
## Behaviour
|
|
48
|
+
|
|
49
|
+
- **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
|
|
50
|
+
telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
|
|
51
|
+
`--max` caps the wait and `--quiet` sets the idle threshold.
|
|
52
|
+
- **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
|
|
53
|
+
room, exits, skills) into JSON for `state`. Options it does not implement (compression,
|
|
54
|
+
MSDP, MXP) are refused rather than mishandled.
|
|
55
|
+
- **One session at a time**, held by a background daemon; a new `connect` replaces it.
|
|
56
|
+
Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: automud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key.
|
|
5
|
+
Author: Charles Norton
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/CharlesCNorton/automud
|
|
8
|
+
Project-URL: Source, https://github.com/CharlesCNorton/automud
|
|
9
|
+
Keywords: mud,telnet,gmcp,agent,cli
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Games/Entertainment
|
|
15
|
+
Classifier: Topic :: Communications
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# AutoMUD
|
|
22
|
+
|
|
23
|
+
A persistent telnet/MUD session you drive with small, discrete commands. There is no
|
|
24
|
+
language model and no API key inside it: the intelligence is whoever runs it, a person, a
|
|
25
|
+
script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
|
|
26
|
+
blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
|
|
27
|
+
connection alive in a small background daemon and exposes simple verbs against it.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pipx install git+https://github.com/CharlesCNorton/automud
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or from a clone:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Standard library only, Python 3.8+.
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
automud connect --demo achaea # or: automud connect <host> <port>
|
|
47
|
+
automud send 2 # send a line, print the reply
|
|
48
|
+
automud send Maelvorn
|
|
49
|
+
automud recv # drain any new output
|
|
50
|
+
automud state # structured game state (GMCP) as JSON
|
|
51
|
+
automud status
|
|
52
|
+
automud close
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| verb | what it does |
|
|
56
|
+
|------|--------------|
|
|
57
|
+
| `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
|
|
58
|
+
| `send TEXT` | send one line, print what comes back |
|
|
59
|
+
| `recv` | print any new output |
|
|
60
|
+
| `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
|
|
61
|
+
| `status` | connection and vitals summary |
|
|
62
|
+
| `log [--tail N]` | full session transcript |
|
|
63
|
+
| `close` | end the session and stop the daemon |
|
|
64
|
+
|
|
65
|
+
Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
|
|
66
|
+
|
|
67
|
+
## Behaviour
|
|
68
|
+
|
|
69
|
+
- **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
|
|
70
|
+
telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
|
|
71
|
+
`--max` caps the wait and `--quiet` sets the idle threshold.
|
|
72
|
+
- **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
|
|
73
|
+
room, exits, skills) into JSON for `state`. Options it does not implement (compression,
|
|
74
|
+
MSDP, MXP) are refused rather than mishandled.
|
|
75
|
+
- **One session at a time**, held by a background daemon; a new `connect` replaces it.
|
|
76
|
+
Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
automud
|
automud-0.1.0/automud.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutoMUD: a persistent telnet/MUD session you drive by hand (or an agent drives).
|
|
3
|
+
|
|
4
|
+
There is no LLM in here and no API key. The intelligence is whoever runs it: a person, a
|
|
5
|
+
script, or an autonomous agent. It solves the parts of a live MUD connection that a
|
|
6
|
+
normal shell can't: a raw telnet session is interactive and blocking, so it can't be held
|
|
7
|
+
across separate commands. A small background daemon keeps the connection open and you talk
|
|
8
|
+
to it with discrete verbs:
|
|
9
|
+
|
|
10
|
+
python AutoMUD.py connect --demo achaea # or: connect achaea.com 23
|
|
11
|
+
python AutoMUD.py send 2 # send a line, print the reply
|
|
12
|
+
python AutoMUD.py send Maelvorn
|
|
13
|
+
python AutoMUD.py recv # drain any new output
|
|
14
|
+
python AutoMUD.py state # structured game state (GMCP) as JSON
|
|
15
|
+
python AutoMUD.py status
|
|
16
|
+
python AutoMUD.py close
|
|
17
|
+
|
|
18
|
+
What the daemon does for you:
|
|
19
|
+
* Smart waiting: send/recv return as soon as the server stops talking (an IAC GA/EOR
|
|
20
|
+
prompt marker, or output going quiet), so you never guess a sleep duration. --max caps
|
|
21
|
+
the wait; --quiet sets the idle threshold.
|
|
22
|
+
* Prompt aware: it treats the telnet GA/EOR marker most MUDs send after a prompt as the
|
|
23
|
+
"your turn" signal, and refuses Suppress-Go-Ahead so that marker keeps flowing.
|
|
24
|
+
* GMCP capture: it negotiates GMCP and parses the structured state modern MUDs push
|
|
25
|
+
(Char.Vitals, Room.Info, etc.) into JSON you can read with `state`. It refuses every
|
|
26
|
+
other option it doesn't understand (compression, MSDP, MXP) rather than choking on it.
|
|
27
|
+
|
|
28
|
+
Config (optional):
|
|
29
|
+
AUTOMUD_DIR : session/state directory (default: <tempdir>/automud)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import socket
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
from typing import Optional
|
|
43
|
+
|
|
44
|
+
AUTOMUD_DIR = os.environ.get("AUTOMUD_DIR") or os.path.join(tempfile.gettempdir(), "automud")
|
|
45
|
+
SESSION_JSON = os.path.join(AUTOMUD_DIR, "session.json")
|
|
46
|
+
OUT_LOG = os.path.join(AUTOMUD_DIR, "out.log")
|
|
47
|
+
DAEMON_LOG = os.path.join(AUTOMUD_DIR, "daemon.log")
|
|
48
|
+
|
|
49
|
+
# Keep at most this many characters of received text in memory (the full stream still goes
|
|
50
|
+
# to OUT_LOG). Trimming only ever drops already-read history, never unread output.
|
|
51
|
+
BUFFER_CAP = 1_000_000
|
|
52
|
+
|
|
53
|
+
DEMOS = {
|
|
54
|
+
"zork": ("telehack.com", 23),
|
|
55
|
+
"chess": ("freechess.org", 5000),
|
|
56
|
+
"achaea": ("achaea.com", 23),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Telnet command bytes
|
|
60
|
+
IAC, DONT, DO, WONT, WILL, SB, SE = 255, 254, 253, 252, 251, 250, 240
|
|
61
|
+
GA, EOR_CMD = 249, 239
|
|
62
|
+
# Telnet options
|
|
63
|
+
OPT_GMCP, OPT_EOR = 201, 25
|
|
64
|
+
# Options we ask the server to enable. GMCP gives structured state; EOR gives prompt markers.
|
|
65
|
+
# We deliberately do NOT request SGA (option 3): suppressing Go-Ahead would kill the other
|
|
66
|
+
# prompt marker. Everything not listed here is refused, which also keeps us from accidentally
|
|
67
|
+
# enabling compression (MCCP) and turning the stream into zlib garbage.
|
|
68
|
+
WANT_DO = {OPT_GMCP, OPT_EOR}
|
|
69
|
+
|
|
70
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[()][AB012]|[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def strip_ansi(text: str) -> str:
|
|
74
|
+
return _ANSI_RE.sub("", text or "")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------ session state files ------------------------------
|
|
78
|
+
|
|
79
|
+
def _write_session(data: dict) -> None:
|
|
80
|
+
os.makedirs(AUTOMUD_DIR, exist_ok=True)
|
|
81
|
+
tmp = SESSION_JSON + ".tmp"
|
|
82
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
83
|
+
json.dump(data, f)
|
|
84
|
+
os.replace(tmp, SESSION_JSON)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_session() -> Optional[dict]:
|
|
88
|
+
try:
|
|
89
|
+
with open(SESSION_JSON, "r", encoding="utf-8") as f:
|
|
90
|
+
return json.load(f)
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------ telnet / GMCP parser ------------------------------
|
|
96
|
+
|
|
97
|
+
class MudConn:
|
|
98
|
+
"""Minimal telnet client: separates plain text from IAC control, answers option
|
|
99
|
+
negotiation (only on state change, so it can't loop), captures GMCP subnegotiation as
|
|
100
|
+
JSON, and flags GA/EOR prompt markers.
|
|
101
|
+
|
|
102
|
+
Receipt accounting uses monotonic counters, not buffer indices, so trimming the in-memory
|
|
103
|
+
buffer never disturbs the read cursor or the wait logic:
|
|
104
|
+
total : chars ever received
|
|
105
|
+
read : chars handed to the client
|
|
106
|
+
base : chars dropped off the front of `buffer` (buffer == stream[base:total])
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, writer: asyncio.StreamWriter, state: dict, log_fh):
|
|
110
|
+
self.w = writer
|
|
111
|
+
self.s = state
|
|
112
|
+
self.log = log_fh
|
|
113
|
+
self.mode = "text" # text | iac | neg | sb | sbiac
|
|
114
|
+
self.cmd = None
|
|
115
|
+
self.sb_opt = None
|
|
116
|
+
self.sb = bytearray()
|
|
117
|
+
self.text = bytearray()
|
|
118
|
+
self.him = {} # server-side option enabled? (None unknown)
|
|
119
|
+
self.us = {} # our-side option enabled?
|
|
120
|
+
|
|
121
|
+
# ---- byte stream ----
|
|
122
|
+
def feed(self, data: bytes) -> None:
|
|
123
|
+
i, n = 0, len(data)
|
|
124
|
+
while i < n:
|
|
125
|
+
if self.mode == "text":
|
|
126
|
+
k = data.find(IAC, i)
|
|
127
|
+
if k == -1:
|
|
128
|
+
self.text += data[i:n]
|
|
129
|
+
break
|
|
130
|
+
if k > i:
|
|
131
|
+
self.text += data[i:k]
|
|
132
|
+
self.mode = "iac"
|
|
133
|
+
i = k + 1
|
|
134
|
+
else:
|
|
135
|
+
self._byte(data[i])
|
|
136
|
+
i += 1
|
|
137
|
+
self._flush_text()
|
|
138
|
+
|
|
139
|
+
def _byte(self, b: int) -> None:
|
|
140
|
+
m = self.mode
|
|
141
|
+
if m == "iac":
|
|
142
|
+
if b == IAC:
|
|
143
|
+
self.text.append(IAC) # escaped 0xFF
|
|
144
|
+
self.mode = "text"
|
|
145
|
+
elif b in (DO, DONT, WILL, WONT):
|
|
146
|
+
self.cmd = b
|
|
147
|
+
self.mode = "neg"
|
|
148
|
+
elif b == SB:
|
|
149
|
+
self.sb_opt = None
|
|
150
|
+
self.sb = bytearray()
|
|
151
|
+
self.mode = "sb"
|
|
152
|
+
elif b in (GA, EOR_CMD):
|
|
153
|
+
self._prompt()
|
|
154
|
+
self.mode = "text"
|
|
155
|
+
else:
|
|
156
|
+
self.mode = "text" # other 1-byte commands: ignore
|
|
157
|
+
elif m == "neg":
|
|
158
|
+
self._negotiate(self.cmd, b)
|
|
159
|
+
self.mode = "text"
|
|
160
|
+
elif m == "sb":
|
|
161
|
+
if b == IAC:
|
|
162
|
+
self.mode = "sbiac"
|
|
163
|
+
elif self.sb_opt is None:
|
|
164
|
+
self.sb_opt = b
|
|
165
|
+
else:
|
|
166
|
+
self.sb.append(b)
|
|
167
|
+
elif m == "sbiac":
|
|
168
|
+
if b == IAC:
|
|
169
|
+
self.sb.append(IAC) # escaped 0xFF inside SB
|
|
170
|
+
self.mode = "sb"
|
|
171
|
+
elif b == SE:
|
|
172
|
+
self._subneg(self.sb_opt, bytes(self.sb))
|
|
173
|
+
self.mode = "text"
|
|
174
|
+
else:
|
|
175
|
+
self.mode = "text" # malformed; resync
|
|
176
|
+
|
|
177
|
+
# ---- handlers ----
|
|
178
|
+
def _append(self, s: str) -> None:
|
|
179
|
+
self.s["buffer"] += s
|
|
180
|
+
self.s["total"] += len(s)
|
|
181
|
+
self.s["last_rx"] = time.monotonic()
|
|
182
|
+
if self.log is not None:
|
|
183
|
+
try:
|
|
184
|
+
self.log.write(s)
|
|
185
|
+
self.log.flush()
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
buf = self.s["buffer"]
|
|
189
|
+
if len(buf) > BUFFER_CAP:
|
|
190
|
+
drop = len(buf) - BUFFER_CAP
|
|
191
|
+
self.s["buffer"] = buf[drop:]
|
|
192
|
+
self.s["base"] += drop
|
|
193
|
+
if self.s["read"] < self.s["base"]: # dropped some unread; don't re-serve it
|
|
194
|
+
self.s["read"] = self.s["base"]
|
|
195
|
+
|
|
196
|
+
def _flush_text(self) -> None:
|
|
197
|
+
if not self.text:
|
|
198
|
+
return
|
|
199
|
+
s = strip_ansi(self.text.decode("utf-8", "replace"))
|
|
200
|
+
self.text = bytearray()
|
|
201
|
+
if s:
|
|
202
|
+
self._append(s)
|
|
203
|
+
|
|
204
|
+
def _prompt(self) -> None:
|
|
205
|
+
self._flush_text()
|
|
206
|
+
self.s["prompt_seen"] = True
|
|
207
|
+
self.s["last_rx"] = time.monotonic()
|
|
208
|
+
|
|
209
|
+
def _raw(self, *seq: int) -> None:
|
|
210
|
+
try:
|
|
211
|
+
self.w.write(bytes(seq))
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def _negotiate(self, cmd: int, opt: int) -> None:
|
|
216
|
+
# Respond only when the option's state actually changes, per the telnet Q-method,
|
|
217
|
+
# so a server that re-announces options can't make us loop.
|
|
218
|
+
if cmd == WILL:
|
|
219
|
+
want = opt in WANT_DO
|
|
220
|
+
if want and not self.him.get(opt):
|
|
221
|
+
self.him[opt] = True
|
|
222
|
+
self._raw(IAC, DO, opt)
|
|
223
|
+
if opt == OPT_GMCP:
|
|
224
|
+
self._gmcp_hello()
|
|
225
|
+
elif not want and self.him.get(opt) is not False:
|
|
226
|
+
self.him[opt] = False
|
|
227
|
+
self._raw(IAC, DONT, opt)
|
|
228
|
+
elif cmd == WONT:
|
|
229
|
+
if self.him.get(opt) is not False:
|
|
230
|
+
self.him[opt] = False
|
|
231
|
+
self._raw(IAC, DONT, opt)
|
|
232
|
+
elif cmd == DO:
|
|
233
|
+
if self.us.get(opt) is not False: # we enable nothing on our side
|
|
234
|
+
self.us[opt] = False
|
|
235
|
+
self._raw(IAC, WONT, opt)
|
|
236
|
+
elif cmd == DONT:
|
|
237
|
+
if self.us.get(opt) is not False:
|
|
238
|
+
self.us[opt] = False
|
|
239
|
+
self._raw(IAC, WONT, opt)
|
|
240
|
+
|
|
241
|
+
def _send_gmcp(self, package: str, payload: str) -> None:
|
|
242
|
+
msg = (package + " " + payload).encode("utf-8")
|
|
243
|
+
try:
|
|
244
|
+
self.w.write(bytes([IAC, SB, OPT_GMCP]) + msg + bytes([IAC, SE]))
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def _gmcp_hello(self) -> None:
|
|
249
|
+
self._send_gmcp("Core.Hello", '{"client":"AutoMUD","version":"1.0"}')
|
|
250
|
+
self._send_gmcp("Core.Supports.Set",
|
|
251
|
+
'["Char 1","Char.Vitals 1","Char.Status 1","Char.Skills 1",'
|
|
252
|
+
'"Room 1","Comm.Channel 1"]')
|
|
253
|
+
|
|
254
|
+
def _subneg(self, opt: int, payload: bytes) -> None:
|
|
255
|
+
if opt != OPT_GMCP:
|
|
256
|
+
return # MSDP/MXP/etc.: ignore, don't choke
|
|
257
|
+
text = payload.decode("utf-8", "replace")
|
|
258
|
+
sp = text.find(" ")
|
|
259
|
+
if sp == -1:
|
|
260
|
+
package, body = text.strip(), ""
|
|
261
|
+
else:
|
|
262
|
+
package, body = text[:sp].strip(), text[sp + 1:]
|
|
263
|
+
value = None
|
|
264
|
+
if body.strip():
|
|
265
|
+
try:
|
|
266
|
+
value = json.loads(body)
|
|
267
|
+
except Exception:
|
|
268
|
+
value = body
|
|
269
|
+
if package:
|
|
270
|
+
self.s["gmcp"][package] = value
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ------------------------------ daemon ------------------------------
|
|
274
|
+
|
|
275
|
+
async def _wait_settled(state: dict, since_total: int, quiet: float, maxw: float) -> None:
|
|
276
|
+
"""Block until the server stops talking: a prompt marker arrived, or output produced
|
|
277
|
+
after `since_total` went idle for `quiet` seconds. Capped at `maxw`."""
|
|
278
|
+
start = time.monotonic()
|
|
279
|
+
# phase 1: wait for genuinely new output (or a prompt) after the snapshot
|
|
280
|
+
while time.monotonic() - start < maxw:
|
|
281
|
+
if state["total"] > since_total or state["prompt_seen"]:
|
|
282
|
+
break
|
|
283
|
+
if not state["connected"]:
|
|
284
|
+
return
|
|
285
|
+
await asyncio.sleep(0.05)
|
|
286
|
+
# phase 2: let the burst settle
|
|
287
|
+
while time.monotonic() - start < maxw:
|
|
288
|
+
if state["prompt_seen"]:
|
|
289
|
+
return
|
|
290
|
+
if time.monotonic() - state["last_rx"] >= quiet:
|
|
291
|
+
return
|
|
292
|
+
await asyncio.sleep(0.05)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _drain(state: dict) -> str:
|
|
296
|
+
"""Return all unread text and advance the read cursor."""
|
|
297
|
+
data = state["buffer"][state["read"] - state["base"]:]
|
|
298
|
+
state["read"] = state["total"]
|
|
299
|
+
state["prompt_seen"] = False
|
|
300
|
+
return data
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _do_op(req: dict, state: dict, writer: asyncio.StreamWriter, stop: asyncio.Event) -> dict:
|
|
304
|
+
op = req.get("op")
|
|
305
|
+
quiet = float(req.get("quiet", 0.3))
|
|
306
|
+
maxw = float(req.get("max", 5.0))
|
|
307
|
+
if op == "send":
|
|
308
|
+
since = state["total"]
|
|
309
|
+
state["prompt_seen"] = False
|
|
310
|
+
try:
|
|
311
|
+
writer.write((req.get("data") or "").encode("utf-8").replace(b"\xff", b"\xff\xff") + b"\r\n")
|
|
312
|
+
await writer.drain()
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return {"ok": False, "error": f"send failed: {e}"}
|
|
315
|
+
await _wait_settled(state, since, quiet, maxw)
|
|
316
|
+
return {"ok": True, "data": _drain(state), "connected": state["connected"]}
|
|
317
|
+
if op == "recv":
|
|
318
|
+
if req.get("block", True):
|
|
319
|
+
await _wait_settled(state, state["read"], quiet, maxw)
|
|
320
|
+
return {"ok": True, "data": _drain(state), "connected": state["connected"]}
|
|
321
|
+
if op == "state":
|
|
322
|
+
return {"ok": True, "gmcp": state["gmcp"], "connected": state["connected"]}
|
|
323
|
+
if op == "status":
|
|
324
|
+
vit = state["gmcp"].get("Char.Vitals") or {}
|
|
325
|
+
return {"ok": True, "connected": state["connected"],
|
|
326
|
+
"unread": state["total"] - state["read"], "total_chars": state["total"],
|
|
327
|
+
"gmcp_packages": sorted(state["gmcp"].keys()), "vitals": vit}
|
|
328
|
+
if op == "close":
|
|
329
|
+
stop.set()
|
|
330
|
+
return {"ok": True}
|
|
331
|
+
return {"ok": False, "error": f"unknown op '{op}'"}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def _daemon_main(host: str, port: int) -> None:
|
|
335
|
+
os.makedirs(AUTOMUD_DIR, exist_ok=True)
|
|
336
|
+
try:
|
|
337
|
+
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=20.0)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
_write_session({"error": str(e), "host": host, "port": port})
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
log_fh = open(OUT_LOG, "a", encoding="utf-8")
|
|
344
|
+
except Exception:
|
|
345
|
+
log_fh = None
|
|
346
|
+
|
|
347
|
+
state = {"buffer": "", "total": 0, "read": 0, "base": 0, "connected": True, "gmcp": {},
|
|
348
|
+
"last_rx": time.monotonic(), "prompt_seen": False}
|
|
349
|
+
conn = MudConn(writer, state, log_fh)
|
|
350
|
+
stop = asyncio.Event()
|
|
351
|
+
|
|
352
|
+
async def control(creader: asyncio.StreamReader, cwriter: asyncio.StreamWriter) -> None:
|
|
353
|
+
try:
|
|
354
|
+
line = await creader.readline()
|
|
355
|
+
req = json.loads(line.decode("utf-8", "replace") or "{}")
|
|
356
|
+
resp = await _do_op(req, state, writer, stop)
|
|
357
|
+
cwriter.write((json.dumps(resp) + "\n").encode("utf-8"))
|
|
358
|
+
await cwriter.drain()
|
|
359
|
+
except Exception as e:
|
|
360
|
+
try:
|
|
361
|
+
cwriter.write((json.dumps({"ok": False, "error": str(e)}) + "\n").encode("utf-8"))
|
|
362
|
+
await cwriter.drain()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
finally:
|
|
366
|
+
cwriter.close()
|
|
367
|
+
|
|
368
|
+
server = await asyncio.start_server(control, "127.0.0.1", 0)
|
|
369
|
+
ctrl_port = server.sockets[0].getsockname()[1]
|
|
370
|
+
_write_session({"host": host, "port": port, "control_port": ctrl_port, "pid": os.getpid()})
|
|
371
|
+
|
|
372
|
+
async def pump() -> None:
|
|
373
|
+
try:
|
|
374
|
+
while not stop.is_set():
|
|
375
|
+
data = await reader.read(4096)
|
|
376
|
+
if not data:
|
|
377
|
+
break
|
|
378
|
+
conn.feed(data)
|
|
379
|
+
finally:
|
|
380
|
+
state["connected"] = False
|
|
381
|
+
|
|
382
|
+
pump_task = asyncio.create_task(pump())
|
|
383
|
+
serve_task = asyncio.create_task(server.serve_forever())
|
|
384
|
+
await stop.wait()
|
|
385
|
+
state["connected"] = False
|
|
386
|
+
for t in (pump_task, serve_task):
|
|
387
|
+
t.cancel()
|
|
388
|
+
for closer in (lambda: writer.close(), lambda: log_fh and log_fh.close(),
|
|
389
|
+
lambda: os.remove(SESSION_JSON)):
|
|
390
|
+
try:
|
|
391
|
+
closer()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ------------------------------ client (the verbs) ------------------------------
|
|
397
|
+
|
|
398
|
+
def _control(op: str, **kw) -> dict:
|
|
399
|
+
sess = _read_session()
|
|
400
|
+
if not sess or "control_port" not in sess:
|
|
401
|
+
return {"ok": False, "error": "no active session (run 'connect' first)"}
|
|
402
|
+
try:
|
|
403
|
+
with socket.create_connection(("127.0.0.1", sess["control_port"]), timeout=30) as s:
|
|
404
|
+
s.sendall((json.dumps({"op": op, **kw}) + "\n").encode("utf-8"))
|
|
405
|
+
buf = b""
|
|
406
|
+
while not buf.endswith(b"\n"):
|
|
407
|
+
chunk = s.recv(65536)
|
|
408
|
+
if not chunk:
|
|
409
|
+
break
|
|
410
|
+
buf += chunk
|
|
411
|
+
return json.loads(buf.decode("utf-8", "replace"))
|
|
412
|
+
except Exception as e:
|
|
413
|
+
return {"ok": False, "error": str(e)}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _spawn_daemon(host: str, port: int) -> None:
|
|
417
|
+
os.makedirs(AUTOMUD_DIR, exist_ok=True)
|
|
418
|
+
try:
|
|
419
|
+
os.remove(SESSION_JSON)
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
open(OUT_LOG, "w", encoding="utf-8").close()
|
|
423
|
+
args = [sys.executable, os.path.abspath(__file__), "--daemon", host, str(port)]
|
|
424
|
+
logf = open(DAEMON_LOG, "a", encoding="utf-8")
|
|
425
|
+
kwargs: dict = {"stdout": logf, "stderr": logf, "stdin": subprocess.DEVNULL}
|
|
426
|
+
if os.name == "nt":
|
|
427
|
+
kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | NEW_PROCESS_GROUP
|
|
428
|
+
else:
|
|
429
|
+
kwargs["start_new_session"] = True
|
|
430
|
+
subprocess.Popen(args, **kwargs)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _print(text: str) -> None:
|
|
434
|
+
sys.stdout.write(text)
|
|
435
|
+
if text and not text.endswith("\n"):
|
|
436
|
+
sys.stdout.write("\n")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def cmd_connect(host: str, port: int, quiet: float, maxw: float) -> int:
|
|
440
|
+
old = _read_session()
|
|
441
|
+
if old and old.get("control_port"): # a real live daemon: ask it to exit
|
|
442
|
+
_control("close")
|
|
443
|
+
for _ in range(30): # and wait for it to clear its session file
|
|
444
|
+
if not os.path.exists(SESSION_JSON):
|
|
445
|
+
break
|
|
446
|
+
time.sleep(0.1)
|
|
447
|
+
_spawn_daemon(host, port)
|
|
448
|
+
deadline = time.time() + 25
|
|
449
|
+
sess = None
|
|
450
|
+
while time.time() < deadline:
|
|
451
|
+
sess = _read_session()
|
|
452
|
+
if sess:
|
|
453
|
+
break
|
|
454
|
+
time.sleep(0.3)
|
|
455
|
+
if not sess:
|
|
456
|
+
print(f"daemon did not start within 25s; see {DAEMON_LOG}")
|
|
457
|
+
return 1
|
|
458
|
+
if sess.get("error"):
|
|
459
|
+
print(f"connect failed: {sess['error']}")
|
|
460
|
+
return 1
|
|
461
|
+
print(f"connected to {host}:{port}")
|
|
462
|
+
_print(_control("recv", block=True, quiet=quiet, max=maxw).get("data", ""))
|
|
463
|
+
return 0
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def cmd_send(text: str, quiet: float, maxw: float) -> int:
|
|
467
|
+
r = _control("send", data=text, quiet=quiet, max=maxw)
|
|
468
|
+
if not r.get("ok"):
|
|
469
|
+
print(f"send failed: {r.get('error')}")
|
|
470
|
+
return 1
|
|
471
|
+
_print(r.get("data", ""))
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def cmd_recv(quiet: float, maxw: float) -> int:
|
|
476
|
+
r = _control("recv", block=True, quiet=quiet, max=maxw)
|
|
477
|
+
if not r.get("ok"):
|
|
478
|
+
print(f"recv failed: {r.get('error')}")
|
|
479
|
+
return 1
|
|
480
|
+
_print(r.get("data", ""))
|
|
481
|
+
return 0
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def cmd_state(key: Optional[str]) -> int:
|
|
485
|
+
r = _control("state")
|
|
486
|
+
if not r.get("ok"):
|
|
487
|
+
print(f"no session: {r.get('error')}")
|
|
488
|
+
return 1
|
|
489
|
+
gmcp = r.get("gmcp", {})
|
|
490
|
+
if key:
|
|
491
|
+
if key not in gmcp:
|
|
492
|
+
print(f"no GMCP package '{key}' yet (have: {', '.join(sorted(gmcp)) or 'none'})")
|
|
493
|
+
return 1
|
|
494
|
+
print(json.dumps(gmcp[key], indent=2))
|
|
495
|
+
elif not gmcp:
|
|
496
|
+
print("no GMCP data yet (the server may not push it until you're in the game)")
|
|
497
|
+
else:
|
|
498
|
+
print(json.dumps(gmcp, indent=2))
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_status() -> int:
|
|
503
|
+
r = _control("status")
|
|
504
|
+
if not r.get("ok"):
|
|
505
|
+
print(f"no session: {r.get('error')}")
|
|
506
|
+
return 1
|
|
507
|
+
vit = r.get("vitals") or {}
|
|
508
|
+
vit_str = f" vitals: hp={vit.get('hp')} mp={vit.get('mp')}" if vit else ""
|
|
509
|
+
print(f"connected={r['connected']} unread={r['unread']} chars "
|
|
510
|
+
f"gmcp=[{', '.join(r.get('gmcp_packages', []))}]" + vit_str)
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def cmd_close() -> int:
|
|
515
|
+
r = _control("close")
|
|
516
|
+
print("closed" if r.get("ok") else f"close failed: {r.get('error')}")
|
|
517
|
+
return 0 if r.get("ok") else 1
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def cmd_log(tail: int) -> int:
|
|
521
|
+
try:
|
|
522
|
+
with open(OUT_LOG, "r", encoding="utf-8") as f:
|
|
523
|
+
data = f.read()
|
|
524
|
+
except Exception:
|
|
525
|
+
print("no session log yet")
|
|
526
|
+
return 1
|
|
527
|
+
if tail > 0:
|
|
528
|
+
data = data[-tail:]
|
|
529
|
+
_print(data)
|
|
530
|
+
return 0
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ------------------------------ entrypoint ------------------------------
|
|
534
|
+
|
|
535
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
536
|
+
p = argparse.ArgumentParser(
|
|
537
|
+
prog="AutoMUD",
|
|
538
|
+
description="Persistent telnet/MUD session driven by discrete verbs, with smart waiting "
|
|
539
|
+
"and GMCP capture. No LLM, no API key; the operator supplies the intelligence.",
|
|
540
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
541
|
+
epilog="Verbs: connect / send / recv / state / status / log / close. Demos: "
|
|
542
|
+
+ ", ".join(sorted(DEMOS)) + ".")
|
|
543
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
544
|
+
|
|
545
|
+
def add_wait(sp):
|
|
546
|
+
sp.add_argument("--quiet", type=float, default=0.3,
|
|
547
|
+
help="seconds of silence that counts as 'server done' (default 0.3)")
|
|
548
|
+
sp.add_argument("--max", type=float, default=5.0,
|
|
549
|
+
help="hard cap on how long to wait for the reply (default 5)")
|
|
550
|
+
|
|
551
|
+
c = sub.add_parser("connect", help="open a session (starts the background daemon)")
|
|
552
|
+
c.add_argument("host", nargs="?", help="telnet host")
|
|
553
|
+
c.add_argument("port", nargs="?", type=int, help="telnet port")
|
|
554
|
+
c.add_argument("--demo", choices=sorted(DEMOS), help="use a built-in demo target")
|
|
555
|
+
add_wait(c)
|
|
556
|
+
|
|
557
|
+
s = sub.add_parser("send", help="send one line, then print the reply")
|
|
558
|
+
s.add_argument("text", nargs="+", help="the line to send (joined with spaces)")
|
|
559
|
+
add_wait(s)
|
|
560
|
+
|
|
561
|
+
r = sub.add_parser("recv", help="print any new output (waits for it to settle)")
|
|
562
|
+
add_wait(r)
|
|
563
|
+
|
|
564
|
+
st = sub.add_parser("state", help="print captured GMCP game state as JSON")
|
|
565
|
+
st.add_argument("--key", help="print only one package, e.g. Char.Vitals or Room.Info")
|
|
566
|
+
|
|
567
|
+
sub.add_parser("status", help="show connection + vitals summary")
|
|
568
|
+
sub.add_parser("close", help="close the session and stop the daemon")
|
|
569
|
+
|
|
570
|
+
lg = sub.add_parser("log", help="print the full session output log")
|
|
571
|
+
lg.add_argument("--tail", type=int, default=0, help="only the last N characters (0 = all)")
|
|
572
|
+
return p
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def main() -> None:
|
|
576
|
+
if len(sys.argv) >= 2 and sys.argv[1] == "--daemon":
|
|
577
|
+
asyncio.run(_daemon_main(sys.argv[2], int(sys.argv[3])))
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
args = build_parser().parse_args()
|
|
581
|
+
if args.cmd == "connect":
|
|
582
|
+
if args.demo:
|
|
583
|
+
host, port = DEMOS[args.demo]
|
|
584
|
+
elif args.host and args.port:
|
|
585
|
+
host, port = args.host, args.port
|
|
586
|
+
else:
|
|
587
|
+
print("usage: connect HOST PORT | connect --demo NAME")
|
|
588
|
+
sys.exit(2)
|
|
589
|
+
sys.exit(cmd_connect(host, port, quiet=args.quiet, maxw=args.max))
|
|
590
|
+
elif args.cmd == "send":
|
|
591
|
+
sys.exit(cmd_send(" ".join(args.text), quiet=args.quiet, maxw=args.max))
|
|
592
|
+
elif args.cmd == "recv":
|
|
593
|
+
sys.exit(cmd_recv(quiet=args.quiet, maxw=args.max))
|
|
594
|
+
elif args.cmd == "state":
|
|
595
|
+
sys.exit(cmd_state(args.key))
|
|
596
|
+
elif args.cmd == "status":
|
|
597
|
+
sys.exit(cmd_status())
|
|
598
|
+
elif args.cmd == "close":
|
|
599
|
+
sys.exit(cmd_close())
|
|
600
|
+
elif args.cmd == "log":
|
|
601
|
+
sys.exit(cmd_log(tail=args.tail))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
if __name__ == "__main__":
|
|
605
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "automud"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Charles Norton" }]
|
|
13
|
+
keywords = ["mud", "telnet", "gmcp", "agent", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Games/Entertainment",
|
|
20
|
+
"Topic :: Communications",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/CharlesCNorton/automud"
|
|
25
|
+
Source = "https://github.com/CharlesCNorton/automud"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
automud = "automud:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
py-modules = ["automud"]
|
automud-0.1.0/setup.cfg
ADDED