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.
Files changed (24) hide show
  1. {clikernel-0.2.13 → clikernel-0.2.15}/CHANGELOG.md +14 -0
  2. {clikernel-0.2.13 → clikernel-0.2.15}/MANIFEST.in +0 -1
  3. {clikernel-0.2.13/clikernel.egg-info → clikernel-0.2.15}/PKG-INFO +6 -4
  4. {clikernel-0.2.13 → clikernel-0.2.15}/README.md +3 -2
  5. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/__init__.py +1 -1
  6. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/_modidx.py +1 -4
  7. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/core.py +3 -49
  8. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/mcp.py +20 -7
  9. clikernel-0.2.15/clikernel/rules.py +169 -0
  10. clikernel-0.2.15/clikernel/skill.py +29 -0
  11. {clikernel-0.2.13 → clikernel-0.2.15/clikernel.egg-info}/PKG-INFO +6 -4
  12. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/SOURCES.txt +2 -0
  13. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/requires.txt +1 -0
  14. {clikernel-0.2.13 → clikernel-0.2.15}/pyproject.toml +3 -2
  15. clikernel-0.2.15/tests/test_rules.py +80 -0
  16. clikernel-0.2.13/clikernel/skill.py +0 -33
  17. {clikernel-0.2.13 → clikernel-0.2.15}/LICENSE +0 -0
  18. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/cli.py +0 -0
  19. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel/stream.py +0 -0
  20. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/dependency_links.txt +0 -0
  21. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/entry_points.txt +0 -0
  22. {clikernel-0.2.13 → clikernel-0.2.15}/clikernel.egg-info/top_level.txt +0 -0
  23. {clikernel-0.2.13 → clikernel-0.2.15}/setup.cfg +0 -0
  24. {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,4 +1,3 @@
1
1
  include README.md
2
2
  include LICENSE
3
3
  include CHANGELOG.md
4
- recursive-include clikernel/dojo_data *
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.2.13
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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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.13"
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 combines `startup.py` and `inspectors.py` into source that can run on another machine.
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, user startup, and inspector setup, in that order."
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, CLIKERNEL_QUIET='1') if quiet else 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
- s = await self.session(host if has_host else self.cur)
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
- return await s.tr.send(msg)
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`, `inspectors.py`, and `gateways.toml`; `~/.config/clikernel/` if unset
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.13
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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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
@@ -5,6 +5,7 @@ mcpmini>=0.0.5
5
5
  rustygate>=0.1.22
6
6
  ipymini>=0.1.23
7
7
  httpx
8
+ ipython
8
9
 
9
10
  [dev]
10
11
  fastship
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68", "wheel"]
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 = { text = "Apache-2.0" }
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