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 CHANGED
@@ -1,54 +1,52 @@
1
1
  # jupyter-link
2
2
 
3
- CLI and AgentSkill to execute code in running Jupyter kernels and persist outputs back to `.ipynb` notebooks.
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
- ## Features
5
+ ## Why
6
6
 
7
- - Discover running sessions and match by notebook path or name
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
- ## Requirements
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
- - Node.js 20+
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
- ## Installation
13
+ ## Install as a Skill
21
14
 
22
- ### As a global CLI
23
15
  ```bash
24
- npm install -g jupyter-link
16
+ npx skills add rarce/jupyter-link
25
17
  ```
26
18
 
27
- ### Via npx (no install)
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
- ### As an AgentSkill
33
- ```bash
34
- npx skills add <owner/repo>
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
- ```bash
40
- # 1. Configure connection (saved to ~/.config/jupyter-link/config.json)
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
- # 2. Verify connectivity
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
- # 3. List running sessions
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
- # 4. Read cell outputs
50
- echo '{"path":"notebook.ipynb","cells":[0,1,2]}' | npx jupyter-link@0.1.0 cell:read
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
- ## Tests
82
+ ## Standalone CLI
83
+
84
+ You can also install and use it directly without the skills framework:
85
85
 
86
86
  ```bash
87
- npm test # Run all tests
88
- npm run test:coverage # With coverage report
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 |
@@ -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
- "collect:outputs": {
129
+ "contents:create": {
109
130
  "aliases": [],
110
131
  "args": {},
111
- "description": "Wait for outputs/reply/idle for a parent_msg_id on a channel",
132
+ "description": "Create a new empty notebook with proper nbformat v4 boilerplate",
112
133
  "flags": {},
113
134
  "hasDynamicHelp": false,
114
135
  "hiddenAliases": [],
115
- "id": "collect:outputs",
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
- "collect",
126
- "outputs.mjs"
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
- "list:sessions": {
234
+ "run:cell": {
193
235
  "aliases": [],
194
236
  "args": {},
195
- "description": "List sessions and optionally filter by path or name",
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": "list:sessions",
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
- "list",
210
- "sessions.mjs"
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
- "open:kernel-channels": {
318
+ "save:notebook": {
256
319
  "aliases": [],
257
320
  "args": {},
258
- "description": "Open persistent kernel WS channels and return a channel_ref",
321
+ "description": "Save notebook (round-trip PUT)",
259
322
  "flags": {},
260
323
  "hasDynamicHelp": false,
261
324
  "hiddenAliases": [],
262
- "id": "open:kernel-channels",
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
- "open",
273
- "kernel-channels.mjs"
335
+ "save",
336
+ "notebook.mjs"
274
337
  ]
275
338
  },
276
- "save:notebook": {
339
+ "sessions:create": {
277
340
  "aliases": [],
278
341
  "args": {},
279
- "description": "Save notebook (round-trip PUT)",
342
+ "description": "Create a Jupyter session (start a kernel) for a notebook",
280
343
  "flags": {},
281
344
  "hasDynamicHelp": false,
282
345
  "hiddenAliases": [],
283
- "id": "save:notebook",
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
- "save",
294
- "notebook.mjs"
356
+ "sessions",
357
+ "create.mjs"
295
358
  ]
296
359
  }
297
360
  },
298
- "version": "0.1.0"
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.0",
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) throw new Error('No running session for target notebook');
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
+ }