j-cli
j-cli — Jupyter CLI for LLM Agents
Overview
j-cli is a CLI tool that lets you operate Jupyter Lab servers. Use it to execute code in kernels, manage sessions, and write outputs back to notebooks. Always use --json (-j) flag when you need to parse the output programmatically.
One-time Claude Code hook install
Run this once per project to prevent Claude from falling back to jupyter nbconvert --execute (or papermill / runipy) instead of j-cli:
j-cli setup claude --local # writes .claude/settings.local.json (gitignored, this machine only)
# or:
j-cli setup claude --project # writes .claude/settings.json (committed, team-shared)
# or:
j-cli setup claude --user # writes ~/.claude/settings.json (global, all projects)
The command is idempotent — re-running updates the hook in place without duplicating it.
What the hooks install:
notebook-exec-guard(Bash, hard deny) — blocksjupyter nbconvert --execute,papermill,runipy, andipython <notebook>.ipynb. These tools bypass j-cli and lose kernel state.python-run-guard(Bash, soft deny) — fires when a command likepython foo.py,uv run python foo.py,pixi run python foo.py, or./foo.pytargets a.pyfile that has a paired.ipynbnext to it. The guard surfaces a "reconsider" message explaining that running the file as a script discards kernel state and py/ipynb pair sync. The agent is expected to usej-cli session+j-cli execinstead. Commands on ordinary scripts (no paired.ipynb) are never intercepted.pair-drift-guard(PreToolUse, Edit/Write) — detects drift that was already present before your edit (e.g. a human teammate edited the.ipynbin JupyterLab). Usesgit merge-file3-way merge (handles cell insertions, deletions, and non-overlapping edits); asks you to re-read the target file after auto-merge, or explains the conflict and what to inspect before picking a side..ipynbis by design gitignored;.pyhistory is the only merge baseline.pair-drift-guard-post(PostToolUse, Edit/Write) — after your own Edit/Write, silently syncs your change to the pair's other side whengit merge-fileproduces no conflicts; warns only when your edit collided with a pre-existing change on the paired side.notebook-edit-guard(PreToolUse, NotebookEdit) — hard-denies directNotebookEditcalls; always use the py:percent round-trip instead.
One-time Codex hook install
Run this once per project to prevent Codex from falling back to jupyter nbconvert --execute (or papermill / runipy) instead of j-cli:
j-cli setup codex # writes .codex/hooks.json (default)
# or:
j-cli setup codex --project # same as default
# or:
j-cli setup codex --user # writes ~/.codex/hooks.json (global, all projects)
The command is idempotent — re-running updates the hook in place without duplicating it.
Prerequisites: Codex hooks require [features]\ncodex_hooks = true in .codex/config.toml. setup codex checks for this and warns if missing.
What the hooks install:
notebook-exec-guard(Bash, hard deny) — blocksjupyter nbconvert --execute,papermill,runipy, andipython <notebook>.ipynb.python-run-guard(Bash, soft deny) — fires when a shell command targets a.pyfile that has a paired.ipynb.pair-drift-guard-pre(PreToolUse, apply_patch) — detects drift before anapply_patchedit touches a paired.pyfile.pair-drift-guard-post(PostToolUse, apply_patch) — afterapply_patch, silently syncs the other side of the pair when possible.
Note:
notebook-edit-guardis not installed for Codex because Codex has noNotebookEdittool; file edits go throughapply_patchinstead.
Installing the git pre-commit hook
Run once per repository to keep .py / .ipynb pairs in sync at commit time:
j-cli setup git # default --project scope
j-cli setup git --project # .githooks/pre-commit + core.hooksPath
j-cli setup git --local # .git/hooks/pre-commit (this clone only)
j-cli setup git --include 'src/*' # only watch .py files under src/
j-cli setup git --include 'a/*' --include 'b/*' # multiple globs (OR logic)
What the installer does:
- Writes a bash shim at the hook path that delegates to
j-cli _hooks pre-commit-pair-sync --project(default): stores the hook under.githooks/pre-commitand setsgit config --local core.hooksPath .githooks--local: writes directly to.git/hooks/pre-commit; does not touchcore.hooksPath- Injects a managed block into
.gitignoreso*.ipynbfiles are never accidentally committed:
# >>> jcli managed (git hooks) >>>
*.ipynb
# <<< jcli managed (git hooks) <<<
The installer is idempotent — re-running updates the hook shim and .gitignore block in place.
Hook behaviour at commit time:
| Situation | Result |
|---|---|
.ipynb staged |
Blocked — unstage it, commit only the .py pair |
| Pair in sync | Silently allowed |
| One side changed (auto-merge possible) | git merge-file 3-way merge; merged content written back; .py re-staged if updated |
| Both sides changed the same cell | Commit blocked — conflict markers printed; resolve manually |
.py not yet committed — no baseline + any drift |
Commit blocked — 2-way diff printed; pick a side first, then commit |
When a conflict or drift is detected, the hook prints a diff (3-way conflict markers or unified diff) and suggests:
j-cli convert ipynb-to-py <nb.ipynb> <nb.py> # take ipynb as truth
j-cli convert py-to-ipynb <nb.py> <nb.ipynb> # take py as truth
Starting the Jupyter server
Before connecting, check whether the server is already running:
j-cli healthcheck > /dev/null 2>&1 && echo "running" || echo "not running"
If the server is already running, skip to the Connection section.
If it is not running, launch it as a fully detached process so it survives after this session ends:
nohup bash -c "$(j-cli serve-cmd --serve-backend lab)" \
> /tmp/jupyter_$(date +%Y%m%d_%H%M%S)_$$.log 2>&1 & disown
How this works:
$(j-cli serve-cmd --serve-backend lab)— captures the launch command (token is never inlined; the output contains the literal$JCLI_JUPYTER_SERVER_TOKENreference)bash -c "..."— the inner bash expands$JCLI_JUPYTER_SERVER_TOKENfrom the environmentnohup … & disown— detaches the process from this session; it survives after Claude exits- Log file includes a timestamp and the launching shell's PID for easy identification
After launching, wait a moment and confirm the server is up:
j-cli healthcheck
--serve-backend must be one of lab, server, or notebook.
Prerequisites
Before using j-cli, check if it is installed:
command -v j-cli > /dev/null && echo "installed" || echo "not installed"
If not installed, install it with:
uv tool install jupyter-jcli
j-cli --version
Note: the PyPI package name is jupyter-jcli, the binary name is j-cli.
Connection
Before running any j-cli command, check if the environment variables are already set:
[ -n "$JCLI_JUPYTER_SERVER_URL" ] && echo "URL: set" || echo "URL: unset"
[ -n "$JCLI_JUPYTER_SERVER_TOKEN" ] && echo "TOKEN: set" || echo "TOKEN: unset"
- If both are set, proceed directly — do not re-export them.
- If either is unset, ask the user for the missing value(s), then export:
export JCLI_JUPYTER_SERVER_URL=http://localhost:8888
export JCLI_JUPYTER_SERVER_TOKEN=<token>
You can also pass them as flags per-command: -s <url> and -t <token>.
Workflow
A typical workflow follows these steps:
- Check connectivity — run
j-cli healthcheck; if it fails the server is not running — start it first (see Starting the Jupyter server above) - Detect kernel spec — if the user provides a
.pyor.ipynbfile, use the parser module to extract the kernel name:
Usefrom jupyter_jcli.parser import parse_file parsed = parse_file("analysis.py") # or "notebook.ipynb" print(parsed.kernel_name) # e.g. "ir", "python3", "julia-1.10"parsed.kernel_nameas the--kernelvalue when creating the session. If it'sNone, fall back topython3or ask the user. - Create a session — use the detected kernel spec (or fall back to
python3) - Execute code — run inline code or cells from files
- Clean up — kill the session when done
Step-by-step Example
# 1. Healthcheck
j-cli healthcheck
# Output: OK Jupyter server v2.14.2 0 kernel(s) running
# 2. Detect kernel spec from the file
python -c "from jupyter_jcli.parser import parse_file; print(parse_file('analysis.py').kernel_name)"
# Output: ir
# 3. Create a session with the detected kernel
j-cli -j session create --kernel ir --name analysis
# Output (JSON): {"session_id": "abc-123", "kernel_id": "def-456", "kernel_name": "ir"}
# 4. Execute inline code (use the session_id from step 3)
j-cli exec abc-123 --code "print(1 + 1)"
# 5. Execute cells from a notebook
j-cli exec abc-123 --file analysis.ipynb --cell 0:5
# 6. Execute from a py:percent file (outputs auto-written to paired .ipynb)
j-cli exec abc-123 --file analysis.py
# 7. Clean up
j-cli session kill abc-123
Commands Reference
healthcheck
Check server connectivity and running kernel count.
j-cli healthcheck
j-cli -j healthcheck
# JSON: {"status": "ok", "version": "2.14.2", "kernels_running": 1}
kernelspec list
List available kernel specifications on the server.
j-cli kernelspec list
j-cli -j kernelspec list
session create
Create a new session. Returns the session_id needed for all subsequent commands.
j-cli session create --kernel python3
j-cli session create --kernel python3 --name my-analysis
j-cli -j session create --kernel python3
# JSON: {"session_id": "...", "kernel_id": "...", "kernel_name": "python3"}
session list
List all active sessions with their kernel state. By default fetches a short variable preview for each idle kernel (VARS column).
j-cli session list # includes VARS column (default)
j-cli session list --no-vars # faster, skips variable fetch
j-cli session list --vars # force fetch even when >10 sessions
j-cli -j session list
# JSON: {"sessions": [{"session_id": "...", "kernel_id": "...", "kernel_name": "python3",
# "kernel_state": "idle", "name": "...",
# "vars_preview": {"names": ["x", "df"], "total": 2}}]}
A hint line in human output points at j-cli vars <SESSION_ID> for the full variable list.
session kill
Delete a session and shut down its kernel.
j-cli session kill <session_id>
kernel interrupt
Interrupt a running kernel (e.g., stuck execution).
j-cli kernel interrupt <session_id>
kernel restart
Restart a kernel (clears all state).
j-cli kernel restart <session_id>
vars
Inspect kernel variables. Use after exec to check what's defined and what values variables hold.
# List all global variables (NAME / TYPE / VALUE table)
j-cli vars <session_id>
j-cli -j vars <session_id>
# JSON: {"session_id": "...", "source": "dap", "variables": [{"name": "x", "type": "int", "value": "42", "variables_reference": 0}]}
# Inspect a single variable
j-cli vars <session_id> --name x
j-cli -j vars <session_id> --name x
# Rich inspection (MIME-typed data; DAP kernels only, e.g. ipykernel)
j-cli vars <session_id> --name df --rich
# Longer timeout (default 10s)
j-cli vars <session_id> --timeout 20
Source: "dap" when the kernel supports the Jupyter debug protocol (e.g. ipykernel); "fallback" when a shell-channel snippet is used instead.
Ordering caveat: variables appear in first-definition order (CPython insertion order). Re-assigning does NOT move a variable to the end. Do NOT infer "most recently modified" from position.
No mtime: the protocol provides no per-variable last-modified timestamp. If you need to know which cells ran, use exec to track state yourself or restart the kernel and re-run.
exec
Execute code in a kernel session. This is the most important command.
Inline code:
j-cli exec <session_id> --code "print('hello')"
j-cli exec <session_id> -c "import pandas as pd; df = pd.read_csv('data.csv'); df.describe()"
From a file:
# All code cells from a notebook (omit --cell to run everything)
j-cli exec <session_id> --file notebook.ipynb
# Single cell (0-indexed)
j-cli exec <session_id> --file notebook.ipynb --cell 3
# Multiple consecutive cells via range
j-cli exec <session_id> --file notebook.ipynb --cell 0:5 # cells 0,1,2,3,4
j-cli exec <session_id> --file notebook.ipynb --cell 3: # cell 3 to end
j-cli exec <session_id> --file notebook.ipynb --cell :3 # cells 0,1,2
# From py:percent file
j-cli exec <session_id> --file script.py --cell 0
Each cell in the range is executed sequentially. Outputs are reported per cell with --- cell N --- separators (or per-cell JSON objects with -j).
Timeout (default: 10s per cell; when set, it's a total budget shared across cells):
j-cli exec <session_id> --code "long_computation()" --timeout 600
JSON output (for parsing results programmatically):
j-cli -j exec <session_id> --code "print('hello')"
# JSON: {"status": "ok", "outputs": [{"type": "stream", "stream_name": "stdout", "text": "hello\n"}]}
j-cli -j exec <session_id> --file notebook.ipynb --cell 0:3
# JSON: {"status": "ok", "cells": [{"cell_index": 0, "outputs": [...], "execution_count": 1}, ...], "notebook_updated": "notebook.ipynb"}
Notebook Writeback
When executing from a file, j-cli automatically writes outputs back to the paired .ipynb:
notebook.ipynb→ outputs written back to itselfanalysis.py(py:percent) → outputs written toanalysis.ipynb; created automatically if it does not existanalysis.dummy.py(py:percent) → outputs written toanalysis.ipynb; created automatically if absentscript.py(plain, no# %%markers or front matter) → outputs printed to stdout only, no.ipynbcreated
A py:percent file is one that has at least one # %% cell marker or a # --- YAML front matter block. Plain scripts without these markers are not treated as notebooks.
This keeps notebooks in sync with their execution results and lets you create a new notebook pair in a single j-cli exec call — no separate j-cli convert py-to-ipynb step required.
Searching notebook content with ripgrep
Use rg with the --pre flag and the bundled preprocessor to search inside .ipynb files:
# Search all notebooks for a pattern
rg --pre skills/j-cli/scripts/rg_ipynb_preprocessor.py 'pattern' .
# Search only .ipynb files
rg --pre skills/j-cli/scripts/rg_ipynb_preprocessor.py -g '*.ipynb' 'pattern' .
# The preprocessor renders each notebook as plain text: cell sources and outputs
# Binary outputs (images, PDFs) are replaced with a size notice
The preprocessor is at skills/j-cli/scripts/rg_ipynb_preprocessor.py and has no
external dependencies.
Py:Percent Format
j-cli supports py:percent format — plain Python files with # %% cell markers:
# ---
# jupyter:
# kernelspec:
# name: python3
# ---
# %%
import matplotlib.pyplot as plt
import numpy as np
# %%
x = np.linspace(0, 10, 100)
plt.plot(x, np.sin(x))
plt.savefig("sine.png")
plt.show()
# %% [markdown]
# ## Results
# The plot above shows a sine wave.
Editing via py:percent round-trip
Never edit .ipynb files directly — use the py:percent round-trip to edit notebook
cells safely without losing outputs:
# 1. Convert notebook to py:percent (outputs are preserved in the .ipynb)
j-cli convert ipynb-to-py analysis.ipynb analysis.py
# 2. Edit analysis.py using normal text tools (Edit tool, etc.)
# Cell markers: # %% (code), # %% [markdown], # %% [raw]
# 3. Write edited sources back — outputs/metadata in the .ipynb are untouched
j-cli convert py-to-ipynb analysis.py analysis.ipynb
If a paired .py already exists (same stem), you can go directly to step 2 and then step 3.
The j-cli convert py-to-ipynb command detects whether the .ipynb already exists:
- Exists → source-only update (outputs, execution counts, metadata preserved)
- Does not exist → new notebook created from the py cells
Policy: The
NotebookEdittool is disabled by thenotebook-edit-guardhook installed viaj-cli setup claude. Always go through the py:percent round-trip instead.
Drift guards at a glance
.ipynb is gitignored by design — only .py history is the merge baseline.
| Who triggers | Hook | When | Meaning | Next step |
|---|---|---|---|---|
| Agent (pre-edit) | pair-drift-guard |
Pre Edit/Write | Drift already existed before your call | Read the message; if auto-merged, re-read the target file; if conflict, inspect and pick a side |
| Agent (post-edit) | pair-drift-guard-post |
Post Edit/Write | Your edit may have diverged the pair | If auto-synced: nothing to do. If warned: pick a side with j-cli convert |
| Agent | notebook-edit-guard |
Pre NotebookEdit | Hard deny; use py:percent round-trip | Follow the three-step convert workflow above |
Error Handling
Errors return structured error codes. In JSON mode:
{"status": "error", "code": "SESSION_NOT_FOUND", "message": "..."}
{"status": "error", "code": "EXECUTION_ERROR", "message": "..."}
{"status": "error", "code": "CONNECTION_FAILED", "message": "..."}
{"status": "error", "code": "PARSE_ERROR", "message": "..."}
Error codes: CONNECTION_FAILED, SESSION_NOT_FOUND, SESSION_CREATE_FAILED, KERNEL_NOT_FOUND, EXECUTION_ERROR, PARSE_ERROR.
All errors exit with code 1.
Tips for Agents
- Always use
-j(JSON mode) when you need to parse output — it gives structured, machine-readable results. - Save the
session_idfromsession create— you need it for every subsequent command. - Use
--cellto run specific cells instead of entire notebooks when debugging. - If execution hangs, use
kernel interruptfollowed by retry. - If kernel state is corrupted, use
kernel restart(this clears all variables). - Images in execution output are automatically extracted to temp files with paths included in the output.
- Clean up sessions with
session killwhen done to free server resources.