clikernel 0.1.2__tar.gz → 0.1.3__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.
@@ -0,0 +1,34 @@
1
+ <!-- do not remove -->
2
+
3
+ ## 0.1.3
4
+
5
+ ### New Features
6
+
7
+ - Add `exit` MCP tool for hard process reset, and add pyskill ([#7](https://github.com/AnswerDotAI/clikernel/issues/7))
8
+ - Add asyncio lock and async wrappers to MCP tools to allow top-level await ([#6](https://github.com/AnswerDotAI/clikernel/issues/6))
9
+ - Add %nbopen/%nbrun line magics for running notebook cells by id prefix ([#5](https://github.com/AnswerDotAI/clikernel/issues/5))
10
+ - Add MCP server exposing persistent IPython session; suppress duplicate tracebacks and defer init to main() ([#3](https://github.com/AnswerDotAI/clikernel/issues/3))
11
+
12
+ ### Bugs Squashed
13
+
14
+ - Set `structured_output`=False on MCP tool decorators ([#4](https://github.com/AnswerDotAI/clikernel/issues/4))
15
+
16
+
17
+ ## 0.1.2
18
+
19
+ ### New Features
20
+
21
+ - Add ONLCR terminal flag control ([#2](https://github.com/AnswerDotAI/clikernel/issues/2))
22
+
23
+
24
+ ## 0.1.1
25
+
26
+ ### New Features
27
+
28
+ - Use a fixed per-session delimiter and emit `.` ack before each response ([#1](https://github.com/AnswerDotAI/clikernel/issues/1))
29
+
30
+
31
+ ## 0.1.0
32
+
33
+ - Initial release
34
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -10,7 +10,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: execnb
13
+ Requires-Dist: execnb>=0.2.3
14
+ Requires-Dist: mcp
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: fastship; extra == "dev"
16
17
  Requires-Dist: build; extra == "dev"
@@ -73,6 +74,20 @@ After execution, `clikernel` prints the acknowledgement, the rendered output, an
73
74
 
74
75
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
75
76
 
77
+ ## Notebook magics
78
+
79
+ `clikernel` registers two line magics wrapping `execnb`'s `nbopen`/`nbrun`, for running cells from a notebook by cell id prefix:
80
+
81
+ ```text
82
+ %nbopen foo.ipynb
83
+ %nbrun ab12
84
+ %nbrun ab12 --above
85
+ %nbrun --all --exported
86
+ %nbrun ab12 --fname other.ipynb
87
+ ```
88
+
89
+ `%nbopen` sets the default notebook for later `%nbrun` calls (as does passing `--fname`). `%nbrun` runs the cell whose id starts with the given prefix; `--above`/`--below` also run the cells before/after it, `--all` runs every code cell, and `--exported` filters to cells with an nbdev `#| export`/`#| exports` directive. The notebook is re-read from disk on each call, and each executed cell's rendered output is printed under a `--- {cell id} ---` header.
90
+
76
91
  `clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
77
92
 
78
93
  ## Why The Protocol Is Odd
@@ -53,6 +53,20 @@ After execution, `clikernel` prints the acknowledgement, the rendered output, an
53
53
 
54
54
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
55
55
 
56
+ ## Notebook magics
57
+
58
+ `clikernel` registers two line magics wrapping `execnb`'s `nbopen`/`nbrun`, for running cells from a notebook by cell id prefix:
59
+
60
+ ```text
61
+ %nbopen foo.ipynb
62
+ %nbrun ab12
63
+ %nbrun ab12 --above
64
+ %nbrun --all --exported
65
+ %nbrun ab12 --fname other.ipynb
66
+ ```
67
+
68
+ `%nbopen` sets the default notebook for later `%nbrun` calls (as does passing `--fname`). `%nbrun` runs the cell whose id starts with the given prefix; `--above`/`--below` also run the cells before/after it, `--all` runs every code cell, and `--exported` filters to cells with an nbdev `#| export`/`#| exports` directive. The notebook is re-read from disk on each call, and each executed cell's rendered output is printed under a `--- {cell id} ---` header.
69
+
56
70
  `clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
57
71
 
58
72
  ## Why The Protocol Is Odd
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.3"
2
+
3
+
4
+
@@ -1,5 +1,4 @@
1
- print("please wait, loading...", flush=True)
2
- import os,secrets,string,sys,tempfile,termios,traceback
1
+ import inspect,os,secrets,shlex,string,sys,tempfile,termios,traceback
3
2
  from pathlib import Path
4
3
 
5
4
  def _state_root():
@@ -16,9 +15,6 @@ def _set_default_dirs():
16
15
  os.environ.setdefault("MPLBACKEND", "Agg")
17
16
 
18
17
 
19
- _set_default_dirs()
20
-
21
-
22
18
  _ALPHANUM = string.ascii_letters + string.digits
23
19
  _MULTILINE = "--"
24
20
 
@@ -51,11 +47,31 @@ def _execute(shell, code):
51
47
  def _request_exit(shell): shell._clikernel_exit = True
52
48
 
53
49
 
50
+ def _magic_wrap(fn):
51
+ "Make a line magic from `fn`: `--name` flags for bool params, `--name value` otherwise, else positional"
52
+ params = inspect.signature(fn).parameters
53
+ def magic(line):
54
+ args,kw = [],{}
55
+ toks = iter(shlex.split(line))
56
+ for t in toks:
57
+ if t.startswith('--'):
58
+ k = t[2:]
59
+ kw[k] = True if params[k].annotation in (bool,'bool') else next(toks)
60
+ else: args.append(t)
61
+ return fn(*args, **kw)
62
+ return magic
63
+
64
+
54
65
  def _make_shell():
55
66
  from execnb.shell import CaptureShell
56
67
  shell = CaptureShell(mpl_format=None, history=False)
68
+ for name in ('nbopen','nbrun'): shell.register_magic_function(_magic_wrap(getattr(shell, name)), 'line', name)
57
69
  shell._clikernel_exit = False
58
70
  shell.ask_exit = lambda: _request_exit(shell)
71
+ # execnb already captures the exception as a structured error output; suppress
72
+ # IPython's own traceback print so it isn't duplicated (in colour) via stdout.
73
+ shell.showtraceback = lambda *a, **k: None
74
+ shell.showsyntaxerror = lambda *a, **k: None
59
75
  return shell
60
76
 
61
77
 
@@ -90,6 +106,8 @@ def _restore_termios(state):
90
106
 
91
107
 
92
108
  def main():
109
+ print("please wait, loading...", flush=True)
110
+ _set_default_dirs()
93
111
  shell = _make_shell()
94
112
  output_state = _disable_output_newline_translation()
95
113
  echo_state = _disable_echo()
@@ -0,0 +1,37 @@
1
+ "MCP server exposing a persistent IPython `CaptureShell` as an `execute` tool."
2
+ import asyncio
3
+
4
+ from clikernel.cli import _set_default_dirs, _make_shell
5
+
6
+
7
+ def main():
8
+ _set_default_dirs()
9
+ from mcp.server.fastmcp import FastMCP
10
+ from fastcore.nbio import render_text
11
+ mcp = FastMCP("clikernel")
12
+ state = {"shell": _make_shell(), "lock": asyncio.Lock()}
13
+
14
+ @mcp.tool(structured_output=False)
15
+ async def execute(code:str # IPython-compatible code to run in the persistent session
16
+ )->str: # Rendered outputs (stdout, display data, last-expression result, errors)
17
+ "Run `code` in the persistent IPython session, keeping state across calls (imports, variables, monkeypatches, cached objects)."
18
+ async with state["lock"]:
19
+ outputs = await asyncio.to_thread(state["shell"].run, code)
20
+ return render_text(outputs)
21
+
22
+ @mcp.tool(structured_output=False)
23
+ async def restart()->str:
24
+ "Discard all session state (imports, variables, monkeypatches, cached objects) and start a fresh IPython shell inside the same server process. Use this for the common case: a bad variable, a half-finished experiment to abandon, or a user-requested clean slate. Note: this does NOT touch `sys.modules` or anything already monkeypatched (e.g. via fastcore's `@patch`) -- those live at the process level, not the shell level, and survive `restart` untouched. If you need a genuinely fresh interpreter (e.g. after reloading a module that other already-imported modules had patched -- symptoms: a stale-class bug where `isinstance`/`is` checks mysteriously fail, or a class is missing a method you know it has), use `exit` instead. After restarting, redo any imports/setup the task still needs."
25
+ async with state["lock"]: state["shell"] = _make_shell()
26
+ return "restarted"
27
+
28
+ @mcp.tool(structured_output=False)
29
+ async def exit()->str:
30
+ "Terminate this MCP server process immediately, for when `restart` isn't enough (see its docstring). This call itself will error (\"Connection closed\") since the process dies before it can respond -- that's expected, not a failure. The next `execute` call transparently reconnects to a freshly-spawned process with `sys.modules` genuinely reset (confirmed via a new pid), no manual reconnect needed. After reconnecting, redo any imports/setup the task still needs."
31
+ import os
32
+ os._exit(0)
33
+
34
+ mcp.run()
35
+
36
+
37
+ if __name__ == "__main__": main()
@@ -0,0 +1,101 @@
1
+ """Use the persistent `clikernel` MCP session as the default workspace for any task advanced through live Python execution -- stateful inspection, file-editing workflows, debugging, experiments, API probes, data transforms, or notebook-style work. Read this before writing, running, or debugging Python code in a session with `clikernel` connected.
2
+
3
+ # Core idea
4
+
5
+ `clikernel` exposes one long-lived IPython process (wrapping `execnb.shell.CaptureShell`) that runs Python/IPython code and keeps state across the whole conversation: imports, live objects, monkeypatches, cached results, API clients, small experiments. Treat it as a notebook-style workbench, not a one-shot script runner.
6
+
7
+ Prefer it over one-off Python scripts (`python -c`, shell heredocs) whenever you need to inspect runtime behavior, test an idea, call a Python API, examine package state, run a live probe, or iterate on an implementation detail. Shell commands are still the right tool for file search, git, project test/build commands, and non-Python tools.
8
+
9
+ There are two ways to drive it, depending on what the host supports:
10
+
11
+ - **MCP server** (`clikernel-mcp`): no delimiter protocol, stdin plumbing, or readiness polling to manage -- call one tool, get back the rendered outputs.
12
+ - **CLI** (`clikernel`): a plain stdin/stdout process using a delimiter protocol, for hosts that drive background terminal sessions instead of MCP tools.
13
+
14
+ # MCP tools
15
+
16
+ `execute`, `restart`, and `exit` are self-documenting -- read each tool's own MCP description rather than looking for docs elsewhere.
17
+
18
+ # CLI protocol
19
+
20
+ When driven as a plain process, `clikernel` prints loading status followed by a random session delimiter -- always `--` plus 5 alphanumeric characters:
21
+
22
+ please wait, loading...
23
+ loading complete. first delimiter:
24
+ --aB3x9
25
+
26
+ Any startup warnings print before the first delimiter. Treat that delimiter as the readiness signal, completion signal, and multiline terminator; it stays the same until the process exits.
27
+
28
+ Send a single line to execute a single-line request. `clikernel` first prints an acknowledgement line (`.`) -- that means request *accepted*, not execution complete -- then the response body, then the session delimiter:
29
+
30
+ 1+1
31
+ .
32
+ 2
33
+ --aB3x9
34
+
35
+ Use a bare `--` line to start multiline input, ending the block with the session delimiter exactly:
36
+
37
+ --
38
+ def f(x):
39
+ return x + 1
40
+
41
+ f(2)
42
+ --aB3x9
43
+
44
+ Do not look for an IPython prompt, do not use `%cpaste`, and do not invent your own terminator. A blank line is a real (empty) request, so it makes a good idle-poll: an idle kernel answers with `.` and the delimiter; if that doesn't come back quickly, the previous request is still running or the process is wedged.
45
+
46
+ Python exceptions render as normal notebook error output. Protocol/worker failures render with readable XML-ish error tags, then the session delimiter.
47
+
48
+ To end the session, send `exit`. In CLI mode there is no `restart` tool -- starting a fresh process *is* the restart, and gives a genuinely fresh interpreter (the equivalent of the MCP `exit` tool).
49
+
50
+ # Notebook magics
51
+
52
+ Two line magics run cells from a `.ipynb` file by cell id prefix:
53
+
54
+ %nbopen foo.ipynb
55
+ %nbrun ab12
56
+ %nbrun ab12 --above
57
+ %nbrun --all --exported
58
+ %nbrun ab12 --fname other.ipynb
59
+
60
+ `%nbopen` sets the default notebook for later `%nbrun` calls (passing `--fname` does too). `%nbrun` runs the cell whose id starts with the given prefix; `--above`/`--below` also run the cells before/after it, `--all` runs every code cell, and `--exported` filters to cells carrying an nbdev `#| export`/`#| exports` directive. The notebook is re-read from disk on each call, so file edits are picked up; each executed cell's output is printed under a `--- {cell id} ---` header. Cell execution shares the persistent session state, and `restart` clears the `%nbopen` default.
61
+
62
+ Prefer these magics over copying cell source into `execute` by hand when working through a notebook -- e.g. after editing a cell, `%nbrun <id>` re-runs it in place, and `%nbrun <id> --above` rebuilds the state it depends on.
63
+
64
+ # Output shape
65
+
66
+ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty stream/display/result/error comes back as just its preferred text form, e.g. `42`. Multiple non-empty outputs use readable XML-ish tags with raw, unescaped body text, e.g.:
67
+
68
+ <stdout>
69
+ hello
70
+ </stdout>
71
+ <execute_result>
72
+ 42
73
+ </execute_result>
74
+
75
+ `display_data`/`execute_result` prefer a non-image, markdown-over-HTML representation; images are ignored. Exceptions come back as a single clean `<error>` traceback -- no color codes, not duplicated.
76
+
77
+ # Interaction rules
78
+
79
+ - Try the simple import or API call first, before mutating environment, monkeypatching, or adding setup. Only change session state after the ordinary path fails and the reason is understood.
80
+ - For file-editing workflows, view the target slice first and make the smallest verified edit -- avoid whole-file rewrites when a line/range/string operation is enough.
81
+ - Default to raw triple-quoted strings (`r'''...'''`) for generated markdown, code, regexes, commands, or other source-like text, since backslashes usually need to survive intact. Use normal triple-quoted strings only when Python's own escape processing is what you want. For risky multiline content, verify with `repr(...)` or a focused readback before writing broadly.
82
+ - Lean on reprs: many objects returned by libraries in this ecosystem (pyskills results especially) have reprs designed for direct reading. End a cell with the bare expression instead of writing a loop to reformat fields by hand -- only drop to attribute access when the repr genuinely omits what you need.
83
+
84
+ # Critical issues
85
+
86
+ - Like Jupyter, only the *last* expression in a cell is printed/returned. `print(...)` any earlier value you need to see.
87
+ - Don't re-run an `import` you've already run this session -- it's persistent, so it's already done. Use `importlib.reload` if you've changed a module and need the change picked up. If a previously-imported name raises `NameError`, the session restarted -- redo whatever imports/setup the task needs.
88
+ - `importlib.reload`ing a module is not always enough to pick up a change: other already-imported modules that did `from x import *`, or that monkeypatched one of its classes (e.g. via fastcore's `@patch`), hold stale references that a targeted reload won't fix. If you hit a stale-class symptom (a class missing a method you know it has, `isinstance` mysteriously failing), you need a fresh interpreter process: the MCP `exit` tool (see the `restart`/`exit` tool descriptions for which does what), or in CLI mode, exit and start a new process.
89
+ - Everything a cell outputs lands in the conversation and stays there. Be surgical: inspect only what's needed, and don't dump large values -- `print(len(v))` first, then decide whether to print in full or filter down.
90
+ - The kernel is scoped to one client session and shared by any subagents within it. If the server restarts or exits, in-memory state is gone -- redo imports and setup.
91
+
92
+ # Pyskills
93
+
94
+ This kernel environment commonly has `pyskills` installed -- a plugin system for discovering additional Python capabilities registered by installed packages. When present, checking it is the first thing to do at the start of a session, and using a relevant pyskill is strongly preferred over ad hoc code. See `pyskills.skill`'s own docs for the full discovery/usage workflow:
95
+
96
+ from pyskills import list_pyskills, doc
97
+ import pyskills.skill
98
+ print(doc(pyskills.skill))
99
+ """
100
+
101
+ __all__ = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -10,7 +10,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: execnb
13
+ Requires-Dist: execnb>=0.2.3
14
+ Requires-Dist: mcp
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: fastship; extra == "dev"
16
17
  Requires-Dist: build; extra == "dev"
@@ -73,6 +74,20 @@ After execution, `clikernel` prints the acknowledgement, the rendered output, an
73
74
 
74
75
  Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output is printed directly. Multiple outputs use raw XML-ish tags, for example `<stdout>`, `<display_data mime="text/markdown">`, and `<execute_result>`.
75
76
 
77
+ ## Notebook magics
78
+
79
+ `clikernel` registers two line magics wrapping `execnb`'s `nbopen`/`nbrun`, for running cells from a notebook by cell id prefix:
80
+
81
+ ```text
82
+ %nbopen foo.ipynb
83
+ %nbrun ab12
84
+ %nbrun ab12 --above
85
+ %nbrun --all --exported
86
+ %nbrun ab12 --fname other.ipynb
87
+ ```
88
+
89
+ `%nbopen` sets the default notebook for later `%nbrun` calls (as does passing `--fname`). `%nbrun` runs the cell whose id starts with the given prefix; `--above`/`--below` also run the cells before/after it, `--all` runs every code cell, and `--exported` filters to cells with an nbdev `#| export`/`#| exports` directive. The notebook is re-read from disk on each call, and each executed cell's rendered output is printed under a `--- {cell id} ---` header.
90
+
76
91
  `clikernel` sets quiet defaults for `IPYTHONDIR`, `MPLCONFIGDIR`, and `MPLBACKEND=Agg` before creating the shell. Existing `IPYTHONDIR` and `MPLCONFIGDIR` values are left alone. Loading messages and any startup warnings are printed before the first delimiter. Set `CLIKERNEL_STATE_DIR` to choose the default parent directory.
77
92
 
78
93
  ## Why The Protocol Is Odd
@@ -5,10 +5,13 @@ README.md
5
5
  pyproject.toml
6
6
  clikernel/__init__.py
7
7
  clikernel/cli.py
8
+ clikernel/mcp.py
9
+ clikernel/skill.py
8
10
  clikernel.egg-info/PKG-INFO
9
11
  clikernel.egg-info/SOURCES.txt
10
12
  clikernel.egg-info/dependency_links.txt
11
13
  clikernel.egg-info/entry_points.txt
12
14
  clikernel.egg-info/requires.txt
13
15
  clikernel.egg-info/top_level.txt
14
- tests/test_cli.py
16
+ tests/test_cli.py
17
+ tests/test_mcp.py
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ clikernel = clikernel.cli:main
3
+ clikernel-mcp = clikernel.mcp:main
4
+
5
+ [pyskills]
6
+ clikernel = clikernel.skill
@@ -1,4 +1,5 @@
1
- execnb
1
+ execnb>=0.2.3
2
+ mcp
2
3
 
3
4
  [dev]
4
5
  fastship
@@ -16,7 +16,8 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  dependencies = [
19
- "execnb",
19
+ "execnb>=0.2.3",
20
+ "mcp",
20
21
  ]
21
22
 
22
23
  [project.optional-dependencies]
@@ -27,11 +28,15 @@ dev = [
27
28
  "pytest",
28
29
  ]
29
30
 
31
+ [project.entry-points.pyskills]
32
+ clikernel = "clikernel.skill"
33
+
30
34
  [project.urls]
31
35
  Homepage = "https://github.com/AnswerDotAI/clikernel"
32
36
 
33
37
  [project.scripts]
34
38
  clikernel = "clikernel.cli:main"
39
+ clikernel-mcp = "clikernel.mcp:main"
35
40
 
36
41
  [tool.setuptools.dynamic]
37
42
  version = { attr = "clikernel.__version__" }
@@ -1,4 +1,4 @@
1
- import os,pty,re,select,shutil,subprocess,tempfile,time
1
+ import json,os,pty,re,select,shutil,subprocess,tempfile,time
2
2
 
3
3
  import pytest
4
4
 
@@ -243,6 +243,21 @@ def test_runtime_errors_return_error_text_and_same_delimiter(kernel):
243
243
  assert next_delim == delim
244
244
 
245
245
 
246
+ def test_error_has_no_ansi_and_no_duplicate_traceback(kernel):
247
+ proc, _, _ = kernel
248
+ body, _ = send(proc, "1/0\n")
249
+ assert "\x1b[" not in body # no ANSI colour codes
250
+ assert body.count("ZeroDivisionError") == 1 # single traceback, not duplicated
251
+ assert "<stdout>" not in body # no captured stdout traceback copy
252
+
253
+
254
+ def test_error_preserves_real_stdout(kernel):
255
+ proc, _, delim = kernel
256
+ body, _ = send(proc, f"--\nprint('before')\n1/0\n{delim}\n")
257
+ assert "<stdout>\nbefore\n</stdout>" in body # genuine prints kept
258
+ assert body.count("ZeroDivisionError") == 1
259
+
260
+
246
261
  @pytest.mark.parametrize("cmd", ["exit()", "quit()"])
247
262
  def test_exit_request_returns_final_delimiter_and_stops_process(kernel, cmd):
248
263
  proc, _, start_delim = kernel
@@ -258,3 +273,37 @@ def test_history_is_disabled(kernel):
258
273
  proc, _, _ = kernel
259
274
  body, _ = send(proc, "get_ipython().history_manager.enabled\n")
260
275
  assert body == "False\n"
276
+
277
+
278
+ NB_CELLS = [("aaa111", "x = 41\nprint('one')"),
279
+ ("bbb222", "#| export\nprint('two', x + 1)"),
280
+ ("ccc333", "print('three')")]
281
+
282
+ def make_nb(path):
283
+ cells = [dict(cell_type="code", id=i, metadata={}, outputs=[], execution_count=None, source=src)
284
+ for i,src in NB_CELLS]
285
+ path.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
286
+ return path
287
+
288
+
289
+ def test_nbopen_nbrun_magics(kernel, tmp_path):
290
+ proc, _, _ = kernel
291
+ nb = make_nb(tmp_path/"t.ipynb")
292
+ body, _ = send(proc, f"%nbopen {nb}\n")
293
+ assert "error" not in body.lower()
294
+ body, _ = send(proc, "%nbrun aaa\n")
295
+ assert "--- aaa111 ---" in body
296
+ assert "one" in body
297
+ body, _ = send(proc, "%nbrun bbb222 --above\n")
298
+ assert "one" in body and "two 42" in body
299
+ body, _ = send(proc, "%nbrun --all --exported\n")
300
+ assert "two 42" in body and "one" not in body and "three" not in body
301
+
302
+
303
+ def test_nbrun_fname_arg_sets_default(kernel, tmp_path):
304
+ proc, _, _ = kernel
305
+ nb = make_nb(tmp_path/"t.ipynb")
306
+ body, _ = send(proc, f"%nbrun aaa --fname {nb}\n")
307
+ assert "one" in body
308
+ body, _ = send(proc, "%nbrun ccc\n")
309
+ assert "three" in body
@@ -0,0 +1,91 @@
1
+ import os,shutil,sys,tempfile
2
+
3
+ import pytest
4
+ from mcp import ClientSession, StdioServerParameters
5
+ from mcp.client.stdio import stdio_client
6
+
7
+
8
+ def _server_params():
9
+ cmd = shutil.which("clikernel-mcp")
10
+ args = [] if cmd else ["-m", "clikernel.mcp"]
11
+ cmd = cmd or sys.executable
12
+ env = dict(os.environ, CLIKERNEL_STATE_DIR=tempfile.mkdtemp(prefix="clikernel-mcp-test-"))
13
+ return StdioServerParameters(command=cmd, args=args, env=env)
14
+
15
+
16
+ async def _drive(codes):
17
+ "Run each code string through the execute tool in one session; return result texts."
18
+ async with stdio_client(_server_params()) as (r,w), ClientSession(r,w) as s:
19
+ await s.initialize()
20
+ out = []
21
+ for c in codes:
22
+ res = await s.call_tool("execute", {"code": c})
23
+ out.append(res.content[0].text if res.content else "")
24
+ return out
25
+
26
+
27
+ async def _drive_ops(ops):
28
+ "Run a mix of ('execute', code) / ('restart',) ops in one session; return result texts."
29
+ async with stdio_client(_server_params()) as (r,w), ClientSession(r,w) as s:
30
+ await s.initialize()
31
+ out = []
32
+ for op in ops:
33
+ name, args = (op[0], {"code": op[1]}) if op[0] == "execute" else (op[0], {})
34
+ res = await s.call_tool(name, args)
35
+ out.append(res.content[0].text if res.content else "")
36
+ return out
37
+
38
+
39
+ @pytest.mark.anyio
40
+ async def test_execute_returns_result(): assert await _drive(["40+2"]) == ["42"]
41
+
42
+
43
+ @pytest.mark.anyio
44
+ async def test_execute_supports_top_level_await():
45
+ r = await _drive(["import asyncio\nawait asyncio.sleep(0)\n42"])
46
+ assert r == ["42"]
47
+
48
+
49
+ @pytest.mark.anyio
50
+ async def test_state_persists_across_calls(): assert (await _drive(["x = 41", "x + 1"]))[1] == "42"
51
+
52
+
53
+ @pytest.mark.anyio
54
+ async def test_multiple_outputs_use_xmlish_blocks():
55
+ r = (await _drive(["print('hi'); 99"]))[0]
56
+ assert "<stdout>\nhi\n</stdout>" in r
57
+ assert "<execute_result>\n99\n</execute_result>" in r
58
+
59
+
60
+ @pytest.mark.anyio
61
+ async def test_error_is_clean():
62
+ r = (await _drive(["1/0"]))[0]
63
+ assert "ZeroDivisionError" in r
64
+ assert "\x1b[" not in r
65
+ assert r.count("ZeroDivisionError") == 1
66
+
67
+
68
+ @pytest.mark.anyio
69
+ async def test_restart_clears_state():
70
+ r = await _drive_ops([("execute","x = 1"), ("restart",), ("execute","x")])
71
+ assert "NameError" in r[2]
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_restart_leaves_kernel_usable():
76
+ r = await _drive_ops([("execute","x = 1"), ("restart",), ("execute","40+2")])
77
+ assert r[2] == "42"
78
+
79
+
80
+ @pytest.mark.anyio
81
+ async def test_nb_magics(tmp_path):
82
+ import json
83
+ cells = [dict(cell_type="code", id="aaa111", metadata={}, outputs=[], execution_count=None, source="print('one')")]
84
+ nb = tmp_path/"t.ipynb"
85
+ nb.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
86
+ r = await _drive([f"%nbopen {nb}", "%nbrun aaa"])
87
+ assert "--- aaa111 ---" in r[1] and "one" in r[1]
88
+
89
+
90
+ @pytest.fixture
91
+ def anyio_backend(): return "asyncio"
@@ -1,20 +0,0 @@
1
- <!-- do not remove -->
2
-
3
- ## 0.1.2
4
-
5
- ### New Features
6
-
7
- - Add ONLCR terminal flag control ([#2](https://github.com/AnswerDotAI/clikernel/issues/2))
8
-
9
-
10
- ## 0.1.1
11
-
12
- ### New Features
13
-
14
- - Use a fixed per-session delimiter and emit `.` ack before each response ([#1](https://github.com/AnswerDotAI/clikernel/issues/1))
15
-
16
-
17
- ## 0.1.0
18
-
19
- - Initial release
20
-
@@ -1,3 +0,0 @@
1
- __version__ = "0.1.2"
2
-
3
-
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- clikernel = clikernel.cli:main
File without changes
File without changes
File without changes