clikernel 0.2.14__tar.gz → 0.2.16__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.14 → clikernel-0.2.16}/CHANGELOG.md +16 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/MANIFEST.in +0 -1
- {clikernel-0.2.14/clikernel.egg-info → clikernel-0.2.16}/PKG-INFO +9 -5
- {clikernel-0.2.14 → clikernel-0.2.16}/README.md +6 -3
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/__init__.py +2 -2
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/_modidx.py +6 -5
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/core.py +19 -52
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/mcp.py +41 -8
- clikernel-0.2.16/clikernel/rules.py +169 -0
- clikernel-0.2.16/clikernel/skill.py +24 -0
- {clikernel-0.2.14 → clikernel-0.2.16/clikernel.egg-info}/PKG-INFO +9 -5
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/SOURCES.txt +2 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/entry_points.txt +1 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/requires.txt +2 -1
- {clikernel-0.2.14 → clikernel-0.2.16}/pyproject.toml +4 -2
- clikernel-0.2.16/tests/test_rules.py +80 -0
- clikernel-0.2.14/clikernel/skill.py +0 -33
- {clikernel-0.2.14 → clikernel-0.2.16}/LICENSE +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/cli.py +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/stream.py +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/dependency_links.txt +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/top_level.txt +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/setup.cfg +0 -0
- {clikernel-0.2.14 → clikernel-0.2.16}/tests/test_stream.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- do not remove -->
|
|
4
4
|
|
|
5
|
+
## 0.2.16
|
|
6
|
+
|
|
7
|
+
### New Features
|
|
8
|
+
|
|
9
|
+
- Add clikernel-hook PreToolUse hook giving Claude Code subagents their own kernels ([#57](https://github.com/AnswerDotAI/clikernel/issues/57))
|
|
10
|
+
- Display only top-level expression results in Python kernels and cap total image data per tool result at 5 MB to avoid stdio disconnects ([#56](https://github.com/AnswerDotAI/clikernel/issues/56))
|
|
11
|
+
- Switch to BPL ([#55](https://github.com/AnswerDotAI/clikernel/issues/55))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## 0.2.15
|
|
15
|
+
|
|
16
|
+
### New Features
|
|
17
|
+
|
|
18
|
+
- Replace kernel-side inspectors with built-in router cell rules checking each exec cell, adding notes or blocking subprocess and sys.path ([#54](https://github.com/AnswerDotAI/clikernel/issues/54))
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
## 0.2.14
|
|
6
22
|
|
|
7
23
|
### Bugs Squashed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
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-Expression: Apache-2.0
|
|
@@ -15,9 +15,10 @@ Requires-Dist: fastcore>=2.2.2
|
|
|
15
15
|
Requires-Dist: jupyasyncclient>=0.2.10
|
|
16
16
|
Requires-Dist: jupywire>=0.1.9
|
|
17
17
|
Requires-Dist: mcpmini>=0.0.5
|
|
18
|
-
Requires-Dist: rustygate>=0.1.
|
|
18
|
+
Requires-Dist: rustygate>=0.1.25
|
|
19
19
|
Requires-Dist: ipymini>=0.1.23
|
|
20
20
|
Requires-Dist: httpx
|
|
21
|
+
Requires-Dist: ipython
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: fastship; extra == "dev"
|
|
23
24
|
Dynamic: license-file
|
|
@@ -60,14 +61,17 @@ create(kernel="py")
|
|
|
60
61
|
exec(code="6*7")
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
Use `kernel="luau"` for bundled Luau, or `kernel="
|
|
64
|
+
Use `kernel="luau"` for bundled Luau, or `kernel="bpl"` for BPL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. In the main conversation, `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
65
|
+
|
|
66
|
+
In Claude Code, subagents share the current kernel unless `clikernel-hook` is registered as a `PreToolUse` hook. With the hook, each subagent’s first `exec` creates a kernel of its own. See [Subagent kernels](https://AnswerDotAI.github.io/clikernel/mcp.html#subagent-kernels) for the settings entry.
|
|
67
|
+
|
|
68
|
+
The router checks each `exec` cell against built-in rules for mistakes agents often make with this toolkit. Most rules add a note to the reply, and `--quiet` leaves the notes out. Two rules always block the cell: shell access through `subprocess`, [`os.system`](https://docs.python.org/3/library/os.html#os.system) or [`os.popen`](https://docs.python.org/3/library/os.html#os.popen), and changes to `sys.path`.
|
|
64
69
|
|
|
65
70
|
## Configuration
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
68
73
|
|
|
69
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.
|
|
70
|
-
- `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).
|
|
71
75
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
72
76
|
|
|
73
77
|
``` toml
|
|
@@ -36,14 +36,17 @@ create(kernel="py")
|
|
|
36
36
|
exec(code="6*7")
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Use `kernel="luau"` for bundled Luau, or `kernel="
|
|
39
|
+
Use `kernel="luau"` for bundled Luau, or `kernel="bpl"` for BPL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. In the main conversation, `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
40
|
+
|
|
41
|
+
In Claude Code, subagents share the current kernel unless `clikernel-hook` is registered as a `PreToolUse` hook. With the hook, each subagent’s first `exec` creates a kernel of its own. See [Subagent kernels](https://AnswerDotAI.github.io/clikernel/mcp.html#subagent-kernels) for the settings entry.
|
|
42
|
+
|
|
43
|
+
The router checks each `exec` cell against built-in rules for mistakes agents often make with this toolkit. Most rules add a note to the reply, and `--quiet` leaves the notes out. Two rules always block the cell: shell access through `subprocess`, [`os.system`](https://docs.python.org/3/library/os.html#os.system) or [`os.popen`](https://docs.python.org/3/library/os.html#os.popen), and changes to `sys.path`.
|
|
40
44
|
|
|
41
45
|
## Configuration
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
44
48
|
|
|
45
49
|
- `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.
|
|
46
|
-
- `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).
|
|
47
50
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
48
51
|
|
|
49
52
|
``` toml
|
|
@@ -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`: Gateway naming, startup delivery, and MCP sessions on rustygate gateways
|
|
7
7
|
- `clikernel.mcp`: Route stdio MCP requests to rustygate gateways
|
|
8
|
-
- `clikernel.skill`: Use
|
|
8
|
+
- `clikernel.skill`: Use `clikernel` MCP as the default persistent Python workspace for file/notebook reads and edits, exploration, library checks, data reshaping and debugging. Imports and variables carry across calls. Read before writing, running or debugging Python 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.16"
|
|
@@ -23,8 +23,8 @@ d = { 'settings': { 'branch': 'main',
|
|
|
23
23
|
'clikernel.core.Gateway.rpc': ('core.html#gateway.rpc', 'clikernel/core.py'),
|
|
24
24
|
'clikernel.core.Gateway.text': ('core.html#gateway.text', 'clikernel/core.py'),
|
|
25
25
|
'clikernel.core.Gateway.tools': ('core.html#gateway.tools', 'clikernel/core.py'),
|
|
26
|
-
'clikernel.core.
|
|
27
|
-
'clikernel.core.
|
|
26
|
+
'clikernel.core._top_level_display': ('core.html#_top_level_display', 'clikernel/core.py'),
|
|
27
|
+
'clikernel.core._with_file': ('core.html#_with_file', 'clikernel/core.py'),
|
|
28
28
|
'clikernel.core.cfg_dir': ('core.html#cfg_dir', 'clikernel/core.py'),
|
|
29
29
|
'clikernel.core.default_gateway': ('core.html#default_gateway', 'clikernel/core.py'),
|
|
30
30
|
'clikernel.core.gateways': ('core.html#gateways', 'clikernel/core.py'),
|
|
@@ -37,6 +37,7 @@ d = { 'settings': { 'branch': 'main',
|
|
|
37
37
|
'clikernel.mcp.Router.dispatch': ('mcp.html#router.dispatch', 'clikernel/mcp.py'),
|
|
38
38
|
'clikernel.mcp.Router.session': ('mcp.html#router.session', 'clikernel/mcp.py'),
|
|
39
39
|
'clikernel.mcp.Router.tools': ('mcp.html#router.tools', 'clikernel/mcp.py'),
|
|
40
|
-
'clikernel.mcp.
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
'clikernel.mcp._cap_images': ('mcp.html#_cap_images', 'clikernel/mcp.py'),
|
|
41
|
+
'clikernel.mcp.agent_input': ('mcp.html#agent_input', 'clikernel/mcp.py'),
|
|
42
|
+
'clikernel.mcp.hook': ('mcp.html#hook', 'clikernel/mcp.py'),
|
|
43
|
+
'clikernel.mcp.main': ('mcp.html#main', 'clikernel/mcp.py')}}}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[Rustygate](https://github.com/AnswerDotAI/rustygate) hosts kernels and serves MCP requests at `POST /mcp`. Clikernel runs alongside it for the duration of a conversation. It routes the LLM client's stdio MCP requests to gateways.
|
|
4
4
|
|
|
5
|
-
This module connects to those gateways. `gateways.toml` gives them names. `session_defaults` supplies kernel startup code and, for a local gateway, the conversation's working directory and environment. It
|
|
5
|
+
This module connects to those gateways. `gateways.toml` gives them names. `session_defaults` supplies kernel startup code and, for a local gateway, the conversation's working directory and environment. It sends `startup.py` as source, so it can run on another machine.
|
|
6
6
|
|
|
7
7
|
`Gateway` represents one MCP session on one gateway. `default_gateway` connects to the local gateway or starts a child process if it cannot connect. The caller must stop any returned child. The MCP router does this when the conversation ends.
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ Docs: https://AnswerDotAI.github.io/clikernel/core.html.md"""
|
|
|
16
16
|
__all__ = ['DEFAULT_URL', 'cfg_dir', 'gateways', 'resolve', 'startup_src', 'session_defaults', 'Gateway', 'default_gateway']
|
|
17
17
|
|
|
18
18
|
# %% ../nbs/00_core.ipynb #2b3b7f4a
|
|
19
|
-
import os, tomllib, httpx
|
|
19
|
+
import os, inspect, tomllib, httpx
|
|
20
20
|
from fastcore.utils import *
|
|
21
21
|
from fastcore.xdg import xdg_config_home
|
|
22
22
|
from mcpmini.core import HTTPTransport, jreq
|
|
@@ -46,64 +46,31 @@ def resolve(host='', cfgdir=None):
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
# %% ../nbs/00_core.ipynb #0d846754
|
|
49
|
-
def
|
|
49
|
+
def _with_file(src, path):
|
|
50
50
|
"Wrap startup source with `__file__` set to its path during execution and deleted afterwards."
|
|
51
51
|
return f'''__file__ = {str(path)!r}
|
|
52
52
|
try: exec(compile({src!r}, __file__, 'exec'))
|
|
53
53
|
finally: del __file__'''
|
|
54
54
|
|
|
55
|
-
# %% ../nbs/00_core.ipynb #
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
import
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
fr, n = _clik_sys._getframe(), 0
|
|
68
|
-
while fr:
|
|
69
|
-
n += fr.f_code.co_name == 'run_cell_async'
|
|
70
|
-
fr = fr.f_back
|
|
71
|
-
if n > 1: return tree # nested run_cell: cell replayed by a tool (%nbrun etc.), not typed
|
|
72
|
-
for f in self.fs:
|
|
73
|
-
try:
|
|
74
|
-
note = f(tree, _clik_src) if len(_clik_inspect.signature(f).parameters) > 1 else f(tree)
|
|
75
|
-
if note: print(note, end='')
|
|
76
|
-
except InputRejected: raise
|
|
77
|
-
except Exception as e: print(f'inspector error (cell runs anyway): {e!r}')
|
|
78
|
-
return tree
|
|
79
|
-
|
|
80
|
-
def _clik_stash(info):
|
|
81
|
-
global _clik_src
|
|
82
|
-
_clik_src = info.raw_cell
|
|
83
|
-
|
|
84
|
-
def _clik_install(src):
|
|
85
|
-
ns = dict(RuleBlock=RuleBlock)
|
|
86
|
-
exec(compile(src, 'inspectors.py', 'exec'), ns)
|
|
87
|
-
fs = list(ns.get('inspectors') or [])
|
|
88
|
-
if callable(ns.get('inspect')): fs.append(ns['inspect'])
|
|
89
|
-
if fs:
|
|
90
|
-
ip = get_ipython()
|
|
91
|
-
ip.events.register('pre_run_cell', _clik_stash)
|
|
92
|
-
ip.ast_transformers.append(_ClikInspect(fs))
|
|
93
|
-
_clik_src = ''
|
|
94
|
-
'''
|
|
95
|
-
|
|
96
|
-
def _inspector_setup(src):
|
|
97
|
-
"Kernel-side source installing the inspectors defined in `src`; a load failure raises, failing the create call"
|
|
98
|
-
return _INSP_RUNNER + f'\n_clik_install({src!r})'
|
|
55
|
+
# %% ../nbs/00_core.ipynb #b0dd40c1
|
|
56
|
+
def _top_level_display(shell):
|
|
57
|
+
"Make `ast_node_interactivity='all'` display top-level expressions only."
|
|
58
|
+
import ast
|
|
59
|
+
run = shell.run_ast_nodes
|
|
60
|
+
async def run_ast_nodes(nodelist, cell_name, interactivity='last_expr', **kwargs):
|
|
61
|
+
if interactivity != 'all': return await run(nodelist, cell_name, interactivity, **kwargs)
|
|
62
|
+
for n in nodelist:
|
|
63
|
+
if await run([n], cell_name, 'last' if isinstance(n, ast.Expr) else 'none', **kwargs): return True
|
|
64
|
+
return False
|
|
65
|
+
shell.run_ast_nodes = run_ast_nodes
|
|
66
|
+
shell.ast_node_interactivity = 'all'
|
|
99
67
|
|
|
100
68
|
# %% ../nbs/00_core.ipynb #1362e6ce
|
|
101
69
|
def startup_src(cfgdir=None):
|
|
102
|
-
"Build Python defaults
|
|
70
|
+
"Build Python defaults and user startup, in that order."
|
|
103
71
|
d = Path(cfgdir) if cfgdir else cfg_dir()
|
|
104
|
-
parts = [
|
|
105
|
-
if (p := d/'startup.py').exists(): parts.append(
|
|
106
|
-
if (p := d/'inspectors.py').exists(): parts.append(_inspector_setup(p.read_text()))
|
|
72
|
+
parts = [inspect.getsource(_top_level_display) + '_top_level_display(get_ipython())\ndel _top_level_display']
|
|
73
|
+
if (p := d/'startup.py').exists(): parts.append(_with_file(p.read_text(), p))
|
|
107
74
|
return '\n'.join(parts)
|
|
108
75
|
|
|
109
76
|
def session_defaults(cfgdir=None, quiet=False, local=True):
|
|
@@ -111,7 +78,7 @@ def session_defaults(cfgdir=None, quiet=False, local=True):
|
|
|
111
78
|
d = dict(startup=startup_src(cfgdir), quiet=quiet)
|
|
112
79
|
if local:
|
|
113
80
|
d['cwd'] = os.getcwd()
|
|
114
|
-
d['env'] = dict(os.environ
|
|
81
|
+
d['env'] = dict(os.environ)
|
|
115
82
|
return d
|
|
116
83
|
|
|
117
84
|
# %% ../nbs/00_core.ipynb #10ed59fc
|
|
@@ -4,6 +4,8 @@ Claude Code starts `clikernel-mcp` for each conversation. Its `Router` accepts M
|
|
|
4
4
|
|
|
5
5
|
Each host has a separate gateway session. The router remembers one current host for calls without an explicit `host`. It removes `host` before forwarding a call but preserves the JSON-RPC request id. Cancellation notifications also retain their original ids.
|
|
6
6
|
|
|
7
|
+
Before forwarding `exec`, the router checks the cell against `clikernel.rules`. Most rules add a note to the reply. Two rules block the cell, which then never reaches a gateway.
|
|
8
|
+
|
|
7
9
|
`main` runs the router and closes its gateway sessions on exit. Closing a session sends an HTTP DELETE. The gateway then stops the kernels that session created with autoclose. If the router started a child gateway, it stops that process too.
|
|
8
10
|
|
|
9
11
|
Docs: https://AnswerDotAI.github.io/clikernel/mcp.html.md"""
|
|
@@ -11,14 +13,15 @@ Docs: https://AnswerDotAI.github.io/clikernel/mcp.html.md"""
|
|
|
11
13
|
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_mcp.ipynb.
|
|
12
14
|
|
|
13
15
|
# %% auto #0
|
|
14
|
-
__all__ = ['HOST_PARAM', 'HOSTED', 'Router', 'main']
|
|
16
|
+
__all__ = ['HOST_PARAM', 'HOSTED', 'Router', 'main', 'agent_input', 'hook']
|
|
15
17
|
|
|
16
18
|
# %% ../nbs/01_mcp.ipynb #28c42902
|
|
17
|
-
import asyncio, httpx
|
|
19
|
+
import asyncio, httpx, json
|
|
18
20
|
from fastcore.utils import *
|
|
19
21
|
from fastcore.script import call_parse, store_true
|
|
20
22
|
from mcpmini.core import serve_stdio, jresp, jerr
|
|
21
23
|
from .core import Gateway, default_gateway, resolve, session_defaults
|
|
24
|
+
from .rules import scan
|
|
22
25
|
from . import __version__
|
|
23
26
|
|
|
24
27
|
# %% ../nbs/01_mcp.ipynb #76e2d782
|
|
@@ -30,7 +33,7 @@ class Router:
|
|
|
30
33
|
def __init__(
|
|
31
34
|
self,
|
|
32
35
|
cfgdir=None, # Config dir for `session_defaults` and `gateways.toml` (the standard one if None)
|
|
33
|
-
quiet=False, # Keep startup output out of replies?
|
|
36
|
+
quiet=False, # Keep startup output and rule notes out of replies?
|
|
34
37
|
):
|
|
35
38
|
self.cfgdir,self.quiet,self.sessions,self.cur,self.child = cfgdir,quiet,{},'',None
|
|
36
39
|
|
|
@@ -52,6 +55,17 @@ async def tools(self:Router):
|
|
|
52
55
|
if t['name'] in HOSTED: t['inputSchema'].setdefault('properties', {})['host'] = dict(HOST_PARAM)
|
|
53
56
|
return ts
|
|
54
57
|
|
|
58
|
+
# %% ../nbs/01_mcp.ipynb #6da913bf
|
|
59
|
+
def _cap_images(content, budget=5 << 20):
|
|
60
|
+
"`content`, with each image that would take the total base64 image data past `budget` bytes replaced by a line of text."
|
|
61
|
+
left,res = budget,[]
|
|
62
|
+
for b in content:
|
|
63
|
+
if b['type'] == 'image':
|
|
64
|
+
if (n := len(b['data'])) > left: b = dict(type='text', text=f"[{b['mimeType']} image of {n:,} bytes omitted: images in one reply are limited to {budget:,} bytes]")
|
|
65
|
+
else: left -= n
|
|
66
|
+
res.append(b)
|
|
67
|
+
return res
|
|
68
|
+
|
|
55
69
|
# %% ../nbs/01_mcp.ipynb #e1b057dd
|
|
56
70
|
@patch
|
|
57
71
|
async def dispatch(self:Router, msg, requester=None):
|
|
@@ -69,16 +83,23 @@ async def dispatch(self:Router, msg, requester=None):
|
|
|
69
83
|
if method == 'tools/list': return jresp(id, dict(tools=await self.tools()))
|
|
70
84
|
if method == 'tools/call':
|
|
71
85
|
args = msg['params'].setdefault('arguments', {})
|
|
86
|
+
fired = scan(args.get('code', '')) if msg['params']['name'] == 'exec' else []
|
|
87
|
+
if blocks := [r.note for r in fired if r.block]:
|
|
88
|
+
return jresp(id, dict(isError=True, content=[dict(type='text', text=' '.join(blocks)+' (If the block seems wrong here, tell your user.)')]))
|
|
72
89
|
has_host = 'host' in args
|
|
73
90
|
host = args.pop('host', '') or ''
|
|
74
91
|
h = host if has_host else self.cur
|
|
75
92
|
s = await self.session(h)
|
|
76
93
|
if has_host and msg['params']['name'] in ('use_kernel', 'create'): self.cur = host
|
|
77
|
-
try:
|
|
94
|
+
try: res = await s.tr.send(msg)
|
|
78
95
|
except httpx.HTTPStatusError as e:
|
|
79
96
|
if e.response.status_code != 404: raise
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
del self.sessions[h]
|
|
98
|
+
res = await (await self.session(h)).tr.send(msg)
|
|
99
|
+
if 'result' in res:
|
|
100
|
+
content = res['result']['content'] = _cap_images(res['result']['content'])
|
|
101
|
+
if fired and not self.quiet: content.insert(0, dict(type='text', text=''.join(f'<note>\n{r.note}\n</note>\n' for r in fired)))
|
|
102
|
+
return res
|
|
82
103
|
return jerr(id, -32601, f'method not found: {method}')
|
|
83
104
|
except Exception as e: return None if id is None else jerr(id, -32603, str(e))
|
|
84
105
|
|
|
@@ -95,8 +116,8 @@ async def aclose(self:Router):
|
|
|
95
116
|
# %% ../nbs/01_mcp.ipynb #3b90f0e8
|
|
96
117
|
@call_parse
|
|
97
118
|
def main(
|
|
98
|
-
quiet:store_true=False, # Keep startup output out of replies
|
|
99
|
-
cfgdir:str=None, # Directory holding `startup.py
|
|
119
|
+
quiet:store_true=False, # Keep startup output and rule notes out of replies
|
|
120
|
+
cfgdir:str=None, # Directory holding `startup.py` and `gateways.toml`; `~/.config/clikernel/` if unset
|
|
100
121
|
):
|
|
101
122
|
"Run `clikernel-mcp` as a stdio server."
|
|
102
123
|
async def _main():
|
|
@@ -105,3 +126,15 @@ def main(
|
|
|
105
126
|
finally: await router.aclose()
|
|
106
127
|
asyncio.run(_main())
|
|
107
128
|
|
|
129
|
+
|
|
130
|
+
# %% ../nbs/01_mcp.ipynb #bc8afe8c
|
|
131
|
+
def agent_input(
|
|
132
|
+
event:dict, # A Claude Code `PreToolUse` hook event
|
|
133
|
+
):
|
|
134
|
+
"`event`'s tool input with `agent` set to the calling subagent's id, or None for a call from the main conversation."
|
|
135
|
+
if aid := event.get('agent_id'): return event['tool_input'] | dict(agent=aid)
|
|
136
|
+
|
|
137
|
+
# %% ../nbs/01_mcp.ipynb #105e6780
|
|
138
|
+
def hook():
|
|
139
|
+
"Run `clikernel-hook`: read a `PreToolUse` event on stdin, and print the `updatedInput` that gives a subagent its own kernel."
|
|
140
|
+
if upd := agent_input(json.load(sys.stdin)): print(json.dumps(dict(hookSpecificOutput=dict(hookEventName='PreToolUse', updatedInput=upd))))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Rules that check agent-written kernel cells for common mistakes with this toolkit. The router checks each `exec` cell before forwarding it: most rules add a note to the reply, and blocking rules stop the cell. Rules see only the cell's text, not which kernel will run it, so each matches Python forms that no other kernel language produces."""
|
|
2
|
+
import ast,re,tokenize
|
|
3
|
+
from IPython.core.inputtransformer2 import TransformerManager
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from fastcore.basics import store_attr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Rule:
|
|
9
|
+
"A named check: `fn(tree, src)` is truthy when `note` applies; `block` stops the cell, and `raw` checks the source as typed rather than as IPython transforms it"
|
|
10
|
+
def __init__(self, name, note, fn, block=False, raw=False): store_attr()
|
|
11
|
+
|
|
12
|
+
_TOOLING = {'lnhashview','lnhashview_file','lnhashview_cell','lnhashview_cells','rg','nbrg','fd',
|
|
13
|
+
'find_msgs','summary_dlg','view_dlg','view_msg','view_msgs','view_file','view_cell','doc','info_md'}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _calls(tree):
|
|
17
|
+
for n in ast.walk(tree):
|
|
18
|
+
if isinstance(n, ast.Call): yield n
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _callee(c): return c.func.id if isinstance(c.func, ast.Name) else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_DATA_EXTS = ('.json','.jsonl','.ndjson','.csv','.tsv','.log')
|
|
25
|
+
|
|
26
|
+
def _textpath(node):
|
|
27
|
+
"A string constant under `node` recognisably names a non-data file: only then is a view-note earned"
|
|
28
|
+
return any(isinstance(n, ast.Constant) and isinstance(n.value, str) and '.' in n.value
|
|
29
|
+
and not n.value.lower().endswith(_DATA_EXTS) for n in ast.walk(node))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_read(c):
|
|
33
|
+
"A read_text()/open().read() call on a recognisably-named non-data file"
|
|
34
|
+
if not (isinstance(c, ast.Call) and isinstance(c.func, ast.Attribute)): return False
|
|
35
|
+
if c.func.attr == 'read_text': return _textpath(c.func.value)
|
|
36
|
+
return c.func.attr == 'read' and isinstance(c.func.value, ast.Call) and _callee(c.func.value) == 'open' and _textpath(c.func.value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_file(tree, src):
|
|
40
|
+
"Only a displayed read (bare expression or print) earns the note: a parser-bound or assigned read never enters context"
|
|
41
|
+
for n in ast.walk(tree):
|
|
42
|
+
if isinstance(n, ast.Expr):
|
|
43
|
+
v = n.value
|
|
44
|
+
if _is_read(v): return True
|
|
45
|
+
if isinstance(v, ast.Call) and _callee(v) == 'print' and any(_is_read(a) for a in v.args): return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _big_replace(tree, src):
|
|
49
|
+
for c in _calls(tree):
|
|
50
|
+
if _callee(c) in ('file_replace_lines','cell_replace_lines'):
|
|
51
|
+
for k in c.keywords:
|
|
52
|
+
if k.arg == 'new_content' and isinstance(k.value, ast.Constant) and str(k.value.value).count('\n') >= 6: return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cell_str_replace(tree, src):
|
|
56
|
+
"Only the obvious single-cell form (a literal id that isn't 'all'): batch replaces over many cells are sanctioned"
|
|
57
|
+
for c in _calls(tree):
|
|
58
|
+
if _callee(c) != 'cell_str_replace': continue
|
|
59
|
+
cid = c.args[0] if c.args else next((k.value for k in c.keywords if k.arg == 'id'), None)
|
|
60
|
+
if isinstance(cid, ast.Constant) and isinstance(cid.value, str) and cid.value != 'all': return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rawstr(tree, src):
|
|
64
|
+
try: toks = list(tokenize.generate_tokens(StringIO(src).readline))
|
|
65
|
+
except tokenize.TokenizeError: return
|
|
66
|
+
for t in toks:
|
|
67
|
+
if t.type == tokenize.STRING:
|
|
68
|
+
m = re.match(r"""([A-Za-z]*)('''|\"\"\")""", t.string)
|
|
69
|
+
if m and 'r' not in m[1].lower() and '\\' in t.string: return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hashcalc(tree, src): return any(_callee(c) in ('lnhash','line_hash') for c in _calls(tree))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmds(c):
|
|
76
|
+
"Top-level command-tuple nodes of an exhash/file_exhash/cell_exhash call"
|
|
77
|
+
if _callee(c) == 'exhash':
|
|
78
|
+
a = c.args[1] if len(c.args) > 1 else None
|
|
79
|
+
return a.elts if isinstance(a, (ast.List, ast.Tuple)) and any(isinstance(e, ast.Tuple) for e in a.elts) else []
|
|
80
|
+
return c.args[2 if _callee(c) == 'cell_exhash' else 1:]
|
|
81
|
+
|
|
82
|
+
def _tuple_payload(tree, src):
|
|
83
|
+
"A lone constant a/i/c payload that is long or contains quotes/backslashes belongs in a %%exhash cell; multi-command calls are exempt, since the one-command magic can't express them atomically"
|
|
84
|
+
for c in _calls(tree):
|
|
85
|
+
if _callee(c) not in ('exhash','file_exhash','cell_exhash'): continue
|
|
86
|
+
cmds = _cmds(c)
|
|
87
|
+
if len(cmds) != 1 or not isinstance(cmds[0], ast.Tuple): continue
|
|
88
|
+
n = cmds[0]
|
|
89
|
+
if len(n.elts) < 3: continue
|
|
90
|
+
cmd,payload = n.elts[1],n.elts[2]
|
|
91
|
+
if not (isinstance(cmd, ast.Constant) and cmd.value in ('a','i','c')): continue
|
|
92
|
+
if not (isinstance(payload, ast.Constant) and isinstance(payload.value, str)): continue
|
|
93
|
+
if len(payload.value) > 20 or any(ch in payload.value for ch in '\'"\\'): return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _s_repls(tree):
|
|
97
|
+
"String-constant replacement fields of s-commands in exhash calls"
|
|
98
|
+
for c in _calls(tree):
|
|
99
|
+
if _callee(c) not in ('exhash','file_exhash','cell_exhash'): continue
|
|
100
|
+
for n in ast.walk(c):
|
|
101
|
+
if not (isinstance(n, ast.Tuple) and len(n.elts) >= 4): continue
|
|
102
|
+
if not (isinstance(n.elts[1], ast.Constant) and n.elts[1].value == 's'): continue
|
|
103
|
+
if isinstance(n.elts[3], ast.Constant) and isinstance(n.elts[3].value, str): yield n.elts[3].value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _s_newline(tree, src): return any('\\n' in r for r in _s_repls(tree))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _s_long(tree, src): return any(len(r) > 120 for r in _s_repls(tree))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _postproc(tree, src):
|
|
114
|
+
for n in ast.walk(tree):
|
|
115
|
+
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Call) and _callee(n.value) in _TOOLING \
|
|
116
|
+
and n.attr in ('splitlines','split','join'): return True
|
|
117
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'join' \
|
|
118
|
+
and isinstance(n.func.value, ast.Constant) \
|
|
119
|
+
and any(_callee(c) in _TOOLING for a in n.args for c in _calls(a)): return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_magic(tree, src):
|
|
123
|
+
# raw-source rule: in the transformed cell every magic becomes run_*_magic, so only literal raw uses count
|
|
124
|
+
return 'run_line_magic' in src or 'run_cell_magic' in src
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _shell_escape(tree, src):
|
|
128
|
+
for n in ast.walk(tree):
|
|
129
|
+
if isinstance(n, ast.Import) and any(a.name.split('.')[0] == 'subprocess' for a in n.names): return True
|
|
130
|
+
if isinstance(n, ast.ImportFrom) and (n.module or '').split('.')[0] == 'subprocess': return True
|
|
131
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in ('system','popen') and isinstance(n.func.value, ast.Name) and n.func.value.id == 'os': return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _sys_path(tree, src):
|
|
135
|
+
for c in _calls(tree):
|
|
136
|
+
f = c.func
|
|
137
|
+
if isinstance(f, ast.Attribute) and f.attr in ('insert','append') and isinstance(f.value, ast.Attribute) \
|
|
138
|
+
and f.value.attr == 'path' and isinstance(f.value.value, ast.Name) and f.value.value.id == 'sys': return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
RULES = [
|
|
142
|
+
Rule('read_file', 'Read files with open_doc (large), lnhashview_file (for editing) or view_file(nums=False).', _read_file),
|
|
143
|
+
Rule('big_replace', 'Replace a whole cell or file with %%exhash <path> [<cell_id>] % c; an inner region with a range-c address.', _big_replace),
|
|
144
|
+
Rule('cell_str_replace', 'Edit notebook cells with %%exhash <path> <cell_id>.', _cell_str_replace),
|
|
145
|
+
Rule('rawstr', 'Write non-trivial strings as r""" raw strings; %%exhash text needs no escaping at all.', _rawstr),
|
|
146
|
+
Rule('hashcalc', 'exhash addresses come only from a fresh lnhashview; never compute them.', _hashcalc),
|
|
147
|
+
Rule('tuple_payload', 'Apply a/i/c text with the %%exhash magic: it needs no quoting or escaping.', _tuple_payload),
|
|
148
|
+
Rule('s_newline', r'A 2-char \n in an s-replacement stays literal text: use a real newline in the string.', _s_newline),
|
|
149
|
+
Rule('s_long', 'Use a c command (%%exhash <addr> c) for an s-replacement over 120 chars, except when the line is much longer still.', _s_long),
|
|
150
|
+
Rule('postproc', "Show tooling results bare; narrow with the tool's own parameters.", _postproc),
|
|
151
|
+
Rule('run_magic', 'Invoke magics directly with % syntax.', _run_magic, raw=True),
|
|
152
|
+
Rule('shell_escape', 'Run shell commands with the Bash tool.', _shell_escape, block=True),
|
|
153
|
+
Rule('sys_path', 'Never modify sys.path; stop and ask the user.', _sys_path, block=True)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_tm = TransformerManager()
|
|
157
|
+
|
|
158
|
+
def _transform(src):
|
|
159
|
+
"IPython-transform `src` so magics and `!` escapes parse; the raw text comes back if transformation fails"
|
|
160
|
+
try: return _tm.transform_cell(src)
|
|
161
|
+
except Exception: return src
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def scan(src):
|
|
165
|
+
"The rules that fire on cell `src`, parsed as IPython would parse it"
|
|
166
|
+
tsrc = _transform(src)
|
|
167
|
+
try: tree = ast.parse(tsrc)
|
|
168
|
+
except SyntaxError: return []
|
|
169
|
+
return [r for r in RULES if r.fn(tree, src if r.raw else tsrc)]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Use `clikernel` MCP as the default persistent Python workspace for file/notebook reads and edits, exploration, library checks, data reshaping and debugging. Imports and variables carry across calls. Read before writing, running or debugging Python with `clikernel` connected.
|
|
2
|
+
|
|
3
|
+
Prefer it to one-off scripts (`python -c`, shell heredocs) and installed in-kernel pyskills to shell equivalents: `rgapi` (`rg()`/`fd()`/`ls()`) for search/listing; `ghapi` for GitHub/local git. Shell stays right for project tests/builds and non-Python tools.
|
|
4
|
+
|
|
5
|
+
MCP tool descriptions give parameters/rules; this guide combines calls.
|
|
6
|
+
|
|
7
|
+
`create(kernel="py")` first; read its reply's startup imports and next steps before running anything. Kernels this conversation creates stop at its end. For one that should outlive it, `create(kernel="py", dlgname="work", autoclose=false)` and tell the user its id. It needs a gateway that keeps running; say so if the reply shows a conversation-started gateway.
|
|
8
|
+
|
|
9
|
+
To continue earlier work or use the user's solveit kernel: `list_kernels`, then `use_kernel` with the id. Attach runs no setup, claims no ownership and never stops that live kernel for you.
|
|
10
|
+
|
|
11
|
+
`restart` reruns Python startup only in kernels this conversation created; redo everything else. `interrupt` stops a long `exec`, keeping state. A kernel-gone reply requires `create` or `use_kernel` before executing again.
|
|
12
|
+
|
|
13
|
+
Remote gateways are named in `~/.config/clikernel/gateways.toml`. Same tools/rules: `host=` on `list_kernels`/`use_kernel`/`create`; plain `exec` follows the selected host until the next selection.
|
|
14
|
+
|
|
15
|
+
`create(kernel="luau")`/`create(kernel="bpl")`: same `exec`/autoclose rules, no Python startup/magics. Each creation reply gives language basics. Luau's `exec(code="help()")` gives the full native API; `help("ex.edit_file")` covers one function. BPL needs `basedpl` in the gateway environment for its `bpl` kernelspec.
|
|
16
|
+
|
|
17
|
+
# Python
|
|
18
|
+
- Magics work as written, including `%%bash`. Change directory with `%cd` (expands `~`), not `os.chdir`.
|
|
19
|
+
- clikernel-created Python kernels display every top-level expression, not expressions inside loops/blocks. User startup can override this.
|
|
20
|
+
- All cell output reaches the conversation: `len(v)` first, then choose what to show.
|
|
21
|
+
- Don't repeat imports. A `NameError` for a name you set up means the kernel restarted or is newly attached: redo setup.
|
|
22
|
+
- After `nbdev-export` or imported-module edits, `importlib.reload` and re-import held names: `from x import y` keeps the old object; `@patch` refreshes on reload by writing onto the shared class. Restart only when a class you hold instances of was redefined. Check loaded behavior by calling it, never `inspect.getsource` (reads disk).
|
|
23
|
+
- Try the simple import/API call before environment changes, monkeypatching or extra setup.
|
|
24
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
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-Expression: Apache-2.0
|
|
@@ -15,9 +15,10 @@ Requires-Dist: fastcore>=2.2.2
|
|
|
15
15
|
Requires-Dist: jupyasyncclient>=0.2.10
|
|
16
16
|
Requires-Dist: jupywire>=0.1.9
|
|
17
17
|
Requires-Dist: mcpmini>=0.0.5
|
|
18
|
-
Requires-Dist: rustygate>=0.1.
|
|
18
|
+
Requires-Dist: rustygate>=0.1.25
|
|
19
19
|
Requires-Dist: ipymini>=0.1.23
|
|
20
20
|
Requires-Dist: httpx
|
|
21
|
+
Requires-Dist: ipython
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: fastship; extra == "dev"
|
|
23
24
|
Dynamic: license-file
|
|
@@ -60,14 +61,17 @@ create(kernel="py")
|
|
|
60
61
|
exec(code="6*7")
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
Use `kernel="luau"` for bundled Luau, or `kernel="
|
|
64
|
+
Use `kernel="luau"` for bundled Luau, or `kernel="bpl"` for BPL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. In the main conversation, `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
65
|
+
|
|
66
|
+
In Claude Code, subagents share the current kernel unless `clikernel-hook` is registered as a `PreToolUse` hook. With the hook, each subagent’s first `exec` creates a kernel of its own. See [Subagent kernels](https://AnswerDotAI.github.io/clikernel/mcp.html#subagent-kernels) for the settings entry.
|
|
67
|
+
|
|
68
|
+
The router checks each `exec` cell against built-in rules for mistakes agents often make with this toolkit. Most rules add a note to the reply, and `--quiet` leaves the notes out. Two rules always block the cell: shell access through `subprocess`, [`os.system`](https://docs.python.org/3/library/os.html#os.system) or [`os.popen`](https://docs.python.org/3/library/os.html#os.popen), and changes to `sys.path`.
|
|
64
69
|
|
|
65
70
|
## Configuration
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
68
73
|
|
|
69
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.
|
|
70
|
-
- `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).
|
|
71
75
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
72
76
|
|
|
73
77
|
``` toml
|
|
@@ -8,6 +8,7 @@ clikernel/_modidx.py
|
|
|
8
8
|
clikernel/cli.py
|
|
9
9
|
clikernel/core.py
|
|
10
10
|
clikernel/mcp.py
|
|
11
|
+
clikernel/rules.py
|
|
11
12
|
clikernel/skill.py
|
|
12
13
|
clikernel/stream.py
|
|
13
14
|
clikernel.egg-info/PKG-INFO
|
|
@@ -16,4 +17,5 @@ clikernel.egg-info/dependency_links.txt
|
|
|
16
17
|
clikernel.egg-info/entry_points.txt
|
|
17
18
|
clikernel.egg-info/requires.txt
|
|
18
19
|
clikernel.egg-info/top_level.txt
|
|
20
|
+
tests/test_rules.py
|
|
19
21
|
tests/test_stream.py
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=77"
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -20,9 +20,10 @@ dependencies = [
|
|
|
20
20
|
"jupyasyncclient>=0.2.10",
|
|
21
21
|
"jupywire>=0.1.9",
|
|
22
22
|
"mcpmini>=0.0.5",
|
|
23
|
-
"rustygate>=0.1.
|
|
23
|
+
"rustygate>=0.1.25",
|
|
24
24
|
"ipymini>=0.1.23",
|
|
25
25
|
"httpx",
|
|
26
|
+
"ipython",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.optional-dependencies]
|
|
@@ -41,6 +42,7 @@ Documentation = "https://AnswerDotAI.github.io/clikernel/"
|
|
|
41
42
|
[project.scripts]
|
|
42
43
|
clikernel = "clikernel.cli:main"
|
|
43
44
|
clikernel-mcp = "clikernel.mcp:main"
|
|
45
|
+
clikernel-hook = "clikernel.mcp:hook"
|
|
44
46
|
|
|
45
47
|
[project.entry-points.nbdev]
|
|
46
48
|
clikernel = "clikernel._modidx:d"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from clikernel.rules import scan
|
|
2
|
+
|
|
3
|
+
def fires(src, name): return any(r.name == name for r in scan(src))
|
|
4
|
+
|
|
5
|
+
def test_rules():
|
|
6
|
+
"Each rule fires on its anti-pattern and stays quiet on the blessed route."
|
|
7
|
+
# read_text/open().read on a recognisable text file -> lnhashview
|
|
8
|
+
assert fires("Path('core.py').read_text()", "read_file")
|
|
9
|
+
assert fires("open('notes.txt').read()", "read_file")
|
|
10
|
+
assert not fires("lnhashview_file(p)", "read_file")
|
|
11
|
+
assert not fires("p.read_text()", "read_file") # unrecognisable path: variables stay quiet
|
|
12
|
+
assert not fires("open(p).read()", "read_file")
|
|
13
|
+
assert not fires("open(p, 'r').read()", "read_file") # a mode string is not a filename
|
|
14
|
+
assert not fires("Path('trace.jsonl').read_text()", "read_file") # data files: a hashed line view can't help
|
|
15
|
+
assert not fires("open(d/'rows.csv').read()", "read_file")
|
|
16
|
+
assert fires("(d/'core.py').read_text()", "read_file")
|
|
17
|
+
assert fires("print(Path('a.py').read_text())", "read_file") # printed is displayed
|
|
18
|
+
assert not fires("yaml.safe_load(Path('wf.yaml').read_text())", "read_file") # parser-bound: never enters context
|
|
19
|
+
assert not fires("src = Path('a.py').read_text()", "read_file") # assigned: consumption unknown, stay quiet
|
|
20
|
+
|
|
21
|
+
# big replace_lines payload -> delete + %%exhash a
|
|
22
|
+
big = "x = 1\n" * 9
|
|
23
|
+
assert fires(f"file_replace_lines(p, new_content={big!r})", "big_replace")
|
|
24
|
+
assert fires(f"cell_replace_lines(cid, new_content={big!r})", "big_replace")
|
|
25
|
+
assert not fires("file_replace_lines(p, new_content='x = 1')", "big_replace")
|
|
26
|
+
|
|
27
|
+
# single-cell cell_str_replace -> %%exhash path cellid; batch replaces (id list / 'all') are sanctioned
|
|
28
|
+
assert fires("cell_str_replace('ab12', 'a', 'b', fname=p)", "cell_str_replace")
|
|
29
|
+
assert not fires("cell_str_replace(['ab12','cd34'], 'a', 'b')", "cell_str_replace")
|
|
30
|
+
assert not fires("cell_str_replace('all', 'a', 'b')", "cell_str_replace")
|
|
31
|
+
assert not fires("cell_str_replace(cids, 'a', 'b')", "cell_str_replace") # variables unknowable: stay quiet
|
|
32
|
+
|
|
33
|
+
# non-raw triple-quote containing backslashes -> r-string
|
|
34
|
+
assert fires('s = """a\\nb"""', "rawstr")
|
|
35
|
+
assert not fires('s = r"""a\\nb"""', "rawstr")
|
|
36
|
+
assert not fires('s = """plain text"""', "rawstr")
|
|
37
|
+
|
|
38
|
+
# computing exhash addresses -> views only
|
|
39
|
+
assert fires("addr = lnhash(3, line)", "hashcalc")
|
|
40
|
+
assert fires("line_hash(s)", "hashcalc")
|
|
41
|
+
|
|
42
|
+
# post-processing tooling results -> bare repr / tool params
|
|
43
|
+
assert fires("'\\n'.join(lnhashview_file(p))", "postproc")
|
|
44
|
+
assert fires("rg('x', p).splitlines()[:5]", "postproc")
|
|
45
|
+
assert not fires("lnhashview_file(p)", "postproc")
|
|
46
|
+
|
|
47
|
+
# programmatic magic invocation -> % syntax
|
|
48
|
+
assert fires("get_ipython().run_line_magic('nbrun', 'abc')", "run_magic")
|
|
49
|
+
assert not fires("%nbrun abc", "run_magic") # a real magic is the blessed route
|
|
50
|
+
|
|
51
|
+
# tuple a/i/c payloads -> %%exhash magic (short quote-free payloads tolerated)
|
|
52
|
+
assert fires("file_exhash(p, (a, 'c', 'a longer replacement line here'))", "tuple_payload")
|
|
53
|
+
assert fires('file_exhash(p, (a, "a", "it\'s"))', "tuple_payload") # quote in payload
|
|
54
|
+
assert fires("cell_exhash(p, cid, (a, 'i', 'x = \\\\n'))", "tuple_payload") # backslash in payload
|
|
55
|
+
assert not fires("file_exhash(p, (a, 'c', 'metric'))", "tuple_payload") # short one-worder: fine
|
|
56
|
+
assert not fires("file_exhash(p, (a, 's', 'longer than twenty chars ok'))", "tuple_payload") # s is not a payload command
|
|
57
|
+
assert not fires("file_exhash(p, (a, 'c', body))", "tuple_payload") # variables unknowable: stay quiet
|
|
58
|
+
assert not fires("file_exhash(p, (a, 'd'), (b, 'c', 'a longer replacement line here'))", "tuple_payload") # multi-command batch: the one-command magic can't express it atomically
|
|
59
|
+
assert not fires("cell_exhash(p, cid, (a, 's', 'x', 'y'), (b, 'i', 'it\'s a long insertion here'))", "tuple_payload")
|
|
60
|
+
assert fires("cell_exhash(p, cid, (a, 'c', 'a longer replacement line here'))", "tuple_payload") # single command: the magic is strictly better
|
|
61
|
+
assert fires("exhash(t, [(a, 'c', 'a longer replacement line here')])", "tuple_payload") # one command in a list works the same
|
|
62
|
+
|
|
63
|
+
# literal \n in an s-replacement -> real newline; oversized s-replacement -> c command
|
|
64
|
+
assert fires(r"file_exhash(p, ('1|aa|', 's', 'x', r'a\nb'))", "s_newline") # 2-char \n stays literal: a mistake
|
|
65
|
+
assert not fires(r"file_exhash(p, ('1|aa|', 's', 'x', 'a\nb'))", "s_newline") # real newline: intended multiline
|
|
66
|
+
assert not fires(r"file_exhash(p, ('1|aa|', 's', r'x\n', 'y'))", "s_newline") # pattern field: regex \n is meaningful
|
|
67
|
+
assert fires(f"file_exhash(p, ('1|aa|', 's', 'x', {'y'*121!r}))", "s_long")
|
|
68
|
+
assert not fires(f"file_exhash(p, ('1|aa|', 's', 'x', {'y'*120!r}))", "s_long")
|
|
69
|
+
|
|
70
|
+
# blockers: Python's own shell routes, but not `!` escapes, since the router can't tell Python from BPL
|
|
71
|
+
assert fires("import subprocess", "shell_escape")
|
|
72
|
+
assert fires("os.system('ls')", "shell_escape")
|
|
73
|
+
assert fires("os.popen('ls')", "shell_escape")
|
|
74
|
+
assert not fires("!5", "shell_escape") # BPL's factorial parses exactly like IPython's `!ls`
|
|
75
|
+
assert not fires("io.popen('ls')", "shell_escape") # Luau's popen
|
|
76
|
+
assert fires("import subprocess.x", "shell_escape") # submodule spellings
|
|
77
|
+
assert fires("from subprocess.x import y", "shell_escape")
|
|
78
|
+
assert fires("%nbrun ab12\nPath('f.py').read_text()", "read_file") # rules still run on cells containing magics
|
|
79
|
+
assert fires("sys.path.insert(0, 'x')", "sys_path")
|
|
80
|
+
assert fires("sys.path.append('x')", "sys_path")
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
Call `create(kernel="py")` before executing Python code. Creation reports what startup imported: read that banner, it says what to do next. New kernels require an explicit implementation. Add `dlgname` to bind a kernel to a dialog. Omit `kernel` only to reuse an existing binding. An explicit implementation must match that binding.
|
|
8
|
-
|
|
9
|
-
A kernel this conversation creates stops when the conversation ends, unless created with `autoclose=false`. Use `create(kernel="py", dlgname="work", autoclose=false)` when a kernel should stay running afterwards, and tell the user its id. This requires a gateway that keeps running. Mention it if the reply shows a conversation-started gateway.
|
|
10
|
-
|
|
11
|
-
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.
|
|
12
|
-
|
|
13
|
-
`restart` gives a fresh interpreter under the same id; Python startup re-runs in Python kernels this conversation created, so redo everything else. `interrupt` stops a long `exec` and keeps state. If a reply says the kernel is gone, call `create` or `use_kernel` before executing again. Execution never creates or switches kernels.
|
|
14
|
-
|
|
15
|
-
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 `exec` runs there until the next selection.
|
|
16
|
-
|
|
17
|
-
# Working in it
|
|
18
|
-
|
|
19
|
-
Run code with `exec(code=...)` in the selected kernel. For native Luau work, select `create(kernel="luau")` first. The same execution tool and autoclose rules apply to every implementation. A `dlgname` execution override uses an existing binding for that call without changing the current selection. These rules also apply on named remote gateways.
|
|
20
|
-
|
|
21
|
-
For APL, use `create(kernel="apl")` then `exec(code="avg←+/÷≢ ⋄ avg 2 4 9")`. `basedpl` must be installed in the gateway's environment. It provides the `apl` kernelspec. Python startup and inspectors do not run in APL.
|
|
22
|
-
|
|
23
|
-
Start native work with `exec(code="help()")` for the bundled guide and examples, or `exec(code='help("ex.edit_file")')` for function details.
|
|
24
|
-
|
|
25
|
-
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:
|
|
26
|
-
|
|
27
|
-
- Magics work as written, including `%%bash` for shell work. `%cd` expands `~` and is the way to change directory: prefer it over `os.chdir`.
|
|
28
|
-
- Python kernels created by clikernel display every expression result by default (`ast_node_interactivity='all'`); user startup can override this.
|
|
29
|
-
- Everything a cell outputs lands in the conversation. Be selective: `len(v)` first, then decide what to show.
|
|
30
|
-
- Don't re-run an `import` already run this session. If a name raises `NameError`, the kernel restarted or is newly attached: redo setup.
|
|
31
|
-
- 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.
|
|
32
|
-
- Try the simple import or API call first, before changing the environment, monkeypatching, or adding setup.
|
|
33
|
-
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|