clikernel 0.2.13__tar.gz → 0.2.15__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.13 → clikernel-0.2.15}/CHANGELOG.md +14 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/MANIFEST.in +0 -1
- {clikernel-0.2.13/clikernel.egg-info → clikernel-0.2.15}/PKG-INFO +6 -4
- {clikernel-0.2.13 → clikernel-0.2.15}/README.md +3 -2
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/__init__.py +1 -1
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/_modidx.py +1 -4
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/core.py +3 -49
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/mcp.py +20 -7
- clikernel-0.2.15/clikernel/rules.py +169 -0
- clikernel-0.2.15/clikernel/skill.py +29 -0
- {clikernel-0.2.13 → clikernel-0.2.15/clikernel.egg-info}/PKG-INFO +6 -4
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/SOURCES.txt +2 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/requires.txt +1 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/pyproject.toml +3 -2
- clikernel-0.2.15/tests/test_rules.py +80 -0
- clikernel-0.2.13/clikernel/skill.py +0 -33
- {clikernel-0.2.13 → clikernel-0.2.15}/LICENSE +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/cli.py +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/stream.py +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/dependency_links.txt +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/entry_points.txt +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/top_level.txt +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/setup.cfg +0 -0
- {clikernel-0.2.13 → clikernel-0.2.15}/tests/test_stream.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- do not remove -->
|
|
4
4
|
|
|
5
|
+
## 0.2.15
|
|
6
|
+
|
|
7
|
+
### New Features
|
|
8
|
+
|
|
9
|
+
- 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))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## 0.2.14
|
|
13
|
+
|
|
14
|
+
### Bugs Squashed
|
|
15
|
+
|
|
16
|
+
- Open a new gateway session after rustygate restarts ([#53](https://github.com/AnswerDotAI/clikernel/pull/53)), thanks to [@jph00](https://github.com/jph00)
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
## 0.2.13
|
|
6
20
|
|
|
7
21
|
### New Features
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.15
|
|
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
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Repository, https://github.com/AnswerDotAI/clikernel
|
|
8
8
|
Project-URL: Documentation, https://AnswerDotAI.github.io/clikernel/
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -18,6 +18,7 @@ Requires-Dist: mcpmini>=0.0.5
|
|
|
18
18
|
Requires-Dist: rustygate>=0.1.22
|
|
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
|
|
@@ -62,12 +63,13 @@ exec(code="6*7")
|
|
|
62
63
|
|
|
63
64
|
Use `kernel="luau"` for bundled Luau, or `kernel="apl"` for APL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
64
65
|
|
|
66
|
+
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`.
|
|
67
|
+
|
|
65
68
|
## Configuration
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
68
71
|
|
|
69
72
|
- `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
73
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
72
74
|
|
|
73
75
|
``` toml
|
|
@@ -38,12 +38,13 @@ exec(code="6*7")
|
|
|
38
38
|
|
|
39
39
|
Use `kernel="luau"` for bundled Luau, or `kernel="apl"` for APL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
40
40
|
|
|
41
|
+
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`.
|
|
42
|
+
|
|
41
43
|
## Configuration
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
44
46
|
|
|
45
47
|
- `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
48
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
48
49
|
|
|
49
50
|
``` toml
|
|
@@ -8,4 +8,4 @@ Modules:
|
|
|
8
8
|
- `clikernel.skill`: Use the `clikernel` MCP session as the default workspace for Python work: reading and changing files, notebook work, trying things out, checking how a library behaves, and reshaping data. One session stays open, so imports and variables carry between calls. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.
|
|
9
9
|
- `clikernel.stream`: Streaming JSON-lines worker protocol: nbformat-shaped output events, and a supervisor for select-based UIs."""
|
|
10
10
|
|
|
11
|
-
__version__ = "0.2.
|
|
11
|
+
__version__ = "0.2.15"
|
|
@@ -23,7 +23,6 @@ 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._inspector_setup': ('core.html#_inspector_setup', 'clikernel/core.py'),
|
|
27
26
|
'clikernel.core._startup_src': ('core.html#_startup_src', 'clikernel/core.py'),
|
|
28
27
|
'clikernel.core.cfg_dir': ('core.html#cfg_dir', 'clikernel/core.py'),
|
|
29
28
|
'clikernel.core.default_gateway': ('core.html#default_gateway', 'clikernel/core.py'),
|
|
@@ -37,6 +36,4 @@ d = { 'settings': { 'branch': 'main',
|
|
|
37
36
|
'clikernel.mcp.Router.dispatch': ('mcp.html#router.dispatch', 'clikernel/mcp.py'),
|
|
38
37
|
'clikernel.mcp.Router.session': ('mcp.html#router.session', 'clikernel/mcp.py'),
|
|
39
38
|
'clikernel.mcp.Router.tools': ('mcp.html#router.tools', 'clikernel/mcp.py'),
|
|
40
|
-
'clikernel.mcp.main': ('mcp.html#main', 'clikernel/mcp.py')}
|
|
41
|
-
'clikernel.skill': {},
|
|
42
|
-
'clikernel.stream': {}}}
|
|
39
|
+
'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
|
|
|
@@ -52,58 +52,12 @@ def _startup_src(src, path):
|
|
|
52
52
|
try: exec(compile({src!r}, __file__, 'exec'))
|
|
53
53
|
finally: del __file__'''
|
|
54
54
|
|
|
55
|
-
# %% ../nbs/00_core.ipynb #3dbf4cb6
|
|
56
|
-
_INSP_RUNNER = r'''
|
|
57
|
-
import inspect as _clik_inspect
|
|
58
|
-
import sys as _clik_sys
|
|
59
|
-
from IPython.core.error import InputRejected
|
|
60
|
-
class RuleBlock(InputRejected):
|
|
61
|
-
"Raise from an inspector to deliberately block a cell; any other inspector exception is a bug, and fails open"
|
|
62
|
-
|
|
63
|
-
class _ClikInspect:
|
|
64
|
-
"Calls each inspector once per cell: 1-arg get the AST, 2-arg also the raw source"
|
|
65
|
-
def __init__(self, fs): self.fs = fs
|
|
66
|
-
def visit(self, tree):
|
|
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})'
|
|
99
|
-
|
|
100
55
|
# %% ../nbs/00_core.ipynb #1362e6ce
|
|
101
56
|
def startup_src(cfgdir=None):
|
|
102
|
-
"Build Python defaults
|
|
57
|
+
"Build Python defaults and user startup, in that order."
|
|
103
58
|
d = Path(cfgdir) if cfgdir else cfg_dir()
|
|
104
59
|
parts = ["get_ipython().ast_node_interactivity = 'all'"]
|
|
105
60
|
if (p := d/'startup.py').exists(): parts.append(_startup_src(p.read_text(), p))
|
|
106
|
-
if (p := d/'inspectors.py').exists(): parts.append(_inspector_setup(p.read_text()))
|
|
107
61
|
return '\n'.join(parts)
|
|
108
62
|
|
|
109
63
|
def session_defaults(cfgdir=None, quiet=False, local=True):
|
|
@@ -111,7 +65,7 @@ def session_defaults(cfgdir=None, quiet=False, local=True):
|
|
|
111
65
|
d = dict(startup=startup_src(cfgdir), quiet=quiet)
|
|
112
66
|
if local:
|
|
113
67
|
d['cwd'] = os.getcwd()
|
|
114
|
-
d['env'] = dict(os.environ
|
|
68
|
+
d['env'] = dict(os.environ)
|
|
115
69
|
return d
|
|
116
70
|
|
|
117
71
|
# %% ../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"""
|
|
@@ -14,14 +16,14 @@ Docs: https://AnswerDotAI.github.io/clikernel/mcp.html.md"""
|
|
|
14
16
|
__all__ = ['HOST_PARAM', 'HOSTED', 'Router', 'main']
|
|
15
17
|
|
|
16
18
|
# %% ../nbs/01_mcp.ipynb #28c42902
|
|
17
|
-
import asyncio
|
|
19
|
+
import asyncio, httpx
|
|
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
|
-
|
|
25
27
|
# %% ../nbs/01_mcp.ipynb #76e2d782
|
|
26
28
|
HOST_PARAM = {'type': 'string', 'description': 'Gateway to target: a gateways.toml name, or empty for the default local gateway'}
|
|
27
29
|
HOSTED = ('list_kernels', 'use_kernel', 'create')
|
|
@@ -31,7 +33,7 @@ class Router:
|
|
|
31
33
|
def __init__(
|
|
32
34
|
self,
|
|
33
35
|
cfgdir=None, # Config dir for `session_defaults` and `gateways.toml` (the standard one if None)
|
|
34
|
-
quiet=False, # Keep startup output out of replies?
|
|
36
|
+
quiet=False, # Keep startup output and rule notes out of replies?
|
|
35
37
|
):
|
|
36
38
|
self.cfgdir,self.quiet,self.sessions,self.cur,self.child = cfgdir,quiet,{},'',None
|
|
37
39
|
|
|
@@ -70,11 +72,22 @@ async def dispatch(self:Router, msg, requester=None):
|
|
|
70
72
|
if method == 'tools/list': return jresp(id, dict(tools=await self.tools()))
|
|
71
73
|
if method == 'tools/call':
|
|
72
74
|
args = msg['params'].setdefault('arguments', {})
|
|
75
|
+
fired = scan(args.get('code', '')) if msg['params']['name'] == 'exec' else []
|
|
76
|
+
if blocks := [r.note for r in fired if r.block]:
|
|
77
|
+
return jresp(id, dict(isError=True, content=[dict(type='text', text=' '.join(blocks)+' (If the block seems wrong here, tell your user.)')]))
|
|
73
78
|
has_host = 'host' in args
|
|
74
79
|
host = args.pop('host', '') or ''
|
|
75
|
-
|
|
80
|
+
h = host if has_host else self.cur
|
|
81
|
+
s = await self.session(h)
|
|
76
82
|
if has_host and msg['params']['name'] in ('use_kernel', 'create'): self.cur = host
|
|
77
|
-
|
|
83
|
+
try: res = await s.tr.send(msg)
|
|
84
|
+
except httpx.HTTPStatusError as e:
|
|
85
|
+
if e.response.status_code != 404: raise
|
|
86
|
+
del self.sessions[h] # a restarted gateway answers 404 for a session id it no longer knows
|
|
87
|
+
res = await (await self.session(h)).tr.send(msg)
|
|
88
|
+
if fired and not self.quiet and 'result' in res:
|
|
89
|
+
res['result']['content'].insert(0, dict(type='text', text=''.join(f'<note>\n{r.note}\n</note>\n' for r in fired)))
|
|
90
|
+
return res
|
|
78
91
|
return jerr(id, -32601, f'method not found: {method}')
|
|
79
92
|
except Exception as e: return None if id is None else jerr(id, -32603, str(e))
|
|
80
93
|
|
|
@@ -91,8 +104,8 @@ async def aclose(self:Router):
|
|
|
91
104
|
# %% ../nbs/01_mcp.ipynb #3b90f0e8
|
|
92
105
|
@call_parse
|
|
93
106
|
def main(
|
|
94
|
-
quiet:store_true=False, # Keep startup output out of replies
|
|
95
|
-
cfgdir:str=None, # Directory holding `startup.py
|
|
107
|
+
quiet:store_true=False, # Keep startup output and rule notes out of replies
|
|
108
|
+
cfgdir:str=None, # Directory holding `startup.py` and `gateways.toml`; `~/.config/clikernel/` if unset
|
|
96
109
|
):
|
|
97
110
|
"Run `clikernel-mcp` as a stdio server."
|
|
98
111
|
async def _main():
|
|
@@ -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 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,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 to one-off scripts (`python -c`, shell heredocs), and in-kernel pyskills to shell equivalents when installed: `rgapi` (`rg()`/`fd()`/`ls()`) for search and listing, `ghapi` for GitHub and local git. Shell stays right for project tests/builds and non-Python tools.
|
|
4
|
+
|
|
5
|
+
# Kernels
|
|
6
|
+
|
|
7
|
+
MCP tool descriptions give each tool's parameters and rules; this covers combining them.
|
|
8
|
+
|
|
9
|
+
`create(kernel="py")` first; its reply reports what startup imported and what to run next: read it before running anything. Kernels this conversation creates stop when it ends; for one that should outlive it, `create(kernel="py", dlgname="work", autoclose=false)` and tell the user its id. That needs a gateway that keeps running: say so if the reply shows a conversation-started gateway.
|
|
10
|
+
|
|
11
|
+
To continue earlier work or use the user's solveit kernel: `list_kernels`, then `use_kernel` with the id. Attaching runs no setup and claims no ownership: the live state is the point, and it's never stopped for you.
|
|
12
|
+
|
|
13
|
+
`restart` re-runs Python startup only in kernels this conversation created; redo everything else. `interrupt` stops a long `exec`, keeping state. If a reply says the kernel is gone, `create` or `use_kernel` before executing again.
|
|
14
|
+
|
|
15
|
+
Remote gateways (named in `~/.config/clikernel/gateways.toml`): same tools, with `host=` on `list_kernels`/`use_kernel`/`create`; all other rules unchanged. After selecting with a host, plain `exec` runs there until the next selection.
|
|
16
|
+
|
|
17
|
+
# Luau and APL
|
|
18
|
+
|
|
19
|
+
`create(kernel="luau")`/`create(kernel="apl")`: same `exec` and autoclose rules; no Python startup or magics. Each `create` reply gives the language basics; Luau's `exec(code="help()")` is its full native API guide (`help("ex.edit_file")` for one function). APL needs `basedpl` in the gateway's environment, which provides the `apl` kernelspec.
|
|
20
|
+
|
|
21
|
+
# Python
|
|
22
|
+
|
|
23
|
+
- Magics work as written, incl. `%%bash`. Change directory with `%cd` (expands `~`), not `os.chdir`.
|
|
24
|
+
- clikernel-created Python kernels display every expression result (`ast_node_interactivity='all'`); user startup can override.
|
|
25
|
+
- All cell output lands in the conversation: `len(v)` first, then choose what to show.
|
|
26
|
+
- Don't re-run imports already run. A `NameError` for a name you set up means the kernel restarted or is newly attached: redo setup.
|
|
27
|
+
- After `nbdev-export` or any edit to an imported module, `importlib.reload` it and re-import held names: `from x import y` keeps the old object; a `@patch`ed method refreshes on reload (the patch writes onto the shared class). Restart only when a class you hold instances of was redefined. Check what's loaded by calling it, never with `inspect.getsource` (reads the file on disk).
|
|
28
|
+
- Try the simple import or API call before changing the environment, monkeypatching, or adding setup.
|
|
29
|
+
"""
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clikernel
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.15
|
|
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
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Repository, https://github.com/AnswerDotAI/clikernel
|
|
8
8
|
Project-URL: Documentation, https://AnswerDotAI.github.io/clikernel/
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -18,6 +18,7 @@ Requires-Dist: mcpmini>=0.0.5
|
|
|
18
18
|
Requires-Dist: rustygate>=0.1.22
|
|
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
|
|
@@ -62,12 +63,13 @@ exec(code="6*7")
|
|
|
62
63
|
|
|
63
64
|
Use `kernel="luau"` for bundled Luau, or `kernel="apl"` for APL when `basedpl` is installed in the gateway’s environment. `create` accepts any kernelspec the gateway finds. `exec` never creates or switches kernels. See [MCP](https://AnswerDotAI.github.io/clikernel/mcp.html) for named gateways, dialog bindings, and other tools.
|
|
64
65
|
|
|
66
|
+
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`.
|
|
67
|
+
|
|
65
68
|
## Configuration
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
Two optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
|
|
68
71
|
|
|
69
72
|
- `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
73
|
- `gateways.toml` names remote gateways and configures authentication without putting tokens in tool arguments:
|
|
72
74
|
|
|
73
75
|
``` 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>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Serve persistent Jupyter kernels to LLMs as concise text, over MCP or a plain stream protocol"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
|
-
license =
|
|
11
|
+
license = "Apache-2.0"
|
|
12
12
|
authors = [{ name = "clikernel contributors" }]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Programming Language :: Python :: 3",
|
|
@@ -23,6 +23,7 @@ dependencies = [
|
|
|
23
23
|
"rustygate>=0.1.22",
|
|
24
24
|
"ipymini>=0.1.23",
|
|
25
25
|
"httpx",
|
|
26
|
+
"ipython",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.optional-dependencies]
|
|
@@ -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 APL
|
|
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") # APL'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
|
|
File without changes
|