clikernel 0.1.2__tar.gz → 0.1.4__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,42 @@
1
+ <!-- do not remove -->
2
+
3
+ ## 0.1.4
4
+
5
+ ### New Features
6
+
7
+ - Add cell inspectors, consolidate terminal handling, and harden MCP supervisor with signal guards and error recovery ([#9](https://github.com/AnswerDotAI/clikernel/issues/9))
8
+ - Switch MCP server from in-process shell to supervised subprocess worker, add interrupt tool and idle-SIGINT handling ([#8](https://github.com/AnswerDotAI/clikernel/issues/8))
9
+
10
+
11
+ ## 0.1.3
12
+
13
+ ### New Features
14
+
15
+ - Add `exit` MCP tool for hard process reset, and add pyskill ([#7](https://github.com/AnswerDotAI/clikernel/issues/7))
16
+ - Add asyncio lock and async wrappers to MCP tools to allow top-level await ([#6](https://github.com/AnswerDotAI/clikernel/issues/6))
17
+ - Add %nbopen/%nbrun line magics for running notebook cells by id prefix ([#5](https://github.com/AnswerDotAI/clikernel/issues/5))
18
+ - Add MCP server exposing persistent IPython session; suppress duplicate tracebacks and defer init to main() ([#3](https://github.com/AnswerDotAI/clikernel/issues/3))
19
+
20
+ ### Bugs Squashed
21
+
22
+ - Set `structured_output`=False on MCP tool decorators ([#4](https://github.com/AnswerDotAI/clikernel/issues/4))
23
+
24
+
25
+ ## 0.1.2
26
+
27
+ ### New Features
28
+
29
+ - Add ONLCR terminal flag control ([#2](https://github.com/AnswerDotAI/clikernel/issues/2))
30
+
31
+
32
+ ## 0.1.1
33
+
34
+ ### New Features
35
+
36
+ - Use a fixed per-session delimiter and emit `.` ack before each response ([#1](https://github.com/AnswerDotAI/clikernel/issues/1))
37
+
38
+
39
+ ## 0.1.0
40
+
41
+ - Initial release
42
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clikernel
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -10,7 +10,9 @@ 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.4
14
+ Requires-Dist: fastcore
15
+ Requires-Dist: mcp
14
16
  Provides-Extra: dev
15
17
  Requires-Dist: fastship; extra == "dev"
16
18
  Requires-Dist: build; extra == "dev"
@@ -73,8 +75,30 @@ After execution, `clikernel` prints the acknowledgement, the rendered output, an
73
75
 
74
76
  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
77
 
78
+ ## Notebook magics
79
+
80
+ `clikernel` registers two line magics wrapping `execnb`'s `nbopen`/`nbrun`, for running cells from a notebook by cell id prefix:
81
+
82
+ ```text
83
+ %nbopen foo.ipynb
84
+ %nbrun ab12
85
+ %nbrun ab12 --above
86
+ %nbrun --all --exported
87
+ %nbrun ab12 --fname other.ipynb
88
+ ```
89
+
90
+ `%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.
91
+
76
92
  `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
93
 
94
+ ## Inspectors
95
+
96
+ `clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
97
+
98
+ Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
99
+
100
+ See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
101
+
78
102
  ## Why The Protocol Is Odd
79
103
 
80
104
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
@@ -105,3 +129,4 @@ ship-gh
105
129
  ship-pypi
106
130
  ship-bump # dev release always later than prod release
107
131
  ```
132
+
@@ -53,8 +53,30 @@ 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
 
72
+ ## Inspectors
73
+
74
+ `clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
75
+
76
+ Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
77
+
78
+ See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
79
+
58
80
  ## Why The Protocol Is Odd
59
81
 
60
82
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
@@ -85,3 +107,4 @@ ship-gh
85
107
  ship-pypi
86
108
  ship-bump # dev release always later than prod release
87
109
  ```
110
+
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.4"
2
+
3
+
4
+
5
+
@@ -0,0 +1,168 @@
1
+ import ast,inspect,os,runpy,secrets,shlex,signal,string,sys,tempfile,termios,traceback,tty
2
+ from pathlib import Path
3
+ from fastcore.xdg import xdg_config_home
4
+
5
+ def _state_root():
6
+ if d := os.environ.get("CLIKERNEL_STATE_DIR"): return Path(d).expanduser()
7
+ return Path(tempfile.gettempdir()) / f"clikernel-{os.getuid()}"
8
+
9
+
10
+ def _set_default_dirs():
11
+ state = _state_root()
12
+ for env, name in (("IPYTHONDIR", "ipython"), ("MPLCONFIGDIR", "matplotlib")):
13
+ path = Path(os.environ.get(env, state/name)).expanduser()
14
+ path.mkdir(parents=True, exist_ok=True)
15
+ os.environ.setdefault(env, str(path))
16
+ os.environ.setdefault("MPLBACKEND", "Agg")
17
+
18
+
19
+ _ALPHANUM = string.ascii_letters + string.digits
20
+ _MULTILINE = "--"
21
+
22
+
23
+ def _new_delim(): return "--" + ''.join(secrets.choice(_ALPHANUM) for _ in range(5))
24
+
25
+
26
+ def _read_block(stdin, delim):
27
+ lines = []
28
+ for line in stdin:
29
+ if line.rstrip("\n") == delim: return "".join(lines), None
30
+ lines.append(line)
31
+ return "", f"missing block terminator: {delim}"
32
+
33
+
34
+ def _format_error(tag, text): return f"<{tag}>\n{text}</{tag}>"
35
+
36
+
37
+ def _write_response(delim, body=None):
38
+ if body: print(body, end='' if body.endswith('\n') else '\n', flush=True)
39
+ print(delim, flush=True)
40
+
41
+
42
+ def _load_inspectors():
43
+ "Cell inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py`: each is called with the cell AST before execution, and may return a note (prepended to the output) or raise (blocking the cell). The file may define `inspect` and/or an `inspectors` list."
44
+ path = xdg_config_home()/"clikernel"/"inspectors.py"
45
+ if not path.exists(): return []
46
+ try: ns = runpy.run_path(str(path))
47
+ except Exception:
48
+ print(f"clikernel: failed to load {path}:\n{traceback.format_exc()}", file=sys.stderr, flush=True)
49
+ return []
50
+ ins = list(ns.get("inspectors", []))
51
+ if callable(ns.get("inspect")): ins.append(ns["inspect"])
52
+ return ins
53
+
54
+
55
+ def _inspect(shell, inspectors, code):
56
+ "Run each inspector on the cell's transformed AST; return concatenated notes. An inspector may raise to block the cell."
57
+ if not inspectors: return ""
58
+ try: tree = ast.parse(shell.transform_cell(code))
59
+ except SyntaxError: return "" # let the cell's own execution report the error
60
+ return "".join(note for f in inspectors if (note := f(tree)))
61
+
62
+
63
+ def _execute(shell, inspectors, code):
64
+ from fastcore.nbio import render_text
65
+ try: note = _inspect(shell, inspectors, code)
66
+ except Exception as e: return "blocked", _format_error("blocked", f"{type(e).__name__}: {e}")
67
+ outputs = shell.run(code)
68
+ return ("error" if shell.exc else "ok"), note + render_text(outputs)
69
+
70
+
71
+ def _request_exit(shell): shell._clikernel_exit = True
72
+
73
+
74
+ def _magic_wrap(fn):
75
+ "Make a line magic from `fn`: `--name` flags for bool params, `--name value` otherwise, else positional"
76
+ params = inspect.signature(fn).parameters
77
+ def magic(line):
78
+ args,kw = [],{}
79
+ toks = iter(shlex.split(line))
80
+ for t in toks:
81
+ if t.startswith('--'):
82
+ k = t[2:]
83
+ kw[k] = True if params[k].annotation in (bool,'bool') else next(toks)
84
+ else: args.append(t)
85
+ return fn(*args, **kw)
86
+ return magic
87
+
88
+
89
+ def _make_shell():
90
+ from execnb.shell import CaptureShell
91
+ shell = CaptureShell(mpl_format=None, history=False)
92
+ for name in ('nbopen','nbrun'): shell.register_magic_function(_magic_wrap(getattr(shell, name)), 'line', name)
93
+ shell._clikernel_exit = False
94
+ shell.ask_exit = lambda: _request_exit(shell)
95
+ # execnb already captures the exception as a structured error output; suppress
96
+ # IPython's own traceback print so it isn't duplicated (in colour) via stdout.
97
+ shell.showtraceback = lambda *a, **k: None
98
+ shell.showsyntaxerror = lambda *a, **k: None
99
+ return shell
100
+
101
+
102
+ def _should_exit(shell): return getattr(shell, "_clikernel_exit", False)
103
+
104
+
105
+ def _next_line(stdin):
106
+ "Read one line; when not a TTY, SIGINT while idle means 'interrupt execution', not 'kill the worker', so ignore it"
107
+ while True:
108
+ try: return stdin.readline()
109
+ except KeyboardInterrupt:
110
+ if stdin.isatty(): raise
111
+
112
+
113
+ def _tty_clear(stream, idx, mask, cc=None):
114
+ "Clear `mask` bits in termios field `idx` when `stream` is a TTY, with optional `cc` char overrides; returns state for `_restore_termios`"
115
+ if not stream.isatty(): return None
116
+ fd = stream.fileno()
117
+ attrs = termios.tcgetattr(fd)
118
+ new_attrs = attrs[:]
119
+ new_attrs[idx] &= ~mask
120
+ if cc:
121
+ new_attrs[6] = attrs[6][:]
122
+ for k, v in cc.items(): new_attrs[6][k] = v
123
+ termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
124
+ return fd, attrs
125
+
126
+
127
+ def _restore_termios(state):
128
+ if state: termios.tcsetattr(state[0], termios.TCSADRAIN, state[1])
129
+
130
+
131
+ def main():
132
+ # Establish our own SIGINT disposition rather than inherit the parent's: a supervisor that ignores
133
+ # SIGINT would otherwise pass SIG_IGN down (inherited across exec), leaving this worker uninterruptible
134
+ signal.signal(signal.SIGINT, signal.default_int_handler)
135
+ print("please wait, loading...", flush=True)
136
+ _set_default_dirs()
137
+ shell = _make_shell()
138
+ inspectors = _load_inspectors()
139
+ # ONLCR off so protocol output stays bare LF; ECHO off (echoed input corrupts the protocol) and ICANON
140
+ # off (canonical mode drops bytes past MAX_CANON with BEL spam; VMIN/VTIME make non-canonical reads
141
+ # return per byte; ISIG stays on so ^C still interrupts)
142
+ output_state = _tty_clear(sys.__stdout__, tty.OFLAG, termios.ONLCR)
143
+ echo_state = _tty_clear(sys.stdin, tty.LFLAG, termios.ECHO | termios.ICANON, {termios.VMIN: 1, termios.VTIME: 0})
144
+ delim = _new_delim()
145
+ print("loading complete. first delimiter:", flush=True)
146
+ _write_response(delim)
147
+ try:
148
+ while True:
149
+ line = _next_line(sys.stdin)
150
+ if not line: break
151
+ line = line.rstrip("\n")
152
+ if line == _MULTILINE:
153
+ code, err = _read_block(sys.stdin, delim)
154
+ if err:
155
+ _write_response(delim, _format_error("protocol-error", err))
156
+ continue
157
+ else: code = line
158
+ print(".", flush=True)
159
+ try: _, outputs = _execute(shell, inspectors, code)
160
+ except BaseException: outputs = _format_error("internal-error", traceback.format_exc())
161
+ _write_response(delim, outputs)
162
+ if _should_exit(shell): break
163
+ finally:
164
+ _restore_termios(echo_state)
165
+ _restore_termios(output_state)
166
+
167
+
168
+ if __name__ == "__main__": main()
@@ -0,0 +1,129 @@
1
+ "MCP server supervising a persistent `clikernel` CLI worker subprocess."
2
+ import asyncio,atexit,os,signal,sys,traceback
3
+
4
+ _MARKER = "loading complete. first delimiter:"
5
+ _DIED = "NOTE: the kernel process had died; a fresh one was started, and all previous session state (imports, variables, monkeypatches) is gone.\n"
6
+ _MULTILINE = "--"
7
+
8
+
9
+ class _Worker:
10
+ def __init__(self):
11
+ self.proc,self.delim,self.started,self.busy,self.desynced = None,None,False,False,False
12
+ self.lock = asyncio.Lock()
13
+
14
+ def alive(self): return self.proc is not None and self.proc.returncode is None
15
+
16
+ async def start(self):
17
+ PIPE = asyncio.subprocess.PIPE
18
+ self.proc = await asyncio.create_subprocess_exec(sys.executable, "-m", "clikernel.cli", limit=2**24, stdin=PIPE, stdout=PIPE)
19
+ while True:
20
+ line = (await self.proc.stdout.readline()).decode()
21
+ if not line: raise RuntimeError("clikernel worker failed to start")
22
+ if line.rstrip("\n") == _MARKER: break
23
+ self.delim = (await self.proc.stdout.readline()).decode().rstrip("\n")
24
+ self.started,self.busy,self.desynced = True,False,False
25
+
26
+ async def kill(self):
27
+ if self.alive():
28
+ self.proc.kill()
29
+ await self.proc.wait()
30
+
31
+ async def run(self, code):
32
+ "Send `code`; return `(acked, body)`. `body` None means the worker died; retry is only safe if it never acked."
33
+ msg = f"{_MULTILINE}\n{code}\n{self.delim}\n" if "\n" in code or code == _MULTILINE else code + "\n"
34
+ try:
35
+ self.proc.stdin.write(msg.encode())
36
+ await self.proc.stdin.drain()
37
+ except Exception: return False, None # write failed before the worker accepted anything: safe to retry
38
+ self.busy = True
39
+ try:
40
+ if not (await self.proc.stdout.readline()): return False, None # "." ack
41
+ lines = []
42
+ while True:
43
+ line = (await self.proc.stdout.readline()).decode()
44
+ if not line: return True, None
45
+ if line.rstrip("\n") == self.delim: return True, "".join(lines).removesuffix("\n")
46
+ lines.append(line)
47
+ except asyncio.CancelledError:
48
+ self.desynced = True
49
+ raise
50
+ except Exception:
51
+ self.desynced = True # unexpected read failure (e.g. a line past the buffer limit): rebuild on the next call
52
+ return True, None
53
+ finally: self.busy = False
54
+
55
+
56
+ def _errbox(): return "<internal-error>\n" + traceback.format_exc() + "</internal-error>"
57
+
58
+
59
+ def _kill_worker(w):
60
+ "Reap the worker child so it can never outlive the supervisor (signal-handler safe: no await)"
61
+ p = w.proc
62
+ if p is not None and p.returncode is None:
63
+ try: os.kill(p.pid, signal.SIGKILL)
64
+ except OSError: pass
65
+
66
+
67
+ def _install_signal_guards(w):
68
+ "A stray group SIGINT must not fell the supervisor; SIGTERM/SIGHUP shut down cleanly, always taking the worker with us (never orphan it)"
69
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
70
+ def _shutdown(signum, frame):
71
+ _kill_worker(w)
72
+ os._exit(0)
73
+ for s in (signal.SIGTERM, signal.SIGHUP):
74
+ try: signal.signal(s, _shutdown)
75
+ except (ValueError, OSError): pass # platform lacks the signal, or not on the main thread
76
+ atexit.register(_kill_worker, w)
77
+
78
+
79
+ def main():
80
+ from mcp.server.fastmcp import FastMCP
81
+ mcp = FastMCP("clikernel")
82
+ w = _Worker()
83
+ _install_signal_guards(w)
84
+
85
+ @mcp.tool(structured_output=False)
86
+ async def execute(code:str # IPython-compatible code to run in the persistent session
87
+ )->str: # Rendered outputs (stdout, display data, last-expression result, errors)
88
+ "Run `code` in the persistent IPython session, keeping state across calls (imports, variables, monkeypatches, cached objects). If the kernel process has died since the last call, a fresh one is started automatically and the response notes that session state was lost."
89
+ try:
90
+ async with w.lock:
91
+ note = ""
92
+ if w.desynced: await w.kill()
93
+ if not w.alive():
94
+ if w.started: note = _DIED
95
+ await w.start()
96
+ acked, body = await w.run(code)
97
+ if body is None and not acked: # died before accepting the request: safe to retry on a fresh worker
98
+ note = _DIED
99
+ await w.start()
100
+ acked, body = await w.run(code)
101
+ if body is None:
102
+ return note + "<internal-error>\nkernel process died while executing this request; a fresh kernel will be started on the next call, with all session state lost\n</internal-error>"
103
+ return note + body
104
+ except Exception:
105
+ w.desynced = True
106
+ return _errbox()
107
+
108
+ @mcp.tool(structured_output=False)
109
+ async def restart()->str:
110
+ "Kill the kernel process and start a fresh one: new pid, `sys.modules` genuinely reset, all session state (imports, variables, monkeypatches, cached objects) discarded. Use for a clean slate, after rebuilding a native extension, or 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). Also works when `execute` is stuck: the stuck call returns an error and the kernel comes back fresh. After restarting, redo any imports/setup the task still needs."
111
+ try:
112
+ await w.kill()
113
+ async with w.lock: await w.start()
114
+ return "restarted"
115
+ except Exception: return _errbox()
116
+
117
+ @mcp.tool(structured_output=False)
118
+ async def interrupt()->str:
119
+ "Interrupt the code the kernel is currently running (SIGINT, i.e. KeyboardInterrupt): the in-flight `execute` call returns with a KeyboardInterrupt traceback, and session state survives. Prefer this over `restart` when a call is merely taking too long. Only meaningful while an `execute` call is running."
120
+ try:
121
+ if not (w.alive() and w.busy): return "nothing is running"
122
+ w.proc.send_signal(signal.SIGINT)
123
+ return "interrupt sent; the running `execute` call will return with a KeyboardInterrupt"
124
+ except Exception: return _errbox()
125
+
126
+ mcp.run()
127
+
128
+
129
+ 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 `interrupt` are self-documenting -- read each tool's own MCP description rather than looking for docs elsewhere. `restart` gives a genuinely fresh interpreter process (new pid, `sys.modules` reset); `interrupt` stops a too-long-running `execute` while keeping session state.
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 `restart` 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 `restart` tool, 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.4
4
4
  Summary: A tiny stdin/stdout IPython worker
5
5
  Author: clikernel contributors
6
6
  License: Apache-2.0
@@ -10,7 +10,9 @@ 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.4
14
+ Requires-Dist: fastcore
15
+ Requires-Dist: mcp
14
16
  Provides-Extra: dev
15
17
  Requires-Dist: fastship; extra == "dev"
16
18
  Requires-Dist: build; extra == "dev"
@@ -73,8 +75,30 @@ After execution, `clikernel` prints the acknowledgement, the rendered output, an
73
75
 
74
76
  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
77
 
78
+ ## Notebook magics
79
+
80
+ `clikernel` registers two line magics wrapping `execnb`'s `nbopen`/`nbrun`, for running cells from a notebook by cell id prefix:
81
+
82
+ ```text
83
+ %nbopen foo.ipynb
84
+ %nbrun ab12
85
+ %nbrun ab12 --above
86
+ %nbrun --all --exported
87
+ %nbrun ab12 --fname other.ipynb
88
+ ```
89
+
90
+ `%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.
91
+
76
92
  `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
93
 
94
+ ## Inspectors
95
+
96
+ `clikernel` can check each cell before it runs, to warn about or forbid certain code. On startup it loads inspectors from `$XDG_CONFIG_HOME/clikernel/inspectors.py` (usually `~/.config/clikernel/inspectors.py`). If that file is absent, nothing changes.
97
+
98
+ Each cell is transformed first (so IPython magics and `!` shell escapes parse), and its AST is passed to every inspector before the cell executes. An inspector returns a string to prepend a note to the cell's output, raises to block the cell (it does not run, and the exception is reported), or returns None to do nothing. Define a function `inspect(tree)` and/or a list `inspectors` of such functions in the file. A broken `inspectors.py` is reported on stderr and skipped, so it cannot stop the kernel starting.
99
+
100
+ See [`examples/inspectors.py`](examples/inspectors.py) for one that blocks `subprocess`, `os.system`/`os.popen`, and `!` escapes, steering the agent to the permission-checked Bash tool instead.
101
+
78
102
  ## Why The Protocol Is Odd
79
103
 
80
104
  `clikernel` is built for a client that reads stdout as tokens. Local echo is disabled when stdin is a TTY. The client already knows the code it sent, so echoing it back only makes the LLM read slow, expensive tokens that add no information.
@@ -105,3 +129,4 @@ ship-gh
105
129
  ship-pypi
106
130
  ship-bump # dev release always later than prod release
107
131
  ```
132
+
@@ -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,6 @@
1
- execnb
1
+ execnb>=0.2.4
2
+ fastcore
3
+ mcp
2
4
 
3
5
  [dev]
4
6
  fastship
@@ -16,7 +16,9 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  dependencies = [
19
- "execnb",
19
+ "execnb>=0.2.4",
20
+ "fastcore",
21
+ "mcp",
20
22
  ]
21
23
 
22
24
  [project.optional-dependencies]
@@ -27,11 +29,15 @@ dev = [
27
29
  "pytest",
28
30
  ]
29
31
 
32
+ [project.entry-points.pyskills]
33
+ clikernel = "clikernel.skill"
34
+
30
35
  [project.urls]
31
36
  Homepage = "https://github.com/AnswerDotAI/clikernel"
32
37
 
33
38
  [project.scripts]
34
39
  clikernel = "clikernel.cli:main"
40
+ clikernel-mcp = "clikernel.mcp:main"
35
41
 
36
42
  [tool.setuptools.dynamic]
37
43
  version = { attr = "clikernel.__version__" }