clikernel 0.1.3__tar.gz → 0.1.4__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.
- {clikernel-0.1.3 → clikernel-0.1.4}/CHANGELOG.md +8 -0
- {clikernel-0.1.3/clikernel.egg-info → clikernel-0.1.4}/PKG-INFO +12 -2
- {clikernel-0.1.3 → clikernel-0.1.4}/README.md +9 -0
- clikernel-0.1.4/clikernel/__init__.py +5 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel/cli.py +55 -23
- clikernel-0.1.4/clikernel/mcp.py +129 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel/skill.py +3 -3
- {clikernel-0.1.3 → clikernel-0.1.4/clikernel.egg-info}/PKG-INFO +12 -2
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel.egg-info/requires.txt +2 -1
- {clikernel-0.1.3 → clikernel-0.1.4}/pyproject.toml +2 -1
- {clikernel-0.1.3 → clikernel-0.1.4}/tests/test_cli.py +131 -155
- clikernel-0.1.4/tests/test_mcp.py +106 -0
- clikernel-0.1.3/clikernel/__init__.py +0 -4
- clikernel-0.1.3/clikernel/mcp.py +0 -37
- clikernel-0.1.3/tests/test_mcp.py +0 -91
- {clikernel-0.1.3 → clikernel-0.1.4}/LICENSE +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/MANIFEST.in +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel.egg-info/SOURCES.txt +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel.egg-info/dependency_links.txt +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel.egg-info/entry_points.txt +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/clikernel.egg-info/top_level.txt +0 -0
- {clikernel-0.1.3 → clikernel-0.1.4}/setup.cfg +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<!-- do not remove -->
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Add cell inspectors, consolidate terminal handling, and harden MCP supervisor with signal guards and error recovery ([#9](https://github.com/AnswerDotAI/clikernel/issues/9))
|
|
8
|
+
- Switch MCP server from in-process shell to supervised subprocess worker, add interrupt tool and idle-SIGINT handling ([#8](https://github.com/AnswerDotAI/clikernel/issues/8))
|
|
9
|
+
|
|
10
|
+
|
|
3
11
|
## 0.1.3
|
|
4
12
|
|
|
5
13
|
### New Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A tiny stdin/stdout IPython worker
|
|
5
5
|
Author: clikernel contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,7 +10,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: execnb>=0.2.
|
|
13
|
+
Requires-Dist: execnb>=0.2.4
|
|
14
|
+
Requires-Dist: fastcore
|
|
14
15
|
Requires-Dist: mcp
|
|
15
16
|
Provides-Extra: dev
|
|
16
17
|
Requires-Dist: fastship; extra == "dev"
|
|
@@ -90,6 +91,14 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
|
|
|
90
91
|
|
|
91
92
|
`clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
|
|
92
93
|
|
|
94
|
+
## Inspectors
|
|
95
|
+
|
|
96
|
+
`clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
|
|
97
|
+
|
|
98
|
+
Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
|
|
99
|
+
|
|
100
|
+
See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
|
|
101
|
+
|
|
93
102
|
## Why The Protocol Is Odd
|
|
94
103
|
|
|
95
104
|
`clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
|
|
@@ -120,3 +129,4 @@ ship-gh
|
|
|
120
129
|
ship-pypi
|
|
121
130
|
ship-bump # dev release always later than prod release
|
|
122
131
|
```
|
|
132
|
+
|
|
@@ -69,6 +69,14 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
|
|
|
69
69
|
|
|
70
70
|
`clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
|
|
71
71
|
|
|
72
|
+
## Inspectors
|
|
73
|
+
|
|
74
|
+
`clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
|
|
75
|
+
|
|
76
|
+
Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
|
|
77
|
+
|
|
78
|
+
See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
|
|
79
|
+
|
|
72
80
|
## Why The Protocol Is Odd
|
|
73
81
|
|
|
74
82
|
`clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
|
|
@@ -99,3 +107,4 @@ ship-gh
|
|
|
99
107
|
ship-pypi
|
|
100
108
|
ship-bump # dev release always later than prod release
|
|
101
109
|
```
|
|
110
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import inspect,os,secrets,shlex,string,sys,tempfile,termios,traceback
|
|
1
|
+
import ast,inspect,os,runpy,secrets,shlex,signal,string,sys,tempfile,termios,traceback,tty
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
from fastcore.xdg import xdg_config_home
|
|
3
4
|
|
|
4
5
|
def _state_root():
|
|
5
6
|
if d := os.environ.get("CLIKERNEL_STATE_DIR"): return Path(d).expanduser()
|
|
@@ -38,10 +39,33 @@ def _write_response(delim, body=None):
|
|
|
38
39
|
print(delim, flush=True)
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
def
|
|
42
|
+
def _load_inspectors():
|
|
43
|
+
"Cell inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py`: each is called with the cell AST before execution, and may return a note (prepended to the output) or raise (blocking the cell). The file may define `inspect` and/or an `inspectors` list."
|
|
44
|
+
path = xdg_config_home()/"clikernel"/"inspectors.py"
|
|
45
|
+
if not path.exists(): return []
|
|
46
|
+
try: ns = runpy.run_path(str(path))
|
|
47
|
+
except Exception:
|
|
48
|
+
print(f"clikernel: failed to load {path}:\n{traceback.format_exc()}", file=sys.stderr, flush=True)
|
|
49
|
+
return []
|
|
50
|
+
ins = list(ns.get("inspectors", []))
|
|
51
|
+
if callable(ns.get("inspect")): ins.append(ns["inspect"])
|
|
52
|
+
return ins
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _inspect(shell, inspectors, code):
|
|
56
|
+
"Run each inspector on the cell's transformed AST; return concatenated notes. An inspector may raise to block the cell."
|
|
57
|
+
if not inspectors: return ""
|
|
58
|
+
try: tree = ast.parse(shell.transform_cell(code))
|
|
59
|
+
except SyntaxError: return "" # let the cell's own execution report the error
|
|
60
|
+
return "".join(note for f in inspectors if (note := f(tree)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _execute(shell, inspectors, code):
|
|
42
64
|
from fastcore.nbio import render_text
|
|
65
|
+
try: note = _inspect(shell, inspectors, code)
|
|
66
|
+
except Exception as e: return "blocked", _format_error("blocked", f"{type(e).__name__}: {e}")
|
|
43
67
|
outputs = shell.run(code)
|
|
44
|
-
return ("error" if shell.exc else "ok"), render_text(outputs)
|
|
68
|
+
return ("error" if shell.exc else "ok"), note + render_text(outputs)
|
|
45
69
|
|
|
46
70
|
|
|
47
71
|
def _request_exit(shell): shell._clikernel_exit = True
|
|
@@ -78,25 +102,24 @@ def _make_shell():
|
|
|
78
102
|
def _should_exit(shell): return getattr(shell, "_clikernel_exit", False)
|
|
79
103
|
|
|
80
104
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
|
88
|
-
return fd, attrs
|
|
89
|
-
|
|
105
|
+
def _next_line(stdin):
|
|
106
|
+
"Read one line; when not a TTY, SIGINT while idle means 'interrupt execution', not 'kill the worker', so ignore it"
|
|
107
|
+
while True:
|
|
108
|
+
try: return stdin.readline()
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
if stdin.isatty(): raise
|
|
90
111
|
|
|
91
|
-
def _restore_echo(state):
|
|
92
|
-
if state: termios.tcsetattr(state[0], termios.TCSADRAIN, state[1])
|
|
93
112
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
def _tty_clear(stream, idx, mask, cc=None):
|
|
114
|
+
"Clear `mask` bits in termios field `idx` when `stream` is a TTY, with optional `cc` char overrides; returns state for `_restore_termios`"
|
|
115
|
+
if not stream.isatty(): return None
|
|
116
|
+
fd = stream.fileno()
|
|
97
117
|
attrs = termios.tcgetattr(fd)
|
|
98
118
|
new_attrs = attrs[:]
|
|
99
|
-
new_attrs[
|
|
119
|
+
new_attrs[idx] &= ~mask
|
|
120
|
+
if cc:
|
|
121
|
+
new_attrs[6] = attrs[6][:]
|
|
122
|
+
for k, v in cc.items(): new_attrs[6][k] = v
|
|
100
123
|
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
|
101
124
|
return fd, attrs
|
|
102
125
|
|
|
@@ -106,16 +129,25 @@ def _restore_termios(state):
|
|
|
106
129
|
|
|
107
130
|
|
|
108
131
|
def main():
|
|
132
|
+
# Establish our own SIGINT disposition rather than inherit the parent's: a supervisor that ignores
|
|
133
|
+
# SIGINT would otherwise pass SIG_IGN down (inherited across exec), leaving this worker uninterruptible
|
|
134
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
109
135
|
print("please wait, loading...", flush=True)
|
|
110
136
|
_set_default_dirs()
|
|
111
137
|
shell = _make_shell()
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
inspectors = _load_inspectors()
|
|
139
|
+
# ONLCR off so protocol output stays bare LF; ECHO off (echoed input corrupts the protocol) and ICANON
|
|
140
|
+
# off (canonical mode drops bytes past MAX_CANON with BEL spam; VMIN/VTIME make non-canonical reads
|
|
141
|
+
# return per byte; ISIG stays on so ^C still interrupts)
|
|
142
|
+
output_state = _tty_clear(sys.__stdout__, tty.OFLAG, termios.ONLCR)
|
|
143
|
+
echo_state = _tty_clear(sys.stdin, tty.LFLAG, termios.ECHO | termios.ICANON, {termios.VMIN: 1, termios.VTIME: 0})
|
|
114
144
|
delim = _new_delim()
|
|
115
145
|
print("loading complete. first delimiter:", flush=True)
|
|
116
146
|
_write_response(delim)
|
|
117
147
|
try:
|
|
118
|
-
|
|
148
|
+
while True:
|
|
149
|
+
line = _next_line(sys.stdin)
|
|
150
|
+
if not line: break
|
|
119
151
|
line = line.rstrip("\n")
|
|
120
152
|
if line == _MULTILINE:
|
|
121
153
|
code, err = _read_block(sys.stdin, delim)
|
|
@@ -124,12 +156,12 @@ def main():
|
|
|
124
156
|
continue
|
|
125
157
|
else: code = line
|
|
126
158
|
print(".", flush=True)
|
|
127
|
-
try: _, outputs = _execute(shell, code)
|
|
159
|
+
try: _, outputs = _execute(shell, inspectors, code)
|
|
128
160
|
except BaseException: outputs = _format_error("internal-error", traceback.format_exc())
|
|
129
161
|
_write_response(delim, outputs)
|
|
130
162
|
if _should_exit(shell): break
|
|
131
163
|
finally:
|
|
132
|
-
|
|
164
|
+
_restore_termios(echo_state)
|
|
133
165
|
_restore_termios(output_state)
|
|
134
166
|
|
|
135
167
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"MCP server supervising a persistent `clikernel` CLI worker subprocess."
|
|
2
|
+
import asyncio,atexit,os,signal,sys,traceback
|
|
3
|
+
|
|
4
|
+
_MARKER = "loading complete. first delimiter:"
|
|
5
|
+
_DIED = "NOTE: the kernel process had died; a fresh one was started, and all previous session state (imports, variables, monkeypatches) is gone.\n"
|
|
6
|
+
_MULTILINE = "--"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Worker:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.proc,self.delim,self.started,self.busy,self.desynced = None,None,False,False,False
|
|
12
|
+
self.lock = asyncio.Lock()
|
|
13
|
+
|
|
14
|
+
def alive(self): return self.proc is not None and self.proc.returncode is None
|
|
15
|
+
|
|
16
|
+
async def start(self):
|
|
17
|
+
PIPE = asyncio.subprocess.PIPE
|
|
18
|
+
self.proc = await asyncio.create_subprocess_exec(sys.executable, "-m", "clikernel.cli", limit=2**24, stdin=PIPE, stdout=PIPE)
|
|
19
|
+
while True:
|
|
20
|
+
line = (await self.proc.stdout.readline()).decode()
|
|
21
|
+
if not line: raise RuntimeError("clikernel worker failed to start")
|
|
22
|
+
if line.rstrip("\n") == _MARKER: break
|
|
23
|
+
self.delim = (await self.proc.stdout.readline()).decode().rstrip("\n")
|
|
24
|
+
self.started,self.busy,self.desynced = True,False,False
|
|
25
|
+
|
|
26
|
+
async def kill(self):
|
|
27
|
+
if self.alive():
|
|
28
|
+
self.proc.kill()
|
|
29
|
+
await self.proc.wait()
|
|
30
|
+
|
|
31
|
+
async def run(self, code):
|
|
32
|
+
"Send `code`; return `(acked, body)`. `body` None means the worker died; retry is only safe if it never acked."
|
|
33
|
+
msg = f"{_MULTILINE}\n{code}\n{self.delim}\n" if "\n" in code or code == _MULTILINE else code + "\n"
|
|
34
|
+
try:
|
|
35
|
+
self.proc.stdin.write(msg.encode())
|
|
36
|
+
await self.proc.stdin.drain()
|
|
37
|
+
except Exception: return False, None # write failed before the worker accepted anything: safe to retry
|
|
38
|
+
self.busy = True
|
|
39
|
+
try:
|
|
40
|
+
if not (await self.proc.stdout.readline()): return False, None # "." ack
|
|
41
|
+
lines = []
|
|
42
|
+
while True:
|
|
43
|
+
line = (await self.proc.stdout.readline()).decode()
|
|
44
|
+
if not line: return True, None
|
|
45
|
+
if line.rstrip("\n") == self.delim: return True, "".join(lines).removesuffix("\n")
|
|
46
|
+
lines.append(line)
|
|
47
|
+
except asyncio.CancelledError:
|
|
48
|
+
self.desynced = True
|
|
49
|
+
raise
|
|
50
|
+
except Exception:
|
|
51
|
+
self.desynced = True # unexpected read failure (e.g. a line past the buffer limit): rebuild on the next call
|
|
52
|
+
return True, None
|
|
53
|
+
finally: self.busy = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _errbox(): return "<internal-error>\n" + traceback.format_exc() + "</internal-error>"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _kill_worker(w):
|
|
60
|
+
"Reap the worker child so it can never outlive the supervisor (signal-handler safe: no await)"
|
|
61
|
+
p = w.proc
|
|
62
|
+
if p is not None and p.returncode is None:
|
|
63
|
+
try: os.kill(p.pid, signal.SIGKILL)
|
|
64
|
+
except OSError: pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _install_signal_guards(w):
|
|
68
|
+
"A stray group SIGINT must not fell the supervisor; SIGTERM/SIGHUP shut down cleanly, always taking the worker with us (never orphan it)"
|
|
69
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
70
|
+
def _shutdown(signum, frame):
|
|
71
|
+
_kill_worker(w)
|
|
72
|
+
os._exit(0)
|
|
73
|
+
for s in (signal.SIGTERM, signal.SIGHUP):
|
|
74
|
+
try: signal.signal(s, _shutdown)
|
|
75
|
+
except (ValueError, OSError): pass # platform lacks the signal, or not on the main thread
|
|
76
|
+
atexit.register(_kill_worker, w)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
from mcp.server.fastmcp import FastMCP
|
|
81
|
+
mcp = FastMCP("clikernel")
|
|
82
|
+
w = _Worker()
|
|
83
|
+
_install_signal_guards(w)
|
|
84
|
+
|
|
85
|
+
@mcp.tool(structured_output=False)
|
|
86
|
+
async def execute(code:str # IPython-compatible code to run in the persistent session
|
|
87
|
+
)->str: # Rendered outputs (stdout, display data, last-expression result, errors)
|
|
88
|
+
"Run `code` in the persistent IPython session, keeping state across calls (imports, variables, monkeypatches, cached objects). If the kernel process has died since the last call, a fresh one is started automatically and the response notes that session state was lost."
|
|
89
|
+
try:
|
|
90
|
+
async with w.lock:
|
|
91
|
+
note = ""
|
|
92
|
+
if w.desynced: await w.kill()
|
|
93
|
+
if not w.alive():
|
|
94
|
+
if w.started: note = _DIED
|
|
95
|
+
await w.start()
|
|
96
|
+
acked, body = await w.run(code)
|
|
97
|
+
if body is None and not acked: # died before accepting the request: safe to retry on a fresh worker
|
|
98
|
+
note = _DIED
|
|
99
|
+
await w.start()
|
|
100
|
+
acked, body = await w.run(code)
|
|
101
|
+
if body is None:
|
|
102
|
+
return note + "<internal-error>\nkernel process died while executing this request; a fresh kernel will be started on the next call, with all session state lost\n</internal-error>"
|
|
103
|
+
return note + body
|
|
104
|
+
except Exception:
|
|
105
|
+
w.desynced = True
|
|
106
|
+
return _errbox()
|
|
107
|
+
|
|
108
|
+
@mcp.tool(structured_output=False)
|
|
109
|
+
async def restart()->str:
|
|
110
|
+
"Kill the kernel process and start a fresh one: new pid, `sys.modules` genuinely reset, all session state (imports, variables, monkeypatches, cached objects) discarded. Use for a clean slate, after rebuilding a native extension, or after reloading a module that other already-imported modules had patched (symptoms: a stale-class bug where `isinstance`/`is` checks mysteriously fail, or a class is missing a method you know it has). Also works when `execute` is stuck: the stuck call returns an error and the kernel comes back fresh. After restarting, redo any imports/setup the task still needs."
|
|
111
|
+
try:
|
|
112
|
+
await w.kill()
|
|
113
|
+
async with w.lock: await w.start()
|
|
114
|
+
return "restarted"
|
|
115
|
+
except Exception: return _errbox()
|
|
116
|
+
|
|
117
|
+
@mcp.tool(structured_output=False)
|
|
118
|
+
async def interrupt()->str:
|
|
119
|
+
"Interrupt the code the kernel is currently running (SIGINT, i.e. KeyboardInterrupt): the in-flight `execute` call returns with a KeyboardInterrupt traceback, and session state survives. Prefer this over `restart` when a call is merely taking too long. Only meaningful while an `execute` call is running."
|
|
120
|
+
try:
|
|
121
|
+
if not (w.alive() and w.busy): return "nothing is running"
|
|
122
|
+
w.proc.send_signal(signal.SIGINT)
|
|
123
|
+
return "interrupt sent; the running `execute` call will return with a KeyboardInterrupt"
|
|
124
|
+
except Exception: return _errbox()
|
|
125
|
+
|
|
126
|
+
mcp.run()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__": main()
|
|
@@ -13,7 +13,7 @@ There are two ways to drive it, depending on what the host supports:
|
|
|
13
13
|
|
|
14
14
|
# MCP tools
|
|
15
15
|
|
|
16
|
-
`execute`, `restart`, and `
|
|
16
|
+
`execute`, `restart`, and `interrupt` are self-documenting -- read each tool's own MCP description rather than looking for docs elsewhere. `restart` gives a genuinely fresh interpreter process (new pid, `sys.modules` reset); `interrupt` stops a too-long-running `execute` while keeping session state.
|
|
17
17
|
|
|
18
18
|
# CLI protocol
|
|
19
19
|
|
|
@@ -45,7 +45,7 @@ Do not look for an IPython prompt, do not use `%cpaste`, and do not invent your
|
|
|
45
45
|
|
|
46
46
|
Python exceptions render as normal notebook error output. Protocol/worker failures render with readable XML-ish error tags, then the session delimiter.
|
|
47
47
|
|
|
48
|
-
To end the session, send `exit`. In CLI mode there is no `restart` tool -- starting a fresh process *is* the restart, and gives a genuinely fresh interpreter (the equivalent of the MCP `
|
|
48
|
+
To end the session, send `exit`. In CLI mode there is no `restart` tool -- starting a fresh process *is* the restart, and gives a genuinely fresh interpreter (the equivalent of the MCP `restart` tool).
|
|
49
49
|
|
|
50
50
|
# Notebook magics
|
|
51
51
|
|
|
@@ -85,7 +85,7 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty stream
|
|
|
85
85
|
|
|
86
86
|
- Like Jupyter, only the *last* expression in a cell is printed/returned. `print(...)` any earlier value you need to see.
|
|
87
87
|
- Don't re-run an `import` you've already run this session -- it's persistent, so it's already done. Use `importlib.reload` if you've changed a module and need the change picked up. If a previously-imported name raises `NameError`, the session restarted -- redo whatever imports/setup the task needs.
|
|
88
|
-
- `importlib.reload`ing a module is not always enough to pick up a change: other already-imported modules that did `from x import *`, or that monkeypatched one of its classes (e.g. via fastcore's `@patch`), hold stale references that a targeted reload won't fix. If you hit a stale-class symptom (a class missing a method you know it has, `isinstance` mysteriously failing), you need a fresh interpreter process: the MCP `
|
|
88
|
+
- `importlib.reload`ing a module is not always enough to pick up a change: other already-imported modules that did `from x import *`, or that monkeypatched one of its classes (e.g. via fastcore's `@patch`), hold stale references that a targeted reload won't fix. If you hit a stale-class symptom (a class missing a method you know it has, `isinstance` mysteriously failing), you need a fresh interpreter process: the MCP `restart` tool, or in CLI mode, exit and start a new process.
|
|
89
89
|
- Everything a cell outputs lands in the conversation and stays there. Be surgical: inspect only what's needed, and don't dump large values -- `print(len(v))` first, then decide whether to print in full or filter down.
|
|
90
90
|
- The kernel is scoped to one client session and shared by any subagents within it. If the server restarts or exits, in-memory state is gone -- redo imports and setup.
|
|
91
91
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A tiny stdin/stdout IPython worker
|
|
5
5
|
Author: clikernel contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,7 +10,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: execnb>=0.2.
|
|
13
|
+
Requires-Dist: execnb>=0.2.4
|
|
14
|
+
Requires-Dist: fastcore
|
|
14
15
|
Requires-Dist: mcp
|
|
15
16
|
Provides-Extra: dev
|
|
16
17
|
Requires-Dist: fastship; extra == "dev"
|
|
@@ -90,6 +91,14 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output
|
|
|
90
91
|
|
|
91
92
|
`clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
|
|
92
93
|
|
|
94
|
+
## Inspectors
|
|
95
|
+
|
|
96
|
+
`clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
|
|
97
|
+
|
|
98
|
+
Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
|
|
99
|
+
|
|
100
|
+
See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
|
|
101
|
+
|
|
93
102
|
## Why The Protocol Is Odd
|
|
94
103
|
|
|
95
104
|
`clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
|
|
@@ -120,3 +129,4 @@ ship-gh
|
|
|
120
129
|
ship-pypi
|
|
121
130
|
ship-bump # dev release always later than prod release
|
|
122
131
|
```
|
|
132
|
+
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import json,os,pty,re,select,shutil,subprocess,tempfile,time
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
1
|
+
import json,os,pty,re,select,shutil,signal,subprocess,tempfile,time
|
|
4
2
|
|
|
5
3
|
DELIM_RE = re.compile(r"--[A-Za-z0-9]{5}")
|
|
6
4
|
TIMEOUT = 5
|
|
@@ -40,9 +38,7 @@ def read_until_ready(proc, timeout=TIMEOUT):
|
|
|
40
38
|
lines.append(line)
|
|
41
39
|
|
|
42
40
|
|
|
43
|
-
def
|
|
44
|
-
cmd = shutil.which("clikernel")
|
|
45
|
-
assert cmd, "clikernel console script is not on PATH"
|
|
41
|
+
def _env(tmp_path):
|
|
46
42
|
env = os.environ.copy()
|
|
47
43
|
state = os.path.join(tempfile.gettempdir(), f"clikernel-{os.getuid()}")
|
|
48
44
|
env["CLIKERNEL_STATE_DIR"] = str(tmp_path / "state")
|
|
@@ -50,6 +46,14 @@ def start_kernel(tmp_path):
|
|
|
50
46
|
env.setdefault("MPLCONFIGDIR", os.path.join(state, "matplotlib"))
|
|
51
47
|
env.setdefault("MPLBACKEND", "Agg")
|
|
52
48
|
env["PYTHONUNBUFFERED"] = "1"
|
|
49
|
+
return env
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def start_kernel(tmp_path, extra_env=None):
|
|
53
|
+
cmd = shutil.which("clikernel")
|
|
54
|
+
assert cmd, "clikernel console script is not on PATH"
|
|
55
|
+
env = _env(tmp_path)
|
|
56
|
+
if extra_env: env.update(extra_env)
|
|
53
57
|
proc = subprocess.Popen([cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
|
54
58
|
proc._stdout_buffer = bytearray()
|
|
55
59
|
return proc
|
|
@@ -58,15 +62,8 @@ def start_kernel(tmp_path):
|
|
|
58
62
|
def start_kernel_pty(tmp_path):
|
|
59
63
|
cmd = shutil.which("clikernel")
|
|
60
64
|
assert cmd, "clikernel console script is not on PATH"
|
|
61
|
-
env = os.environ.copy()
|
|
62
|
-
state = os.path.join(tempfile.gettempdir(), f"clikernel-{os.getuid()}")
|
|
63
|
-
env["CLIKERNEL_STATE_DIR"] = str(tmp_path / "state")
|
|
64
|
-
env.setdefault("IPYTHONDIR", os.path.join(state, "ipython"))
|
|
65
|
-
env.setdefault("MPLCONFIGDIR", os.path.join(state, "matplotlib"))
|
|
66
|
-
env.setdefault("MPLBACKEND", "Agg")
|
|
67
|
-
env["PYTHONUNBUFFERED"] = "1"
|
|
68
65
|
master, slave = pty.openpty()
|
|
69
|
-
try: proc = subprocess.Popen([cmd], stdin=slave, stdout=slave, stderr=subprocess.PIPE, env=
|
|
66
|
+
try: proc = subprocess.Popen([cmd], stdin=slave, stdout=slave, stderr=subprocess.PIPE, env=_env(tmp_path), close_fds=True)
|
|
70
67
|
finally: os.close(slave)
|
|
71
68
|
proc._pty_master = master
|
|
72
69
|
proc._pty_buffer = bytearray()
|
|
@@ -122,6 +119,7 @@ def read_pty_until_ready(proc, timeout=TIMEOUT):
|
|
|
122
119
|
if DELIM_RE.fullmatch(s): return "".join(lines), s
|
|
123
120
|
lines.append(line)
|
|
124
121
|
|
|
122
|
+
|
|
125
123
|
def _read_pty_rawline(proc, timeout):
|
|
126
124
|
buf = proc._pty_buffer
|
|
127
125
|
while b"\n" not in buf:
|
|
@@ -147,163 +145,141 @@ def read_pty_raw_until_ready(proc, timeout=TIMEOUT):
|
|
|
147
145
|
body.extend(line)
|
|
148
146
|
|
|
149
147
|
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
NB_CELLS = [("aaa111", "x = 41\nprint('one')"),
|
|
149
|
+
("bbb222", "#| export\nprint('two', x + 1)"),
|
|
150
|
+
("ccc333", "print('three')")]
|
|
151
|
+
|
|
152
|
+
def make_nb(path):
|
|
153
|
+
cells = [dict(cell_type="code", id=i, metadata={}, outputs=[], execution_count=None, source=src) for i,src in NB_CELLS]
|
|
154
|
+
path.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_cli(tmp_path):
|
|
159
|
+
"One pipe-driven worker through the whole protocol, ending with exit(); then a fresh worker for quit()."
|
|
152
160
|
proc = start_kernel(tmp_path)
|
|
153
161
|
try:
|
|
154
162
|
body, delim = read_until_ready(proc)
|
|
155
|
-
|
|
163
|
+
assert body == "please wait, loading...\nloading complete. first delimiter:\n"
|
|
164
|
+
assert DELIM_RE.fullmatch(delim)
|
|
165
|
+
|
|
166
|
+
# ack arrives before the result; result then same delimiter
|
|
167
|
+
proc.stdin.write(b"1+1\n")
|
|
168
|
+
proc.stdin.flush()
|
|
169
|
+
assert _readline(proc, TIMEOUT) == ".\n"
|
|
170
|
+
body, nd = read_until_ready(proc)
|
|
171
|
+
assert body == "2\n" and nd == delim
|
|
172
|
+
|
|
173
|
+
# state persists
|
|
174
|
+
assert send(proc, "x=41\n")[0] == ""
|
|
175
|
+
assert send(proc, "x+1\n")[0] == "42\n"
|
|
176
|
+
|
|
177
|
+
# multiline block; multiple outputs use xmlish blocks
|
|
178
|
+
assert send(proc, f"--\ndef f(x):\n return x + 1\n\nf(41)\n{delim}\n")[0] == "42\n"
|
|
179
|
+
code = ("--\nfrom IPython.display import Markdown, display\nprint('hello')\n"
|
|
180
|
+
f"display(Markdown('**shown**'))\n42\n{delim}\n")
|
|
181
|
+
body, _ = send(proc, code)
|
|
182
|
+
assert '<stdout>\nhello\n</stdout>\n' in body
|
|
183
|
+
assert '<display_data mime="text/markdown">\n**shown**\n</display_data>\n' in body
|
|
184
|
+
assert '<execute_result>\n42\n</execute_result>\n' in body
|
|
185
|
+
|
|
186
|
+
# errors: clean, single traceback, no ansi, genuine stdout preserved
|
|
187
|
+
body, nd = send(proc, "1/0\n")
|
|
188
|
+
assert "ZeroDivisionError" in body and nd == delim
|
|
189
|
+
assert "\x1b[" not in body and body.count("ZeroDivisionError") == 1 and "<stdout>" not in body
|
|
190
|
+
body, _ = send(proc, f"--\nprint('before')\n1/0\n{delim}\n")
|
|
191
|
+
assert "<stdout>\nbefore\n</stdout>" in body and body.count("ZeroDivisionError") == 1
|
|
192
|
+
|
|
193
|
+
assert send(proc, "get_ipython().history_manager.enabled\n")[0] == "False\n"
|
|
194
|
+
|
|
195
|
+
# SIGINT: ignored while idle (non-tty); interrupts running code
|
|
196
|
+
time.sleep(0.2)
|
|
197
|
+
proc.send_signal(signal.SIGINT)
|
|
198
|
+
time.sleep(0.2)
|
|
199
|
+
assert proc.poll() is None
|
|
200
|
+
assert send(proc, "1+1\n")[0] == "2\n"
|
|
201
|
+
proc.stdin.write(b"import time; time.sleep(30)\n")
|
|
202
|
+
proc.stdin.flush()
|
|
203
|
+
assert _readline(proc, TIMEOUT) == ".\n"
|
|
204
|
+
time.sleep(0.3)
|
|
205
|
+
proc.send_signal(signal.SIGINT)
|
|
206
|
+
body, _ = read_until_ready(proc)
|
|
207
|
+
assert "KeyboardInterrupt" in body
|
|
208
|
+
assert send(proc, "1+1\n")[0] == "2\n"
|
|
209
|
+
|
|
210
|
+
# notebook magics
|
|
211
|
+
nb = make_nb(tmp_path/"t.ipynb")
|
|
212
|
+
assert "error" not in send(proc, f"%nbopen {nb}\n")[0].lower()
|
|
213
|
+
body, _ = send(proc, "%nbrun aaa\n")
|
|
214
|
+
assert "--- aaa111 ---" in body and "one" in body
|
|
215
|
+
body, _ = send(proc, "%nbrun bbb222 --above\n")
|
|
216
|
+
assert "one" in body and "two 42" in body
|
|
217
|
+
body, _ = send(proc, "%nbrun --all --exported\n")
|
|
218
|
+
assert "two 42" in body and "one" not in body and "three" not in body
|
|
219
|
+
|
|
220
|
+
# exit(): empty body, final delimiter, clean stop
|
|
221
|
+
body, nd = send(proc, "exit()\n")
|
|
222
|
+
assert body == "" and nd == delim
|
|
223
|
+
proc.wait(timeout=TIMEOUT)
|
|
224
|
+
assert proc.returncode == 0
|
|
156
225
|
finally: stop_kernel(proc)
|
|
157
226
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
proc, _, delim = kernel
|
|
167
|
-
body, next_delim = send(proc, "1+1\n")
|
|
168
|
-
assert body == "2\n"
|
|
169
|
-
assert DELIM_RE.fullmatch(next_delim)
|
|
170
|
-
assert next_delim == delim
|
|
227
|
+
proc = start_kernel(tmp_path)
|
|
228
|
+
try:
|
|
229
|
+
_, delim = read_until_ready(proc)
|
|
230
|
+
body, nd = send(proc, "quit()\n")
|
|
231
|
+
assert body == "" and nd == delim
|
|
232
|
+
proc.wait(timeout=TIMEOUT)
|
|
233
|
+
assert proc.returncode == 0
|
|
234
|
+
finally: stop_kernel(proc)
|
|
171
235
|
|
|
172
236
|
|
|
173
|
-
def
|
|
237
|
+
def test_cli_tty(tmp_path):
|
|
238
|
+
"One pty-driven worker: input isn't echoed, and output newlines stay LF (no ONLCR CR)."
|
|
174
239
|
proc = start_kernel_pty(tmp_path)
|
|
175
240
|
try:
|
|
176
241
|
body, delim = read_pty_until_ready(proc)
|
|
177
242
|
assert body == "please wait, loading...\nloading complete. first delimiter:\n"
|
|
178
243
|
os.write(proc._pty_master, b"1+1\n")
|
|
179
244
|
assert _read_ptyline(proc, TIMEOUT) == ".\n"
|
|
180
|
-
body,
|
|
181
|
-
assert body == "2\n"
|
|
182
|
-
assert next_delim == delim
|
|
183
|
-
finally: stop_kernel(proc)
|
|
184
|
-
|
|
185
|
-
def test_tty_output_keeps_newlines_as_lf(tmp_path):
|
|
186
|
-
proc = start_kernel_pty(tmp_path)
|
|
187
|
-
try:
|
|
188
|
-
_, delim = read_pty_until_ready(proc)
|
|
245
|
+
body, nd = read_pty_until_ready(proc)
|
|
246
|
+
assert body == "2\n" and nd == delim
|
|
189
247
|
os.write(proc._pty_master, b"print('hello')\n")
|
|
190
|
-
body,
|
|
248
|
+
body, nd2, raw_delim = read_pty_raw_until_ready(proc)
|
|
191
249
|
assert body == b".\nhello\n"
|
|
192
|
-
assert raw_delim ==
|
|
250
|
+
assert raw_delim == nd2.encode() + b"\n"
|
|
193
251
|
assert b"\r" not in body + raw_delim
|
|
194
|
-
assert
|
|
195
|
-
finally: stop_kernel(proc)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def test_request_ack_is_written_before_result(kernel):
|
|
199
|
-
proc, _, delim = kernel
|
|
200
|
-
assert proc.stdin is not None
|
|
201
|
-
proc.stdin.write(b"1+1\n")
|
|
202
|
-
proc.stdin.flush()
|
|
203
|
-
assert _readline(proc, TIMEOUT) == ".\n"
|
|
204
|
-
body, next_delim = read_until_ready(proc)
|
|
205
|
-
assert body == "2\n"
|
|
206
|
-
assert next_delim == delim
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def test_state_persists_across_requests(kernel):
|
|
210
|
-
proc, _, _ = kernel
|
|
211
|
-
body, _ = send(proc, "x=41\n")
|
|
212
|
-
assert body == ""
|
|
213
|
-
body, _ = send(proc, "x+1\n")
|
|
214
|
-
assert body == "42\n"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def test_multiline_request_uses_current_delimiter(kernel):
|
|
218
|
-
proc, _, delim = kernel
|
|
219
|
-
body, _ = send(proc, f"--\ndef f(x):\n return x + 1\n\nf(41)\n{delim}\n")
|
|
220
|
-
assert body == "42\n"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_multiple_outputs_use_xmlish_blocks(kernel):
|
|
224
|
-
proc, _, delim = kernel
|
|
225
|
-
code = (
|
|
226
|
-
"--\n"
|
|
227
|
-
"from IPython.display import Markdown, display\n"
|
|
228
|
-
"print('hello')\n"
|
|
229
|
-
"display(Markdown('**shown**'))\n"
|
|
230
|
-
"42\n"
|
|
231
|
-
f"{delim}\n")
|
|
232
|
-
body, _ = send(proc, code)
|
|
233
|
-
assert '<stdout>\nhello\n</stdout>\n' in body
|
|
234
|
-
assert '<display_data mime="text/markdown">\n**shown**\n</display_data>\n' in body
|
|
235
|
-
assert '<execute_result>\n42\n</execute_result>\n' in body
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def test_runtime_errors_return_error_text_and_same_delimiter(kernel):
|
|
239
|
-
proc, _, delim = kernel
|
|
240
|
-
body, next_delim = send(proc, "1/0\n")
|
|
241
|
-
assert "ZeroDivisionError" in body
|
|
242
|
-
assert DELIM_RE.fullmatch(next_delim)
|
|
243
|
-
assert next_delim == delim
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def test_error_has_no_ansi_and_no_duplicate_traceback(kernel):
|
|
247
|
-
proc, _, _ = kernel
|
|
248
|
-
body, _ = send(proc, "1/0\n")
|
|
249
|
-
assert "\x1b[" not in body # no ANSI colour codes
|
|
250
|
-
assert body.count("ZeroDivisionError") == 1 # single traceback, not duplicated
|
|
251
|
-
assert "<stdout>" not in body # no captured stdout traceback copy
|
|
252
|
+
assert nd2 == delim
|
|
252
253
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
@pytest.mark.parametrize("cmd", ["exit()", "quit()"])
|
|
262
|
-
def test_exit_request_returns_final_delimiter_and_stops_process(kernel, cmd):
|
|
263
|
-
proc, _, start_delim = kernel
|
|
264
|
-
body, delim = send(proc, f"{cmd}\n")
|
|
265
|
-
assert body == ""
|
|
266
|
-
assert DELIM_RE.fullmatch(delim)
|
|
267
|
-
assert delim == start_delim
|
|
268
|
-
proc.wait(timeout=TIMEOUT)
|
|
269
|
-
assert proc.returncode == 0
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def test_history_is_disabled(kernel):
|
|
273
|
-
proc, _, _ = kernel
|
|
274
|
-
body, _ = send(proc, "get_ipython().history_manager.enabled\n")
|
|
275
|
-
assert body == "False\n"
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
NB_CELLS = [("aaa111", "x = 41\nprint('one')"),
|
|
279
|
-
("bbb222", "#| export\nprint('two', x + 1)"),
|
|
280
|
-
("ccc333", "print('three')")]
|
|
281
|
-
|
|
282
|
-
def make_nb(path):
|
|
283
|
-
cells = [dict(cell_type="code", id=i, metadata={}, outputs=[], execution_count=None, source=src)
|
|
284
|
-
for i,src in NB_CELLS]
|
|
285
|
-
path.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
|
|
286
|
-
return path
|
|
254
|
+
# long lines must survive the pty: canonical mode drops bytes past MAX_CANON and spams BEL
|
|
255
|
+
os.write(proc._pty_master, f"--\ns = 'b' * 2\ns += 'b{'b' * 5000}'\nlen(s)\n{delim}\n".encode())
|
|
256
|
+
assert _read_ptyline(proc, TIMEOUT) == ".\n"
|
|
257
|
+
body, nd = read_pty_until_ready(proc)
|
|
258
|
+
assert body == "5003\n" and nd == delim
|
|
259
|
+
finally: stop_kernel(proc)
|
|
287
260
|
|
|
288
261
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
proc
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
262
|
+
INSPECTORS_SRC = r'''
|
|
263
|
+
import ast
|
|
264
|
+
def inspect(tree):
|
|
265
|
+
for n in ast.walk(tree):
|
|
266
|
+
if isinstance(n, ast.Import) and any(a.name == "subprocess" for a in n.names):
|
|
267
|
+
return "<reminder>\nuse the Bash tool, not subprocess\n</reminder>\n"
|
|
268
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "blockme":
|
|
269
|
+
raise RuntimeError("blocked by policy")
|
|
270
|
+
'''
|
|
271
|
+
|
|
272
|
+
def test_cli_inspectors(tmp_path):
|
|
273
|
+
"Inspectors loaded from XDG inspectors.py: a returned note is prepended to output, and a raising inspector blocks the cell before it runs."
|
|
274
|
+
xdg = tmp_path/"xdg"
|
|
275
|
+
(xdg/"clikernel").mkdir(parents=True)
|
|
276
|
+
(xdg/"clikernel"/"inspectors.py").write_text(INSPECTORS_SRC)
|
|
277
|
+
proc = start_kernel(tmp_path, {"XDG_CONFIG_HOME": str(xdg)})
|
|
278
|
+
try:
|
|
279
|
+
read_until_ready(proc)
|
|
280
|
+
body, _ = send(proc, "import subprocess\n")
|
|
281
|
+
assert "use the Bash tool" in body # note prepended, cell still ran (no error)
|
|
282
|
+
assert send(proc, "1+1\n")[0] == "2\n" # unrelated cell: no note
|
|
283
|
+
body, _ = send(proc, "blockme()\n")
|
|
284
|
+
assert "blocked by policy" in body and "NameError" not in body # blocked before running
|
|
285
|
+
finally: stop_kernel(proc)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import asyncio, json, os, shutil, signal, sys, tempfile
|
|
2
|
+
import pytest
|
|
3
|
+
from mcp import ClientSession, StdioServerParameters
|
|
4
|
+
from mcp.client.stdio import stdio_client
|
|
5
|
+
from clikernel.mcp import _Worker, _install_signal_guards, _kill_worker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _server_params():
|
|
9
|
+
cmd = shutil.which("clikernel-mcp")
|
|
10
|
+
args = [] if cmd else ["-m", "clikernel.mcp"]
|
|
11
|
+
cmd = cmd or sys.executable
|
|
12
|
+
env = dict(os.environ, CLIKERNEL_STATE_DIR=tempfile.mkdtemp(prefix="clikernel-mcp-test-"))
|
|
13
|
+
return StdioServerParameters(command=cmd, args=args, env=env)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _text(s, name, **args):
|
|
17
|
+
res = await s.call_tool(name, args)
|
|
18
|
+
return res.content[0].text if res.content else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.anyio
|
|
22
|
+
async def test_mcp(tmp_path):
|
|
23
|
+
"One server through everything: execute semantics, magics, restart, interrupt, and worker-death recovery."
|
|
24
|
+
async with stdio_client(_server_params()) as (r, w), ClientSession(r, w) as s:
|
|
25
|
+
await s.initialize()
|
|
26
|
+
assert {t.name for t in (await s.list_tools()).tools} == {"execute", "restart", "interrupt"}
|
|
27
|
+
|
|
28
|
+
# execute: results, top-level await, state, xmlish outputs, clean errors
|
|
29
|
+
assert await _text(s, "execute", code="40+2") == "42"
|
|
30
|
+
assert await _text(s, "execute", code="import asyncio\nawait asyncio.sleep(0)\n42") == "42"
|
|
31
|
+
await _text(s, "execute", code="x = 41")
|
|
32
|
+
assert await _text(s, "execute", code="x + 1") == "42"
|
|
33
|
+
r_ = await _text(s, "execute", code="print('hi'); 99")
|
|
34
|
+
assert "<stdout>\nhi\n</stdout>" in r_ and "<execute_result>\n99\n</execute_result>" in r_
|
|
35
|
+
r_ = await _text(s, "execute", code="1/0")
|
|
36
|
+
assert "ZeroDivisionError" in r_ and "\x1b[" not in r_ and r_.count("ZeroDivisionError") == 1
|
|
37
|
+
|
|
38
|
+
# notebook magics
|
|
39
|
+
cells = [dict(cell_type="code", id="aaa111", metadata={}, outputs=[], execution_count=None, source="print('one')")]
|
|
40
|
+
nb = tmp_path/"t.ipynb"
|
|
41
|
+
nb.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
|
|
42
|
+
await _text(s, "execute", code=f"%nbopen {nb}")
|
|
43
|
+
r_ = await _text(s, "execute", code="%nbrun aaa")
|
|
44
|
+
assert "--- aaa111 ---" in r_ and "one" in r_
|
|
45
|
+
|
|
46
|
+
# restart: clean return, fresh pid, sys.modules genuinely reset, kernel usable
|
|
47
|
+
pid1 = int(await _text(s, "execute", code="import os, sys; sys.modules['fakemod'] = sys; os.getpid()"))
|
|
48
|
+
assert await _text(s, "restart") == "restarted"
|
|
49
|
+
pid2 = int(await _text(s, "execute", code="import os; os.getpid()"))
|
|
50
|
+
assert pid2 != pid1
|
|
51
|
+
assert await _text(s, "execute", code="import sys; 'fakemod' in sys.modules") == "False"
|
|
52
|
+
assert "NameError" in await _text(s, "execute", code="x")
|
|
53
|
+
|
|
54
|
+
# interrupt: idle reports so; a running execute returns KeyboardInterrupt and state survives
|
|
55
|
+
assert "nothing" in (await _text(s, "interrupt")).lower()
|
|
56
|
+
task = asyncio.create_task(s.call_tool("execute", {"code": "import time; time.sleep(30); 'fin'+'ished'"}))
|
|
57
|
+
await asyncio.sleep(1)
|
|
58
|
+
assert "interrupt" in (await _text(s, "interrupt")).lower()
|
|
59
|
+
out = (await task).content[0].text
|
|
60
|
+
assert "KeyboardInterrupt" in out and "finished" not in out
|
|
61
|
+
assert await _text(s, "execute", code="40+2") == "42"
|
|
62
|
+
|
|
63
|
+
# exit() run as code recycles the worker: next call notes lost state
|
|
64
|
+
await _text(s, "execute", code="exit()")
|
|
65
|
+
r_ = await _text(s, "execute", code="40+2")
|
|
66
|
+
assert "42" in r_ and "state" in r_
|
|
67
|
+
|
|
68
|
+
# externally killed while idle: next call self-heals with a note
|
|
69
|
+
pid3 = int(await _text(s, "execute", code="import os; os.getpid()"))
|
|
70
|
+
os.kill(pid3, signal.SIGKILL)
|
|
71
|
+
r_ = await _text(s, "execute", code="40+2")
|
|
72
|
+
assert "42" in r_ and "state" in r_
|
|
73
|
+
|
|
74
|
+
# killed mid-execute: that call reports the death; the next one recovers
|
|
75
|
+
pid4 = int(await _text(s, "execute", code="import os; os.getpid()"))
|
|
76
|
+
task = asyncio.create_task(s.call_tool("execute", {"code": "import time; time.sleep(30)"}))
|
|
77
|
+
await asyncio.sleep(1)
|
|
78
|
+
os.kill(pid4, signal.SIGKILL)
|
|
79
|
+
assert "died" in (await task).content[0].text
|
|
80
|
+
r_ = await _text(s, "execute", code="40+2")
|
|
81
|
+
assert "42" in r_
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.anyio
|
|
85
|
+
async def test_supervisor_guards(monkeypatch, tmp_path):
|
|
86
|
+
"SIGINT can't fell the supervisor, SIGTERM/SIGHUP get a clean-shutdown handler, and the worker child is reaped so it never outlives us."
|
|
87
|
+
monkeypatch.setenv("CLIKERNEL_STATE_DIR", str(tmp_path))
|
|
88
|
+
orig = {s: signal.getsignal(s) for s in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
|
89
|
+
w = _Worker()
|
|
90
|
+
try:
|
|
91
|
+
await w.start()
|
|
92
|
+
pid = w.proc.pid
|
|
93
|
+
_install_signal_guards(w)
|
|
94
|
+
assert signal.getsignal(signal.SIGINT) is signal.SIG_IGN
|
|
95
|
+
for s in (signal.SIGTERM, signal.SIGHUP):
|
|
96
|
+
assert callable(signal.getsignal(s)) and signal.getsignal(s) not in (signal.SIG_DFL, signal.SIG_IGN)
|
|
97
|
+
_kill_worker(w)
|
|
98
|
+
await w.proc.wait()
|
|
99
|
+
with pytest.raises(ProcessLookupError): os.kill(pid, 0)
|
|
100
|
+
finally:
|
|
101
|
+
for s, h in orig.items(): signal.signal(s, h)
|
|
102
|
+
await w.kill()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.fixture
|
|
106
|
+
def anyio_backend(): return "asyncio"
|
clikernel-0.1.3/clikernel/mcp.py
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
"MCP server exposing a persistent IPython `CaptureShell` as an `execute` tool."
|
|
2
|
-
import asyncio
|
|
3
|
-
|
|
4
|
-
from clikernel.cli import _set_default_dirs, _make_shell
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def main():
|
|
8
|
-
_set_default_dirs()
|
|
9
|
-
from mcp.server.fastmcp import FastMCP
|
|
10
|
-
from fastcore.nbio import render_text
|
|
11
|
-
mcp = FastMCP("clikernel")
|
|
12
|
-
state = {"shell": _make_shell(), "lock": asyncio.Lock()}
|
|
13
|
-
|
|
14
|
-
@mcp.tool(structured_output=False)
|
|
15
|
-
async def execute(code:str # IPython-compatible code to run in the persistent session
|
|
16
|
-
)->str: # Rendered outputs (stdout, display data, last-expression result, errors)
|
|
17
|
-
"Run `code` in the persistent IPython session, keeping state across calls (imports, variables, monkeypatches, cached objects)."
|
|
18
|
-
async with state["lock"]:
|
|
19
|
-
outputs = await asyncio.to_thread(state["shell"].run, code)
|
|
20
|
-
return render_text(outputs)
|
|
21
|
-
|
|
22
|
-
@mcp.tool(structured_output=False)
|
|
23
|
-
async def restart()->str:
|
|
24
|
-
"Discard all session state (imports, variables, monkeypatches, cached objects) and start a fresh IPython shell inside the same server process. Use this for the common case: a bad variable, a half-finished experiment to abandon, or a user-requested clean slate. Note: this does NOT touch `sys.modules` or anything already monkeypatched (e.g. via fastcore's `@patch`) -- those live at the process level, not the shell level, and survive `restart` untouched. If you need a genuinely fresh interpreter (e.g. after reloading a module that other already-imported modules had patched -- symptoms: a stale-class bug where `isinstance`/`is` checks mysteriously fail, or a class is missing a method you know it has), use `exit` instead. After restarting, redo any imports/setup the task still needs."
|
|
25
|
-
async with state["lock"]: state["shell"] = _make_shell()
|
|
26
|
-
return "restarted"
|
|
27
|
-
|
|
28
|
-
@mcp.tool(structured_output=False)
|
|
29
|
-
async def exit()->str:
|
|
30
|
-
"Terminate this MCP server process immediately, for when `restart` isn't enough (see its docstring). This call itself will error (\"Connection closed\") since the process dies before it can respond -- that's expected, not a failure. The next `execute` call transparently reconnects to a freshly-spawned process with `sys.modules` genuinely reset (confirmed via a new pid), no manual reconnect needed. After reconnecting, redo any imports/setup the task still needs."
|
|
31
|
-
import os
|
|
32
|
-
os._exit(0)
|
|
33
|
-
|
|
34
|
-
mcp.run()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if __name__ == "__main__": main()
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import os,shutil,sys,tempfile
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
from mcp import ClientSession, StdioServerParameters
|
|
5
|
-
from mcp.client.stdio import stdio_client
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _server_params():
|
|
9
|
-
cmd = shutil.which("clikernel-mcp")
|
|
10
|
-
args = [] if cmd else ["-m", "clikernel.mcp"]
|
|
11
|
-
cmd = cmd or sys.executable
|
|
12
|
-
env = dict(os.environ, CLIKERNEL_STATE_DIR=tempfile.mkdtemp(prefix="clikernel-mcp-test-"))
|
|
13
|
-
return StdioServerParameters(command=cmd, args=args, env=env)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
async def _drive(codes):
|
|
17
|
-
"Run each code string through the execute tool in one session; return result texts."
|
|
18
|
-
async with stdio_client(_server_params()) as (r,w), ClientSession(r,w) as s:
|
|
19
|
-
await s.initialize()
|
|
20
|
-
out = []
|
|
21
|
-
for c in codes:
|
|
22
|
-
res = await s.call_tool("execute", {"code": c})
|
|
23
|
-
out.append(res.content[0].text if res.content else "")
|
|
24
|
-
return out
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def _drive_ops(ops):
|
|
28
|
-
"Run a mix of ('execute', code) / ('restart',) ops in one session; return result texts."
|
|
29
|
-
async with stdio_client(_server_params()) as (r,w), ClientSession(r,w) as s:
|
|
30
|
-
await s.initialize()
|
|
31
|
-
out = []
|
|
32
|
-
for op in ops:
|
|
33
|
-
name, args = (op[0], {"code": op[1]}) if op[0] == "execute" else (op[0], {})
|
|
34
|
-
res = await s.call_tool(name, args)
|
|
35
|
-
out.append(res.content[0].text if res.content else "")
|
|
36
|
-
return out
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@pytest.mark.anyio
|
|
40
|
-
async def test_execute_returns_result(): assert await _drive(["40+2"]) == ["42"]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@pytest.mark.anyio
|
|
44
|
-
async def test_execute_supports_top_level_await():
|
|
45
|
-
r = await _drive(["import asyncio\nawait asyncio.sleep(0)\n42"])
|
|
46
|
-
assert r == ["42"]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@pytest.mark.anyio
|
|
50
|
-
async def test_state_persists_across_calls(): assert (await _drive(["x = 41", "x + 1"]))[1] == "42"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@pytest.mark.anyio
|
|
54
|
-
async def test_multiple_outputs_use_xmlish_blocks():
|
|
55
|
-
r = (await _drive(["print('hi'); 99"]))[0]
|
|
56
|
-
assert "<stdout>\nhi\n</stdout>" in r
|
|
57
|
-
assert "<execute_result>\n99\n</execute_result>" in r
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@pytest.mark.anyio
|
|
61
|
-
async def test_error_is_clean():
|
|
62
|
-
r = (await _drive(["1/0"]))[0]
|
|
63
|
-
assert "ZeroDivisionError" in r
|
|
64
|
-
assert "\x1b[" not in r
|
|
65
|
-
assert r.count("ZeroDivisionError") == 1
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@pytest.mark.anyio
|
|
69
|
-
async def test_restart_clears_state():
|
|
70
|
-
r = await _drive_ops([("execute","x = 1"), ("restart",), ("execute","x")])
|
|
71
|
-
assert "NameError" in r[2]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@pytest.mark.anyio
|
|
75
|
-
async def test_restart_leaves_kernel_usable():
|
|
76
|
-
r = await _drive_ops([("execute","x = 1"), ("restart",), ("execute","40+2")])
|
|
77
|
-
assert r[2] == "42"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@pytest.mark.anyio
|
|
81
|
-
async def test_nb_magics(tmp_path):
|
|
82
|
-
import json
|
|
83
|
-
cells = [dict(cell_type="code", id="aaa111", metadata={}, outputs=[], execution_count=None, source="print('one')")]
|
|
84
|
-
nb = tmp_path/"t.ipynb"
|
|
85
|
-
nb.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
|
|
86
|
-
r = await _drive([f"%nbopen {nb}", "%nbrun aaa"])
|
|
87
|
-
assert "--- aaa111 ---" in r[1] and "one" in r[1]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@pytest.fixture
|
|
91
|
-
def anyio_backend(): return "asyncio"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|