clikernel 0.2.8__tar.gz → 0.2.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {clikernel-0.2.8 → clikernel-0.2.10}/CHANGELOG.md +14 -0
  2. clikernel-0.2.10/PKG-INFO +94 -0
  3. clikernel-0.2.10/README.md +71 -0
  4. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel/__init__.py +3 -3
  5. clikernel-0.2.10/clikernel/_modidx.py +41 -0
  6. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel/cli.py +12 -8
  7. clikernel-0.2.10/clikernel/core.py +164 -0
  8. clikernel-0.2.10/clikernel/mcp.py +92 -0
  9. clikernel-0.2.10/clikernel/skill.py +29 -0
  10. clikernel-0.2.10/clikernel.egg-info/PKG-INFO +94 -0
  11. clikernel-0.2.10/clikernel.egg-info/requires.txt +9 -0
  12. {clikernel-0.2.8 → clikernel-0.2.10}/pyproject.toml +4 -5
  13. clikernel-0.2.8/PKG-INFO +0 -77
  14. clikernel-0.2.8/README.md +0 -53
  15. clikernel-0.2.8/clikernel/_modidx.py +0 -41
  16. clikernel-0.2.8/clikernel/core.py +0 -238
  17. clikernel-0.2.8/clikernel/mcp.py +0 -111
  18. clikernel-0.2.8/clikernel/skill.py +0 -23
  19. clikernel-0.2.8/clikernel.egg-info/PKG-INFO +0 -77
  20. clikernel-0.2.8/clikernel.egg-info/requires.txt +0 -10
  21. {clikernel-0.2.8 → clikernel-0.2.10}/LICENSE +0 -0
  22. {clikernel-0.2.8 → clikernel-0.2.10}/MANIFEST.in +0 -0
  23. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel/stream.py +0 -0
  24. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel.egg-info/SOURCES.txt +0 -0
  25. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel.egg-info/dependency_links.txt +0 -0
  26. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel.egg-info/entry_points.txt +0 -0
  27. {clikernel-0.2.8 → clikernel-0.2.10}/clikernel.egg-info/top_level.txt +0 -0
  28. {clikernel-0.2.8 → clikernel-0.2.10}/setup.cfg +0 -0
  29. {clikernel-0.2.8 → clikernel-0.2.10}/tests/test_stream.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  <!-- do not remove -->
4
4
 
5
+ ## 0.2.10
6
+
7
+ ### New Features
8
+
9
+ - Add Luau kernel support: lua tool and create(language=...), Python-only startup/inspectors ([#48](https://github.com/AnswerDotAI/clikernel/issues/48))
10
+
11
+
12
+ ## 0.2.9
13
+
14
+ ### Breaking Changes
15
+
16
+ - 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)
17
+
18
+
5
19
  ## 0.2.8
6
20
 
7
21
  ### New Features
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: clikernel
3
+ Version: 0.2.10
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.11
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 or Luau session. Imports, variables, and results remain available between tool calls. An agent can create a kernel or attach to an existing one, including a user’s live solveit kernel, on a local or named remote gateway.
30
+
31
+ [rustygate](https://github.com/AnswerDotAI/rustygate) hosts the Jupyter kernels and provides their MCP tools. `clikernel` is the conversation-side router: the MCP host launches it over stdio, and it forwards requests to gateways over HTTP. It selects gateways from `gateways.toml`, supplies `startup.py` and `inspectors.py` to Python kernels it creates, and starts a private local gateway when needed. The default kernel is [ipymini](https://github.com/AnswerDotAI/ipymini).
32
+
33
+ Kernel ownership determines what happens when the conversation ends:
34
+
35
+ - A session closes kernels it created with `autoclose`, including the automatic kernel used by bare `py` or `lua` and kernels created with `create`’s default settings.
36
+ - Attaching with `use_kernel` does not make that session responsible for closing the kernel.
37
+ - A kernel created with `autoclose=false` on a resident gateway can outlive the conversation. A later conversation can attach and continue using its state.
38
+ - A private child gateway ends with its conversation. Use a resident gateway for kernels that need to survive across conversations.
39
+
40
+ ## Install
41
+
42
+ ``` sh
43
+ pip install clikernel
44
+ ```
45
+
46
+ This installs rustygate and ipymini. No service setup is required for a conversation-local kernel: clikernel starts a private gateway if it cannot find one. To retain kernels across conversations, run a resident gateway, for example through launchd or systemd:
47
+
48
+ ``` sh
49
+ rustygate --port 8787
50
+ ```
51
+
52
+ ## Use with an MCP host
53
+
54
+ Register the stdio server with your MCP host. For Claude Code:
55
+
56
+ ``` sh
57
+ claude mcp add clikernel -- clikernel-mcp
58
+ ```
59
+
60
+ Use `py(code=...)` for Python/IPython or `lua(code=...)` for bundled Luau. Either starts its language’s kernel when none is current. There is one current kernel: a language mismatch errors without switching or running the code. IPython magics, including `%%bash`, are Python-only.
61
+
62
+ For a named kernel, use `create(dlgname="work", language="luau")`. Omit `language` to reuse an existing binding unchanged, or default a new kernel to Python. An explicit language must match an existing binding. The other tools are `list_kernels`, `use_kernel`, `delete_kernel`, `restart`, and `interrupt`; creation, selection, and listing report the language.
63
+
64
+ These tools forward to rustygate. `list_kernels`, `use_kernel`, and `create` also accept a `host` naming a gateway from `gateways.toml`. One MCP registration can therefore reach multiple machines. Replies retain the gateway’s text and image blocks. Python startup and inspectors run only in Python, never Luau, including after restart.
65
+
66
+ `$CLIKERNEL_HOST` overrides the default gateway URL, `http://127.0.0.1:8787`. If no gateway answers, clikernel uses a private child gateway for the conversation. The ownership rules above determine which kernels close at session end.
67
+
68
+ Pass `--quiet`, as in `clikernel-mcp --quiet`, to omit startup output from replies. Python startup code still runs.
69
+
70
+ ## Configuration
71
+
72
+ Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`:
73
+
74
+ - `startup.py` runs in each Python kernel clikernel creates, with `__file__` set to its path. Its output appears in the reply announcing the kernel unless `--quiet` is set.
75
+ - `inspectors.py` installs Python cell inspectors after startup. Define `inspect`, a list named `inspectors`, or both. Each inspector runs once before a cell: a one-argument inspector takes the cell’s AST, and a two-argument inspector takes the AST and raw source. Return a string to print a note before the output. Raise the provided `RuleBlock` to block execution. Other exceptions produce a warning and allow the cell to run. See [examples/inspectors.py](examples/inspectors.py).
76
+ - `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
77
+
78
+ ``` toml
79
+ [gateways.solveit]
80
+ url = "https://solveit.example.com/gate"
81
+ token_env = "SOLVEIT_TOKEN"
82
+ verify = false # optional: accept a self-signed certificate
83
+ ```
84
+
85
+ ## The stream protocol
86
+
87
+ Run `clikernel` as a plain CLI process for clients that read a text stream rather than MCP messages. It uses a delimiter-framed stdin/stdout protocol:
88
+
89
+ - Input is not echoed.
90
+ - Each request gets a `.` acknowledgement.
91
+ - A per-process random delimiter marks the end of each response.
92
+ - Multiline cells are framed by `--` and the delimiter.
93
+
94
+ The startup banner supplies the protocol instructions and delimiter. Running `clikernel` without arguments creates a kernel and stops it on exit. `--kernel <id>` attaches to an existing kernel and leaves it running on exit.
@@ -0,0 +1,71 @@
1
+ # clikernel
2
+
3
+
4
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
5
+
6
+ `clikernel` gives an LLM agent a persistent Python or Luau session. Imports, variables, and results remain available between tool calls. An agent can create a kernel or attach to an existing one, including a user’s live solveit kernel, on a local or named remote gateway.
7
+
8
+ [rustygate](https://github.com/AnswerDotAI/rustygate) hosts the Jupyter kernels and provides their MCP tools. `clikernel` is the conversation-side router: the MCP host launches it over stdio, and it forwards requests to gateways over HTTP. It selects gateways from `gateways.toml`, supplies `startup.py` and `inspectors.py` to Python kernels it creates, and starts a private local gateway when needed. The default kernel is [ipymini](https://github.com/AnswerDotAI/ipymini).
9
+
10
+ Kernel ownership determines what happens when the conversation ends:
11
+
12
+ - A session closes kernels it created with `autoclose`, including the automatic kernel used by bare `py` or `lua` and kernels created with `create`’s default settings.
13
+ - Attaching with `use_kernel` does not make that session responsible for closing the kernel.
14
+ - A kernel created with `autoclose=false` on a resident gateway can outlive the conversation. A later conversation can attach and continue using its state.
15
+ - A private child gateway ends with its conversation. Use a resident gateway for kernels that need to survive across conversations.
16
+
17
+ ## Install
18
+
19
+ ``` sh
20
+ pip install clikernel
21
+ ```
22
+
23
+ This installs rustygate and ipymini. No service setup is required for a conversation-local kernel: clikernel starts a private gateway if it cannot find one. To retain kernels across conversations, run a resident gateway, for example through launchd or systemd:
24
+
25
+ ``` sh
26
+ rustygate --port 8787
27
+ ```
28
+
29
+ ## Use with an MCP host
30
+
31
+ Register the stdio server with your MCP host. For Claude Code:
32
+
33
+ ``` sh
34
+ claude mcp add clikernel -- clikernel-mcp
35
+ ```
36
+
37
+ Use `py(code=...)` for Python/IPython or `lua(code=...)` for bundled Luau. Either starts its language’s kernel when none is current. There is one current kernel: a language mismatch errors without switching or running the code. IPython magics, including `%%bash`, are Python-only.
38
+
39
+ For a named kernel, use `create(dlgname="work", language="luau")`. Omit `language` to reuse an existing binding unchanged, or default a new kernel to Python. An explicit language must match an existing binding. The other tools are `list_kernels`, `use_kernel`, `delete_kernel`, `restart`, and `interrupt`; creation, selection, and listing report the language.
40
+
41
+ These tools forward to rustygate. `list_kernels`, `use_kernel`, and `create` also accept a `host` naming a gateway from `gateways.toml`. One MCP registration can therefore reach multiple machines. Replies retain the gateway’s text and image blocks. Python startup and inspectors run only in Python, never Luau, including after restart.
42
+
43
+ `$CLIKERNEL_HOST` overrides the default gateway URL, `http://127.0.0.1:8787`. If no gateway answers, clikernel uses a private child gateway for the conversation. The ownership rules above determine which kernels close at session end.
44
+
45
+ Pass `--quiet`, as in `clikernel-mcp --quiet`, to omit startup output from replies. Python startup code still runs.
46
+
47
+ ## Configuration
48
+
49
+ Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`:
50
+
51
+ - `startup.py` runs in each Python kernel clikernel creates, with `__file__` set to its path. Its output appears in the reply announcing the kernel unless `--quiet` is set.
52
+ - `inspectors.py` installs Python cell inspectors after startup. Define `inspect`, a list named `inspectors`, or both. Each inspector runs once before a cell: a one-argument inspector takes the cell’s AST, and a two-argument inspector takes the AST and raw source. Return a string to print a note before the output. Raise the provided `RuleBlock` to block execution. Other exceptions produce a warning and allow the cell to run. See [examples/inspectors.py](examples/inspectors.py).
53
+ - `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
54
+
55
+ ``` toml
56
+ [gateways.solveit]
57
+ url = "https://solveit.example.com/gate"
58
+ token_env = "SOLVEIT_TOKEN"
59
+ verify = false # optional: accept a self-signed certificate
60
+ ```
61
+
62
+ ## The stream protocol
63
+
64
+ Run `clikernel` as a plain CLI process for clients that read a text stream rather than MCP messages. It uses a delimiter-framed stdin/stdout protocol:
65
+
66
+ - Input is not echoed.
67
+ - Each request gets a `.` acknowledgement.
68
+ - A per-process random delimiter marks the end of each response.
69
+ - Multiline cells are framed by `--` and the delimiter.
70
+
71
+ The startup banner supplies the protocol instructions and delimiter. Running `clikernel` without arguments creates a kernel and stops it on exit. `--kernel <id>` attaches to an existing kernel and leaves it running on exit.
@@ -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`: Connect to gateway-hosted kernels and turn execution into concise text
7
- - `clikernel.mcp`: The MCP frontend: `Client` as tools on stdio
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.8"
11
+ __version__ = "0.2.10"
@@ -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 a thin client of a gateway kernel. 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.
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 Client
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
- c = Client()
133
- info = run(c.connect(host, kernel))
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(c.execute(code), loop)
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(c.interrupt(), loop)
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
- if not kernel: run(c.stop()) # created for this run, cleaned up by this run
147
- run(c.aclose())
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,164 @@
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
+ cfgdir = Path(cfgdir) if cfgdir else cfg_dir()
37
+ g = gateways(cfgdir).get(host)
38
+ if g is None: raise ValueError(f"unknown gateway {host!r}: not a URL, and not in {cfgdir/'gateways.toml'}")
39
+ return g['url'], g.get('token') or os.environ.get(g.get('token_env','')) or None, g.get('verify', True)
40
+
41
+
42
+ # %% ../nbs/00_core.ipynb #0d846754
43
+ def _startup_src(src, path):
44
+ "The startup file's source wrapped so `__file__` is bound to its path during the run, and absent after"
45
+ return f'''__file__ = {str(path)!r}
46
+ try: exec(compile({src!r}, __file__, 'exec'))
47
+ finally: del __file__'''
48
+
49
+ # %% ../nbs/00_core.ipynb #3dbf4cb6
50
+ _INSP_RUNNER = r'''
51
+ import inspect as _clik_inspect
52
+ import sys as _clik_sys
53
+ from IPython.core.error import InputRejected
54
+ class RuleBlock(InputRejected):
55
+ "Raise from an inspector to deliberately block a cell; any other inspector exception is a bug, and fails open"
56
+
57
+ class _ClikInspect:
58
+ "Calls each inspector once per cell: 1-arg get the AST, 2-arg also the raw source"
59
+ def __init__(self, fs): self.fs = fs
60
+ def visit(self, tree):
61
+ fr, n = _clik_sys._getframe(), 0
62
+ while fr:
63
+ n += fr.f_code.co_name == 'run_cell_async'
64
+ fr = fr.f_back
65
+ if n > 1: return tree # nested run_cell: cell replayed by a tool (%nbrun etc.), not typed
66
+ for f in self.fs:
67
+ try:
68
+ note = f(tree, _clik_src) if len(_clik_inspect.signature(f).parameters) > 1 else f(tree)
69
+ if note: print(note, end='')
70
+ except InputRejected: raise
71
+ except Exception as e: print(f'inspector error (cell runs anyway): {e!r}')
72
+ return tree
73
+
74
+ def _clik_stash(info):
75
+ global _clik_src
76
+ _clik_src = info.raw_cell
77
+
78
+ def _clik_install(src):
79
+ ns = dict(RuleBlock=RuleBlock)
80
+ exec(compile(src, 'inspectors.py', 'exec'), ns)
81
+ fs = list(ns.get('inspectors') or [])
82
+ if callable(ns.get('inspect')): fs.append(ns['inspect'])
83
+ if fs:
84
+ ip = get_ipython()
85
+ ip.events.register('pre_run_cell', _clik_stash)
86
+ ip.ast_transformers.append(_ClikInspect(fs))
87
+ _clik_src = ''
88
+ '''
89
+
90
+ def _inspector_setup(src):
91
+ "Kernel-side source installing the inspectors defined in `src`; a load failure raises, failing the create call"
92
+ return _INSP_RUNNER + f'\n_clik_install({src!r})'
93
+
94
+ # %% ../nbs/00_core.ipynb #1362e6ce
95
+ def startup_src(cfgdir=None):
96
+ "The composed startup source for one kernel: `startup.py` wrapped, then the inspector installer"
97
+ d = Path(cfgdir) if cfgdir else cfg_dir()
98
+ parts = []
99
+ if (p := d/'startup.py').exists(): parts.append(_startup_src(p.read_text(), p))
100
+ if (p := d/'inspectors.py').exists(): parts.append(_inspector_setup(p.read_text()))
101
+ return '\n'.join(parts)
102
+
103
+ def session_defaults(cfgdir=None, quiet=False, local=True):
104
+ "The `rustygate` initialize extension: startup source and quiet, plus cwd and env for a local gateway"
105
+ d = dict(startup=startup_src(cfgdir), quiet=quiet)
106
+ if local:
107
+ d['cwd'] = os.getcwd()
108
+ d['env'] = dict(os.environ, CLIKERNEL_QUIET='1') if quiet else dict(os.environ)
109
+ return d
110
+
111
+ # %% ../nbs/00_core.ipynb #10ed59fc
112
+ class Gateway:
113
+ "One MCP session on one rustygate: initialize with session defaults, call tools, DELETE at close"
114
+ def __init__(self,
115
+ url, # The gateway base URL, e.g. 'http://127.0.0.1:8787'
116
+ token=None, # Gateway auth token, sent as a bearer token
117
+ verify=True, # Verify TLS certificates?
118
+ ):
119
+ client = httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(None, connect=10))
120
+ self.url,self._id = url,0
121
+ self.tr = HTTPTransport(f"{url.rstrip('/')}/mcp", token=token, http_client=client)
122
+
123
+ async def rpc(self, method, **params):
124
+ "One JSON-RPC request, returning its result and raising on a protocol-level error"
125
+ self._id += 1
126
+ r = await self.tr.send(jreq(method, self._id, **params))
127
+ if 'error' in r: raise RuntimeError(f"{r['error']['code']}: {r['error']['message']}")
128
+ return r['result']
129
+
130
+ async def initialize(self, defaults=None):
131
+ "Open the MCP session, sending `defaults` as the `rustygate` extension; returns self"
132
+ await self.tr.start()
133
+ self.info = await self.rpc('initialize', protocolVersion='2025-11-25', capabilities={},
134
+ clientInfo=dict(name='clikernel', version=__version__), rustygate=defaults or {})
135
+ await self.tr.send(jreq('notifications/initialized'))
136
+ return self
137
+
138
+ async def tools(self): return (await self.rpc('tools/list'))['tools']
139
+ async def call(self, name, **args): return await self.rpc('tools/call', name=name, arguments=args)
140
+
141
+ async def text(self, name, **args):
142
+ "A tool call's text blocks joined; raises on `isError`"
143
+ r = await self.call(name, **args)
144
+ t = ''.join(c.get('text','') for c in r['content'] if c['type'] == 'text')
145
+ if r.get('isError'): raise RuntimeError(t)
146
+ return t
147
+
148
+ async def aclose(self):
149
+ "End the MCP session — the gateway stops the kernels this session created with autoclose — and drop the connection"
150
+ await self.tr.delete()
151
+ await self.tr.aclose()
152
+
153
+ # %% ../nbs/00_core.ipynb #0d054375
154
+ async def default_gateway(
155
+ cfgdir=None, # Config dir for `session_defaults` (the standard one if None)
156
+ quiet=False, # Keep startup output out of replies?
157
+ ):
158
+ "An initialized `Gateway` on the default local gateway, plus the owned child rustygate when none was running (else None)"
159
+ url, token, verify = resolve('', cfgdir)
160
+ d = session_defaults(cfgdir, quiet)
161
+ try: return await Gateway(url, token, verify).initialize(d), None
162
+ except httpx.ConnectError:
163
+ child = start_gateway()
164
+ 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
+
@@ -0,0 +1,29 @@
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 `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
+
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
+
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
+
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
+
15
+ # Working in it
16
+
17
+ For native Luau work use `lua(code=...)`, or `create(dlgname, language="luau")` for a named kernel. The first `lua` auto-starts Luau when no kernel is selected, with the same autoclose rules as Python. There is one current kernel: `py` requires Python and `lua` requires Luau, so a mismatch errors rather than switching. Use `use_kernel` or `create` to select deliberately. Omit `language` to reuse an existing binding unchanged or default a new one to Python; an explicit language must match an existing binding. A `dlgname` execution override changes only that call. These rules also apply on named remote gateways.
18
+
19
+ Start native work with `lua(code="help()")` for the bundled guide and examples, or `lua(code='help("ex.edit_file")')` for function details.
20
+
21
+ Luau has persistent globals, cell-local `local` variables, and native `rg.search`, `rg.find`, `fs.read_text`, `ex.edit_text`, `ex.view_file`/`ex.edit_file`, `ex.view_cell`/`ex.edit_cell`, `os.execute`, and `io.popen` APIs. File edits use arrays of command fields (like Python exhash tuples); `{inplace=false}` previews. Shell commands use `/bin/sh -c`; pipes support read/lines/write/flush/close and persist until closed. Interrupts terminate subprocess groups and close outstanding pipes. Python startup/inspectors and IPython magics do not apply to Luau. The following guidance is for Python:
22
+
23
+ - Magics work as written, including `%%bash` for shell work. `%cd` expands `~` and is the way to change directory: prefer it over `os.chdir`.
24
+ - Only the last expression in a cell displays. `print(...)` any earlier value you need to see.
25
+ - Everything a cell outputs lands in the conversation. Be selective: `len(v)` first, then decide what to show.
26
+ - Don't re-run an `import` already run this session. If a name raises `NameError`, the kernel restarted or is newly attached: redo setup.
27
+ - 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.
28
+ - Try the simple import or API call first, before changing the environment, monkeypatching, or adding setup.
29
+ """