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.
Files changed (24) hide show
  1. {clikernel-0.2.14 → clikernel-0.2.16}/CHANGELOG.md +16 -0
  2. {clikernel-0.2.14 → clikernel-0.2.16}/MANIFEST.in +0 -1
  3. {clikernel-0.2.14/clikernel.egg-info → clikernel-0.2.16}/PKG-INFO +9 -5
  4. {clikernel-0.2.14 → clikernel-0.2.16}/README.md +6 -3
  5. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/__init__.py +2 -2
  6. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/_modidx.py +6 -5
  7. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/core.py +19 -52
  8. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/mcp.py +41 -8
  9. clikernel-0.2.16/clikernel/rules.py +169 -0
  10. clikernel-0.2.16/clikernel/skill.py +24 -0
  11. {clikernel-0.2.14 → clikernel-0.2.16/clikernel.egg-info}/PKG-INFO +9 -5
  12. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/SOURCES.txt +2 -0
  13. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/entry_points.txt +1 -0
  14. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/requires.txt +2 -1
  15. {clikernel-0.2.14 → clikernel-0.2.16}/pyproject.toml +4 -2
  16. clikernel-0.2.16/tests/test_rules.py +80 -0
  17. clikernel-0.2.14/clikernel/skill.py +0 -33
  18. {clikernel-0.2.14 → clikernel-0.2.16}/LICENSE +0 -0
  19. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/cli.py +0 -0
  20. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel/stream.py +0 -0
  21. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/dependency_links.txt +0 -0
  22. {clikernel-0.2.14 → clikernel-0.2.16}/clikernel.egg-info/top_level.txt +0 -0
  23. {clikernel-0.2.14 → clikernel-0.2.16}/setup.cfg +0 -0
  24. {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,4 +1,3 @@
1
1
  include README.md
2
2
  include LICENSE
3
3
  include CHANGELOG.md
4
- recursive-include clikernel/dojo_data *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.2.14
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.22
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="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
+ 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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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="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.
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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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 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.
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.14"
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._inspector_setup': ('core.html#_inspector_setup', 'clikernel/core.py'),
27
- 'clikernel.core._startup_src': ('core.html#_startup_src', 'clikernel/core.py'),
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.main': ('mcp.html#main', 'clikernel/mcp.py')},
41
- 'clikernel.skill': {},
42
- 'clikernel.stream': {}}}
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 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
 
@@ -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 _startup_src(src, path):
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 #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})'
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, user startup, and inspector setup, in that order."
70
+ "Build Python defaults and user startup, in that order."
103
71
  d = Path(cfgdir) if cfgdir else cfg_dir()
104
- parts = ["get_ipython().ast_node_interactivity = 'all'"]
105
- 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()))
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, CLIKERNEL_QUIET='1') if quiet else 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: return await s.tr.send(msg)
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
- del self.sessions[h] # a restarted gateway answers 404 for a session id it no longer knows
81
- return await (await self.session(h)).tr.send(msg)
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`, `inspectors.py`, and `gateways.toml`; `~/.config/clikernel/` if unset
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.14
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.22
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="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
+ 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
- Three optional files in `$XDG_CONFIG_HOME/clikernel/` configure the router, usually under `~/.config/clikernel/`, or under the directory `clikernel-mcp --cfgdir` names:
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,6 @@
1
1
  [console_scripts]
2
2
  clikernel = clikernel.cli:main
3
+ clikernel-hook = clikernel.mcp:hook
3
4
  clikernel-mcp = clikernel.mcp:main
4
5
 
5
6
  [nbdev]
@@ -2,9 +2,10 @@ fastcore>=2.2.2
2
2
  jupyasyncclient>=0.2.10
3
3
  jupywire>=0.1.9
4
4
  mcpmini>=0.0.5
5
- rustygate>=0.1.22
5
+ rustygate>=0.1.25
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>=77", "wheel"]
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.22",
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