clikernel 0.2.7__tar.gz → 0.2.9__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.2.7 → clikernel-0.2.9}/CHANGELOG.md +14 -0
- clikernel-0.2.9/PKG-INFO +76 -0
- clikernel-0.2.9/README.md +53 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel/__init__.py +3 -3
- clikernel-0.2.9/clikernel/_modidx.py +41 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel/cli.py +12 -8
- clikernel-0.2.9/clikernel/core.py +163 -0
- clikernel-0.2.9/clikernel/mcp.py +92 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel/skill.py +5 -5
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel/stream.py +1 -2
- clikernel-0.2.9/clikernel.egg-info/PKG-INFO +76 -0
- clikernel-0.2.9/clikernel.egg-info/requires.txt +9 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/pyproject.toml +5 -6
- clikernel-0.2.7/PKG-INFO +0 -77
- clikernel-0.2.7/README.md +0 -53
- clikernel-0.2.7/clikernel/_modidx.py +0 -41
- clikernel-0.2.7/clikernel/core.py +0 -238
- clikernel-0.2.7/clikernel/mcp.py +0 -111
- clikernel-0.2.7/clikernel.egg-info/PKG-INFO +0 -77
- clikernel-0.2.7/clikernel.egg-info/requires.txt +0 -10
- {clikernel-0.2.7 → clikernel-0.2.9}/LICENSE +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/MANIFEST.in +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel.egg-info/SOURCES.txt +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel.egg-info/dependency_links.txt +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel.egg-info/entry_points.txt +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/clikernel.egg-info/top_level.txt +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/setup.cfg +0 -0
- {clikernel-0.2.7 → clikernel-0.2.9}/tests/test_stream.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- do not remove -->
|
|
4
4
|
|
|
5
|
+
## 0.2.9
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Replace the Jupyter-protocol client with a stdio MCP router over rustygate ([#47](https://github.com/AnswerDotAI/clikernel/pull/47)), thanks to [@jph00](https://github.com/jph00)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## 0.2.8
|
|
13
|
+
|
|
14
|
+
### New Features
|
|
15
|
+
|
|
16
|
+
- Stream worker iterates run() directly ([#46](https://github.com/AnswerDotAI/clikernel/pull/46)), thanks to [@jph00](https://github.com/jph00)
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
## 0.2.7
|
|
6
20
|
|
|
7
21
|
### New Features
|
clikernel-0.2.9/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clikernel
|
|
3
|
+
Version: 0.2.9
|
|
4
|
+
Summary: Serve persistent Jupyter kernels to LLMs as concise text, over MCP or a plain stream protocol
|
|
5
|
+
Author: clikernel contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/AnswerDotAI/clikernel
|
|
8
|
+
Project-URL: Documentation, https://AnswerDotAI.github.io/clikernel/
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: fastcore>=2.2.2
|
|
15
|
+
Requires-Dist: jupyasyncclient>=0.2.10
|
|
16
|
+
Requires-Dist: jupywire>=0.1.9
|
|
17
|
+
Requires-Dist: mcpmini>=0.0.4
|
|
18
|
+
Requires-Dist: rustygate>=0.1.7
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: fastship; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# clikernel
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
28
|
+
|
|
29
|
+
`clikernel` gives an LLM agent a persistent Python workbench built from two processes. A gateway ([rustygate](https://github.com/AnswerDotAI/rustygate)) hosts real Jupyter kernels ([ipymini](https://github.com/AnswerDotAI/ipymini) by default) and serves the MCP tool surface itself; kernels live there and persist until explicitly stopped. `clikernel` starts and stops with each conversation: a router the MCP host launches, speaking stdio MCP to the model and forwarding to gateways over HTTP. It adds what a single fixed endpoint cannot: gateway naming from `gateways.toml` (a `host` argument on the kernel-selection tools reaches any machine you’ve named), delivery of your `startup.py` and `inspectors.py` into every kernel a conversation creates, and a local gateway that always exists — found running, or started as a child that lives exactly as long as the conversation.
|
|
30
|
+
|
|
31
|
+
Kernel scope is the gateway’s rule, one rule everywhere: a session’s end stops the kernels it created with autoclose (the bare-`py` auto kernel, and `create`’s default) and nothing else. A kernel created with `autoclose=false` on a persistent gateway outlives the conversation, and a later conversation reattaches with `use_kernel` and finds its state intact — including the user’s live solveit kernel.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
``` sh
|
|
36
|
+
pip install clikernel
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This brings [rustygate](https://github.com/AnswerDotAI/rustygate) and a kernel ([ipymini](https://github.com/AnswerDotAI/ipymini)) with it, and no service setup is needed: a conversation that finds no gateway starts its own. Run a resident gateway when kernels should outlive conversations (e.g. via launchd/systemd):
|
|
40
|
+
|
|
41
|
+
``` sh
|
|
42
|
+
rustygate --port 8787
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Use with an MCP host
|
|
46
|
+
|
|
47
|
+
Register the stdio server with your MCP host, e.g. for Claude Code:
|
|
48
|
+
|
|
49
|
+
``` sh
|
|
50
|
+
claude mcp add clikernel -- clikernel-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The tools are rustygate’s, forwarded: `py` (the normal tool — it auto-starts a kernel when none is current, and magics like a `%%bash` first line run as written), `list_kernels`, `create`, `use_kernel`, `delete_kernel`, `restart`, and `interrupt`. The router adds one thing to them: `list_kernels`, `use_kernel`, and `create` take a `host` naming a gateway from `gateways.toml`, so one MCP entry reaches every machine you’ve named. Replies carry text and image blocks exactly as the gateway rendered them.
|
|
54
|
+
|
|
55
|
+
A conversation cleans up after itself: its end stops the auto kernel and every `create` it made, unless `autoclose=false` asked for a keeper. Kernels reached with `use_kernel` are never touched. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`), and when nothing answers there, the conversation runs on a private child gateway that ends with it.
|
|
56
|
+
|
|
57
|
+
Pass `--quiet` (`clikernel-mcp --quiet`) for a host whose sessions should not see banner noise: startup still runs, but its output stays out of every reply.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Three optional files in `$XDG_CONFIG_HOME/clikernel/` (usually `~/.config/clikernel/`):
|
|
62
|
+
|
|
63
|
+
- `startup.py` — run in every kernel clikernel creates, with `__file__` bound to its path; its output returns in the reply that announces the kernel.
|
|
64
|
+
- `inspectors.py` — cell inspectors installed after startup. The file may define `inspect` and/or a list `inspectors`; each is called once per cell before it runs (1-arg: the cell’s AST; 2-arg: AST and raw source). Return a string to print a note before the cell’s output, raise `RuleBlock` (provided in the namespace) to block the cell; any other exception warns and the cell runs. See `examples/inspectors.py`.
|
|
65
|
+
- `gateways.toml` — named remote gateways, so tokens never appear in tool arguments:
|
|
66
|
+
|
|
67
|
+
``` toml
|
|
68
|
+
[gateways.solveit]
|
|
69
|
+
url = "https://solveit.example.com/gate"
|
|
70
|
+
token_env = "SOLVEIT_TOKEN"
|
|
71
|
+
verify = false # optional: accept a self-signed certificate
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## The stream protocol
|
|
75
|
+
|
|
76
|
+
Run `clikernel` as a plain CLI process and the same client speaks a delimiter-framed stdin/stdout protocol for token-reading clients: no echo, a cheap `.` acknowledgement per request, responses ended by a per-process random delimiter, multiline cells framed by `--` and the delimiter. The full recipe is announced in the process’s own startup banner. Run bare it creates a kernel and stops it on exit; `--kernel <id>` attaches to an existing kernel and leaves it as found.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# clikernel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
5
|
+
|
|
6
|
+
`clikernel` gives an LLM agent a persistent Python workbench built from two processes. A gateway ([rustygate](https://github.com/AnswerDotAI/rustygate)) hosts real Jupyter kernels ([ipymini](https://github.com/AnswerDotAI/ipymini) by default) and serves the MCP tool surface itself; kernels live there and persist until explicitly stopped. `clikernel` starts and stops with each conversation: a router the MCP host launches, speaking stdio MCP to the model and forwarding to gateways over HTTP. It adds what a single fixed endpoint cannot: gateway naming from `gateways.toml` (a `host` argument on the kernel-selection tools reaches any machine you’ve named), delivery of your `startup.py` and `inspectors.py` into every kernel a conversation creates, and a local gateway that always exists — found running, or started as a child that lives exactly as long as the conversation.
|
|
7
|
+
|
|
8
|
+
Kernel scope is the gateway’s rule, one rule everywhere: a session’s end stops the kernels it created with autoclose (the bare-`py` auto kernel, and `create`’s default) and nothing else. A kernel created with `autoclose=false` on a persistent gateway outlives the conversation, and a later conversation reattaches with `use_kernel` and finds its state intact — including the user’s live solveit kernel.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
``` sh
|
|
13
|
+
pip install clikernel
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This brings [rustygate](https://github.com/AnswerDotAI/rustygate) and a kernel ([ipymini](https://github.com/AnswerDotAI/ipymini)) with it, and no service setup is needed: a conversation that finds no gateway starts its own. Run a resident gateway when kernels should outlive conversations (e.g. via launchd/systemd):
|
|
17
|
+
|
|
18
|
+
``` sh
|
|
19
|
+
rustygate --port 8787
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use with an MCP host
|
|
23
|
+
|
|
24
|
+
Register the stdio server with your MCP host, e.g. for Claude Code:
|
|
25
|
+
|
|
26
|
+
``` sh
|
|
27
|
+
claude mcp add clikernel -- clikernel-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The tools are rustygate’s, forwarded: `py` (the normal tool — it auto-starts a kernel when none is current, and magics like a `%%bash` first line run as written), `list_kernels`, `create`, `use_kernel`, `delete_kernel`, `restart`, and `interrupt`. The router adds one thing to them: `list_kernels`, `use_kernel`, and `create` take a `host` naming a gateway from `gateways.toml`, so one MCP entry reaches every machine you’ve named. Replies carry text and image blocks exactly as the gateway rendered them.
|
|
31
|
+
|
|
32
|
+
A conversation cleans up after itself: its end stops the auto kernel and every `create` it made, unless `autoclose=false` asked for a keeper. Kernels reached with `use_kernel` are never touched. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`), and when nothing answers there, the conversation runs on a private child gateway that ends with it.
|
|
33
|
+
|
|
34
|
+
Pass `--quiet` (`clikernel-mcp --quiet`) for a host whose sessions should not see banner noise: startup still runs, but its output stays out of every reply.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Three optional files in `$XDG_CONFIG_HOME/clikernel/` (usually `~/.config/clikernel/`):
|
|
39
|
+
|
|
40
|
+
- `startup.py` — run in every kernel clikernel creates, with `__file__` bound to its path; its output returns in the reply that announces the kernel.
|
|
41
|
+
- `inspectors.py` — cell inspectors installed after startup. The file may define `inspect` and/or a list `inspectors`; each is called once per cell before it runs (1-arg: the cell’s AST; 2-arg: AST and raw source). Return a string to print a note before the cell’s output, raise `RuleBlock` (provided in the namespace) to block the cell; any other exception warns and the cell runs. See `examples/inspectors.py`.
|
|
42
|
+
- `gateways.toml` — named remote gateways, so tokens never appear in tool arguments:
|
|
43
|
+
|
|
44
|
+
``` toml
|
|
45
|
+
[gateways.solveit]
|
|
46
|
+
url = "https://solveit.example.com/gate"
|
|
47
|
+
token_env = "SOLVEIT_TOKEN"
|
|
48
|
+
verify = false # optional: accept a self-signed certificate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The stream protocol
|
|
52
|
+
|
|
53
|
+
Run `clikernel` as a plain CLI process and the same client speaks a delimiter-framed stdin/stdout protocol for token-reading clients: no echo, a cheap `.` acknowledgement per request, responses ended by a per-process random delimiter, multiline cells framed by `--` and the delimiter. The full recipe is announced in the process’s own startup banner. Run bare it creates a kernel and stops it on exit; `--kernel <id>` attaches to an existing kernel and leaves it as found.
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Modules:
|
|
4
4
|
|
|
5
5
|
- `clikernel.cli`: The stream-protocol frontend: the service on stdin/stdout for token-reading clients
|
|
6
|
-
- `clikernel.core`:
|
|
7
|
-
- `clikernel.mcp`: The MCP frontend:
|
|
6
|
+
- `clikernel.core`: Gateway naming, startup delivery, and MCP sessions on rustygate gateways
|
|
7
|
+
- `clikernel.mcp`: The MCP frontend: a stdio router over rustygate gateways
|
|
8
8
|
- `clikernel.skill`: Use the `clikernel` MCP session as the default workspace for Python work: reading and changing files, notebook work, trying things out, checking how a library behaves, and reshaping data. One session stays open, so imports and variables carry between calls. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.
|
|
9
9
|
- `clikernel.stream`: Streaming JSON-lines worker protocol: nbformat-shaped output events, and a supervisor for select-based UIs."""
|
|
10
10
|
|
|
11
|
-
__version__ = "0.2.
|
|
11
|
+
__version__ = "0.2.9"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Autogenerated by nbdev
|
|
2
|
+
|
|
3
|
+
d = { 'settings': { 'branch': 'main',
|
|
4
|
+
'doc_baseurl': '/clikernel',
|
|
5
|
+
'doc_host': 'https://AnswerDotAI.github.io',
|
|
6
|
+
'git_url': 'https://github.com/AnswerDotAI/clikernel',
|
|
7
|
+
'lib_path': 'clikernel'},
|
|
8
|
+
'syms': { 'clikernel.cli': { 'clikernel.cli._new_delim': ('cli.html#_new_delim', 'clikernel/cli.py'),
|
|
9
|
+
'clikernel.cli._next_line': ('cli.html#_next_line', 'clikernel/cli.py'),
|
|
10
|
+
'clikernel.cli._read_block': ('cli.html#_read_block', 'clikernel/cli.py'),
|
|
11
|
+
'clikernel.cli._restore_termios': ('cli.html#_restore_termios', 'clikernel/cli.py'),
|
|
12
|
+
'clikernel.cli._tty_clear': ('cli.html#_tty_clear', 'clikernel/cli.py'),
|
|
13
|
+
'clikernel.cli._write_response': ('cli.html#_write_response', 'clikernel/cli.py'),
|
|
14
|
+
'clikernel.cli.fmt_error': ('cli.html#fmt_error', 'clikernel/cli.py'),
|
|
15
|
+
'clikernel.cli.main': ('cli.html#main', 'clikernel/cli.py'),
|
|
16
|
+
'clikernel.cli.serve_stream': ('cli.html#serve_stream', 'clikernel/cli.py')},
|
|
17
|
+
'clikernel.core': { 'clikernel.core.Gateway': ('core.html#gateway', 'clikernel/core.py'),
|
|
18
|
+
'clikernel.core.Gateway.__init__': ('core.html#gateway.__init__', 'clikernel/core.py'),
|
|
19
|
+
'clikernel.core.Gateway.aclose': ('core.html#gateway.aclose', 'clikernel/core.py'),
|
|
20
|
+
'clikernel.core.Gateway.call': ('core.html#gateway.call', 'clikernel/core.py'),
|
|
21
|
+
'clikernel.core.Gateway.initialize': ('core.html#gateway.initialize', 'clikernel/core.py'),
|
|
22
|
+
'clikernel.core.Gateway.rpc': ('core.html#gateway.rpc', 'clikernel/core.py'),
|
|
23
|
+
'clikernel.core.Gateway.text': ('core.html#gateway.text', 'clikernel/core.py'),
|
|
24
|
+
'clikernel.core.Gateway.tools': ('core.html#gateway.tools', 'clikernel/core.py'),
|
|
25
|
+
'clikernel.core._inspector_setup': ('core.html#_inspector_setup', 'clikernel/core.py'),
|
|
26
|
+
'clikernel.core._startup_src': ('core.html#_startup_src', 'clikernel/core.py'),
|
|
27
|
+
'clikernel.core.cfg_dir': ('core.html#cfg_dir', 'clikernel/core.py'),
|
|
28
|
+
'clikernel.core.default_gateway': ('core.html#default_gateway', 'clikernel/core.py'),
|
|
29
|
+
'clikernel.core.gateways': ('core.html#gateways', 'clikernel/core.py'),
|
|
30
|
+
'clikernel.core.resolve': ('core.html#resolve', 'clikernel/core.py'),
|
|
31
|
+
'clikernel.core.session_defaults': ('core.html#session_defaults', 'clikernel/core.py'),
|
|
32
|
+
'clikernel.core.startup_src': ('core.html#startup_src', 'clikernel/core.py')},
|
|
33
|
+
'clikernel.mcp': { 'clikernel.mcp.Router': ('mcp.html#router', 'clikernel/mcp.py'),
|
|
34
|
+
'clikernel.mcp.Router.__init__': ('mcp.html#router.__init__', 'clikernel/mcp.py'),
|
|
35
|
+
'clikernel.mcp.Router.aclose': ('mcp.html#router.aclose', 'clikernel/mcp.py'),
|
|
36
|
+
'clikernel.mcp.Router.dispatch': ('mcp.html#router.dispatch', 'clikernel/mcp.py'),
|
|
37
|
+
'clikernel.mcp.Router.session': ('mcp.html#router.session', 'clikernel/mcp.py'),
|
|
38
|
+
'clikernel.mcp.Router.tools': ('mcp.html#router.tools', 'clikernel/mcp.py'),
|
|
39
|
+
'clikernel.mcp.main': ('mcp.html#main', 'clikernel/mcp.py')},
|
|
40
|
+
'clikernel.skill': {},
|
|
41
|
+
'clikernel.stream': {}}}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""The stream-protocol frontend: the service on stdin/stdout for token-reading clients
|
|
2
2
|
|
|
3
|
-
The `clikernel` command: the delimiter-framed stdin/stdout protocol v1 established (documented in the README, rationale unchanged — a client that reads stdout as tokens wants no echo, a cheap ack byte, and a per-process random delimiter to read until). The protocol machinery ports from v1 verbatim; underneath, the process is now
|
|
3
|
+
The `clikernel` command: the delimiter-framed stdin/stdout protocol v1 established (documented in the README, rationale unchanged — a client that reads stdout as tokens wants no echo, a cheap ack byte, and a per-process random delimiter to read until). The protocol machinery ports from v1 verbatim; underneath, the process is now one MCP session on a gateway. Run bare it creates a kernel and stops it again on exit — whoever ran the command made that decision by running it — while `--kernel` attaches to an existing kernel and leaves it exactly as found. Ctrl-C during a long cell translates into a kernel interrupt, jupyter-console style, instead of killing the process.
|
|
4
4
|
|
|
5
5
|
Docs: https://AnswerDotAI.github.io/clikernel/cli.html.md"""
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ __all__ = ['fmt_error', 'serve_stream', 'main']
|
|
|
13
13
|
import asyncio, secrets, signal, string, sys, termios, threading, traceback, tty
|
|
14
14
|
from fastcore.utils import *
|
|
15
15
|
from fastcore.script import call_parse
|
|
16
|
-
from .core import
|
|
16
|
+
from .core import Gateway, default_gateway, resolve, session_defaults
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
# %% ../nbs/02_cli.ipynb #d8413fff
|
|
@@ -129,21 +129,25 @@ def main(
|
|
|
129
129
|
loop = asyncio.new_event_loop()
|
|
130
130
|
threading.Thread(target=loop.run_forever, daemon=True).start()
|
|
131
131
|
def run(coro): return asyncio.run_coroutine_threadsafe(coro, loop).result()
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
async def _open():
|
|
133
|
+
if not host: return await default_gateway()
|
|
134
|
+
url, token, verify = resolve(host)
|
|
135
|
+
return await Gateway(url, token, verify).initialize(session_defaults(local=False)), None
|
|
136
|
+
g, child = run(_open())
|
|
137
|
+
info = run(g.text('use_kernel', kernel=kernel) if kernel else g.text('py', code=''))
|
|
134
138
|
stop = False
|
|
135
139
|
def execute(code):
|
|
136
140
|
nonlocal stop
|
|
137
141
|
if code.strip() in _EXITS:
|
|
138
142
|
stop = True
|
|
139
143
|
return ''
|
|
140
|
-
fut = asyncio.run_coroutine_threadsafe(
|
|
144
|
+
fut = asyncio.run_coroutine_threadsafe(g.text('py', code=code), loop)
|
|
141
145
|
while True:
|
|
142
146
|
try: return fut.result()
|
|
143
|
-
except KeyboardInterrupt: asyncio.run_coroutine_threadsafe(
|
|
147
|
+
except KeyboardInterrupt: asyncio.run_coroutine_threadsafe(g.call('interrupt'), loop)
|
|
144
148
|
try: serve_stream(execute, info=info, should_exit=lambda: stop)
|
|
145
149
|
finally:
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
run(g.aclose()) # ends the session: the kernel this run created dies with it, an attached one survives
|
|
151
|
+
if child: child.stop()
|
|
148
152
|
loop.call_soon_threadsafe(loop.stop)
|
|
149
153
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Gateway naming, startup delivery, and MCP sessions on rustygate gateways
|
|
2
|
+
|
|
3
|
+
clikernel is the LLM side of a two-process design: a gateway ([rustygate](https://github.com/AnswerDotAI/rustygate)) hosts the kernels and serves MCP itself at `POST /mcp`; clikernel starts and stops with each conversation and routes the harness's stdio MCP to gateways. This module is the client layer: gateway naming from `gateways.toml`, the per-session kernel-creation defaults (the conversation's cwd and environment, with `startup.py` and `inspectors.py` composed into one startup source), `Gateway` — one MCP session on one gateway — and `default_gateway`, which finds the local gateway or starts an owned child that lives exactly as long as the conversation. Kernel lifecycle policy lives gateway-side: ending a session stops the kernels it created with autoclose and nothing else.
|
|
4
|
+
|
|
5
|
+
Docs: https://AnswerDotAI.github.io/clikernel/core.html.md"""
|
|
6
|
+
|
|
7
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
|
|
8
|
+
|
|
9
|
+
# %% auto #0
|
|
10
|
+
__all__ = ['DEFAULT_URL', 'cfg_dir', 'gateways', 'resolve', 'startup_src', 'session_defaults', 'Gateway', 'default_gateway']
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/00_core.ipynb #2b3b7f4a
|
|
13
|
+
import os, tomllib, httpx
|
|
14
|
+
from fastcore.utils import *
|
|
15
|
+
from fastcore.xdg import xdg_config_home
|
|
16
|
+
from mcpmini.core import HTTPTransport, jreq
|
|
17
|
+
from rustygate.tools import start_gateway
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
# %% ../nbs/00_core.ipynb #596419b8
|
|
21
|
+
DEFAULT_URL = 'http://127.0.0.1:8787'
|
|
22
|
+
|
|
23
|
+
def cfg_dir():
|
|
24
|
+
"The clikernel config directory"
|
|
25
|
+
return xdg_config_home()/'clikernel'
|
|
26
|
+
|
|
27
|
+
def gateways(cfgdir=None):
|
|
28
|
+
"Named gateways from `gateways.toml`: `{name: {url, token | token_env, verify}}`"
|
|
29
|
+
p = (Path(cfgdir) if cfgdir else cfg_dir())/'gateways.toml'
|
|
30
|
+
return tomllib.loads(p.read_text()).get('gateways', {}) if p.exists() else {}
|
|
31
|
+
|
|
32
|
+
def resolve(host='', cfgdir=None):
|
|
33
|
+
"`(url, token, verify)` for `host`: empty = the default local gateway, a URL = itself, else a `gateways.toml` name"
|
|
34
|
+
if not host: return os.environ.get('CLIKERNEL_HOST', DEFAULT_URL), os.environ.get('CLIKERNEL_TOKEN'), True
|
|
35
|
+
if '://' in host: return host, os.environ.get('CLIKERNEL_TOKEN'), True
|
|
36
|
+
g = gateways(cfgdir).get(host)
|
|
37
|
+
if g is None: raise ValueError(f"unknown gateway {host!r}: not a URL, and not in {cfg_dir()/'gateways.toml'}")
|
|
38
|
+
return g['url'], g.get('token') or os.environ.get(g.get('token_env','')) or None, g.get('verify', True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# %% ../nbs/00_core.ipynb #0d846754
|
|
42
|
+
def _startup_src(src, path):
|
|
43
|
+
"The startup file's source wrapped so `__file__` is bound to its path during the run, and absent after"
|
|
44
|
+
return f'''__file__ = {str(path)!r}
|
|
45
|
+
try: exec(compile({src!r}, __file__, 'exec'))
|
|
46
|
+
finally: del __file__'''
|
|
47
|
+
|
|
48
|
+
# %% ../nbs/00_core.ipynb #3dbf4cb6
|
|
49
|
+
_INSP_RUNNER = r'''
|
|
50
|
+
import inspect as _clik_inspect
|
|
51
|
+
import sys as _clik_sys
|
|
52
|
+
from IPython.core.error import InputRejected
|
|
53
|
+
class RuleBlock(InputRejected):
|
|
54
|
+
"Raise from an inspector to deliberately block a cell; any other inspector exception is a bug, and fails open"
|
|
55
|
+
|
|
56
|
+
class _ClikInspect:
|
|
57
|
+
"Calls each inspector once per cell: 1-arg get the AST, 2-arg also the raw source"
|
|
58
|
+
def __init__(self, fs): self.fs = fs
|
|
59
|
+
def visit(self, tree):
|
|
60
|
+
fr, n = _clik_sys._getframe(), 0
|
|
61
|
+
while fr:
|
|
62
|
+
n += fr.f_code.co_name == 'run_cell_async'
|
|
63
|
+
fr = fr.f_back
|
|
64
|
+
if n > 1: return tree # nested run_cell: cell replayed by a tool (%nbrun etc.), not typed
|
|
65
|
+
for f in self.fs:
|
|
66
|
+
try:
|
|
67
|
+
note = f(tree, _clik_src) if len(_clik_inspect.signature(f).parameters) > 1 else f(tree)
|
|
68
|
+
if note: print(note, end='')
|
|
69
|
+
except InputRejected: raise
|
|
70
|
+
except Exception as e: print(f'inspector error (cell runs anyway): {e!r}')
|
|
71
|
+
return tree
|
|
72
|
+
|
|
73
|
+
def _clik_stash(info):
|
|
74
|
+
global _clik_src
|
|
75
|
+
_clik_src = info.raw_cell
|
|
76
|
+
|
|
77
|
+
def _clik_install(src):
|
|
78
|
+
ns = dict(RuleBlock=RuleBlock)
|
|
79
|
+
exec(compile(src, 'inspectors.py', 'exec'), ns)
|
|
80
|
+
fs = list(ns.get('inspectors') or [])
|
|
81
|
+
if callable(ns.get('inspect')): fs.append(ns['inspect'])
|
|
82
|
+
if fs:
|
|
83
|
+
ip = get_ipython()
|
|
84
|
+
ip.events.register('pre_run_cell', _clik_stash)
|
|
85
|
+
ip.ast_transformers.append(_ClikInspect(fs))
|
|
86
|
+
_clik_src = ''
|
|
87
|
+
'''
|
|
88
|
+
|
|
89
|
+
def _inspector_setup(src):
|
|
90
|
+
"Kernel-side source installing the inspectors defined in `src`; a load failure raises, failing the create call"
|
|
91
|
+
return _INSP_RUNNER + f'\n_clik_install({src!r})'
|
|
92
|
+
|
|
93
|
+
# %% ../nbs/00_core.ipynb #1362e6ce
|
|
94
|
+
def startup_src(cfgdir=None):
|
|
95
|
+
"The composed startup source for one kernel: `startup.py` wrapped, then the inspector installer"
|
|
96
|
+
d = Path(cfgdir) if cfgdir else cfg_dir()
|
|
97
|
+
parts = []
|
|
98
|
+
if (p := d/'startup.py').exists(): parts.append(_startup_src(p.read_text(), p))
|
|
99
|
+
if (p := d/'inspectors.py').exists(): parts.append(_inspector_setup(p.read_text()))
|
|
100
|
+
return '\n'.join(parts)
|
|
101
|
+
|
|
102
|
+
def session_defaults(cfgdir=None, quiet=False, local=True):
|
|
103
|
+
"The `rustygate` initialize extension: startup source and quiet, plus cwd and env for a local gateway"
|
|
104
|
+
d = dict(startup=startup_src(cfgdir), quiet=quiet)
|
|
105
|
+
if local:
|
|
106
|
+
d['cwd'] = os.getcwd()
|
|
107
|
+
d['env'] = dict(os.environ, CLIKERNEL_QUIET='1') if quiet else dict(os.environ)
|
|
108
|
+
return d
|
|
109
|
+
|
|
110
|
+
# %% ../nbs/00_core.ipynb #10ed59fc
|
|
111
|
+
class Gateway:
|
|
112
|
+
"One MCP session on one rustygate: initialize with session defaults, call tools, DELETE at close"
|
|
113
|
+
def __init__(self,
|
|
114
|
+
url, # The gateway base URL, e.g. 'http://127.0.0.1:8787'
|
|
115
|
+
token=None, # Gateway auth token, sent as a bearer token
|
|
116
|
+
verify=True, # Verify TLS certificates?
|
|
117
|
+
):
|
|
118
|
+
client = httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(None, connect=10))
|
|
119
|
+
self.url,self._id = url,0
|
|
120
|
+
self.tr = HTTPTransport(f"{url.rstrip('/')}/mcp", token=token, http_client=client)
|
|
121
|
+
|
|
122
|
+
async def rpc(self, method, **params):
|
|
123
|
+
"One JSON-RPC request, returning its result and raising on a protocol-level error"
|
|
124
|
+
self._id += 1
|
|
125
|
+
r = await self.tr.send(jreq(method, self._id, **params))
|
|
126
|
+
if 'error' in r: raise RuntimeError(f"{r['error']['code']}: {r['error']['message']}")
|
|
127
|
+
return r['result']
|
|
128
|
+
|
|
129
|
+
async def initialize(self, defaults=None):
|
|
130
|
+
"Open the MCP session, sending `defaults` as the `rustygate` extension; returns self"
|
|
131
|
+
await self.tr.start()
|
|
132
|
+
self.info = await self.rpc('initialize', protocolVersion='2025-11-25', capabilities={},
|
|
133
|
+
clientInfo=dict(name='clikernel', version=__version__), rustygate=defaults or {})
|
|
134
|
+
await self.tr.send(jreq('notifications/initialized'))
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
async def tools(self): return (await self.rpc('tools/list'))['tools']
|
|
138
|
+
async def call(self, name, **args): return await self.rpc('tools/call', name=name, arguments=args)
|
|
139
|
+
|
|
140
|
+
async def text(self, name, **args):
|
|
141
|
+
"A tool call's text blocks joined; raises on `isError`"
|
|
142
|
+
r = await self.call(name, **args)
|
|
143
|
+
t = ''.join(c.get('text','') for c in r['content'] if c['type'] == 'text')
|
|
144
|
+
if r.get('isError'): raise RuntimeError(t)
|
|
145
|
+
return t
|
|
146
|
+
|
|
147
|
+
async def aclose(self):
|
|
148
|
+
"End the MCP session — the gateway stops the kernels this session created with autoclose — and drop the connection"
|
|
149
|
+
await self.tr.delete()
|
|
150
|
+
await self.tr.aclose()
|
|
151
|
+
|
|
152
|
+
# %% ../nbs/00_core.ipynb #0d054375
|
|
153
|
+
async def default_gateway(
|
|
154
|
+
cfgdir=None, # Config dir for `session_defaults` (the standard one if None)
|
|
155
|
+
quiet=False, # Keep startup output out of replies?
|
|
156
|
+
):
|
|
157
|
+
"An initialized `Gateway` on the default local gateway, plus the owned child rustygate when none was running (else None)"
|
|
158
|
+
url, token, verify = resolve('', cfgdir)
|
|
159
|
+
d = session_defaults(cfgdir, quiet)
|
|
160
|
+
try: return await Gateway(url, token, verify).initialize(d), None
|
|
161
|
+
except httpx.ConnectError:
|
|
162
|
+
child = start_gateway()
|
|
163
|
+
return await Gateway(child.url).initialize(d), child
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The MCP frontend: a stdio router over rustygate gateways
|
|
2
|
+
|
|
3
|
+
The frontend Claude Code launches per conversation. `Router` speaks stdio MCP to the harness and forwards to gateways: rustygate serves the tool surface itself, so the router defines no tools — it fetches the local gateway's `tools/list`, adds a `host` parameter to the kernel-selection tools, and forwards `tools/call` verbatim to the right gateway, ids intact so cancellation maps through. `main` serves a `Router` on stdio and ends every gateway session on the way out — the DELETE that stops each session's autoclose kernels, and the owned child gateway with them.
|
|
4
|
+
|
|
5
|
+
Docs: https://AnswerDotAI.github.io/clikernel/mcp.html.md"""
|
|
6
|
+
|
|
7
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_mcp.ipynb.
|
|
8
|
+
|
|
9
|
+
# %% auto #0
|
|
10
|
+
__all__ = ['HOST_PARAM', 'HOSTED', 'Router', 'main']
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/01_mcp.ipynb #28c42902
|
|
13
|
+
import asyncio
|
|
14
|
+
from fastcore.utils import *
|
|
15
|
+
from fastcore.script import call_parse, store_true
|
|
16
|
+
from mcpmini.core import serve_stdio, jresp, jerr
|
|
17
|
+
from .core import Gateway, default_gateway, resolve, session_defaults
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# %% ../nbs/01_mcp.ipynb #76e2d782
|
|
22
|
+
HOST_PARAM = {'type': 'string', 'description': 'Gateway to target: a gateways.toml name, or empty for the default local gateway'}
|
|
23
|
+
HOSTED = ('list_kernels', 'use_kernel', 'create')
|
|
24
|
+
|
|
25
|
+
class Router:
|
|
26
|
+
"Forward the harness's stdio MCP to rustygate gateways: one `Gateway` session per host, one current"
|
|
27
|
+
def __init__(self,
|
|
28
|
+
cfgdir=None, # Config dir for `session_defaults` and `gateways.toml` (the standard one if None)
|
|
29
|
+
quiet=False, # Keep startup output out of replies?
|
|
30
|
+
):
|
|
31
|
+
self.cfgdir,self.quiet,self.sessions,self.cur,self.child = cfgdir,quiet,{},'',None
|
|
32
|
+
|
|
33
|
+
async def session(self, host=''):
|
|
34
|
+
"The initialized `Gateway` for `host`, made on first use; empty means the default local gateway"
|
|
35
|
+
if host not in self.sessions:
|
|
36
|
+
if host:
|
|
37
|
+
url, token, verify = resolve(host, self.cfgdir)
|
|
38
|
+
self.sessions[host] = await Gateway(url, token, verify).initialize(session_defaults(self.cfgdir, self.quiet, local=False))
|
|
39
|
+
else: self.sessions[host], self.child = await default_gateway(self.cfgdir, self.quiet)
|
|
40
|
+
return self.sessions[host]
|
|
41
|
+
|
|
42
|
+
async def aclose(self):
|
|
43
|
+
"End every gateway session — stopping each one's autoclose kernels — then the owned child gateway"
|
|
44
|
+
for s in self.sessions.values(): await s.aclose()
|
|
45
|
+
if self.child: self.child.stop()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# %% ../nbs/01_mcp.ipynb #79da6a3e
|
|
49
|
+
@patch
|
|
50
|
+
async def tools(self:Router):
|
|
51
|
+
"The local gateway's tools, with `host` added to the kernel-selection tools"
|
|
52
|
+
ts = await (await self.session()).tools()
|
|
53
|
+
for t in ts:
|
|
54
|
+
if t['name'] in HOSTED: t['inputSchema'].setdefault('properties', {})['host'] = dict(HOST_PARAM)
|
|
55
|
+
return ts
|
|
56
|
+
|
|
57
|
+
@patch
|
|
58
|
+
async def dispatch(self:Router, msg, requester=None):
|
|
59
|
+
"One JSON-RPC message from the harness: initialize and ping answered here, the tool surface forwarded"
|
|
60
|
+
method,id = msg.get('method'), msg.get('id')
|
|
61
|
+
try:
|
|
62
|
+
if method == 'initialize':
|
|
63
|
+
info = (await self.session()).info
|
|
64
|
+
return jresp(id, dict(protocolVersion=msg['params'].get('protocolVersion', '2025-06-18'), capabilities=dict(tools={}),
|
|
65
|
+
serverInfo=dict(name='clikernel', version=__version__), instructions=info.get('instructions')))
|
|
66
|
+
if id is None:
|
|
67
|
+
if method == 'notifications/cancelled': await (await self.session(self.cur)).tr.send(msg)
|
|
68
|
+
return None
|
|
69
|
+
if method == 'ping': return jresp(id, {})
|
|
70
|
+
if method == 'tools/list': return jresp(id, dict(tools=await self.tools()))
|
|
71
|
+
if method == 'tools/call':
|
|
72
|
+
args = msg['params'].setdefault('arguments', {})
|
|
73
|
+
has_host = 'host' in args
|
|
74
|
+
host = args.pop('host', '') or ''
|
|
75
|
+
s = await self.session(host if has_host else self.cur)
|
|
76
|
+
if has_host and msg['params']['name'] in ('use_kernel', 'create'): self.cur = host
|
|
77
|
+
return await s.tr.send(msg)
|
|
78
|
+
return jerr(id, -32601, f'method not found: {method}')
|
|
79
|
+
except Exception as e: return None if id is None else jerr(id, -32603, str(e))
|
|
80
|
+
|
|
81
|
+
# %% ../nbs/01_mcp.ipynb #3b90f0e8
|
|
82
|
+
@call_parse
|
|
83
|
+
def main(
|
|
84
|
+
quiet:store_true=False, # Keep startup output out of replies
|
|
85
|
+
):
|
|
86
|
+
"The `clikernel-mcp` console script: the router on stdio"
|
|
87
|
+
async def _main():
|
|
88
|
+
router = Router(quiet=quiet)
|
|
89
|
+
try: await serve_stdio(router)
|
|
90
|
+
finally: await router.aclose()
|
|
91
|
+
asyncio.run(_main())
|
|
92
|
+
|
|
@@ -4,17 +4,17 @@ Prefer it over one-off Python scripts (`python -c`, shell heredocs). Prefer in-k
|
|
|
4
4
|
|
|
5
5
|
# Starting and stopping
|
|
6
6
|
|
|
7
|
-
The first `
|
|
7
|
+
The first `py` creates a kernel and reports what it imported: read that banner, it says what to do next. That kernel stops when the conversation ends, as does any kernel `create` makes unless it was created with `autoclose=false`. Use `create(dlgname, autoclose=false)` when a kernel should stay running afterwards, and tell the user its id — that only helps on a gateway that itself keeps running, so mention it if the reply shows a conversation-started gateway.
|
|
8
8
|
|
|
9
|
-
To continue earlier work, or to use the user's solveit kernel, `list_kernels` then `
|
|
9
|
+
To continue earlier work, or to use the user's solveit kernel, `list_kernels` then `use_kernel` with the id. Attaching runs no setup and claims no ownership: the kernel's live state is the point, and it is never stopped for you.
|
|
10
10
|
|
|
11
|
-
`restart` gives a fresh interpreter under the same id, so redo
|
|
11
|
+
`restart` gives a fresh interpreter under the same id; startup re-runs in kernels this conversation created, so redo everything else. `interrupt` stops a long `py` and keeps state. If a reply says the kernel is gone, the next `py` starts a fresh one.
|
|
12
12
|
|
|
13
|
-
Remote gateways are the same
|
|
13
|
+
Remote gateways are the same tools with a `host` argument on `list_kernels`, `use_kernel`, and `create`, named in `~/.config/clikernel/gateways.toml`. After selecting with a host, plain `py` runs there until the next selection.
|
|
14
14
|
|
|
15
15
|
# Working in it
|
|
16
16
|
|
|
17
|
-
- Magics work as written. `%cd` expands `~` and is the way to change directory: prefer it over `os.chdir`.
|
|
17
|
+
- Magics work as written, including `%%bash` for shell work. `%cd` expands `~` and is the way to change directory: prefer it over `os.chdir`.
|
|
18
18
|
- Only the last expression in a cell displays. `print(...)` any earlier value you need to see.
|
|
19
19
|
- Everything a cell outputs lands in the conversation. Be selective: `len(v)` first, then decide what to show.
|
|
20
20
|
- Don't re-run an `import` already run this session. If a name raises `NameError`, the kernel restarted or is newly attached: redo setup.
|
|
@@ -22,9 +22,8 @@ def _emit(obj):
|
|
|
22
22
|
|
|
23
23
|
async def _do_exec(kc, req):
|
|
24
24
|
rid = req.get('id')
|
|
25
|
-
|
|
25
|
+
async for m in kc.run(req['code']):
|
|
26
26
|
if m['msg_type'] in OUTPUT_MSGS: _emit(dict(ev='out', id=rid, output=msg2out(m)))
|
|
27
|
-
await kc.run(req['code'], on_output=_out)
|
|
28
27
|
_emit({'ev':'done','id':rid})
|
|
29
28
|
|
|
30
29
|
async def _do_complete(kc, req):
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clikernel
|
|
3
|
+
Version: 0.2.9
|
|
4
|
+
Summary: Serve persistent Jupyter kernels to LLMs as concise text, over MCP or a plain stream protocol
|
|
5
|
+
Author: clikernel contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/AnswerDotAI/clikernel
|
|
8
|
+
Project-URL: Documentation, https://AnswerDotAI.github.io/clikernel/
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: fastcore>=2.2.2
|
|
15
|
+
Requires-Dist: jupyasyncclient>=0.2.10
|
|
16
|
+
Requires-Dist: jupywire>=0.1.9
|
|
17
|
+
Requires-Dist: mcpmini>=0.0.4
|
|
18
|
+
Requires-Dist: rustygate>=0.1.7
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: fastship; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# clikernel
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
28
|
+
|
|
29
|
+
`clikernel` gives an LLM agent a persistent Python workbench built from two processes. A gateway ([rustygate](https://github.com/AnswerDotAI/rustygate)) hosts real Jupyter kernels ([ipymini](https://github.com/AnswerDotAI/ipymini) by default) and serves the MCP tool surface itself; kernels live there and persist until explicitly stopped. `clikernel` starts and stops with each conversation: a router the MCP host launches, speaking stdio MCP to the model and forwarding to gateways over HTTP. It adds what a single fixed endpoint cannot: gateway naming from `gateways.toml` (a `host` argument on the kernel-selection tools reaches any machine you’ve named), delivery of your `startup.py` and `inspectors.py` into every kernel a conversation creates, and a local gateway that always exists — found running, or started as a child that lives exactly as long as the conversation.
|
|
30
|
+
|
|
31
|
+
Kernel scope is the gateway’s rule, one rule everywhere: a session’s end stops the kernels it created with autoclose (the bare-`py` auto kernel, and `create`’s default) and nothing else. A kernel created with `autoclose=false` on a persistent gateway outlives the conversation, and a later conversation reattaches with `use_kernel` and finds its state intact — including the user’s live solveit kernel.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
``` sh
|
|
36
|
+
pip install clikernel
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This brings [rustygate](https://github.com/AnswerDotAI/rustygate) and a kernel ([ipymini](https://github.com/AnswerDotAI/ipymini)) with it, and no service setup is needed: a conversation that finds no gateway starts its own. Run a resident gateway when kernels should outlive conversations (e.g. via launchd/systemd):
|
|
40
|
+
|
|
41
|
+
``` sh
|
|
42
|
+
rustygate --port 8787
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Use with an MCP host
|
|
46
|
+
|
|
47
|
+
Register the stdio server with your MCP host, e.g. for Claude Code:
|
|
48
|
+
|
|
49
|
+
``` sh
|
|
50
|
+
claude mcp add clikernel -- clikernel-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The tools are rustygate’s, forwarded: `py` (the normal tool — it auto-starts a kernel when none is current, and magics like a `%%bash` first line run as written), `list_kernels`, `create`, `use_kernel`, `delete_kernel`, `restart`, and `interrupt`. The router adds one thing to them: `list_kernels`, `use_kernel`, and `create` take a `host` naming a gateway from `gateways.toml`, so one MCP entry reaches every machine you’ve named. Replies carry text and image blocks exactly as the gateway rendered them.
|
|
54
|
+
|
|
55
|
+
A conversation cleans up after itself: its end stops the auto kernel and every `create` it made, unless `autoclose=false` asked for a keeper. Kernels reached with `use_kernel` are never touched. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`), and when nothing answers there, the conversation runs on a private child gateway that ends with it.
|
|
56
|
+
|
|
57
|
+
Pass `--quiet` (`clikernel-mcp --quiet`) for a host whose sessions should not see banner noise: startup still runs, but its output stays out of every reply.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Three optional files in `$XDG_CONFIG_HOME/clikernel/` (usually `~/.config/clikernel/`):
|
|
62
|
+
|
|
63
|
+
- `startup.py` — run in every kernel clikernel creates, with `__file__` bound to its path; its output returns in the reply that announces the kernel.
|
|
64
|
+
- `inspectors.py` — cell inspectors installed after startup. The file may define `inspect` and/or a list `inspectors`; each is called once per cell before it runs (1-arg: the cell’s AST; 2-arg: AST and raw source). Return a string to print a note before the cell’s output, raise `RuleBlock` (provided in the namespace) to block the cell; any other exception warns and the cell runs. See `examples/inspectors.py`.
|
|
65
|
+
- `gateways.toml` — named remote gateways, so tokens never appear in tool arguments:
|
|
66
|
+
|
|
67
|
+
``` toml
|
|
68
|
+
[gateways.solveit]
|
|
69
|
+
url = "https://solveit.example.com/gate"
|
|
70
|
+
token_env = "SOLVEIT_TOKEN"
|
|
71
|
+
verify = false # optional: accept a self-signed certificate
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## The stream protocol
|
|
75
|
+
|
|
76
|
+
Run `clikernel` as a plain CLI process and the same client speaks a delimiter-framed stdin/stdout protocol for token-reading clients: no echo, a cheap `.` acknowledgement per request, responses ended by a per-process random delimiter, multiline cells framed by `--` and the delimiter. The full recipe is announced in the process’s own startup banner. Run bare it creates a kernel and stops it on exit; `--kernel <id>` attaches to an existing kernel and leaves it as found.
|