claude-nbexec 0.1.0__py3-none-any.whl

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,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-nbexec
3
+ Version: 0.1.0
4
+ Summary: CLI daemon that proxies code execution to remote Jupyter kernels with local notebook logging
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: jupyter-kernel-client>=0.3
8
+ Requires-Dist: nbformat>=5.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # nbexec
12
+
13
+ A CLI tool that lets AI agents (like Claude Code) execute code on remote Jupyter kernels. All executed code and outputs are logged to a local `.ipynb` notebook file for human review.
14
+
15
+ ## Why
16
+
17
+ When an AI agent needs to run code on a remote compute environment — a PySpark cluster, a GPU machine, a data warehouse notebook server — there's no simple way to do it interactively. The agent can't open a Jupyter UI. It needs to send code, get results, and move on.
18
+
19
+ nbexec bridges this gap. The agent calls `nbexec exec --code "..."` and gets text output on stdout. It can also run an existing `.ipynb` notebook on the same kernel with `nbexec exec --file ./analysis.ipynb` — all code cells execute sequentially, and variables persist across all exec calls in the session. Behind the scenes, a daemon holds a persistent WebSocket connection to the remote Jupyter kernel, and every cell + output is recorded in a local `.ipynb` file that you can open in VS Code or Jupyter to see exactly what the agent did.
20
+
21
+ ## How it works
22
+
23
+ ```
24
+ Agent (Claude Code) nbexec daemon Remote Jupyter Server
25
+ ─────────────────── ───────────── ─────────────────────
26
+ (background process)
27
+
28
+ exec --code "..." ────────────► Unix socket request
29
+ append cell to .ipynb
30
+ send to kernel via WS ─────► kernel executes code
31
+ ◄─── results on iopub
32
+ write output to .ipynb
33
+ stdout: result ◄──────────── return output
34
+ ```
35
+
36
+ The daemon is a long-running background process that holds persistent WebSocket connections to one or more remote Jupyter servers. CLI commands (`exec`, `session create`, etc.) are thin clients that talk to the daemon over a Unix socket — each `exec` is a synchronous request/response.
37
+
38
+ This is the same protocol VS Code uses when you connect a local notebook to a remote Jupyter server. The notebook document stays local, only code strings are sent to the kernel. nbexec replicates this model for CLI/agent use, using [jupyter-kernel-client](https://github.com/datalayer/jupyter-kernel-client) to manage the kernel connection.
39
+
40
+ ## Why a CLI and not an MCP server or raw HTTP
41
+
42
+ **Agents don't think in cells.** Existing Jupyter MCP servers expose notebook operations — create cell, edit cell, move cell, run cell. But an agent executing code on a remote kernel doesn't care about cells. It just wants to send code and get results. It doesn't need to edit cell 5 or reorder cells — if something went wrong, it sends corrected code as the next execution. nbexec matches this model: send code, get output, move on. The notebook is just a side effect for human review, not something the agent manages.
43
+
44
+ **Clean context.** An MCP server's tool definitions live in the agent's prompt at all times. nbexec adds nothing to the prompt until the agent actually needs it — the skill loads on demand, and `--help` is only fetched when invoked.
45
+
46
+ **Full visibility.** Everything inside an MCP server is opaque to the agent — it can only call the tools that are exposed. With a CLI, the agent has access to the source code, can inspect how things work, and can understand or work around issues on its own.
47
+
48
+ **Persistent connections without agent coupling.** The daemon runs as a separate process, managing WebSocket connections and kernel sessions independently. The agent doesn't need to hold connections or re-establish them between calls. Sessions survive across multiple agent conversations. An MCP server's lifecycle is tied to the agent process that started it.
49
+
50
+ **Fewer tokens than raw HTTP.** The agent could call the Jupyter REST API directly via curl, but that means generating verbose HTTP requests for every cell execution, manually managing XSRF tokens, parsing WebSocket message framing, and tracking kernel/session IDs. A single `nbexec exec --session spark --code "..."` replaces all of that. Less generated tokens, simpler logic, same result.
51
+
52
+ **Self-documenting from the CLI.** The agent runs `nbexec --help` and gets everything it needs — commands, options, examples, workflow patterns. No need to embed documentation in MCP tool descriptions or maintain it in two places.
53
+
54
+ ## Inspiration
55
+
56
+ The architectural pattern — a long-lived daemon process, CLI-driven interaction, persistent state across calls, and a skill file for agent discovery — is inspired by [OpenClaw](https://github.com/openclaw/openclaw). nbexec applies the same intuition to a narrower problem: giving AI coding agents structured access to remote Jupyter kernels.
57
+
58
+ ## Installation
59
+
60
+ Requires Python 3.10+.
61
+
62
+ ```bash
63
+ uv tool install claude-nbexec
64
+ ```
65
+
66
+ Or with pip:
67
+
68
+ ```bash
69
+ pip install claude-nbexec
70
+ ```
71
+
72
+ ### Install the Claude Code skill
73
+
74
+ Clone the repo and run the skill installer:
75
+
76
+ ```bash
77
+ git clone https://github.com/anish749/claude-nbexec.git && cd claude-nbexec
78
+ ./install-skill.sh
79
+ ```
80
+
81
+ This installs a skill to `$CLAUDE_CONFIG_DIR/skills/nbexec/` that teaches Claude Code when and how to use nbexec.
82
+
83
+ ### Usage
84
+
85
+ All commands and options are documented in `nbexec --help`.
@@ -0,0 +1,21 @@
1
+ nbexec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ nbexec/paths.py,sha256=Zvx9wxDQjL3HNPuf61YHJdUfK3DmvoJoAytoRWmlQTw,362
3
+ nbexec/protocol.py,sha256=drrVlASCFd1hq7rro-CxhbUm9a66HNcG4_zkoN2_YvY,815
4
+ nbexec/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ nbexec/cli/client.py,sha256=Jf6oKcFogSu2NMoq0nEVRo0x_vhy0MOwhE3dVZj7l6I,1564
6
+ nbexec/cli/daemon_cmds.py,sha256=ZRfIlJEOUeh6kxlVNaLe2iXMvpvizJYjrqqdPA3zJ68,787
7
+ nbexec/cli/exec_cmd.py,sha256=Zz8tGtA2SjzcZJcc99LQScXzvx2OHtMwxZhtjnky8Ok,3161
8
+ nbexec/cli/interrupt_cmd.py,sha256=9l2jb7oSwSqhDou4qUNyayPdOC_lslTS9MP5sGioGpg,392
9
+ nbexec/cli/main.py,sha256=ySmjJmf_9tbwKWHtw2-afB6fdPgsA26BQonjEHqrkco,6416
10
+ nbexec/cli/session_cmds.py,sha256=LXS6jYza0dst5VkV7Wzwebx5a3vkTK6WThevYwA8QWQ,1860
11
+ nbexec/daemon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ nbexec/daemon/process.py,sha256=zHGksZlPnc0wxF_Cmn8RQdU5m1U8BjlwunNfwM67Rb0,3005
13
+ nbexec/daemon/server.py,sha256=VH2ZrsS4uikdKeSTZYYuq5Vxa1UnUn4C2ZrOkixEkKI,4601
14
+ nbexec/daemon/state.py,sha256=lOB6p1-0WB2bYzcZ8oSOBD1syfrLtJsMu9-_2OGM-iA,1583
15
+ nbexec/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ nbexec/session/manager.py,sha256=kYB-9CwRPCB-SA3kJy3mAlYdq7ujZYyAzubivSq4tik,5238
17
+ nbexec/session/notebook.py,sha256=-KLUJtVjDfMPG__eSTeJ-6Ext37N3WACp0InlsgH42I,2798
18
+ claude_nbexec-0.1.0.dist-info/METADATA,sha256=-BCO3MRdRp03ki_vswAqzFlX-BrL8Ogyc3Q_vhzmV2Q,5703
19
+ claude_nbexec-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
+ claude_nbexec-0.1.0.dist-info/entry_points.txt,sha256=MODFLALZOKEnYXZN2W8TmkG27NsTeTki5U8JX8fF9UY,47
21
+ claude_nbexec-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nbexec = nbexec.cli.main:cli
nbexec/__init__.py ADDED
File without changes
nbexec/cli/__init__.py ADDED
File without changes
nbexec/cli/client.py ADDED
@@ -0,0 +1,51 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+
5
+ from nbexec import protocol as proto
6
+ from nbexec.paths import socket_path
7
+
8
+
9
+ def send_to_daemon(method: str, params: dict | None = None, timeout: float | None = None) -> dict:
10
+ """Send a request to the daemon and return the response.
11
+
12
+ Raises SystemExit on connection errors so CLI commands fail cleanly.
13
+ """
14
+ sock = socket_path()
15
+ if not sock.exists():
16
+ print("Error: daemon is not running. Start it with: nbexec daemon start", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+ request = proto.make_request(method, params)
20
+ response = asyncio.run(_send(str(sock), request, timeout))
21
+
22
+ if not response.get("ok"):
23
+ print(f"Error: {response.get('error', 'unknown error')}", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+ return response["result"]
27
+
28
+
29
+ async def _send(sock_path: str, request: dict, timeout: float | None) -> dict:
30
+ try:
31
+ reader, writer = await asyncio.open_unix_connection(sock_path)
32
+ except (ConnectionRefusedError, FileNotFoundError):
33
+ print("Error: cannot connect to daemon. Is it running?", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ try:
37
+ writer.write(proto.encode(request))
38
+ await writer.drain()
39
+
40
+ line = await asyncio.wait_for(reader.readline(), timeout=timeout)
41
+ if not line:
42
+ print("Error: daemon closed connection", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ return proto.decode(line)
46
+ finally:
47
+ writer.close()
48
+ try:
49
+ await writer.wait_closed()
50
+ except Exception:
51
+ pass
@@ -0,0 +1,38 @@
1
+ import json
2
+ import click
3
+
4
+ from nbexec.daemon.process import start_daemon, stop_daemon, daemon_status
5
+
6
+
7
+ @click.group()
8
+ def daemon():
9
+ """Manage the nbexec daemon."""
10
+ pass
11
+
12
+
13
+ @daemon.command()
14
+ def start():
15
+ """Start the daemon in the background."""
16
+ if start_daemon():
17
+ click.echo("Daemon started.")
18
+ else:
19
+ click.echo("Daemon is already running.")
20
+
21
+
22
+ @daemon.command()
23
+ def stop():
24
+ """Stop the daemon."""
25
+ if stop_daemon():
26
+ click.echo("Daemon stopped.")
27
+ else:
28
+ click.echo("Daemon is not running.")
29
+
30
+
31
+ @daemon.command()
32
+ def status():
33
+ """Check daemon status."""
34
+ info = daemon_status()
35
+ if info["running"]:
36
+ click.echo(f"Running (pid={info['pid']}, socket={info['socket']})")
37
+ else:
38
+ click.echo("Not running.")
nbexec/cli/exec_cmd.py ADDED
@@ -0,0 +1,81 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import click
5
+ import nbformat
6
+
7
+ from nbexec import protocol as proto
8
+ from .client import send_to_daemon
9
+
10
+
11
+ def _exec_one(session_id, code, timeout):
12
+ """Execute a single code string and print output. Returns True on success."""
13
+ result = send_to_daemon(
14
+ proto.EXEC,
15
+ {"session_id": session_id, "code": code},
16
+ timeout=timeout,
17
+ )
18
+ text = result.get("text", "")
19
+ if text:
20
+ click.echo(text)
21
+ return result.get("status") != "error"
22
+
23
+
24
+ def _exec_notebook(session_id, notebook_path, timeout, from_cell, to_cell):
25
+ """Execute code cells from a .ipynb file sequentially."""
26
+ nb = nbformat.read(notebook_path, as_version=4)
27
+ code_cells = [c for c in nb.cells if c.cell_type == "code" and c.source.strip()]
28
+ total = len(code_cells)
29
+ if not code_cells:
30
+ click.echo("No code cells found in notebook", err=True)
31
+ sys.exit(1)
32
+
33
+ # --from-cell and --to-cell are 1-based, inclusive
34
+ start = (from_cell or 1) - 1
35
+ end = to_cell or total
36
+
37
+ if start >= total:
38
+ click.echo(f"--from-cell {from_cell} is beyond the {total} code cells in the notebook", err=True)
39
+ sys.exit(1)
40
+
41
+ selected = code_cells[start:end]
42
+ if not selected:
43
+ click.echo("No code cells in the specified range", err=True)
44
+ sys.exit(1)
45
+
46
+ for i, cell in enumerate(selected, start + 1):
47
+ click.echo(f"--- cell {i}/{total} ---", err=True)
48
+ ok = _exec_one(session_id, cell.source, timeout)
49
+ if not ok:
50
+ click.echo(f"Cell {i} failed, stopping.", err=True)
51
+ sys.exit(1)
52
+
53
+
54
+ @click.command()
55
+ @click.option("--session", "session_id", required=True, help="Session ID")
56
+ @click.option("--code", default=None, help="Code to execute")
57
+ @click.option("--file", "file_path", default=None, type=click.Path(exists=True), help="File containing code to execute (.py or .ipynb)")
58
+ @click.option("--timeout", default=None, type=float, help="Execution timeout in seconds (default: no timeout)")
59
+ @click.option("--from-cell", "from_cell", default=None, type=int, help="Start from this code cell (1-based, inclusive). Only for .ipynb files.")
60
+ @click.option("--to-cell", "to_cell", default=None, type=int, help="Stop at this code cell (1-based, inclusive). Only for .ipynb files.")
61
+ def exec_code(session_id, code, file_path, timeout, from_cell, to_cell):
62
+ """Execute code on a remote kernel."""
63
+ if (from_cell or to_cell) and (file_path is None or not file_path.endswith(".ipynb")):
64
+ click.echo("--from-cell and --to-cell can only be used with .ipynb files", err=True)
65
+ sys.exit(1)
66
+
67
+ if code is None and file_path is None:
68
+ # Read from stdin
69
+ if sys.stdin.isatty():
70
+ click.echo("Error: provide --code, --file, or pipe code via stdin", err=True)
71
+ sys.exit(1)
72
+ code = sys.stdin.read()
73
+ elif file_path is not None:
74
+ if file_path.endswith(".ipynb"):
75
+ _exec_notebook(session_id, file_path, timeout, from_cell, to_cell)
76
+ return
77
+ code = Path(file_path).read_text()
78
+
79
+ ok = _exec_one(session_id, code, timeout)
80
+ if not ok:
81
+ sys.exit(1)
@@ -0,0 +1,12 @@
1
+ import click
2
+
3
+ from nbexec import protocol as proto
4
+ from .client import send_to_daemon
5
+
6
+
7
+ @click.command()
8
+ @click.option("--session", "session_id", required=True, help="Session ID")
9
+ def interrupt(session_id):
10
+ """Interrupt a running execution on a remote kernel."""
11
+ send_to_daemon(proto.INTERRUPT, {"session_id": session_id})
12
+ click.echo(f"Interrupt sent to session '{session_id}'.")
nbexec/cli/main.py ADDED
@@ -0,0 +1,179 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from .daemon_cmds import daemon
6
+ from .session_cmds import session
7
+ from .exec_cmd import exec_code
8
+ from .interrupt_cmd import interrupt
9
+
10
+ USAGE = """\
11
+ nbexec — CLI daemon that proxies code execution to remote Jupyter kernels
12
+ with local notebook logging.
13
+
14
+ Designed for AI agent use. An agent sends code strings to a remote Jupyter
15
+ kernel (e.g. PySpark, Python) and gets text results back. All executed code
16
+ and outputs are recorded in a local .ipynb file for human inspection.
17
+
18
+ Architecture: a background daemon holds persistent WebSocket connections to
19
+ remote Jupyter servers. CLI commands talk to the daemon via a Unix socket.
20
+ Multiple sessions can run simultaneously, each connected to a different
21
+ (or the same) Jupyter server with its own kernel and notebook file.
22
+
23
+ Usage:
24
+ nbexec <command> [options]
25
+
26
+ Commands:
27
+
28
+ daemon start
29
+ Start the nbexec daemon in the background. The daemon listens on a
30
+ Unix socket at ~/.local/state/nbexec/nbexec.sock and manages all
31
+ kernel connections. Idempotent — prints a message if already running.
32
+
33
+ Example:
34
+ nbexec daemon start
35
+
36
+ daemon stop
37
+ Stop the daemon. Closes all sessions (shutting down remote kernels),
38
+ saves all notebooks, removes the socket and PID file.
39
+
40
+ Example:
41
+ nbexec daemon stop
42
+
43
+ daemon status
44
+ Check if the daemon is running. Prints PID and socket path if running.
45
+
46
+ Example:
47
+ nbexec daemon status
48
+
49
+ session create
50
+ Create a new session: connects to a remote Jupyter server, starts a
51
+ kernel, and creates a local .ipynb notebook file. The session ID is
52
+ used in subsequent exec and close commands.
53
+
54
+ Options:
55
+ --server URL Jupyter server URL (required, e.g. http://localhost:8888)
56
+ --token TOKEN Jupyter server auth token (required)
57
+ --notebook PATH Local path for the .ipynb log file (required)
58
+ --name NAME Session name/ID (optional, auto-generated if omitted)
59
+ --kernel NAME Kernel name (default: python3)
60
+
61
+ Examples:
62
+ nbexec session create --server http://localhost:8888 --token abc123 \\
63
+ --notebook ./spark_session.ipynb --name spark
64
+ nbexec session create --server http://localhost:9999 --token xyz \\
65
+ --notebook ./analysis.ipynb --name analysis
66
+
67
+ session list
68
+ List all active sessions. Shows session ID, server URL, cell count,
69
+ and notebook path for each session.
70
+
71
+ Example:
72
+ nbexec session list
73
+
74
+ session close
75
+ Close a session: shuts down the remote kernel, saves the notebook
76
+ file, and removes the session from the daemon.
77
+
78
+ Options:
79
+ --session ID Session ID to close (required)
80
+
81
+ Example:
82
+ nbexec session close --session spark
83
+
84
+ exec
85
+ Execute code on a remote kernel. Sends the code string to the kernel,
86
+ waits for completion, prints the output to stdout, and records the
87
+ cell and outputs in the session's notebook file.
88
+
89
+ Exit code is 0 on success, 1 on execution error.
90
+
91
+ Options:
92
+ --session ID Session ID (required)
93
+ --file PATH File containing code to execute (recommended).
94
+ Supports .ipynb files — all code cells in the
95
+ notebook are executed sequentially on the session's
96
+ kernel. Execution stops on the first cell error.
97
+ --code CODE Code string to execute (simple one-liners only)
98
+ --from-cell N Start from code cell N (1-based, inclusive, .ipynb only)
99
+ --to-cell N Stop at code cell N (1-based, inclusive, .ipynb only)
100
+
101
+ If neither --code nor --file is given, reads code from stdin.
102
+
103
+ Variables persist across exec calls within the same session (same
104
+ kernel). Each exec appends a new cell to the notebook.
105
+
106
+ interrupt
107
+ Interrupt a currently running execution on a remote kernel. Sends a
108
+ SIGINT to the kernel process, which will cause most Python code to
109
+ raise a KeyboardInterrupt. Use this to cancel long-running cells.
110
+
111
+ Options:
112
+ --session ID Session ID (required)
113
+
114
+ Example:
115
+ nbexec interrupt --session spark
116
+
117
+ IMPORTANT — how to send code:
118
+
119
+ Prefer --file for anything beyond a trivial one-liner. Write the
120
+ code to a temporary file first, then pass the path. This avoids
121
+ bash escaping issues with quotes, newlines, and special characters
122
+ that are common in Python/SQL code.
123
+
124
+ Use --code only for simple single-line expressions like:
125
+ nbexec exec --session spark --code "df.show()"
126
+ nbexec exec --session spark --code "print(x)"
127
+
128
+ For multiline code, write to a file first, then use --file:
129
+ nbexec exec --session spark --file /tmp/cell.py
130
+
131
+ To run an existing .ipynb notebook (e.g. shared setup, a saved
132
+ analysis, or a notebook the user points you to), pass it to --file.
133
+ All code cells run sequentially on the session's kernel, and variables
134
+ persist in both directions:
135
+ nbexec exec --session spark --file ./analysis.ipynb
136
+
137
+ Use --from-cell and --to-cell to run a subset of code cells. Both are
138
+ 1-based and inclusive. For a notebook with 10 code cells:
139
+ --from-cell 5 runs cells 5, 6, 7, 8, 9, 10
140
+ --to-cell 3 runs cells 1, 2, 3
141
+ --from-cell 3 --to-cell 5 runs cells 3, 4, 5
142
+
143
+ Agent Workflow Examples:
144
+
145
+ Start a session, run a notebook, then explore interactively:
146
+ nbexec daemon start
147
+ nbexec session create --server http://localhost:8888 --token $TOKEN \\
148
+ --notebook ./session.ipynb --name spark
149
+ nbexec exec --session spark --file ./analysis.ipynb
150
+ nbexec exec --session spark --code "df.show()"
151
+ nbexec exec --session spark --file /tmp/query.py
152
+ nbexec session close --session spark
153
+ nbexec daemon stop
154
+
155
+ Inspect what was executed:
156
+ Open the .ipynb file in VS Code or Jupyter to see all cells and outputs.
157
+ The notebook is updated after every exec call.
158
+
159
+ Runtime:
160
+ PID file: ~/.local/state/nbexec/daemon.pid
161
+ Unix socket: ~/.local/state/nbexec/nbexec.sock
162
+ Log file: ~/.local/state/nbexec/daemon.log
163
+ """
164
+
165
+
166
+ class NbexecGroup(click.Group):
167
+ def format_help(self, ctx, formatter):
168
+ formatter.write(USAGE)
169
+
170
+
171
+ @click.group(cls=NbexecGroup)
172
+ def cli():
173
+ pass
174
+
175
+
176
+ cli.add_command(daemon)
177
+ cli.add_command(session)
178
+ cli.add_command(exec_code, name="exec")
179
+ cli.add_command(interrupt)
@@ -0,0 +1,53 @@
1
+ import click
2
+
3
+ from nbexec import protocol as proto
4
+ from .client import send_to_daemon
5
+
6
+
7
+ @click.group()
8
+ def session():
9
+ """Manage kernel sessions."""
10
+ pass
11
+
12
+
13
+ @session.command()
14
+ @click.option("--server", required=True, help="Jupyter server URL (e.g. http://localhost:8888)")
15
+ @click.option("--token", default="", help="Jupyter server token (optional if server has no auth)")
16
+ @click.option("--notebook", required=True, help="Path for the local .ipynb file")
17
+ @click.option("--name", default=None, help="Session name (auto-generated if omitted)")
18
+ @click.option("--kernel", "kernel_name", default="python3", help="Kernel name")
19
+ def create(server, token, notebook, name, kernel_name):
20
+ """Create a new session connected to a remote Jupyter server."""
21
+ result = send_to_daemon(proto.SESSION_CREATE, {
22
+ "server_url": server,
23
+ "token": token,
24
+ "notebook_path": notebook,
25
+ "name": name,
26
+ "kernel_name": kernel_name,
27
+ })
28
+ click.echo(f"Session created: {result['session_id']}")
29
+ click.echo(f" Name: {result['name']}")
30
+ click.echo(f" Server: {result['server_url']}")
31
+ click.echo(f" Notebook: {result['notebook_path']}")
32
+
33
+
34
+ @session.command("list")
35
+ def list_sessions():
36
+ """List active sessions."""
37
+ sessions = send_to_daemon(proto.SESSION_LIST)
38
+ if not sessions:
39
+ click.echo("No active sessions.")
40
+ return
41
+ for s in sessions:
42
+ click.echo(
43
+ f" {s['session_id']:<20s} {s['server_url']:<30s} "
44
+ f"cells={s['cell_count']:<4d} {s['notebook_path']}"
45
+ )
46
+
47
+
48
+ @session.command()
49
+ @click.option("--session", "session_id", required=True, help="Session ID to close")
50
+ def close(session_id):
51
+ """Close a session and its remote kernel."""
52
+ send_to_daemon(proto.SESSION_CLOSE, {"session_id": session_id})
53
+ click.echo(f"Session '{session_id}' closed.")
File without changes
@@ -0,0 +1,135 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import signal
5
+ import sys
6
+ import time
7
+
8
+ from nbexec.paths import pid_path, log_path, socket_path
9
+ from .server import DaemonServer
10
+
11
+
12
+ def _setup_logging():
13
+ lp = log_path()
14
+ logging.basicConfig(
15
+ filename=str(lp),
16
+ level=logging.INFO,
17
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
18
+ )
19
+
20
+
21
+ def _write_pid():
22
+ pid_path().write_text(str(os.getpid()))
23
+
24
+
25
+ def _remove_pid():
26
+ pid_path().unlink(missing_ok=True)
27
+
28
+
29
+ def daemonize() -> None:
30
+ """Double-fork to fully detach from the terminal."""
31
+ # First fork
32
+ pid = os.fork()
33
+ if pid > 0:
34
+ # Parent waits briefly for the daemon to create its PID file
35
+ for _ in range(20):
36
+ if pid_path().exists():
37
+ return
38
+ time.sleep(0.1)
39
+ return
40
+
41
+ # New session
42
+ os.setsid()
43
+
44
+ # Second fork
45
+ pid = os.fork()
46
+ if pid > 0:
47
+ os._exit(0)
48
+
49
+ # Redirect stdio
50
+ sys.stdin.close()
51
+ lp = log_path()
52
+ sys.stdout = open(lp, "a")
53
+ sys.stderr = sys.stdout
54
+
55
+ _setup_logging()
56
+ _write_pid()
57
+
58
+ def handle_sigterm(*_):
59
+ # The asyncio event loop will pick this up
60
+ raise SystemExit(0)
61
+
62
+ signal.signal(signal.SIGTERM, handle_sigterm)
63
+
64
+ try:
65
+ server = DaemonServer()
66
+ asyncio.run(server.run())
67
+ finally:
68
+ _remove_pid()
69
+ socket_path().unlink(missing_ok=True)
70
+ os._exit(0)
71
+
72
+
73
+ def start_daemon() -> bool:
74
+ """Start the daemon. Returns True if started, False if already running."""
75
+ if is_daemon_running():
76
+ return False
77
+ # Clean stale files
78
+ pid_path().unlink(missing_ok=True)
79
+ socket_path().unlink(missing_ok=True)
80
+ daemonize()
81
+ return True
82
+
83
+
84
+ def stop_daemon() -> bool:
85
+ """Stop the daemon via SIGTERM. Returns True if stopped."""
86
+ pp = pid_path()
87
+ if not pp.exists():
88
+ return False
89
+
90
+ pid = int(pp.read_text().strip())
91
+ try:
92
+ os.kill(pid, signal.SIGTERM)
93
+ except ProcessLookupError:
94
+ pp.unlink(missing_ok=True)
95
+ return False
96
+
97
+ # Wait for it to die
98
+ for _ in range(30):
99
+ try:
100
+ os.kill(pid, 0)
101
+ time.sleep(0.1)
102
+ except ProcessLookupError:
103
+ pp.unlink(missing_ok=True)
104
+ return True
105
+ return False
106
+
107
+
108
+ def is_daemon_running() -> bool:
109
+ pp = pid_path()
110
+ if not pp.exists():
111
+ return False
112
+ try:
113
+ pid = int(pp.read_text().strip())
114
+ os.kill(pid, 0)
115
+ return True
116
+ except (ProcessLookupError, ValueError):
117
+ pp.unlink(missing_ok=True)
118
+ return False
119
+
120
+
121
+ def daemon_status() -> dict:
122
+ pp = pid_path()
123
+ if not pp.exists():
124
+ return {"running": False}
125
+ try:
126
+ pid = int(pp.read_text().strip())
127
+ os.kill(pid, 0)
128
+ return {
129
+ "running": True,
130
+ "pid": pid,
131
+ "socket": str(socket_path()),
132
+ }
133
+ except (ProcessLookupError, ValueError):
134
+ pp.unlink(missing_ok=True)
135
+ return {"running": False}
@@ -0,0 +1,127 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ from nbexec import protocol as proto
7
+ from nbexec.paths import socket_path
8
+ from .state import DaemonState
9
+
10
+ logger = logging.getLogger("nbexec.daemon")
11
+
12
+
13
+ class DaemonServer:
14
+ def __init__(self):
15
+ self.state = DaemonState()
16
+ self.executor = ThreadPoolExecutor(max_workers=8)
17
+ self.shutdown_event = asyncio.Event()
18
+
19
+ async def handle_client(
20
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
21
+ ):
22
+ try:
23
+ while True:
24
+ line = await reader.readline()
25
+ if not line:
26
+ break
27
+ try:
28
+ request = proto.decode(line)
29
+ except json.JSONDecodeError:
30
+ writer.write(proto.encode(proto.make_error("?", "Invalid JSON")))
31
+ await writer.drain()
32
+ continue
33
+
34
+ req_id = request.get("id", "?")
35
+ method = request.get("method", "")
36
+ params = request.get("params", {})
37
+
38
+ response = await self.dispatch(req_id, method, params)
39
+ writer.write(proto.encode(response))
40
+ await writer.drain()
41
+ except asyncio.CancelledError:
42
+ pass
43
+ except Exception as e:
44
+ logger.exception("Error handling client: %s", e)
45
+ finally:
46
+ writer.close()
47
+ try:
48
+ await writer.wait_closed()
49
+ except Exception:
50
+ pass
51
+
52
+ async def dispatch(self, req_id: str, method: str, params: dict) -> dict:
53
+ loop = asyncio.get_event_loop()
54
+
55
+ try:
56
+ if method == proto.DAEMON_STOP:
57
+ self.shutdown_event.set()
58
+ return proto.make_response(req_id, {"message": "shutting down"})
59
+
60
+ elif method == proto.SESSION_CREATE:
61
+ session = await loop.run_in_executor(
62
+ self.executor,
63
+ lambda: self.state.create_session(
64
+ server_url=params["server_url"],
65
+ token=params["token"],
66
+ notebook_path=params["notebook_path"],
67
+ name=params.get("name"),
68
+ kernel_name=params.get("kernel_name", "python3"),
69
+ ),
70
+ )
71
+ return proto.make_response(req_id, session.to_info())
72
+
73
+ elif method == proto.SESSION_LIST:
74
+ return proto.make_response(req_id, self.state.list_sessions())
75
+
76
+ elif method == proto.SESSION_CLOSE:
77
+ await loop.run_in_executor(
78
+ self.executor,
79
+ lambda: self.state.close_session(params["session_id"]),
80
+ )
81
+ return proto.make_response(req_id, {"closed": params["session_id"]})
82
+
83
+ elif method == proto.EXEC:
84
+ session = self.state.get_session(params["session_id"])
85
+ result = await loop.run_in_executor(
86
+ self.executor,
87
+ lambda: session.execute(params["code"]),
88
+ )
89
+ return proto.make_response(req_id, result)
90
+
91
+ elif method == proto.INTERRUPT:
92
+ session = self.state.get_session(params["session_id"])
93
+ await loop.run_in_executor(
94
+ self.executor,
95
+ lambda: session.interrupt(),
96
+ )
97
+ return proto.make_response(req_id, {"interrupted": params["session_id"]})
98
+
99
+ else:
100
+ return proto.make_error(req_id, f"Unknown method: {method}")
101
+
102
+ except KeyError as e:
103
+ return proto.make_error(req_id, str(e))
104
+ except ValueError as e:
105
+ return proto.make_error(req_id, str(e))
106
+ except Exception as e:
107
+ logger.exception("Error in dispatch: %s", e)
108
+ return proto.make_error(req_id, f"{type(e).__name__}: {e}")
109
+
110
+ async def run(self) -> None:
111
+ sock = socket_path()
112
+ # Clean up stale socket
113
+ sock.unlink(missing_ok=True)
114
+
115
+ server = await asyncio.start_unix_server(self.handle_client, path=str(sock))
116
+ logger.info("Daemon listening on %s", sock)
117
+
118
+ # Wait for shutdown signal
119
+ await self.shutdown_event.wait()
120
+
121
+ logger.info("Shutting down...")
122
+ server.close()
123
+ await server.wait_closed()
124
+ self.state.close_all()
125
+ self.executor.shutdown(wait=False)
126
+ sock.unlink(missing_ok=True)
127
+ logger.info("Daemon stopped.")
nbexec/daemon/state.py ADDED
@@ -0,0 +1,54 @@
1
+ import secrets
2
+ from pathlib import Path
3
+
4
+ from nbexec.session.manager import Session
5
+
6
+
7
+ class DaemonState:
8
+ """Registry of active sessions."""
9
+
10
+ def __init__(self):
11
+ self.sessions: dict[str, Session] = {}
12
+
13
+ def create_session(
14
+ self,
15
+ server_url: str,
16
+ token: str,
17
+ notebook_path: str,
18
+ name: str | None = None,
19
+ kernel_name: str = "python3",
20
+ ) -> Session:
21
+ session_id = name or secrets.token_hex(4)
22
+ if session_id in self.sessions:
23
+ raise ValueError(f"Session '{session_id}' already exists")
24
+
25
+ session = Session(
26
+ session_id=session_id,
27
+ server_url=server_url,
28
+ token=token,
29
+ notebook_path=Path(notebook_path),
30
+ name=name,
31
+ kernel_name=kernel_name,
32
+ )
33
+ session.start()
34
+ self.sessions[session_id] = session
35
+ return session
36
+
37
+ def get_session(self, session_id: str) -> Session:
38
+ if session_id not in self.sessions:
39
+ raise KeyError(f"Session '{session_id}' not found")
40
+ return self.sessions[session_id]
41
+
42
+ def list_sessions(self) -> list[dict]:
43
+ return [s.to_info() for s in self.sessions.values()]
44
+
45
+ def close_session(self, session_id: str) -> None:
46
+ session = self.sessions.pop(session_id, None)
47
+ if session is None:
48
+ raise KeyError(f"Session '{session_id}' not found")
49
+ session.close()
50
+
51
+ def close_all(self) -> None:
52
+ for session in self.sessions.values():
53
+ session.close()
54
+ self.sessions.clear()
nbexec/paths.py ADDED
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def runtime_dir() -> Path:
5
+ d = Path.home() / ".local" / "state" / "nbexec"
6
+ d.mkdir(parents=True, exist_ok=True)
7
+ return d
8
+
9
+
10
+ def pid_path() -> Path:
11
+ return runtime_dir() / "daemon.pid"
12
+
13
+
14
+ def socket_path() -> Path:
15
+ return runtime_dir() / "nbexec.sock"
16
+
17
+
18
+ def log_path() -> Path:
19
+ return runtime_dir() / "daemon.log"
nbexec/protocol.py ADDED
@@ -0,0 +1,36 @@
1
+ import json
2
+ import uuid
3
+ from typing import Any
4
+
5
+
6
+ # Method names
7
+ DAEMON_STOP = "daemon.stop"
8
+ SESSION_CREATE = "session.create"
9
+ SESSION_LIST = "session.list"
10
+ SESSION_CLOSE = "session.close"
11
+ EXEC = "exec"
12
+ INTERRUPT = "interrupt"
13
+
14
+
15
+ def make_request(method: str, params: dict[str, Any] | None = None) -> dict:
16
+ return {
17
+ "id": uuid.uuid4().hex[:12],
18
+ "method": method,
19
+ "params": params or {},
20
+ }
21
+
22
+
23
+ def make_response(request_id: str, result: Any = None) -> dict:
24
+ return {"id": request_id, "ok": True, "result": result}
25
+
26
+
27
+ def make_error(request_id: str, error: str) -> dict:
28
+ return {"id": request_id, "ok": False, "error": error}
29
+
30
+
31
+ def encode(msg: dict) -> bytes:
32
+ return json.dumps(msg).encode("utf-8") + b"\n"
33
+
34
+
35
+ def decode(line: bytes) -> dict:
36
+ return json.loads(line.strip())
File without changes
@@ -0,0 +1,157 @@
1
+ from pathlib import Path
2
+ from datetime import datetime, timezone
3
+
4
+ import requests
5
+ from jupyter_kernel_client import KernelClient
6
+
7
+ from .notebook import NotebookWriter
8
+
9
+
10
+ class Session:
11
+ """A session owns a remote kernel connection and a local notebook file."""
12
+
13
+ def __init__(
14
+ self,
15
+ session_id: str,
16
+ server_url: str,
17
+ token: str,
18
+ notebook_path: Path,
19
+ name: str | None = None,
20
+ kernel_name: str = "python3",
21
+ ):
22
+ self.session_id = session_id
23
+ self.server_url = server_url.rstrip("/")
24
+ self.token = token
25
+ self.notebook_path = Path(notebook_path)
26
+ self.name = name or session_id
27
+ self.kernel_name = kernel_name
28
+ self.kernel: KernelClient | None = None
29
+ self.notebook: NotebookWriter | None = None
30
+ self.created_at = datetime.now(timezone.utc).isoformat()
31
+ self._execution_count = 0
32
+
33
+ def start(self) -> None:
34
+ self.notebook = NotebookWriter(self.notebook_path)
35
+
36
+ # Fetch XSRF cookie from the server (needed for POST requests)
37
+ headers = {}
38
+ xsrf_cookie = self._fetch_xsrf_cookie()
39
+ if xsrf_cookie:
40
+ headers["X-XSRFToken"] = xsrf_cookie
41
+ headers["Cookie"] = f"_xsrf={xsrf_cookie}"
42
+
43
+ self.kernel = KernelClient(
44
+ server_url=self.server_url,
45
+ token=self.token or None,
46
+ headers=headers,
47
+ )
48
+ self.kernel.start(name=self.kernel_name)
49
+
50
+ def _fetch_xsrf_cookie(self) -> str | None:
51
+ """Fetch XSRF token from the Jupyter server."""
52
+ try:
53
+ resp = requests.get(f"{self.server_url}/tree", timeout=10)
54
+ xsrf = resp.cookies.get("_xsrf")
55
+ return xsrf
56
+ except Exception:
57
+ return None
58
+
59
+ def execute(self, code: str) -> dict:
60
+ if self.kernel is None or self.notebook is None:
61
+ raise RuntimeError("Session not started")
62
+
63
+ cell_index = self.notebook.add_cell(code)
64
+ self._execution_count += 1
65
+
66
+ try:
67
+ reply = self.kernel.execute(code)
68
+ except Exception as e:
69
+ error_output = {
70
+ "output_type": "error",
71
+ "ename": type(e).__name__,
72
+ "evalue": str(e),
73
+ "traceback": [str(e)],
74
+ }
75
+ self.notebook.set_outputs(cell_index, [error_output])
76
+ self.notebook.set_execution_count(cell_index, self._execution_count)
77
+ self.notebook.flush()
78
+ return {
79
+ "status": "error",
80
+ "execution_count": self._execution_count,
81
+ "cell_index": cell_index,
82
+ "outputs": [error_output],
83
+ "text": str(e),
84
+ }
85
+
86
+ outputs = self._extract_outputs(reply)
87
+ exec_count = reply.get("execution_count", self._execution_count)
88
+ self.notebook.set_outputs(cell_index, outputs)
89
+ self.notebook.set_execution_count(cell_index, exec_count)
90
+ self.notebook.flush()
91
+
92
+ text = self._outputs_to_text(outputs)
93
+ status = reply.get("status", "ok")
94
+
95
+ return {
96
+ "status": status,
97
+ "execution_count": exec_count,
98
+ "cell_index": cell_index,
99
+ "outputs": outputs,
100
+ "text": text,
101
+ }
102
+
103
+ def interrupt(self) -> None:
104
+ if self.kernel is None:
105
+ raise RuntimeError("Session not started")
106
+ self.kernel.interrupt()
107
+
108
+ def close(self) -> None:
109
+ if self.kernel is not None:
110
+ try:
111
+ self.kernel.stop()
112
+ except Exception:
113
+ pass
114
+ self.kernel = None
115
+ if self.notebook is not None:
116
+ try:
117
+ self.notebook.flush()
118
+ except Exception:
119
+ pass
120
+
121
+ def to_info(self) -> dict:
122
+ return {
123
+ "session_id": self.session_id,
124
+ "name": self.name,
125
+ "server_url": self.server_url,
126
+ "notebook_path": str(self.notebook_path),
127
+ "cell_count": self.notebook.cell_count if self.notebook else 0,
128
+ "created_at": self.created_at,
129
+ }
130
+
131
+ @staticmethod
132
+ def _extract_outputs(reply: dict) -> list[dict]:
133
+ """Extract outputs from jupyter-kernel-client reply.
134
+
135
+ Reply format: {"execution_count": int, "status": str, "outputs": list[dict]}
136
+ Outputs follow nbformat structure.
137
+ """
138
+ return reply.get("outputs", [])
139
+
140
+ @staticmethod
141
+ def _outputs_to_text(outputs: list[dict]) -> str:
142
+ """Flatten outputs to plain text for CLI display."""
143
+ parts = []
144
+ for o in outputs:
145
+ otype = o.get("output_type", "")
146
+ if otype == "stream":
147
+ parts.append(o.get("text", ""))
148
+ elif otype == "error":
149
+ tb = o.get("traceback", [])
150
+ parts.append("\n".join(tb) if tb else o.get("evalue", ""))
151
+ elif otype in ("execute_result", "display_data"):
152
+ data = o.get("data", {})
153
+ if "text/plain" in data:
154
+ parts.append(data["text/plain"])
155
+ elif "text/html" in data:
156
+ parts.append(data["text/html"])
157
+ return "\n".join(parts)
@@ -0,0 +1,85 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import nbformat
6
+ from nbformat.v4 import new_code_cell, new_notebook
7
+
8
+
9
+ class NotebookWriter:
10
+ """Append-only .ipynb writer. Adds code cells and their outputs."""
11
+
12
+ def __init__(self, path: Path):
13
+ self.path = Path(path)
14
+ if self.path.exists():
15
+ with open(self.path) as f:
16
+ self.nb = nbformat.read(f, as_version=4)
17
+ else:
18
+ self.nb = new_notebook()
19
+ self.nb.metadata["kernelspec"] = {
20
+ "display_name": "Python 3",
21
+ "language": "python",
22
+ "name": "python3",
23
+ }
24
+ self.flush()
25
+
26
+ def add_cell(self, source: str) -> int:
27
+ cell = new_code_cell(source=source)
28
+ self.nb.cells.append(cell)
29
+ return len(self.nb.cells) - 1
30
+
31
+ def set_outputs(self, cell_index: int, outputs: list[dict]) -> None:
32
+ cell = self.nb.cells[cell_index]
33
+ cell.outputs = [self._normalize_output(o) for o in outputs]
34
+
35
+ def set_execution_count(self, cell_index: int, count: int | None) -> None:
36
+ if count is not None:
37
+ self.nb.cells[cell_index].execution_count = count
38
+
39
+ def flush(self) -> None:
40
+ """Atomic write: write to temp file then rename."""
41
+ dir_ = self.path.parent
42
+ dir_.mkdir(parents=True, exist_ok=True)
43
+ fd, tmp = tempfile.mkstemp(dir=dir_, suffix=".ipynb.tmp")
44
+ try:
45
+ with os.fdopen(fd, "w") as f:
46
+ nbformat.write(self.nb, f)
47
+ os.replace(tmp, self.path)
48
+ except BaseException:
49
+ try:
50
+ os.unlink(tmp)
51
+ except OSError:
52
+ pass
53
+ raise
54
+
55
+ @property
56
+ def cell_count(self) -> int:
57
+ return len(self.nb.cells)
58
+
59
+ @staticmethod
60
+ def _normalize_output(output: dict) -> nbformat.NotebookNode:
61
+ """Convert a raw output dict to an nbformat output node."""
62
+ otype = output.get("output_type", "execute_result")
63
+
64
+ if otype == "stream":
65
+ return nbformat.v4.new_output(
66
+ output_type="stream",
67
+ name=output.get("name", "stdout"),
68
+ text=output.get("text", ""),
69
+ )
70
+ elif otype == "error":
71
+ return nbformat.v4.new_output(
72
+ output_type="error",
73
+ ename=output.get("ename", ""),
74
+ evalue=output.get("evalue", ""),
75
+ traceback=output.get("traceback", []),
76
+ )
77
+ else:
78
+ # execute_result or display_data
79
+ data = output.get("data", {})
80
+ metadata = output.get("metadata", {})
81
+ return nbformat.v4.new_output(
82
+ output_type=otype,
83
+ data=data,
84
+ metadata=metadata,
85
+ )