jupyter-link 0.1.0 → 0.2.1
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.
- package/README.md +36 -36
- package/SKILL.md +180 -0
- package/oclif.manifest.json +84 -21
- package/package.json +4 -2
- package/skills.json +30 -0
- package/src/commands/contents/create.mjs +37 -0
- package/src/commands/open/kernel-channels.mjs +7 -1
- package/src/commands/run/cell.mjs +64 -0
- package/src/commands/sessions/create.mjs +26 -0
package/README.md
CHANGED
|
@@ -1,54 +1,52 @@
|
|
|
1
1
|
# jupyter-link
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [AgentSkill](https://agentskills.io) that lets AI agents execute code in running Jupyter kernels and persist outputs back to `.ipynb` notebooks.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Why
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Read/write notebooks via Jupyter Contents API (nbformat v4)
|
|
9
|
-
- Insert or update code cells with agent metadata (`metadata.agent`)
|
|
10
|
-
- Open persistent kernel WebSocket channels for execution
|
|
11
|
-
- Send `execute_request` and collect iopub/shell outputs
|
|
12
|
-
- Map outputs to nbformat v4 format (stream, execute_result, display_data, error)
|
|
13
|
-
- Persistent config — set URL and token once, use everywhere
|
|
7
|
+
AI agents like Claude Code or Codex are great at writing and executing code, but they work in a terminal — no charts, no rich tables, no inline visualizations. Jupyter has all of that, but it's a manual, interactive tool.
|
|
14
8
|
|
|
15
|
-
|
|
9
|
+
**jupyter-link bridges the two.** The agent writes code, executes it in your notebook's kernel, and persists the outputs — while you keep JupyterLab open as your live dashboard. Changes appear in real time. You see rendered DataFrames, plots, and errors exactly as Jupyter displays them, without copy-pasting anything.
|
|
16
10
|
|
|
17
|
-
-
|
|
18
|
-
- A running Jupyter Server (JupyterLab/Notebook)
|
|
11
|
+
The result: **the agent codes, Jupyter renders, you supervise.** You can jump in at any point — edit a cell, re-run something, add notes — and the agent picks up where you left off. True human-AI collaboration on notebooks, where each side uses the interface it's best at.
|
|
19
12
|
|
|
20
|
-
##
|
|
13
|
+
## Install as a Skill
|
|
21
14
|
|
|
22
|
-
### As a global CLI
|
|
23
15
|
```bash
|
|
24
|
-
|
|
16
|
+
npx skills add rarce/jupyter-link
|
|
25
17
|
```
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
echo '{}' | npx jupyter-link@0.1.0 check:env
|
|
30
|
-
```
|
|
19
|
+
Once installed, the agent uses `npx jupyter-link@0.1.0` to run commands. No global install required.
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
- Discover running Jupyter sessions and match by notebook path or name
|
|
24
|
+
- Read/write notebooks via Contents API (nbformat v4)
|
|
25
|
+
- Insert or update code cells with agent metadata
|
|
26
|
+
- Execute code in kernels via persistent WebSocket channels
|
|
27
|
+
- Collect outputs (stream, execute_result, display_data, error)
|
|
28
|
+
- Persist execution results and save notebooks
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Node.js 20+
|
|
33
|
+
- A running Jupyter Server (JupyterLab or Notebook)
|
|
36
34
|
|
|
37
35
|
## Quick Start
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
echo '{"url":"http://localhost:8888","token":"your-token"}' | npx jupyter-link@0.1.0 config:set
|
|
37
|
+
1. Start your Jupyter Server (JupyterLab or Notebook)
|
|
38
|
+
2. Tell your agent:
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
echo '{}' | npx jupyter-link@0.1.0 check:env
|
|
40
|
+
> Connect to my Jupyter Server at http://localhost:8888 with token `abc123`, then run the code `print("hello")` in `notebook.ipynb`
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
echo '{}' | npx jupyter-link@0.1.0 list:sessions
|
|
42
|
+
The agent will use the skill to configure the connection, open a kernel channel, execute the code, and persist the output to the notebook.
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
### Other things you can ask
|
|
45
|
+
|
|
46
|
+
- *"Show me the outputs of cells 4, 8 and 12 in my notebook"*
|
|
47
|
+
- *"Execute this data processing code in my notebook and save the results"*
|
|
48
|
+
- *"List all running Jupyter sessions"*
|
|
49
|
+
- *"Insert a new cell at the end of notebook.ipynb with this code: ..."*
|
|
52
50
|
|
|
53
51
|
## Commands
|
|
54
52
|
|
|
@@ -81,11 +79,13 @@ Priority: environment variables > config file > defaults.
|
|
|
81
79
|
| Config file | `url` | `token` | `port` |
|
|
82
80
|
| Default | `http://127.0.0.1:8888` | — | `32123` |
|
|
83
81
|
|
|
84
|
-
##
|
|
82
|
+
## Standalone CLI
|
|
83
|
+
|
|
84
|
+
You can also install and use it directly without the skills framework:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
npm
|
|
88
|
-
|
|
87
|
+
npm install -g jupyter-link
|
|
88
|
+
echo '{}' | jupyter-link check:env
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
## License
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jupyter-link
|
|
3
|
+
description: Execute code in running Jupyter kernels and persist outputs to the target notebook via Jupyter Server REST API and kernel WebSocket channels. Implements discovery of sessions, cell insert/update, execution, and output mapping (nbformat v4).
|
|
4
|
+
compatibility: Requires Node.js 20+ and npm
|
|
5
|
+
allowed-tools: Bash(npx jupyter-link:*)
|
|
6
|
+
metadata:
|
|
7
|
+
version: "0.2.1"
|
|
8
|
+
author: Roberto Arce
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## IMPORTANT: Always use the `jupyter-link` CLI via npx
|
|
12
|
+
|
|
13
|
+
**NEVER use Python, curl, or raw HTTP requests to interact with Jupyter Server.**
|
|
14
|
+
All operations MUST go through `npx jupyter-link@0.2.1`. Every command reads JSON from stdin and writes JSON to stdout.
|
|
15
|
+
|
|
16
|
+
## Commands Reference
|
|
17
|
+
|
|
18
|
+
### Configure connection (persistent — run once)
|
|
19
|
+
```bash
|
|
20
|
+
# Save URL and token to ~/.config/jupyter-link/config.json
|
|
21
|
+
echo '{"url":"http://localhost:8888","token":"your-token-here"}' | npx jupyter-link@0.2.1 config:set
|
|
22
|
+
|
|
23
|
+
# Show effective config and where each value comes from
|
|
24
|
+
echo '{}' | npx jupyter-link@0.2.1 config:get
|
|
25
|
+
```
|
|
26
|
+
After `config:set`, all subsequent commands use the saved config. No need to pass env vars.
|
|
27
|
+
Environment variables (`JUPYTER_URL`, `JUPYTER_TOKEN`) still override the config file if set.
|
|
28
|
+
|
|
29
|
+
### Check connectivity
|
|
30
|
+
```bash
|
|
31
|
+
echo '{}' | npx jupyter-link@0.2.1 check:env
|
|
32
|
+
```
|
|
33
|
+
Returns `{"ok":true|false, "sessions_ok":..., "contents_ok":...}`
|
|
34
|
+
|
|
35
|
+
### Create a notebook
|
|
36
|
+
```bash
|
|
37
|
+
# Create an empty Python3 notebook (generates nbformat v4 boilerplate)
|
|
38
|
+
echo '{"path":"notebooks/new.ipynb"}' | npx jupyter-link@0.2.1 contents:create
|
|
39
|
+
|
|
40
|
+
# Create with a specific kernel
|
|
41
|
+
echo '{"path":"notebooks/new.ipynb","kernel_name":"julia-1.9"}' | npx jupyter-link@0.2.1 contents:create
|
|
42
|
+
```
|
|
43
|
+
Returns `{"ok":true,"created":true|false,"path":"..."}`. If notebook already exists, returns `created:false` without overwriting.
|
|
44
|
+
|
|
45
|
+
### Create a session (start a kernel)
|
|
46
|
+
```bash
|
|
47
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 sessions:create
|
|
48
|
+
echo '{"path":"notebooks/my.ipynb","kernel_name":"python3"}' | npx jupyter-link@0.2.1 sessions:create
|
|
49
|
+
```
|
|
50
|
+
Returns the full Jupyter session object with `id`, `kernel.id`, `kernel.name`, etc. If a session already exists for the notebook, returns it without creating a duplicate.
|
|
51
|
+
|
|
52
|
+
### List sessions
|
|
53
|
+
```bash
|
|
54
|
+
echo '{}' | npx jupyter-link@0.2.1 list:sessions
|
|
55
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 list:sessions
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Read cells (preferred for inspection)
|
|
59
|
+
```bash
|
|
60
|
+
# Summary of all cells (index, type, source preview, has_outputs)
|
|
61
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 cell:read
|
|
62
|
+
|
|
63
|
+
# Read specific cells by index
|
|
64
|
+
echo '{"path":"notebooks/my.ipynb","cells":[4,8,10,12]}' | npx jupyter-link@0.2.1 cell:read
|
|
65
|
+
|
|
66
|
+
# Read a single cell
|
|
67
|
+
echo '{"path":"notebooks/my.ipynb","cell_id":4}' | npx jupyter-link@0.2.1 cell:read
|
|
68
|
+
|
|
69
|
+
# Read a range of cells (start inclusive, end exclusive)
|
|
70
|
+
echo '{"path":"notebooks/my.ipynb","range":[4,10]}' | npx jupyter-link@0.2.1 cell:read
|
|
71
|
+
|
|
72
|
+
# Control output truncation (default: 3000 chars per field)
|
|
73
|
+
echo '{"path":"notebooks/my.ipynb","cells":[4],"max_chars":5000}' | npx jupyter-link@0.2.1 cell:read
|
|
74
|
+
```
|
|
75
|
+
Returns `{"total_cells":N,"cells":[...]}` with source, outputs, execution_count, and agent metadata.
|
|
76
|
+
Binary outputs (images, PDFs) are replaced with size placeholders. Error tracebacks keep last 5 frames.
|
|
77
|
+
|
|
78
|
+
### Read notebook (full JSON)
|
|
79
|
+
```bash
|
|
80
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 contents:read
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Write notebook
|
|
84
|
+
```bash
|
|
85
|
+
echo '{"path":"notebooks/my.ipynb","nb_json":{...}}' | npx jupyter-link@0.2.1 contents:write
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Insert a code cell
|
|
89
|
+
```bash
|
|
90
|
+
echo '{"path":"notebooks/my.ipynb","code":"print(42)"}' | npx jupyter-link@0.2.1 cell:insert
|
|
91
|
+
echo '{"path":"notebooks/my.ipynb","code":"print(42)","index":0}' | npx jupyter-link@0.2.1 cell:insert
|
|
92
|
+
```
|
|
93
|
+
Returns `{"cell_id":N,"index":N}`. Defaults to appending at end.
|
|
94
|
+
|
|
95
|
+
### Update a cell
|
|
96
|
+
```bash
|
|
97
|
+
echo '{"path":"notebooks/my.ipynb","cell_id":3,"code":"x=1"}' | npx jupyter-link@0.2.1 cell:update
|
|
98
|
+
echo '{"path":"notebooks/my.ipynb","cell_id":3,"outputs":[...],"execution_count":5}' | npx jupyter-link@0.2.1 cell:update
|
|
99
|
+
```
|
|
100
|
+
If `cell_id` is omitted, updates the latest agent-managed cell.
|
|
101
|
+
|
|
102
|
+
### Open kernel channels (persistent WebSocket)
|
|
103
|
+
```bash
|
|
104
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 open:kernel-channels
|
|
105
|
+
echo '{"kernel_id":"..."}' | npx jupyter-link@0.2.1 open:kernel-channels
|
|
106
|
+
```
|
|
107
|
+
Returns `{"channel_ref":"ch-...","session_id":"..."}`. Auto-starts daemon if needed.
|
|
108
|
+
**Auto-creates session**: If no running session exists for the notebook, automatically creates one (defaults to `python3` kernel, override with `kernel_name`).
|
|
109
|
+
|
|
110
|
+
### Run cell (insert + execute + collect + update in one step)
|
|
111
|
+
```bash
|
|
112
|
+
echo '{"path":"notebooks/my.ipynb","channel_ref":"ch-...","code":"print(42)"}' | npx jupyter-link@0.2.1 run:cell
|
|
113
|
+
echo '{"path":"notebooks/my.ipynb","channel_ref":"ch-...","code":"import time; time.sleep(5)","timeout_s":30}' | npx jupyter-link@0.2.1 run:cell
|
|
114
|
+
```
|
|
115
|
+
Returns `{"cell_id":N,"status":"ok"|"error","execution_count":N,"outputs":[...]}`.
|
|
116
|
+
This is the **recommended** way to execute code — it handles the full pipeline: insert cell, execute on kernel, collect outputs, and update the cell with results.
|
|
117
|
+
|
|
118
|
+
### Execute code on a channel
|
|
119
|
+
```bash
|
|
120
|
+
echo '{"channel_ref":"ch-...","code":"print(123)"}' | npx jupyter-link@0.2.1 execute:code
|
|
121
|
+
```
|
|
122
|
+
Returns `{"parent_msg_id":"msg-..."}`.
|
|
123
|
+
|
|
124
|
+
### Collect execution outputs
|
|
125
|
+
```bash
|
|
126
|
+
echo '{"channel_ref":"ch-...","parent_msg_id":"msg-..."}' | npx jupyter-link@0.2.1 collect:outputs
|
|
127
|
+
echo '{"channel_ref":"ch-...","parent_msg_id":"msg-...","timeout_s":120}' | npx jupyter-link@0.2.1 collect:outputs
|
|
128
|
+
```
|
|
129
|
+
Returns `{"outputs":[...],"execution_count":N,"status":"ok"|"error"|"timeout"}`.
|
|
130
|
+
|
|
131
|
+
### Close channels
|
|
132
|
+
```bash
|
|
133
|
+
echo '{"channel_ref":"ch-..."}' | npx jupyter-link@0.2.1 close:channels
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Save notebook
|
|
137
|
+
```bash
|
|
138
|
+
echo '{"path":"notebooks/my.ipynb"}' | npx jupyter-link@0.2.1 save:notebook
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Typical workflow
|
|
142
|
+
|
|
143
|
+
### Recommended (using `run:cell`)
|
|
144
|
+
1. **Configure**: `echo '{"url":"...","token":"..."}' | npx jupyter-link@0.2.1 config:set`
|
|
145
|
+
2. **Check env**: `echo '{}' | npx jupyter-link@0.2.1 check:env`
|
|
146
|
+
3. **Create notebook** (if needed): `echo '{"path":"..."}' | npx jupyter-link@0.2.1 contents:create`
|
|
147
|
+
4. **Open channel**: `echo '{"path":"..."}' | npx jupyter-link@0.2.1 open:kernel-channels` → get `channel_ref` (auto-creates session if needed)
|
|
148
|
+
5. **Run cell** (repeat): `echo '{"path":"...","channel_ref":"...","code":"..."}' | npx jupyter-link@0.2.1 run:cell` → get `cell_id`, `status`, `outputs`
|
|
149
|
+
6. **Save**: `echo '{"path":"..."}' | npx jupyter-link@0.2.1 save:notebook`
|
|
150
|
+
7. **Close**: `echo '{"channel_ref":"..."}' | npx jupyter-link@0.2.1 close:channels`
|
|
151
|
+
|
|
152
|
+
### Granular (step-by-step control)
|
|
153
|
+
1. **Configure**: `echo '{"url":"...","token":"..."}' | npx jupyter-link@0.2.1 config:set`
|
|
154
|
+
2. **Check env**: `echo '{}' | npx jupyter-link@0.2.1 check:env`
|
|
155
|
+
3. **Create notebook** (if needed): `echo '{"path":"..."}' | npx jupyter-link@0.2.1 contents:create`
|
|
156
|
+
4. **Create session** (if needed): `echo '{"path":"..."}' | npx jupyter-link@0.2.1 sessions:create`
|
|
157
|
+
5. **Open channel**: `echo '{"path":"..."}' | npx jupyter-link@0.2.1 open:kernel-channels` → get `channel_ref`
|
|
158
|
+
6. **Insert cell**: `echo '{"path":"...","code":"..."}' | npx jupyter-link@0.2.1 cell:insert` → get `index`
|
|
159
|
+
7. **Execute**: `echo '{"channel_ref":"...","code":"..."}' | npx jupyter-link@0.2.1 execute:code` → get `parent_msg_id`
|
|
160
|
+
8. **Collect**: `echo '{"channel_ref":"...","parent_msg_id":"..."}' | npx jupyter-link@0.2.1 collect:outputs` → get outputs
|
|
161
|
+
9. **Update cell**: `echo '{"path":"...","cell_id":N,"outputs":[...],"execution_count":N}' | npx jupyter-link@0.2.1 cell:update`
|
|
162
|
+
10. **Save**: `echo '{"path":"..."}' | npx jupyter-link@0.2.1 save:notebook`
|
|
163
|
+
11. **Close**: `echo '{"channel_ref":"..."}' | npx jupyter-link@0.2.1 close:channels`
|
|
164
|
+
|
|
165
|
+
## Notes
|
|
166
|
+
|
|
167
|
+
- Inserts cells at end by default. Reuses latest agent cell (`metadata.agent.role="jupyter-driver"`) when `cell_id` is omitted in update.
|
|
168
|
+
- Kernel errors are surfaced as `error` outputs with traceback.
|
|
169
|
+
- Persistent channels are managed by a daemon on `127.0.0.1:${JUPYTER_LINK_PORT:-32123}`. Auto-starts on first use.
|
|
170
|
+
|
|
171
|
+
## Canonical parameter names
|
|
172
|
+
|
|
173
|
+
| Primary | Fallback | Used in |
|
|
174
|
+
|----------------|--------------|-------------------------------------|
|
|
175
|
+
| `path` | `notebook` | All notebook commands |
|
|
176
|
+
| `code` | `source` | insert, update, execute, run:cell |
|
|
177
|
+
| `channel_ref` | `ref` | execute, collect, close, run:cell |
|
|
178
|
+
| `parent_msg_id`| `parent_id` | collect |
|
|
179
|
+
| `nb_json` | `content` | write |
|
|
180
|
+
| `kernel_name` | `kernel` | sessions:create, contents:create, open:kernel-channels |
|
package/oclif.manifest.json
CHANGED
|
@@ -84,6 +84,27 @@
|
|
|
84
84
|
"env.mjs"
|
|
85
85
|
]
|
|
86
86
|
},
|
|
87
|
+
"collect:outputs": {
|
|
88
|
+
"aliases": [],
|
|
89
|
+
"args": {},
|
|
90
|
+
"description": "Wait for outputs/reply/idle for a parent_msg_id on a channel",
|
|
91
|
+
"flags": {},
|
|
92
|
+
"hasDynamicHelp": false,
|
|
93
|
+
"hiddenAliases": [],
|
|
94
|
+
"id": "collect:outputs",
|
|
95
|
+
"pluginAlias": "jupyter-link",
|
|
96
|
+
"pluginName": "jupyter-link",
|
|
97
|
+
"pluginType": "core",
|
|
98
|
+
"strict": true,
|
|
99
|
+
"enableJsonFlag": false,
|
|
100
|
+
"isESM": true,
|
|
101
|
+
"relativePath": [
|
|
102
|
+
"src",
|
|
103
|
+
"commands",
|
|
104
|
+
"collect",
|
|
105
|
+
"outputs.mjs"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
87
108
|
"close:channels": {
|
|
88
109
|
"aliases": [],
|
|
89
110
|
"args": {},
|
|
@@ -105,14 +126,14 @@
|
|
|
105
126
|
"channels.mjs"
|
|
106
127
|
]
|
|
107
128
|
},
|
|
108
|
-
"
|
|
129
|
+
"contents:create": {
|
|
109
130
|
"aliases": [],
|
|
110
131
|
"args": {},
|
|
111
|
-
"description": "
|
|
132
|
+
"description": "Create a new empty notebook with proper nbformat v4 boilerplate",
|
|
112
133
|
"flags": {},
|
|
113
134
|
"hasDynamicHelp": false,
|
|
114
135
|
"hiddenAliases": [],
|
|
115
|
-
"id": "
|
|
136
|
+
"id": "contents:create",
|
|
116
137
|
"pluginAlias": "jupyter-link",
|
|
117
138
|
"pluginName": "jupyter-link",
|
|
118
139
|
"pluginType": "core",
|
|
@@ -122,8 +143,8 @@
|
|
|
122
143
|
"relativePath": [
|
|
123
144
|
"src",
|
|
124
145
|
"commands",
|
|
125
|
-
"
|
|
126
|
-
"
|
|
146
|
+
"contents",
|
|
147
|
+
"create.mjs"
|
|
127
148
|
]
|
|
128
149
|
},
|
|
129
150
|
"contents:read": {
|
|
@@ -168,6 +189,27 @@
|
|
|
168
189
|
"write.mjs"
|
|
169
190
|
]
|
|
170
191
|
},
|
|
192
|
+
"list:sessions": {
|
|
193
|
+
"aliases": [],
|
|
194
|
+
"args": {},
|
|
195
|
+
"description": "List sessions and optionally filter by path or name",
|
|
196
|
+
"flags": {},
|
|
197
|
+
"hasDynamicHelp": false,
|
|
198
|
+
"hiddenAliases": [],
|
|
199
|
+
"id": "list:sessions",
|
|
200
|
+
"pluginAlias": "jupyter-link",
|
|
201
|
+
"pluginName": "jupyter-link",
|
|
202
|
+
"pluginType": "core",
|
|
203
|
+
"strict": true,
|
|
204
|
+
"enableJsonFlag": false,
|
|
205
|
+
"isESM": true,
|
|
206
|
+
"relativePath": [
|
|
207
|
+
"src",
|
|
208
|
+
"commands",
|
|
209
|
+
"list",
|
|
210
|
+
"sessions.mjs"
|
|
211
|
+
]
|
|
212
|
+
},
|
|
171
213
|
"execute:code": {
|
|
172
214
|
"aliases": [],
|
|
173
215
|
"args": {},
|
|
@@ -189,14 +231,14 @@
|
|
|
189
231
|
"code.mjs"
|
|
190
232
|
]
|
|
191
233
|
},
|
|
192
|
-
"
|
|
234
|
+
"run:cell": {
|
|
193
235
|
"aliases": [],
|
|
194
236
|
"args": {},
|
|
195
|
-
"description": "
|
|
237
|
+
"description": "Insert a cell, execute it, collect outputs, and update the cell in one step",
|
|
196
238
|
"flags": {},
|
|
197
239
|
"hasDynamicHelp": false,
|
|
198
240
|
"hiddenAliases": [],
|
|
199
|
-
"id": "
|
|
241
|
+
"id": "run:cell",
|
|
200
242
|
"pluginAlias": "jupyter-link",
|
|
201
243
|
"pluginName": "jupyter-link",
|
|
202
244
|
"pluginType": "core",
|
|
@@ -206,8 +248,29 @@
|
|
|
206
248
|
"relativePath": [
|
|
207
249
|
"src",
|
|
208
250
|
"commands",
|
|
209
|
-
"
|
|
210
|
-
"
|
|
251
|
+
"run",
|
|
252
|
+
"cell.mjs"
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
"open:kernel-channels": {
|
|
256
|
+
"aliases": [],
|
|
257
|
+
"args": {},
|
|
258
|
+
"description": "Open persistent kernel WS channels and return a channel_ref",
|
|
259
|
+
"flags": {},
|
|
260
|
+
"hasDynamicHelp": false,
|
|
261
|
+
"hiddenAliases": [],
|
|
262
|
+
"id": "open:kernel-channels",
|
|
263
|
+
"pluginAlias": "jupyter-link",
|
|
264
|
+
"pluginName": "jupyter-link",
|
|
265
|
+
"pluginType": "core",
|
|
266
|
+
"strict": true,
|
|
267
|
+
"enableJsonFlag": false,
|
|
268
|
+
"isESM": true,
|
|
269
|
+
"relativePath": [
|
|
270
|
+
"src",
|
|
271
|
+
"commands",
|
|
272
|
+
"open",
|
|
273
|
+
"kernel-channels.mjs"
|
|
211
274
|
]
|
|
212
275
|
},
|
|
213
276
|
"config:get": {
|
|
@@ -252,14 +315,14 @@
|
|
|
252
315
|
"set.mjs"
|
|
253
316
|
]
|
|
254
317
|
},
|
|
255
|
-
"
|
|
318
|
+
"save:notebook": {
|
|
256
319
|
"aliases": [],
|
|
257
320
|
"args": {},
|
|
258
|
-
"description": "
|
|
321
|
+
"description": "Save notebook (round-trip PUT)",
|
|
259
322
|
"flags": {},
|
|
260
323
|
"hasDynamicHelp": false,
|
|
261
324
|
"hiddenAliases": [],
|
|
262
|
-
"id": "
|
|
325
|
+
"id": "save:notebook",
|
|
263
326
|
"pluginAlias": "jupyter-link",
|
|
264
327
|
"pluginName": "jupyter-link",
|
|
265
328
|
"pluginType": "core",
|
|
@@ -269,18 +332,18 @@
|
|
|
269
332
|
"relativePath": [
|
|
270
333
|
"src",
|
|
271
334
|
"commands",
|
|
272
|
-
"
|
|
273
|
-
"
|
|
335
|
+
"save",
|
|
336
|
+
"notebook.mjs"
|
|
274
337
|
]
|
|
275
338
|
},
|
|
276
|
-
"
|
|
339
|
+
"sessions:create": {
|
|
277
340
|
"aliases": [],
|
|
278
341
|
"args": {},
|
|
279
|
-
"description": "
|
|
342
|
+
"description": "Create a Jupyter session (start a kernel) for a notebook",
|
|
280
343
|
"flags": {},
|
|
281
344
|
"hasDynamicHelp": false,
|
|
282
345
|
"hiddenAliases": [],
|
|
283
|
-
"id": "
|
|
346
|
+
"id": "sessions:create",
|
|
284
347
|
"pluginAlias": "jupyter-link",
|
|
285
348
|
"pluginName": "jupyter-link",
|
|
286
349
|
"pluginType": "core",
|
|
@@ -290,10 +353,10 @@
|
|
|
290
353
|
"relativePath": [
|
|
291
354
|
"src",
|
|
292
355
|
"commands",
|
|
293
|
-
"
|
|
294
|
-
"
|
|
356
|
+
"sessions",
|
|
357
|
+
"create.mjs"
|
|
295
358
|
]
|
|
296
359
|
}
|
|
297
360
|
},
|
|
298
|
-
"version": "0.1
|
|
361
|
+
"version": "0.2.1"
|
|
299
362
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyter-link",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI and AgentSkill to execute code in Jupyter kernels and persist outputs to notebooks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"bin",
|
|
28
28
|
"src",
|
|
29
29
|
"scripts",
|
|
30
|
-
"oclif.manifest.json"
|
|
30
|
+
"oclif.manifest.json",
|
|
31
|
+
"skills.json",
|
|
32
|
+
"SKILL.md"
|
|
31
33
|
],
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@oclif/core": "^3.26.3"
|
package/skills.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jupyter-link",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Execute code in Jupyter kernels and persist outputs to notebooks",
|
|
5
|
+
"using": "scripts",
|
|
6
|
+
"environment": {
|
|
7
|
+
"JUPYTER_URL": {"description": "Base URL, e.g. http://127.0.0.1:8888", "required": false},
|
|
8
|
+
"JUPYTER_TOKEN": {"description": "Server token if required", "required": false}
|
|
9
|
+
},
|
|
10
|
+
"tools": {
|
|
11
|
+
"config_set": { "description": "Save Jupyter connection settings (url, token, port) to persistent config file", "command": "npx jupyter-link@0.2.1 config:set", "stdin": "json", "stdout": "json" },
|
|
12
|
+
"config_get": { "description": "Show effective configuration with source (env/file/default) for each field", "command": "npx jupyter-link@0.2.1 config:get", "stdin": "json", "stdout": "json" },
|
|
13
|
+
"check_env": { "description": "Verify connectivity and basic Jupyter Server compatibility", "command": "npx jupyter-link@0.2.1 check:env", "stdin": "json", "stdout": "json" },
|
|
14
|
+
"list_sessions": { "description": "List sessions and optionally filter by path or name", "command": "npx jupyter-link@0.2.1 list:sessions", "stdin": "json", "stdout": "json" },
|
|
15
|
+
"read_notebook": { "description": "Read a notebook JSON via Contents API", "command": "npx jupyter-link@0.2.1 contents:read", "stdin": "json", "stdout": "json" },
|
|
16
|
+
"write_notebook": { "description": "Write notebook JSON via Contents API", "command": "npx jupyter-link@0.2.1 contents:write", "stdin": "json", "stdout": "json" },
|
|
17
|
+
"read_cell": { "description": "Read specific cells with source, outputs, and metadata. Supports single cell, list, range, or full summary", "command": "npx jupyter-link@0.2.1 cell:read", "stdin": "json", "stdout": "json" },
|
|
18
|
+
"insert_cell": { "description": "Insert a code cell with agent metadata", "command": "npx jupyter-link@0.2.1 cell:insert", "stdin": "json", "stdout": "json" },
|
|
19
|
+
"update_cell": { "description": "Update a code cell (source/outputs/execution_count/metadata)", "command": "npx jupyter-link@0.2.1 cell:update", "stdin": "json", "stdout": "json" },
|
|
20
|
+
"open_kernel_channels": { "description": "Open persistent kernel WS channels and return a channel_ref. Auto-creates session if none exists for the notebook", "command": "npx jupyter-link@0.2.1 open:kernel-channels", "stdin": "json", "stdout": "json" },
|
|
21
|
+
"execute_code": { "description": "Send execute_request on an open channel and return parent_msg_id", "command": "npx jupyter-link@0.2.1 execute:code", "stdin": "json", "stdout": "json" },
|
|
22
|
+
"collect_outputs": { "description": "Wait for outputs/reply/idle for a parent_msg_id on a channel", "command": "npx jupyter-link@0.2.1 collect:outputs", "stdin": "json", "stdout": "json" },
|
|
23
|
+
"close_channels": { "description": "Close a previously opened channel_ref", "command": "npx jupyter-link@0.2.1 close:channels", "stdin": "json", "stdout": "json" },
|
|
24
|
+
"save_notebook": { "description": "Save notebook (round-trip PUT)", "command": "npx jupyter-link@0.2.1 save:notebook", "stdin": "json", "stdout": "json" },
|
|
25
|
+
"create_notebook": { "description": "Create a new empty notebook via Contents API. Idempotent (returns existing if found). Accepts kernel_name, language, display_name", "command": "npx jupyter-link@0.2.1 contents:create", "stdin": "json", "stdout": "json" },
|
|
26
|
+
"create_session": { "description": "Create a Jupyter session (start kernel) for a notebook. Idempotent (returns existing session if found)", "command": "npx jupyter-link@0.2.1 sessions:create", "stdin": "json", "stdout": "json" },
|
|
27
|
+
"run_cell": { "description": "Insert, execute, and collect outputs for a cell in one compound command. Returns cell index, outputs, and execution status", "command": "npx jupyter-link@0.2.1 run:cell", "stdin": "json", "stdout": "json" },
|
|
28
|
+
"test_api": { "description": "Validate Jupyter Server REST API compatibility using the official OpenAPI schema", "command": "node scripts/test_api.mjs", "stdin": "json", "stdout": "json" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { getConfig, httpJson, readStdinJson, ok, assertNodeVersion } from '../../lib/common.mjs';
|
|
3
|
+
|
|
4
|
+
export default class ContentsCreate extends Command {
|
|
5
|
+
static description = 'Create a new empty notebook with proper nbformat v4 boilerplate';
|
|
6
|
+
async run() {
|
|
7
|
+
assertNodeVersion();
|
|
8
|
+
const input = await readStdinJson();
|
|
9
|
+
const path = input.path ?? input.notebook;
|
|
10
|
+
if (!path) throw new Error('path is required');
|
|
11
|
+
const kernelName = input.kernel_name ?? input.kernel ?? 'python3';
|
|
12
|
+
const displayName = input.display_name ?? kernelName.replace(/^\w/, c => c.toUpperCase()).replace(/(\d)/, ' $1');
|
|
13
|
+
const language = input.language ?? (kernelName.startsWith('python') ? 'python' : kernelName);
|
|
14
|
+
const { baseUrl, token } = getConfig();
|
|
15
|
+
|
|
16
|
+
// Check if notebook already exists
|
|
17
|
+
try {
|
|
18
|
+
const existing = await httpJson('GET', `${baseUrl}/api/contents/${encodeURIComponent(path)}`, token);
|
|
19
|
+
if (existing && existing.type === 'notebook') return ok({ ok: true, created: false, path });
|
|
20
|
+
} catch { /* 404 — does not exist, proceed to create */ }
|
|
21
|
+
|
|
22
|
+
const nb = {
|
|
23
|
+
nbformat: 4,
|
|
24
|
+
nbformat_minor: 5,
|
|
25
|
+
metadata: {
|
|
26
|
+
kernelspec: { display_name: displayName, language, name: kernelName },
|
|
27
|
+
language_info: { name: language },
|
|
28
|
+
},
|
|
29
|
+
cells: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
await httpJson('PUT', `${baseUrl}/api/contents/${encodeURIComponent(path)}`, token, {
|
|
33
|
+
type: 'notebook', format: 'json', content: nb,
|
|
34
|
+
});
|
|
35
|
+
ok({ ok: true, created: true, path });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -15,7 +15,13 @@ export default class OpenKernelChannels extends Command {
|
|
|
15
15
|
const sessions = await httpJson('GET', `${baseUrl}/api/sessions`, token);
|
|
16
16
|
let session = sessions.find(s => s.notebook && s.notebook.path === nbPath);
|
|
17
17
|
if (!session) { const name = nbPath.split('/').pop(); session = sessions.find(s => s.notebook && s.notebook.path && s.notebook.path.endsWith('/' + name)); }
|
|
18
|
-
if (!session)
|
|
18
|
+
if (!session) {
|
|
19
|
+
// Auto-create session if kernel_name is provided (or default to python3)
|
|
20
|
+
const kernelName = input.kernel_name ?? input.kernel ?? 'python3';
|
|
21
|
+
const name = nbPath.split('/').pop();
|
|
22
|
+
const body = { path: nbPath, name, type: 'notebook', kernel: { name: kernelName } };
|
|
23
|
+
session = await httpJson('POST', `${baseUrl}/api/sessions`, token, body);
|
|
24
|
+
}
|
|
19
25
|
kernelId = session.kernel && session.kernel.id;
|
|
20
26
|
if (!kernelId) throw new Error('Selected session has no kernel id');
|
|
21
27
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { getConfig, httpJson, readStdinJson, ok, assertNodeVersion, nowIso } from '../../lib/common.mjs';
|
|
3
|
+
import { ensureDaemon, request } from '../../lib/daemonClient.mjs';
|
|
4
|
+
|
|
5
|
+
export default class RunCell extends Command {
|
|
6
|
+
static description = 'Insert a cell, execute it, collect outputs, and update the cell in one step';
|
|
7
|
+
async run() {
|
|
8
|
+
assertNodeVersion();
|
|
9
|
+
const input = await readStdinJson();
|
|
10
|
+
const path = input.path ?? input.notebook;
|
|
11
|
+
const channelRef = input.channel_ref ?? input.ref;
|
|
12
|
+
const code = input.code ?? input.source ?? '';
|
|
13
|
+
const timeoutS = input.timeout_s ?? 60;
|
|
14
|
+
const index = input.index;
|
|
15
|
+
const position = input.position || 'end';
|
|
16
|
+
const agentMeta = input.metadata || {};
|
|
17
|
+
|
|
18
|
+
if (!path) throw new Error('path is required');
|
|
19
|
+
if (!channelRef) throw new Error('channel_ref is required (call open:kernel-channels first)');
|
|
20
|
+
if (!code) throw new Error('code is required');
|
|
21
|
+
|
|
22
|
+
const { baseUrl, token } = getConfig();
|
|
23
|
+
|
|
24
|
+
// 1. Insert cell into notebook
|
|
25
|
+
const nb = await httpJson('GET', `${baseUrl}/api/contents/${encodeURIComponent(path)}?content=1`, token);
|
|
26
|
+
if (nb.type !== 'notebook') throw new Error('Path is not a notebook');
|
|
27
|
+
const nbj = nb.content;
|
|
28
|
+
const cells = nbj.cells || (nbj.cells = []);
|
|
29
|
+
const meta = { role: 'jupyter-driver', created_at: nowIso(), auto_save: false, ...agentMeta };
|
|
30
|
+
const cell = { cell_type: 'code', metadata: { agent: meta }, source: code, outputs: [], execution_count: null };
|
|
31
|
+
let insertAt;
|
|
32
|
+
if (typeof index === 'number') insertAt = Math.max(0, Math.min(index, cells.length));
|
|
33
|
+
else insertAt = position === 'start' ? 0 : cells.length;
|
|
34
|
+
cells.splice(insertAt, 0, cell);
|
|
35
|
+
await httpJson('PUT', `${baseUrl}/api/contents/${encodeURIComponent(path)}`, token, { type: 'notebook', format: 'json', content: nbj });
|
|
36
|
+
const cellId = insertAt;
|
|
37
|
+
|
|
38
|
+
// 2. Execute code on kernel channel
|
|
39
|
+
await ensureDaemon();
|
|
40
|
+
const execOut = await request('exec', { channel_ref: channelRef, code, allow_stdin: false, stop_on_error: input.stop_on_error !== false });
|
|
41
|
+
if (execOut.error) throw new Error(execOut.error);
|
|
42
|
+
const parentMsgId = execOut.parent_msg_id;
|
|
43
|
+
|
|
44
|
+
// 3. Collect outputs
|
|
45
|
+
const collectOut = await request('collect', { channel_ref: channelRef, parent_msg_id: parentMsgId, timeout_s: timeoutS });
|
|
46
|
+
if (collectOut.error) throw new Error(collectOut.error);
|
|
47
|
+
const outputs = collectOut.outputs || [];
|
|
48
|
+
const executionCount = collectOut.execution_count;
|
|
49
|
+
const status = collectOut.status || 'unknown';
|
|
50
|
+
|
|
51
|
+
// 4. Update cell with outputs
|
|
52
|
+
const nb2 = await httpJson('GET', `${baseUrl}/api/contents/${encodeURIComponent(path)}?content=1`, token);
|
|
53
|
+
const nbj2 = nb2.content;
|
|
54
|
+
const cells2 = nbj2.cells || [];
|
|
55
|
+
if (cellId >= 0 && cellId < cells2.length) {
|
|
56
|
+
const targetCell = cells2[cellId];
|
|
57
|
+
targetCell.outputs = outputs;
|
|
58
|
+
if (executionCount !== undefined && executionCount !== null) targetCell.execution_count = executionCount;
|
|
59
|
+
await httpJson('PUT', `${baseUrl}/api/contents/${encodeURIComponent(path)}`, token, { type: 'notebook', format: 'json', content: nbj2 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ok({ cell_id: cellId, status, execution_count: executionCount ?? null, outputs });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { getConfig, httpJson, readStdinJson, ok, assertNodeVersion } from '../../lib/common.mjs';
|
|
3
|
+
|
|
4
|
+
export default class SessionsCreate extends Command {
|
|
5
|
+
static description = 'Create a Jupyter session (start a kernel) for a notebook';
|
|
6
|
+
async run() {
|
|
7
|
+
assertNodeVersion();
|
|
8
|
+
const input = await readStdinJson();
|
|
9
|
+
const path = input.path ?? input.notebook;
|
|
10
|
+
if (!path) throw new Error('path is required');
|
|
11
|
+
const kernelName = input.kernel_name ?? input.kernel ?? 'python3';
|
|
12
|
+
const type = input.type ?? 'notebook';
|
|
13
|
+
const { baseUrl, token } = getConfig();
|
|
14
|
+
|
|
15
|
+
// Check if a session already exists for this notebook
|
|
16
|
+
const sessions = await httpJson('GET', `${baseUrl}/api/sessions`, token);
|
|
17
|
+
const existing = sessions.find(s => s.notebook && s.notebook.path === path);
|
|
18
|
+
if (existing) return ok(existing);
|
|
19
|
+
|
|
20
|
+
// Create new session via Jupyter Sessions API
|
|
21
|
+
const name = path.split('/').pop();
|
|
22
|
+
const body = { path, name, type, kernel: { name: kernelName } };
|
|
23
|
+
const session = await httpJson('POST', `${baseUrl}/api/sessions`, token, body);
|
|
24
|
+
ok(session);
|
|
25
|
+
}
|
|
26
|
+
}
|