CLI tool for LLM agents to operate Jupyter Lab servers.
j-cli enables AI agents (and humans) to remotely control Jupyter servers — execute code in kernels, manage sessions, and write outputs back to notebooks, all from the command line.
# latest release
uv tool install jupyter-jcli
# latest dev version
uv tool install git+https://github.com/tttpob/jcli.git
# verify the installed CLI
j-cli --versionRequires Python 3.10+.
Note: the PyPI package name is jupyter-jcli, while the installed binary is j-cli. MCP support for Claude Code and Codex notebook-output integration is included in the default installation.
Use direnv so the env vars are loaded automatically whenever you enter the project directory:
# .envrc
export JCLI_JUPYTER_SERVER_URL=http://localhost:8888
export JCLI_JUPYTER_SERVER_TOKEN=your-tokendirenv allow# stdout is pipe-safe — the hint line goes to stderr
bash -c "$(j-cli serve-cmd --serve-backend lab)"This prints (and immediately executes) a command like:
jupyter lab --ServerApp.token="$JCLI_JUPYTER_SERVER_TOKEN" \
--ServerApp.ip=localhost --ServerApp.port=8888 --no-browser
The token value is never inlined; it is always referenced as $JCLI_JUPYTER_SERVER_TOKEN.
j-cli healthcheckInstall hooks for your coding agent so it redirects notebook edits through j-cli:
j-cli setup claude
# or
j-cli setup codex
# or
j-cli setup dsh
# or
j-cli setup opencodeInstall the git pre-commit hook to keep .py / .ipynb pairs in sync:
j-cli setup gitAfter a post-edit pair sync with a git baseline, the agent hook appends a cell
summary to its context. ~ marks edited cells, + marks inserted cells, and
- old:N records deleted baseline cells at their current insertion point.
If your notebooks live in a subdirectory, limit pair detection to that path
(avoids false positives elsewhere in the repo). --include can be repeated:
j-cli setup git --include "notebooks/*"
# or multiple directories
j-cli setup git --include "notebooks/*" --include "experiments/*"| Flag | Description |
|---|---|
-s, --server-url |
Jupyter server URL (env: JCLI_JUPYTER_SERVER_URL, default: http://localhost:8888) |
-t, --token |
Auth token (env: JCLI_JUPYTER_SERVER_TOKEN) |
-j, --json |
Output as JSON for programmatic use; exec --file streams JSON Lines |
--version |
Show version |
Check server connectivity and running kernel count.
j-cli healthcheckList available kernel specifications.
j-cli kernelspec listInspect kernel metadata declared by a py:percent file or notebook.
j-cli -j kernelspec inspect-file analysis.pyj-cli session create --kernel python3 --name my-session
j-cli session list
j-cli session kill <session_selector>JSON output from session create and session list includes both the stable full
session_id and the shortest unique session_selector accepted by subsequent
commands. Human output uses the same short selector.
j-cli kernel interrupt <session_selector>
j-cli kernel restart <session_selector>Inspect notebook cells without executing them. summary shows the complete source
for short cells. For longer Python cells, it extracts imports, definitions, writes,
and calls and includes a source preview. show prints complete source for a cell
or range. map returns the cell alignment for a paired .py / .ipynb,
including stable IDs, Python source line ranges, pair changes, and each side's
change relative to the latest pair baseline.
j-cli notebook summary analysis.py
j-cli notebook show analysis.py --cell 4
j-cli notebook show analysis.py --cell 3:7
j-cli -j notebook summary analysis.ipynb
j-cli -j notebook map analysis.pymap reports alignment as id, content, position, or null for an
unpaired cell. change, python_change, and notebook_change use equal,
edited, inserted, and deleted; baseline changes are null when no git or
sticky pair baseline exists.
Cell specs are 0-indexed and use the same half-open range syntax as exec.
Read output already saved in an .ipynb without executing a kernel:
# list physical outputs for cell 4
j-cli -j notebook outputs analysis.ipynb --cell 4
# read output 1, or request one exact MIME representation
j-cli -j notebook output analysis.ipynb --cell 4 --output 1
j-cli -j notebook output analysis.ipynb --cell 4 --output 1 --mime text/html
# a py:percent path maps to its paired notebook only when the match is reliable
j-cli -j notebook output analysis.py --cell 4 --output 1Cell and output indexes are physical, zero-based indexes in the supplied file and saved notebook. For .py, j-cli accepts only a unique stable cell ID or a unique non-conflicting (cell type, source) match; it never guesses by position or similarity. File-execution JSON keeps cell_index as the executed source-file index and reports notebook_cell_index when the actual saved .ipynb position differs.
These commands read saved state, which may be older than the current source. They do not execute, synchronize pairs, write baselines, create caches, or start a background process. HTML and SVG are returned as original source text, JSON remains structured, and only existing raster MIME data is returned as an image.
Inline code and plain Python files have no notebook writeback target. Short text-only results remain inline and do not create .j-cli. Rich output or more than 4,000 text characters is saved below the command's real current working directory:
<cwd>/.j-cli/outputs/<run-id>/manifest.json
The command reports this absolute path as output_manifest. Read it later with:
j-cli -j output show /absolute/path/to/manifest.json
j-cli -j output show /absolute/path/to/manifest.json --output 0
j-cli -j output show /absolute/path/to/manifest.json --output 0 --mime text/htmlPersisted output is working data, not a permanent archive. A new persisted run triggers opportunistic cleanup with a 7-day retention limit and a newest-50-run limit; exceeding either makes a complete managed run eligible. There is no cleanup daemon. Preview or override cleanup from the workspace whose .j-cli directory should be managed:
j-cli output clean --dry-run
j-cli output clean --days 7 --max-runs 50
# equivalent defaults/overrides for automatic and manual cleanup
export JCLI_OUTPUT_RETENTION_DAYS=7
export JCLI_OUTPUT_MAX_RUNS=50Cleanup retains unrecognized or uncertain entries. j-cli setup git adds **/.j-cli/ to its managed .gitignore block. See output migration for the change from temporary image paths and output protocol for MIME, paging, and transport details.
setup claude, setup codex, setup dsh, and setup opencode install all three components by default: the bundled j-cli skill, notebook guards (hook), and the notebook-output integration (tool). Every command defaults to local scope. Use repeatable --only skill|hook|tool for exact incremental operations; unspecified components are preserved, and --remove applies to the same selection (all three when --only is absent). Plugin and MCP details are implementation details of tool, not additional selectable components.
For hosts without a native local layer, local scope uses the normal project paths plus exact j-cli-managed entries in the closest config folder's .gitignore; it never ignores the whole host folder or changes user ignore lines. Switching a selected component to --project removes its managed ignore block. If a selected target is already Git-tracked, local setup fails before writing and tells you to untrack it explicitly; it never runs git rm.
Skills use the host's discovery paths, preferring the shared .agents/skills/j-cli project location when supported. Removing a skill at a shared path affects every host that discovers that path. Override only the skill root with --skill-dir PATH (the target becomes PATH/j-cli); this does not force-overwrite conflicts and does not relocate hook/tool configuration.
| Host | Project/local skill directory | User skill directory |
|---|---|---|
| Claude | .claude/skills/j-cli |
~/.claude/skills/j-cli |
| Codex | .agents/skills/j-cli |
$CODEX_HOME/skills/j-cli (default ~/.codex/skills/j-cli) |
| DSH | .agents/skills/j-cli |
$DSH_AGENTS_HOME/skills/j-cli (default ~/.agents/skills/j-cli) |
| OpenCode | .agents/skills/j-cli |
~/.agents/skills/j-cli |
The skill is bundled with the Python package and installs offline without Node, an external skill installer, or a source checkout. After upgrading j-cli, rerun setup <host> --only skill with the same scope and optional --skill-dir to update the installed copy. Without --force, existing unmanaged directories, symbolic links left by other installers, and modified managed files are not overwritten. Normal uninstall preserves extra user files.
Project/local installation checks the selected components in the host's known global discovery/configuration locations and warns if a matching global integration exists. It still installs at the requested scope, never modifies the global copy, and leaves loading precedence to the host. The check includes alternate supported skill directories, not just j-cli's preferred installation path; for example, OpenCode discovers global skills in .agents, .claude, and its own config directory. Warnings go to stderr so JSON stdout remains parseable.
Use --force to take over conflicting targets for the selected components:
j-cli setup dsh --force # replace selected targets at local scope
j-cli setup claude --only tool --force # take over the named MCP entry only
j-cli setup dsh --only skill --force # replace the complete target j-cli skill
j-cli setup dsh --user --only skill --force # explicitly target the global skillBack up local changes first. Forced skill installation replaces the entire target j-cli/ directory, including extra files. If that target is a symbolic link, it replaces the link itself without modifying the linked source. For hooks, MCP and plugins, force remains confined to j-cli's hook entries, the jcli-notebook-output server, or the dedicated jcli.js/jcli.ts plugin and its corresponding configuration row; unrelated user configuration is preserved. It does not bypass invalid configuration or the local-scope Git tracking check, and cannot be combined with --remove. Finding a global copy never expands the force operation to that copy.
DSH and OpenCode share one plugin file between hook and tool. Selecting one capability does not remove the other, including its local ignore rules. To make the entire plugin project-shared, select both with --project --only hook --only tool (or use --project for all components). User-written ignore rules are never removed; check them if project files remain ignored.
Install the bundled skill, Claude Code native hooks, and the jcli-notebook-output MCP tool.
j-cli setup claude # local skill + hook + tool (default)
j-cli setup claude --project # shared project components
j-cli setup claude --user # user-global components
j-cli setup claude --only skill # no Claude CLI or MCP prerequisite
j-cli setup claude --only hook --only tool # exact two-component update
j-cli setup claude --remove --only tool # preserve skill and hooks
j-cli setup claude --only skill --skill-dir ./agent-skillsThe install command is idempotent — re-running updates hooks in place without duplicating them. It uses the official claude mcp add command and rejects an existing jcli-notebook-output entry pointing to another command unless installation explicitly uses --force. The server exposes one read-only call, read_notebook_output, which lists a cell's saved outputs when output_index is omitted and reads one output when it is provided. Project and local installs explicitly allow the current project root; user installs defer root discovery to the MCP client's roots capability instead of binding the setup directory. A user-scoped server returns ROOTS_REQUIRED if the client provides no roots. --remove removes the selected managed components (skill, hooks, and the matching MCP entry by default), preserving unrelated user configuration and notebook output data. If the hook settings file becomes empty after removal it is deleted.
The notebook-output server is included in the default jupyter-jcli installation; no extra is needed.
What gets installed (5 hooks):
| Hook | Event | Trigger | Action |
|---|---|---|---|
notebook-exec-guard |
PreToolUse (Bash) | jupyter nbconvert --execute, papermill, runipy, ipython <.ipynb> |
Hard deny, redirect to j-cli |
python-run-guard |
PreToolUse (Bash) | Shell command targeting a .py with a paired .ipynb |
Soft deny, suggest a j-cli session |
pair-drift-guard |
PreToolUse (Edit/Write) | Edit targeting a paired .py / .ipynb |
Detect existing drift, auto-merge, deny stale edits |
pair-drift-guard-post |
PostToolUse (Edit/Write) | After an edit completes | Auto-sync the other side of the pair |
notebook-edit-guard |
PreToolUse (NotebookEdit) | Direct notebook edit | Hard deny, require the py:percent workflow |
Install a pre-commit hook shim that runs j-cli _hooks pre-commit-pair-sync and update .gitignore to exclude paired .ipynb files and workspace-local **/.j-cli/ output data.
j-cli setup git # default: .githooks/pre-commit + set core.hooksPath
j-cli setup git --local # .git/hooks/pre-commit (this clone only)
j-cli setup git --include "src/*.py" # only sync matching files
# remove the managed hook and gitignore block
j-cli setup git --remove
j-cli setup git --local --remove--remove deletes the hook only if it was written by j-cli, leaves core.hooksPath alone if it points to a non-j-cli directory, and removes the managed .gitignore block. Unrecognised hooks are skipped with a warning.
The project installer writes a shim at .githooks/pre-commit, sets the local
core.hooksPath, and adds a managed .gitignore block for *.ipynb and
**/.j-cli/. The local installer writes .git/hooks/pre-commit without
changing core.hooksPath. Re-running either form updates its managed content
without duplication.
| Commit-time situation | Result |
|---|---|
.ipynb staged |
Blocked; unstage it and commit only the .py pair |
| Pair in sync | Silently allowed |
| One side changed and auto-merge succeeds | Merge both sides and re-stage an updated .py |
| Both sides changed the same cell | Block with conflict markers for manual resolution |
New .py has no baseline and differs from its pair |
Block with a two-way diff until one side is selected |
Resolve a conflict by explicitly choosing the source of truth:
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 truthInstall the shared .agents skill, Codex native hooks, and the jcli-notebook-output MCP tool.
j-cli setup codex # local all; exact files are gitignored
j-cli setup codex --project # shared project files
j-cli setup codex --user # $CODEX_HOME (default ~/.codex)
j-cli setup codex --only skill # project .agents/skills/j-cli
j-cli setup codex --remove --only hook # preserve skill and MCP toolPrerequisites: Codex hooks require [features]\ncodex_hooks = true in .codex/config.toml. setup codex checks for this and warns if missing. See Codex hooks docs.
The install command is idempotent — re-running updates hooks in place without duplicating them. Codex's own MCP CLI writes the selected config.toml, preserving unrelated TOML content; an existing jcli-notebook-output entry with another command is rejected unless installation explicitly uses --force. The server exposes the same single read_notebook_output call as Claude setup. Project installs pass the current project as an explicit allowed root, while user installs defer to client-provided MCP roots and return ROOTS_REQUIRED if none are available. --remove removes the selected managed components (skill, hooks, and the matching MCP entry by default), preserving unrelated configuration and notebook output data. MCP support is included in the default jupyter-jcli installation.
What gets installed (4 hooks):
| Hook | Event | Trigger | Action |
|---|---|---|---|
notebook-exec-guard |
PreToolUse (Bash) | jupyter nbconvert --execute, papermill, runipy, ipython <.ipynb> |
Hard deny, redirect to j-cli |
python-run-guard |
PreToolUse (Bash) | Bash command targeting a .py with a paired .ipynb |
Soft deny, suggest j-cli session |
pair-drift-guard-pre |
PreToolUse (apply_patch) | apply_patch touching a paired .py / .ipynb |
Detect drift, auto-merge, deny stale edits |
pair-drift-guard-post |
PostToolUse (apply_patch) | After apply_patch completes |
Auto-sync the other side of the pair |
notebook-edit-guardis not installed for Codex — Codex has noNotebookEdittool; file edits go throughapply_patchinstead.
Install the self-contained native TypeScript adapter for DeepSeek Harness (DSH).
The adapter is packaged inside jupyter-jcli, has no official DSH hook-bridge
dependency, and invokes the installed j-cli guards through DSH's native shell
injection. It does not require tsc, tsx, npm, or a separate Node project.
Workspace installation still requires dsh-workspace-overlay to mount
.dsh/cordis.yml.
j-cli setup dsh # local skill + hook + tool
j-cli setup dsh --project # shared workspace files
j-cli setup dsh --proj # alias for --project
j-cli setup dsh --global # alias for --user
j-cli setup dsh --only hook # enable guards independently
j-cli setup dsh --remove --only tool # keep plugin while hooks remain| Scope | Flags | Skill | Cordis file | Native adapter |
|---|---|---|---|---|
| Workspace local | default, --local |
<cwd>/.agents/skills/j-cli |
<cwd>/.dsh/cordis.yml |
<cwd>/.dsh/plugins/jcli.ts |
| Workspace shared | --project, --proj |
<cwd>/.agents/skills/j-cli |
same workspace paths | same workspace path |
| User | --global, --user |
${DSH_AGENTS_HOME:-~/.agents}/skills/j-cli |
$DSH_HOME/cordis.patch.yml |
$DSH_HOME/plugins/jcli.ts |
$DSH_HOME defaults to ~/.dsh; $DSH_AGENTS_HOME defaults to ~/.agents.
Workspace paths use the canonical current working directory. The workspace module name is deliberately relative to the
.dsh composition file, so the whole workspace can be moved together. Global
and workspace scopes always use separate adapter paths.
The installer validates the packaged resource, every existing YAML/JSON input,
and TS ownership before writing. Adapter replacement is atomic and an existing
TS file without the j-cli managed header is overwritten only with --force; removal still requires ownership.
Re-running is idempotent; YAML comments, !!js tags, explicit document markers,
empty sequences, unrelated rows, and unrelated legacy settings are preserved.
When both scopes are present, setup warns because DSH could run both adapters.
Ensure the DSH runtime uses a Node release that supports direct TypeScript
modules (Node 24.19 is the tested runtime), and ensure its PATH resolves the
updated j-cli installation. No compiler or package manager is needed at
runtime. The native adapter also exposes the single read-only
read_notebook_output tool with the same list-or-read arguments as the
Claude/Codex MCP integration; it invokes the installed j-cli and needs no
separate MCP dependency installation.
Re-running setup dsh migrates a managed legacy bridge row in place to the
native row. An old .dsh/jcli-hooks.json (or the global file with the same name)
is cleaned only of j-cli-managed entries; user entries remain in place. If custom
entries remain,
setup warns that the native adapter does not execute those legacy hooks and they
must be configured separately. --remove also understands this old layout,
never deletes user configuration, and does not delete notebooks or saved output
data. See hook exit codes:
0 means allow/success, 2 means an explicit pre-hook refusal, and 1 means
parse, I/O, Git, synchronization, or baseline failure. Post-hook failures keep
the tool result and add a diagnostic.
Install the shared .agents skill and a self-contained OpenCode plugin. The plugin can enable guards and the notebook-output tool independently.
j-cli setup opencode # local skill + hook + tool
j-cli setup opencode --project # shared project components
j-cli setup opencode --user # user-global components
j-cli setup opencode --only tool # output tool without guards
j-cli setup opencode --remove --only hook # preserve tool and skillThe installer normally updates only files carrying the j-cli managed marker. --force allows replacement of an unrelated jcli.js with the selected capabilities, but removal still requires ownership. Avoid installing both project and user copies because OpenCode loads both plugin directories.
The plugin covers OpenCode's bash, edit, write, and apply_patch tools. It resolves bash paths against the tool's workdir, passes edits through the existing j-cli guards, converts deny decisions into tool errors, and appends post-edit sync notices to the tool output. It also exposes the single read-only read_notebook_output tool, using OpenCode's normal read permission check and the shared list-or-read contract; it needs no separate MCP dependency installation.
The plugin runs j-cli from PATH. Set JCLI_BIN=/absolute/path/to/j-cli before starting OpenCode when the executable is installed in another environment. Removing the plugin removes managed integration files only; it does not delete notebooks or saved output data.
The internal _hooks --platform option selects the hook input format, not
all supported integrations. It accepts claude (default), codex, and dsh;
unknown values are rejected before reading the payload. OpenCode converts
bash, edit, and write events to the default Claude format and
apply_patch events to the Codex format, so it needs no separate opencode value.
notebook-edit-guard accepts all three input formats and checks tool_name
for NotebookEdit in each. Only Claude setup installs this guard. Codex, DSH,
and OpenCode integrations protect notebook file edits through
pair-drift-guard-pre instead.
Print a copy-pasteable Jupyter launch command that references the token via an environment variable rather than inlining it.
# set env vars (token is never echoed to the terminal)
export JCLI_JUPYTER_SERVER_URL=http://localhost:8888
export JCLI_JUPYTER_SERVER_TOKEN=your-token
j-cli serve-cmd --serve-backend lab
# → jupyter lab --ServerApp.token="$JCLI_JUPYTER_SERVER_TOKEN" \
# --ServerApp.ip=localhost --ServerApp.port=8888 --no-browser
# override host / port / root dir
j-cli serve-cmd --serve-backend lab --ip 0.0.0.0 --port 9000 --root-dir /work
# remove --no-browser (useful for desktop Jupyter)
j-cli serve-cmd --serve-backend notebook --browser
# JSON output (for programmatic use)
j-cli -j serve-cmd --serve-backend serverThe hint line (# paste this into a shell …) is written to stderr so the command itself can be used safely in $() substitution. The token reference "$JCLI_JUPYTER_SERVER_TOKEN" is always a literal shell variable reference — the actual token value is never inlined.
--serve-backend must be one of lab, server, or notebook.
Inspect variables in a kernel session.
# list all variables (NAME / TYPE / VALUE table)
j-cli vars <session_selector>
# inspect a single variable
j-cli vars <session_selector> --name x
# rich inspection (MIME-typed data, DAP kernels only)
j-cli vars <session_selector> --name x --rich
# JSON output for programmatic use
j-cli -j vars <session_selector>
j-cli -j vars <session_selector> --name xSource: when the kernel advertises debugger support (kernel_info_reply.supported_features contains "debugger"), the DAP inspectVariables control-channel path is used (source="dap"). Otherwise a shell-channel code snippet is executed (source="fallback").
Ordering caveat: variables are returned in first-definition order (CPython dict insertion order). Re-assigning a variable does not move it to the end; only del x; x = … does. Do not infer recency from position in the list.
No mtime: the Jupyter debug protocol does not expose per-variable last-modified timestamps. No mtime or last_execution_count field is available in the protocol.
By default, session list fetches a short variable preview for each idle kernel:
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 sessionsHuman output shows the shortest unique session ID prefix, with at least three characters. Commands accept a session selector: a full ID, the displayed short ID, or an exact unique session name. If a selector matches multiple sessions, the command exits without choosing one. A hint line at the bottom points at j-cli vars <SESSION_SELECTOR> for the full variable list.
In JSON mode (-j), each session object includes the shortest unique
session_selector and gains a vars_preview key:
{"session_id": "...", "session_selector": "abc", "vars_preview": {"names": ["x", "df"], "total": 2}}Execute code in a kernel session. Supports inline code, py:percent files, and Jupyter notebooks.
# inline code
j-cli exec <session_selector> --code "import pandas as pd; df = pd.read_csv('data.csv'); df.head()"
# display every top-level expression in inline code
j-cli exec <session_selector> --code $'df.head()\ndf.describe()' --display-mode all
# execute from py:percent file
j-cli exec <session_selector> --file analysis.py
# display every top-level expression instead of only the last one
j-cli exec <session_selector> --file analysis.py --display-mode all
# execute specific cells from a notebook
j-cli exec <session_selector> --file notebook.ipynb --cell 0:3
# execute a single cell
j-cli exec <session_selector> --file notebook.ipynb --cell 5Cell spec formats (0-indexed):
| Spec | Meaning |
|---|---|
3 |
Cell 3 only |
3:7 |
Cells 3, 4, 5, 6 |
3: |
Cell 3 to end |
:5 |
Cells 0 through 4 |
Inline code and file execution default to --display-mode last_expr, matching VS Code notebook behavior by displaying only the final expression. Use --display-mode all to display every top-level table or figure expression, or --display-mode last_expr_or_assign to also display a final assignment. For file execution, each selected cell runs sequentially; j-cli prints and writes back its outputs before starting the next cell. If a cell fails, j-cli writes back its error output and stops before executing later cells. j-cli restores the kernel's previous display mode after execution.
Execution timeout: Without --timeout, j-cli gives each cell a 10-second deadline. An explicit --timeout sets one total budget for all selected cells. When the deadline expires during a cell, j-cli interrupts the remote execution, waits for the kernel to report idle, and returns TIMEOUT. The kernel process, session, and variables created before the interrupted cell remain available. If the interrupt request fails, j-cli returns INTERRUPT_FAILED; check session list --no-vars before deciding whether to interrupt or restart the kernel.
Human mode is intended for direct reading by people and agents. Use --json when a script needs structured output; j-cli --json exec --file ... streams one JSON object per completed cell to stdout. A successful run ends with a summary object. A failed run omits the summary and writes its structured error to stderr. Display summaries are bounded by a total budget and a per-entry limit; when entries are omitted, the response reports that omission. Treat the saved notebook or output_manifest, not the display summary, as the complete result.
Notebook writeback: When executing from a py:percent file (one with # %% cell markers or a # --- front matter block), each completed cell's outputs are automatically written back to the paired .ipynb before j-cli formats that cell's response. If later display formatting fails, the diagnostic states that the notebook output was already saved. If analysis.ipynb does not yet exist, j-cli creates it automatically before the first cell executes. Plain Python scripts without markers are executed normally without creating a notebook; their rich or oversized output uses output_manifest storage instead.
Convert baseline refresh: When j-cli convert syncs a canonical managed pair (foo.py ↔ foo.ipynb, or foo.dummy.py ↔ foo.ipynb) inside a git repo, it also refreshes the sticky pair baseline under refs/jcli/pair-sync/*. This lets later drift checks compare against the last successful pair sync instead of falling back to an older HEAD.
If you convert to a non-canonical output path such as foo.py -> custom.ipynb or nb.ipynb -> custom.py, j-cli treats that as an export/conversion only and does not refresh the sticky baseline.
If a hook appears to run but produces no visible effect (silent exit 0 with no
sync, no deny message), enable the per-hook debug log to capture stdin/stdout/stderr.
Edit .claude/settings.local.json and append --debug to the hook command you
want to inspect, e.g.:
"command": "j-cli _hooks pair-drift-guard-post --debug"
Trigger the hook, then inspect the log:
ls /tmp/jcli-$UID/
cat /tmp/jcli-$UID/pair-drift-guard-post-*.log | jq .
Each invocation writes one JSON file containing the incoming payload, outgoing
decision (if any), stderr, exit code, and any exception. Remove --debug when
done — log files accumulate in /tmp and are not rotated.
Override the log directory with JCLI_DEBUG_LOG_DIR=/path/to/dir if /tmp is
not writable or you want the logs elsewhere.
For OpenCode, inspect the OpenCode application log for entries with service
j-cli. The plugin logs subprocess startup failures, non-zero exits, stderr,
and malformed guard output while allowing the tool call to continue.
If refs/jcli/pair-sync/* accumulates over time, clean stale entries with:
j-cli _hooks gc-pair-sync-refs
j-cli _hooks gc-pair-sync-refs --dry-run
j-cli supports the py:percent format — plain Python files with cell markers:
# ---
# jupyter:
# kernelspec:
# name: python3
# ---
# %% id="imports"
import numpy as np
# %% id="summary"
x = np.random.randn(100)
print(x.mean())j-cli stores nbformat cell IDs on markers as id="...". Keep the ID when
editing an existing cell. Legacy markers without IDs remain supported; j-cli
uses content alignment for them.
Assign missing IDs in place before converting a mixed-ID file. When a paired notebook exists, the command reuses its aligned cell IDs:
j-cli convert assign-ids analysis.pyj-cli comments IPython magic commands in py:percent files so Python tools can
parse them, then restores the commands when syncing to .ipynb. Python-body
cell magics such as %%timeit and %%writefile keep their body as Python code;
other cell magics are commented through the end of the cell.
# install with test dependencies
uv sync --extra test
# run tests (requires a real Jupyter server, started automatically by fixtures)
uv run pytest -n 4 --dist loadscope -vMIT