clikernel 0.2.5__tar.gz → 0.2.7__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.
@@ -2,6 +2,25 @@
2
2
 
3
3
  <!-- do not remove -->
4
4
 
5
+ ## 0.2.7
6
+
7
+ ### New Features
8
+
9
+ - Add stdin support via MCP elicitation, kernel env propagation, and Client async context manager for scoped kernel lifecycle ([#45](https://github.com/AnswerDotAI/clikernel/issues/45))
10
+ - Simplify MCP tool docstrings by using literal docstrings with conditional suffixes for non-quiet mode instead of f-string assignment ([#44](https://github.com/AnswerDotAI/clikernel/issues/44))
11
+ - Add --quiet option to suppress kernel startup output from connect, restart, and auto-connect execute replies ([#43](https://github.com/AnswerDotAI/clikernel/issues/43))
12
+
13
+
14
+ ## 0.2.6
15
+
16
+ ### New Features
17
+
18
+ - Replace httpx2 exception handling with fastspec APIError in kernel restart error paths ([#42](https://github.com/AnswerDotAI/clikernel/issues/42))
19
+ - Stop closing kernel managers on disconnect or kernel switch ([#40](https://github.com/AnswerDotAI/clikernel/issues/40))
20
+ - Rename httpx import to httpx2 ([#39](https://github.com/AnswerDotAI/clikernel/issues/39))
21
+ - restart now recovers when the kernel is gone by creating a fresh kernel on the same gateway, with startup files reapplied ([#38](https://github.com/AnswerDotAI/clikernel/issues/38))
22
+
23
+
5
24
  ## 0.2.5
6
25
 
7
26
  ### New Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Serve persistent Jupyter kernels to LLMs as concise text, over MCP or a plain stream protocol
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -12,8 +12,9 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: fastcore>=2.2.2
15
- Requires-Dist: jupyasyncclient>=0.2.2
16
- Requires-Dist: mcpmini>=0.0.1
15
+ Requires-Dist: jupyasyncclient>=0.2.9
16
+ Requires-Dist: fastspec>=0.2.2
17
+ Requires-Dist: mcpmini>=0.0.3
17
18
  Requires-Dist: aidialog>=0.0.7
18
19
  Requires-Dist: pillow
19
20
  Provides-Extra: dev
@@ -50,7 +51,11 @@ Register the stdio server with your MCP host, e.g. for Claude Code:
50
51
  claude mcp add clikernel -- clikernel-mcp
51
52
  ```
52
53
 
53
- The tools mirror the gateway’s kernel API plus one composite: `connect` (create a fresh kernel — running `startup.py` and installing inspectors — or attach to an existing one by id), `execute` (run code, get concise text), `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
54
+ The tools mirror the gateway’s kernel API: `connect`, `execute`, `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. When executed code requests input, the MCP client shows an elicitation dialog while the same `execute` call remains active. Repeated prompts repeat that callback. Password-marked prompts are refused.
55
+
56
+ An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
57
+
58
+ 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, and an auto-connecting `execute` returns just the result.
54
59
 
55
60
  ## Configuration
56
61
 
@@ -27,7 +27,11 @@ Register the stdio server with your MCP host, e.g. for Claude Code:
27
27
  claude mcp add clikernel -- clikernel-mcp
28
28
  ```
29
29
 
30
- The tools mirror the gateway’s kernel API plus one composite: `connect` (create a fresh kernel — running `startup.py` and installing inspectors — or attach to an existing one by id), `execute` (run code, get concise text), `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
30
+ The tools mirror the gateway’s kernel API: `connect`, `execute`, `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. When executed code requests input, the MCP client shows an elicitation dialog while the same `execute` call remains active. Repeated prompts repeat that callback. Password-marked prompts are refused.
31
+
32
+ An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
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, and an auto-connecting `execute` returns just the result.
31
35
 
32
36
  ## Configuration
33
37
 
@@ -5,7 +5,7 @@ Modules:
5
5
  - `clikernel.cli`: The stream-protocol frontend: the service on stdin/stdout for token-reading clients
6
6
  - `clikernel.core`: Connect to gateway-hosted kernels and turn execution into concise text
7
7
  - `clikernel.mcp`: The MCP frontend: `Client` as tools on stdio
8
- - `clikernel.skill`: Use the persistent `clikernel` MCP session as the default workspace for any task advanced through live Python execution -- stateful inspection, file-editing workflows, debugging, experiments, API probes, data transforms, or notebook-style work. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.
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.5"
11
+ __version__ = "0.2.7"
@@ -15,8 +15,11 @@ d = { 'settings': { 'branch': 'main',
15
15
  'clikernel.cli.main': ('cli.html#main', 'clikernel/cli.py'),
16
16
  'clikernel.cli.serve_stream': ('cli.html#serve_stream', 'clikernel/cli.py')},
17
17
  'clikernel.core': { 'clikernel.core.Client': ('core.html#client', 'clikernel/core.py'),
18
+ 'clikernel.core.Client.__aenter__': ('core.html#client.__aenter__', 'clikernel/core.py'),
19
+ 'clikernel.core.Client.__aexit__': ('core.html#client.__aexit__', 'clikernel/core.py'),
18
20
  'clikernel.core.Client.__init__': ('core.html#client.__init__', 'clikernel/core.py'),
19
21
  'clikernel.core.Client._setup': ('core.html#client._setup', 'clikernel/core.py'),
22
+ 'clikernel.core.Client._startkw': ('core.html#client._startkw', 'clikernel/core.py'),
20
23
  'clikernel.core.Client._use': ('core.html#client._use', 'clikernel/core.py'),
21
24
  'clikernel.core.Client.aclose': ('core.html#client.aclose', 'clikernel/core.py'),
22
25
  'clikernel.core.Client.connect': ('core.html#client.connect', 'clikernel/core.py'),
@@ -11,6 +11,7 @@ __all__ = ['DEFAULT_URL', 'MAXLEN', 'STATE_LOST', 'STATE_LOST_SETUP', 'cfg_dir',
11
11
 
12
12
  # %% ../nbs/00_core.ipynb #2b3b7f4a
13
13
  import os, tomllib
14
+ from fastspec.errors import APIError
14
15
  from fastcore.utils import *
15
16
  from fastcore.nbio import render_text
16
17
  from fastcore.xdg import xdg_config_home
@@ -96,16 +97,21 @@ STATE_LOST_SETUP = 'NOTE: the kernel restarted with a fresh interpreter: startup
96
97
 
97
98
  class Client:
98
99
  "One conversation's connection: a gateway manager, and the current kernel"
99
- def __init__(self, cfgdir=None): self.cfgdir,self.mgr,self.kc,self.kid,self.auto,self.made = cfgdir,None,None,None,False,False
100
+ def __init__(self, cfgdir=None, quiet=False): self.cfgdir,self.quiet,self.mgr,self.kc,self.kid,self.auto,self.made = cfgdir,quiet,None,None,None,False,False
100
101
 
101
102
  async def _use(self, mgr, kid):
102
103
  "Point at `kid` on `mgr`, dropping any previous ws (never the kernel)"
103
104
  if self.kc: await self.kc.aclose()
104
- if self.mgr is not None and self.mgr is not mgr: await self.mgr.aclose()
105
105
  self.mgr,self.kid,self.kc = mgr,kid,mgr.client(kid)
106
106
  self.kc.start_channels()
107
107
  await self.kc.wait_for_ready(timeout=30)
108
108
 
109
+ def _startkw(self):
110
+ "Creation kwargs for a local kernel: the conversation's cwd and env, with quiet advertised as CLIKERNEL_QUIET"
111
+ env = dict(os.environ)
112
+ if self.quiet: env['CLIKERNEL_QUIET'] = '1'
113
+ return dict(cwd=os.getcwd(), env=env)
114
+
109
115
  # %% ../nbs/00_core.ipynb #94497e4a
110
116
  @patch
111
117
  async def _setup(self:Client):
@@ -139,19 +145,19 @@ async def connect(self:Client, host='', kernel='', auto=False):
139
145
  await self._use(mgr, kid)
140
146
  self.made = False
141
147
  return f'connected to existing kernel {kid} on {url}' + note
142
- kw = dict(cwd=os.getcwd(), env=dict(os.environ)) if not host else {} # the default gateway is local by definition: kernels start where, and as, the conversation lives (cwd and environment - so kernel-side tools that key state to the conversation, like llmdojo's doc-state, resolve it correctly)
148
+ kw = self._startkw() if not host else {} # the default gateway is local by definition: kernels start where, and as, the conversation lives (cwd and environment - so kernel-side tools that key state to the conversation, like llmdojo's doc-state, resolve it correctly)
143
149
  kid = await mgr.start_kernel(**kw)
144
150
  await self._use(mgr, kid)
145
151
  self.made = True
146
152
  out = await self._setup()
147
153
  self.auto = auto
148
- return f'created kernel {kid} on {url}' + (f'\n{out}' if out.strip() else '') + note
154
+ return f'created kernel {kid} on {url}' + (f'\n{out}' if out.strip() and not self.quiet else '') + note
149
155
 
150
156
  @patch
151
- async def execute_outs(self:Client, code):
157
+ async def execute_outs(self:Client, code, **kw):
152
158
  "Run `code` in the current kernel; nbformat-style output dicts (or a protocol note string)"
153
159
  if not self.kc: return 'no kernel: call `connect` first'
154
- try: return await self.kc.run(code)
160
+ try: return await self.kc.exec_outs(code, **kw)
155
161
  except DeadKernelError: return 'NOTE: the kernel process died. `connect` to create or attach to another.'
156
162
 
157
163
  @patch
@@ -168,7 +174,6 @@ async def list_kernels(self:Client, host=''):
168
174
  url,tok,ver = resolve(host, self.cfgdir)
169
175
  mgr = JupyAsyncMultiKernelManager(url, token=tok, verify=ver)
170
176
  ks = await mgr.list_kernels()
171
- await mgr.aclose()
172
177
  else: ks = await self.mgr.list_kernels()
173
178
  if not ks: return 'no kernels'
174
179
  def _l(k): return f"{k['id']} {k.get('execution_state','?')} connections={k.get('connections','?')}" + (' <- current' if k['id']==self.kid else '')
@@ -191,11 +196,23 @@ async def stop(self:Client, kernel=''):
191
196
  async def restart(self:Client):
192
197
  "Restart the current kernel: same id, fresh interpreter; a kernel this client created gets `startup.py` and `inspectors.py` again"
193
198
  if not self.kc: return 'no kernel: call `connect` first'
194
- await self.mgr.restart_kernel(self.kid)
199
+ try: await self.mgr.restart_kernel(self.kid)
200
+ except APIError as e:
201
+ if e.status_code is None:
202
+ return f'restart did not complete ({e.error_type}): retry, or `connect` for a fresh kernel; check the gateway if this persists'
203
+ if e.status_code != 404:
204
+ return f'restart failed ({e.message}); the kernel is still listed: retry, `stop` it, or `connect` for a fresh one'
205
+ old,kw = self.kid,{}
206
+ await self.kc.aclose()
207
+ if self.mgr.base_url == resolve('', self.cfgdir)[0]: kw = self._startkw()
208
+ await self._use(self.mgr, await self.mgr.start_kernel(**kw))
209
+ self.made = True
210
+ out = await self._setup()
211
+ return f'kernel {old} was gone; created fresh kernel {self.kid}.\n' + STATE_LOST_SETUP + (f'\n{out}' if out.strip() and not self.quiet else '')
195
212
  await self.kc.wait_for_ready(timeout=30)
196
213
  if not self.made: return STATE_LOST
197
214
  out = await self._setup()
198
- return STATE_LOST_SETUP + (f'\n{out}' if out.strip() else '')
215
+ return STATE_LOST_SETUP + (f'\n{out}' if out.strip() and not self.quiet else '')
199
216
 
200
217
  @patch
201
218
  async def interrupt(self:Client):
@@ -208,5 +225,14 @@ async def interrupt(self:Client):
208
225
  async def aclose(self:Client):
209
226
  "Drop connections; kernels are left exactly as they are"
210
227
  if self.kc: await self.kc.aclose()
211
- if self.mgr: await self.mgr.aclose()
212
228
  self.mgr,self.kc,self.kid = None,None,None
229
+
230
+ # %% ../nbs/00_core.ipynb #6559aa40
231
+ @patch
232
+ async def __aenter__(self:Client): return self
233
+
234
+ @patch
235
+ async def __aexit__(self:Client, *exc):
236
+ "Stop the current kernel if this client created it (an attached kernel is left as found), then drop connections"
237
+ if self.made and self.kid: await self.stop()
238
+ await self.aclose()
@@ -1,6 +1,6 @@
1
1
  """The MCP frontend: `Client` as tools on stdio
2
2
 
3
- The frontend Claude Code launches per conversation: `mk_server` closes the tools over a `Client`, and `main` (the `clikernel-mcp` console script) serves it on stdio via mcpmini. There is no instructions machinery and nothing eager — usage is taught by skill text, the server answers `initialize` instantly, and nothing happens until the first tool call. An `execute` with no kernel connected auto-connects first, so `connect` is only needed to attach, to reach another gateway, or to make a kernel that outlives the conversation. Tool descriptions carry v1's hard-won wording.
3
+ The frontend Claude Code launches per conversation. `mk_server` closes the tools over a `Client`, and `main` serves them on stdio through mcpmini. The server answers `initialize` without starting a kernel. The first `execute` auto-connects when needed. `connect` remains available for attaching, selecting another gateway, or creating a kernel that outlives the conversation. A kernel `input_request` becomes an MCP elicitation inside the same `execute` call. jupywire sends the elicitation response back to the requesting kernel.
4
4
 
5
5
  Docs: https://AnswerDotAI.github.io/clikernel/mcp.html.md"""
6
6
 
@@ -12,6 +12,7 @@ __all__ = ['part2block', 'mk_server', 'main']
12
12
  # %% ../nbs/01_mcp.ipynb #28c42902
13
13
  import asyncio
14
14
  from fastcore.utils import *
15
+ from fastcore.script import call_parse, store_true
15
16
  from mcpmini.core import MCPServer, serve_stdio
16
17
  from aidialog.dialog import Message
17
18
  from aidialog.hist import output_parts, merge_media
@@ -32,21 +33,32 @@ def part2block(p):
32
33
  def mk_server(c:Client):
33
34
  "An `MCPServer` whose tools close over `c`: connect, execute, and the lifecycle verbs"
34
35
  alock = asyncio.Lock()
36
+ input_schema = dict(type='object', properties=dict(value=dict(type='string')), required=['value'])
37
+
38
+ async def _input(msg):
39
+ content = msg['content']
40
+ if content.get('password'): raise RuntimeError('password prompts are not supported through MCP')
41
+ r = await srv.elicit(content.get('prompt',''), input_schema)
42
+ if r.get('action') != 'accept': raise RuntimeError(f"input {r.get('action','cancelled')}")
43
+ return r['content']['value']
44
+
35
45
  async def connect(
36
46
  host:str='', # Gateway: empty for the local default, a `gateways.toml` name, or a URL
37
47
  kernel:str='', # Kernel id (or unique prefix) to attach to; empty creates a fresh kernel
38
48
  )->str:
39
- "Connect to a kernel. With `kernel`: attach to that existing kernel exactly as it is (nothing is run) - this is how a later conversation returns to live state, and how to reach a kernel someone else created. Without: create a fresh kernel, run the user's startup.py in it, and install their inspectors; the reply includes the new kernel's id (reusable in a later `connect`) and the startup output. Kernels made or attached this way persist until explicitly stopped: disconnecting, switching, and conversation end never kill them. (Connecting also immediately stops the auto kernel, if `execute` had created one.)"
49
+ "Connect to a kernel. With `kernel`: attach to that existing kernel exactly as it is (nothing is run) - this is how a later conversation returns to live state, and how to reach a kernel someone else created. Without: create a fresh kernel, run the user's startup.py in it, and install their inspectors; the reply includes the new kernel's id (reusable in a later `connect`). Kernels made or attached this way persist until explicitly stopped: disconnecting, switching, and conversation end never kill them. (Connecting also immediately stops the auto kernel, if `execute` had created one.)"
40
50
  return await c.connect(host, kernel)
41
51
 
42
52
  async def execute(
43
53
  code:str, # Python/IPython code to run
44
54
  ):
45
- "Run `code` in the current kernel, keeping state across calls (imports, variables, monkeypatches, cached objects). With no kernel connected, one is auto-created first (default gateway, startup.py and inspectors run), and its connect banner - kernel id and startup output - is prepended to the reply: read it. An auto-created kernel is scoped to the conversation: it stops when the conversation ends or when `connect` moves elsewhere; use `connect` for a kernel that should outlive the conversation. If the reply says the kernel died, `connect` again. Image outputs (plots etc.) come back as image blocks, resized to a token-friendly size, each preceded by its `<media id=...>` tag."
55
+ "Run `code` in the current kernel, keeping state across calls (imports, variables, monkeypatches, cached objects). With no kernel connected, one is auto-created first (default gateway, startup.py and inspectors run). An auto-created kernel is scoped to the conversation: it stops when the conversation ends or when `connect` moves elsewhere; use `connect` for a kernel that should outlive the conversation. Kernel input prompts use the MCP client's elicitation UI. Password prompts are refused. If the reply says the kernel died, `connect` again. Image outputs (plots etc.) come back as image blocks, resized to a token-friendly size, each preceded by its `<media id=...>` tag."
46
56
  pre = ''
47
- async with alock: # parallel first calls must not each create a kernel
48
- if not c.kc: pre = await c.connect(auto=True) + '\n'
49
- r = await c.execute_outs(code)
57
+ async with alock:
58
+ if not c.kc:
59
+ banner = await c.connect(auto=True)
60
+ if not c.quiet: pre = banner + '\n'
61
+ r = await c.execute_outs(code, on_stdin=_input)
50
62
  if isinstance(r, str): return pre + r
51
63
  res = merge_media(render_text(r, tb_maxlen=MAXLEN), output_parts(Message(msg_type='code', output=r)))
52
64
  if isinstance(res, str): return pre + res
@@ -74,14 +86,21 @@ def mk_server(c:Client):
74
86
  "Interrupt the code the current kernel is running (SIGINT, i.e. KeyboardInterrupt): the in-flight `execute` 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` is running."
75
87
  return await c.interrupt()
76
88
 
77
- return MCPServer('clikernel', [connect, execute, list_kernels, stop_kernel, restart, interrupt], version=__version__)
89
+ if not c.quiet:
90
+ connect.__doc__ += " The startup output also appears in the reply."
91
+ execute.__doc__ += " The connect banner - kernel id and startup output - is prepended to that first reply: read it."
92
+ srv = MCPServer('clikernel', [connect, execute, list_kernels, stop_kernel, restart, interrupt], version=__version__)
93
+ return srv
78
94
 
79
95
 
80
96
  # %% ../nbs/01_mcp.ipynb #3b90f0e8
81
- def main():
97
+ @call_parse
98
+ def main(
99
+ quiet:store_true=False, # Keep startup output out of replies; an auto-connecting `execute` returns just the result
100
+ ):
82
101
  "The `clikernel-mcp` console script: the tools on stdio, state is one `Client`"
83
102
  async def _main():
84
- c = Client()
103
+ c = Client(quiet=quiet)
85
104
  try: await serve_stdio(mk_server(c))
86
105
  finally:
87
106
  if c.auto: # an execute-made kernel is conversation-scoped; the gateway may already be gone
@@ -0,0 +1,23 @@
1
+ """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.
2
+
3
+ Prefer it over one-off Python scripts (`python -c`, shell heredocs). Prefer in-kernel tools over shell equivalents when they exist: file search and directory listing go through the `rgapi` pyskill (`rg()`/`fd()`/`ls()`), and GitHub and local git work through the `ghapi` pyskill, when those are installed. Shell commands remain the right tool for project test/build commands and non-Python tools.
4
+
5
+ # Starting and stopping
6
+
7
+ The first `execute` creates a kernel and reports what it imported: read that banner, it says what to do next. That kernel stops when the conversation ends. Use a bare `connect` instead when the kernel should stay running afterwards, and tell the user its id.
8
+
9
+ To continue earlier work, or to use the user's solveit kernel, `list_kernels` then `connect` with the id. Attaching runs no setup: the kernel's live state is the point.
10
+
11
+ `restart` gives a fresh interpreter under the same id, so redo any setup. `interrupt` stops a long `execute` and keeps state. If a reply says the kernel has stopped, `connect` again. If `connect` fails, the gateway server is not running: tell the user rather than working around it.
12
+
13
+ Remote gateways are the same verbs with a `host`, named in `~/.config/clikernel/gateways.toml`.
14
+
15
+ # Working in it
16
+
17
+ - Magics work as written. `%cd` expands `~` and is the way to change directory: prefer it over `os.chdir`.
18
+ - Only the last expression in a cell displays. `print(...)` any earlier value you need to see.
19
+ - Everything a cell outputs lands in the conversation. Be selective: `len(v)` first, then decide what to show.
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.
21
+ - After an `nbdev-export` (or any edit to a module already imported), `importlib.reload` that module and re-import any names you hold from it: a name bound by `from x import y` keeps the old object, while a `@patch`ed method refreshes with the reload because the patch writes onto the shared class. Restart only when a class you hold instances of was itself redefined. Check what is loaded by calling it, never with `inspect.getsource`, which reads the file on disk.
22
+ - Try the simple import or API call first, before changing the environment, monkeypatching, or adding setup.
23
+ """
@@ -13,6 +13,8 @@ so the compact protocol exercises the full real stack -- which is its point:
13
13
  a self-contained test client, and a reminder to keep the layers flexible.
14
14
  """
15
15
  import json,os,select,signal,subprocess,sys,time
16
+ from fastcore.nbio import msg2out
17
+ from jupywire.route import OUTPUT_MSGS
16
18
 
17
19
  def _emit(obj):
18
20
  sys.stdout.write(json.dumps(obj) + '\n')
@@ -20,7 +22,9 @@ def _emit(obj):
20
22
 
21
23
  async def _do_exec(kc, req):
22
24
  rid = req.get('id')
23
- async for o in kc.run(req['code']): _emit(dict(ev='out', id=rid, output=o))
25
+ def _out(m):
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)
24
28
  _emit({'ev':'done','id':rid})
25
29
 
26
30
  async def _do_complete(kc, req):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Serve persistent Jupyter kernels to LLMs as concise text, over MCP or a plain stream protocol
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -12,8 +12,9 @@ Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: fastcore>=2.2.2
15
- Requires-Dist: jupyasyncclient>=0.2.2
16
- Requires-Dist: mcpmini>=0.0.1
15
+ Requires-Dist: jupyasyncclient>=0.2.9
16
+ Requires-Dist: fastspec>=0.2.2
17
+ Requires-Dist: mcpmini>=0.0.3
17
18
  Requires-Dist: aidialog>=0.0.7
18
19
  Requires-Dist: pillow
19
20
  Provides-Extra: dev
@@ -50,7 +51,11 @@ Register the stdio server with your MCP host, e.g. for Claude Code:
50
51
  claude mcp add clikernel -- clikernel-mcp
51
52
  ```
52
53
 
53
- The tools mirror the gateway’s kernel API plus one composite: `connect` (create a fresh kernel — running `startup.py` and installing inspectors — or attach to an existing one by id), `execute` (run code, get concise text), `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
54
+ The tools mirror the gateway’s kernel API: `connect`, `execute`, `list_kernels`, `stop_kernel`, `restart`, and `interrupt`. When executed code requests input, the MCP client shows an elicitation dialog while the same `execute` call remains active. Repeated prompts repeat that callback. Password-marked prompts are refused.
55
+
56
+ An `execute` with no kernel connected auto-creates one, scoped to the conversation: it stops at conversation end, or when `connect` moves elsewhere. Kernels made or attached with an explicit `connect` are stopped only by `stop_kernel` — a later conversation reattaches by id and continues where the last one stopped. `$CLIKERNEL_HOST` overrides the default gateway (`http://127.0.0.1:8787`).
57
+
58
+ 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, and an auto-connecting `execute` returns just the result.
54
59
 
55
60
  ## Configuration
56
61
 
@@ -1,6 +1,7 @@
1
1
  fastcore>=2.2.2
2
- jupyasyncclient>=0.2.2
3
- mcpmini>=0.0.1
2
+ jupyasyncclient>=0.2.9
3
+ fastspec>=0.2.2
4
+ mcpmini>=0.0.3
4
5
  aidialog>=0.0.7
5
6
  pillow
6
7
 
@@ -17,8 +17,9 @@ classifiers = [
17
17
 
18
18
  dependencies = [
19
19
  "fastcore>=2.2.2",
20
- "jupyasyncclient>=0.2.2",
21
- "mcpmini>=0.0.1",
20
+ "jupyasyncclient>=0.2.9",
21
+ "fastspec>=0.2.2",
22
+ "mcpmini>=0.0.3",
22
23
  "aidialog>=0.0.7",
23
24
  "pillow",
24
25
  ]
@@ -1,55 +0,0 @@
1
- """Use the persistent `clikernel` MCP session as the default workspace for any task advanced through live Python execution -- stateful inspection, file-editing workflows, debugging, experiments, API probes, data transforms, or notebook-style work. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.
2
-
3
- # Core idea
4
-
5
- clikernel connects this conversation to Jupyter kernels hosted by a gateway server (rustygate) that runs all the time, independently of any conversation. State lasts the whole conversation -- imports, live objects, monkeypatches, cached results -- and a kernel you `connect` explicitly persists across conversations too. Treat it as a notebook-style workbench, not a one-shot script runner.
6
-
7
- Prefer it over one-off Python scripts (`python -c`, shell heredocs) whenever you need to inspect runtime behavior, test an idea, call a Python API, examine package state, run a live probe, or iterate on an implementation detail. Prefer in-kernel tools over shell equivalents when they exist: file search and directory listing go through the `rgapi` pyskill (`rg()`/`fd()`/`ls()`), and GitHub and local git work through the `ghapi` pyskill, when those are installed. Shell commands remain the right tool for project test/build commands and non-Python tools. Run them through the harness's shell tool, never `subprocess`/`os.system` from the kernel, which would bypass the harness's permission hooks.
8
-
9
- # The lifecycle contract
10
-
11
- The lifecycle has one implicit convenience and no implicit destruction beyond it. An `execute` with no kernel connected auto-creates one (default gateway, `startup.py` and inspectors as usual), and the connect banner arrives prepended to that first reply. That auto kernel is scoped to the conversation: it stops when the conversation ends, or the moment `connect` moves anywhere else. Kernels you `connect` explicitly -- created bare or attached by id -- are never stopped except by `stop_kernel`. The tools are self-documenting -- read each tool's MCP description -- and the shape of a session is:
12
-
13
- - Start of work: just `execute` (the first call auto-connects; read the banner: it says what is imported and what to do next), or a bare `connect` when the kernel should outlive the conversation.
14
- - Returning to earlier work (the user asks to continue where a previous conversation left off, or to use their solveit kernel): `list_kernels` to see what's running, then `connect` with the kernel id (or unique prefix). Attach runs nothing -- the kernel's live state is the point.
15
- - End of work: an auto kernel stops itself with the conversation. `stop_kernel` an explicitly created kernel when it was for this task only; leave it running if the user wants to return to it, and tell the user its id so they can.
16
-
17
- `restart` gives the current kernel a genuinely fresh interpreter under the same id (a kernel this conversation created gets `startup.py` and inspectors again; redo any other setup); `interrupt` stops a too-long `execute` while keeping state. If a reply says the kernel died, `connect` again. If `connect` fails because the gateway is unreachable, the gateway server is not running -- report that to the user rather than working around it.
18
-
19
- Remote gateways (a rustygate or solveit instance elsewhere) are the same verbs with a `host`: a name from `~/.config/clikernel/gateways.toml` (`[gateways.<name>]` tables with `url`, `token` or `token_env`, and optional `verify = false` for self-signed TLS) or a URL. Tokens live in the config file, never in tool arguments.
20
-
21
- # Notebook magics
22
-
23
- `execute` runs IPython, so magics work as written. The `%nbrun` line magic (registered by aidialog, which the standard startup imports) runs cells from a `.ipynb` file by cell id prefix -- see `doc(dsk)` after startup for its options. It runs *in the kernel*, so its cells share session state and are checked by the session's inspectors.
24
-
25
- `%cd` expands `~` and is the idiomatic way to change the kernel's directory: prefer it over `os.chdir`.
26
-
27
- # Session setup
28
-
29
- `connect` (creating) first runs `~/.config/clikernel/startup.py` (with `__file__` bound, so the file can locate its neighbors), then installs inspectors from `~/.config/clikernel/inspectors.py`. Both travel as source, so remote kernels get the same setup as local ones. Inspectors see each cell before it runs (1-arg: the AST; 2-arg: AST and raw source): a returned string prints as a note ahead of the cell's output, raising `RuleBlock` (provided in the file's namespace) blocks the cell, and inspector bugs warn and fail open. Attached kernels are taken as found: no startup, no inspectors.
30
-
31
- # Output shape
32
-
33
- Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output comes back as its preferred text form, e.g. `42`; multiple outputs use readable XML-ish tags (`<stdout>`, `<execute_result>`, ...) with raw, unescaped body text. Exceptions come back as one clean traceback: ANSI-stripped, over-long lines capped at 180 characters with `File `/`Cell ` locations and the exception message always whole. `input()` fails fast in-band (`allow_stdin=False`) rather than hanging. Image outputs (plots etc.) arrive as MCP image blocks, resized to a token-friendly budget, each preceded by a `<media id=...>` text tag naming it; when the image cannot be delivered, a `<media-unavailable>` note appears in the text instead.
34
-
35
- # Interaction rules
36
-
37
- - Try the simple import or API call first, before mutating environment, monkeypatching, or adding setup.
38
- - Like Jupyter, only the *last* expression in a cell displays. `print(...)` any earlier value you need to see.
39
- - Don't re-run an `import` already run this session. If a previously-imported name raises `NameError`, the kernel restarted or is newly attached -- redo setup.
40
- - `importlib.reload` is not always enough: `from x import *` consumers and `@patch`-decorated classes hold stale references. On stale-class symptoms, use the `restart` tool.
41
- - Everything a cell outputs lands in the conversation. Be surgical: `print(len(v))` first, then decide what to show.
42
- - A kernel is shared by anything connected to it: subagents in this session, or the user's own client. Assume shared state is a feature, not a surprise.
43
-
44
- # Pyskills
45
-
46
- This environment commonly has `pyskills` installed. When present, check it first and prefer a relevant pyskill over ad hoc code:
47
-
48
- from pyskills import list_pyskills, doc
49
- import pyskills.skill
50
- doc(pyskills.skill)
51
-
52
- # The stream protocol
53
-
54
- Driven as a plain CLI process (`clikernel [--host H] [--kernel K]`), the same client speaks a delimiter-framed stdin/stdout protocol, documented in its own startup banner and the README. Run bare it creates a kernel and stops it on exit; `--kernel` attaches and leaves it running.
55
- """
File without changes
File without changes
File without changes
File without changes