jupyter-link 0.1.0
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/LICENSE +21 -0
- package/README.md +93 -0
- package/bin/run +20 -0
- package/oclif.manifest.json +299 -0
- package/package.json +55 -0
- package/scripts/check_env.mjs +18 -0
- package/scripts/close_channels.mjs +16 -0
- package/scripts/collect_outputs.mjs +18 -0
- package/scripts/daemon.mjs +133 -0
- package/scripts/exec.mjs +116 -0
- package/scripts/execute_code.mjs +16 -0
- package/scripts/insert_cell.mjs +28 -0
- package/scripts/ipc_client.mjs +2 -0
- package/scripts/jupyter_proto.mjs +54 -0
- package/scripts/list_sessions.mjs +22 -0
- package/scripts/noop_collect_outputs.mjs +8 -0
- package/scripts/noop_open_channels.mjs +9 -0
- package/scripts/open_kernel_channels.mjs +31 -0
- package/scripts/read_cell.mjs +80 -0
- package/scripts/read_notebook.mjs +15 -0
- package/scripts/save_notebook.mjs +16 -0
- package/scripts/test_api.mjs +56 -0
- package/scripts/update_cell.mjs +43 -0
- package/scripts/util.mjs +12 -0
- package/scripts/write_notebook.mjs +17 -0
- package/src/commands/cell/insert.mjs +27 -0
- package/src/commands/cell/read.mjs +102 -0
- package/src/commands/cell/update.mjs +31 -0
- package/src/commands/check/env.mjs +14 -0
- package/src/commands/close/channels.mjs +18 -0
- package/src/commands/collect/outputs.mjs +20 -0
- package/src/commands/config/get.mjs +17 -0
- package/src/commands/config/set.mjs +16 -0
- package/src/commands/contents/read.mjs +17 -0
- package/src/commands/contents/write.mjs +18 -0
- package/src/commands/execute/code.mjs +18 -0
- package/src/commands/list/sessions.mjs +23 -0
- package/src/commands/open/kernel-channels.mjs +28 -0
- package/src/commands/save/notebook.mjs +17 -0
- package/src/lib/common.mjs +79 -0
- package/src/lib/daemonClient.mjs +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roberto Arce
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# jupyter-link
|
|
2
|
+
|
|
3
|
+
CLI and AgentSkill to execute code in running Jupyter kernels and persist outputs back to `.ipynb` notebooks.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
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
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Node.js 20+
|
|
18
|
+
- A running Jupyter Server (JupyterLab/Notebook)
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### As a global CLI
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g jupyter-link
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Via npx (no install)
|
|
28
|
+
```bash
|
|
29
|
+
echo '{}' | npx jupyter-link@0.1.0 check:env
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### As an AgentSkill
|
|
33
|
+
```bash
|
|
34
|
+
npx skills add <owner/repo>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
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
|
|
42
|
+
|
|
43
|
+
# 2. Verify connectivity
|
|
44
|
+
echo '{}' | npx jupyter-link@0.1.0 check:env
|
|
45
|
+
|
|
46
|
+
# 3. List running sessions
|
|
47
|
+
echo '{}' | npx jupyter-link@0.1.0 list:sessions
|
|
48
|
+
|
|
49
|
+
# 4. Read cell outputs
|
|
50
|
+
echo '{"path":"notebook.ipynb","cells":[0,1,2]}' | npx jupyter-link@0.1.0 cell:read
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
All commands read JSON from stdin and write JSON to stdout.
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
|---------|-------------|
|
|
59
|
+
| `config:set` | Save connection settings (url, token, port) |
|
|
60
|
+
| `config:get` | Show effective config with source per field |
|
|
61
|
+
| `check:env` | Verify Jupyter Server connectivity |
|
|
62
|
+
| `list:sessions` | List sessions, filter by path or name |
|
|
63
|
+
| `cell:read` | Read specific cells with outputs (preferred) |
|
|
64
|
+
| `cell:insert` | Insert a code cell with agent metadata |
|
|
65
|
+
| `cell:update` | Update cell source, outputs, execution_count |
|
|
66
|
+
| `contents:read` | Read full notebook JSON |
|
|
67
|
+
| `contents:write` | Write notebook JSON |
|
|
68
|
+
| `open:kernel-channels` | Open persistent WebSocket to kernel |
|
|
69
|
+
| `execute:code` | Send execute_request, get parent_msg_id |
|
|
70
|
+
| `collect:outputs` | Wait for outputs/reply/idle |
|
|
71
|
+
| `close:channels` | Close a channel |
|
|
72
|
+
| `save:notebook` | Save notebook (round-trip PUT) |
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
Priority: environment variables > config file > defaults.
|
|
77
|
+
|
|
78
|
+
| Source | URL | Token | Daemon Port |
|
|
79
|
+
|--------|-----|-------|-------------|
|
|
80
|
+
| Env var | `JUPYTER_URL` | `JUPYTER_TOKEN` | `JUPYTER_LINK_PORT` |
|
|
81
|
+
| Config file | `url` | `token` | `port` |
|
|
82
|
+
| Default | `http://127.0.0.1:8888` | — | `32123` |
|
|
83
|
+
|
|
84
|
+
## Tests
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm test # Run all tests
|
|
88
|
+
npm run test:coverage # With coverage report
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
package/bin/run
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from '@oclif/core';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
run(process.argv.slice(2), { root: resolve(__dirname, '..') }).then(() => {
|
|
9
|
+
// ok
|
|
10
|
+
}).catch((err) => {
|
|
11
|
+
// Print minimal error and exit non-zero
|
|
12
|
+
const msg = (err && err.message) || String(err);
|
|
13
|
+
process.stderr.write(msg + '\n');
|
|
14
|
+
try {
|
|
15
|
+
// emit JSON error for AgentSkills
|
|
16
|
+
process.stdout.write(JSON.stringify({ error: msg, stack: err && err.stack }) + '\n');
|
|
17
|
+
} catch {}
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
{
|
|
2
|
+
"commands": {
|
|
3
|
+
"cell:insert": {
|
|
4
|
+
"aliases": [],
|
|
5
|
+
"args": {},
|
|
6
|
+
"description": "Insert a code cell with agent metadata",
|
|
7
|
+
"flags": {},
|
|
8
|
+
"hasDynamicHelp": false,
|
|
9
|
+
"hiddenAliases": [],
|
|
10
|
+
"id": "cell:insert",
|
|
11
|
+
"pluginAlias": "jupyter-link",
|
|
12
|
+
"pluginName": "jupyter-link",
|
|
13
|
+
"pluginType": "core",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"enableJsonFlag": false,
|
|
16
|
+
"isESM": true,
|
|
17
|
+
"relativePath": [
|
|
18
|
+
"src",
|
|
19
|
+
"commands",
|
|
20
|
+
"cell",
|
|
21
|
+
"insert.mjs"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"cell:read": {
|
|
25
|
+
"aliases": [],
|
|
26
|
+
"args": {},
|
|
27
|
+
"description": "Read specific cells from a notebook with their outputs, source, and metadata",
|
|
28
|
+
"flags": {},
|
|
29
|
+
"hasDynamicHelp": false,
|
|
30
|
+
"hiddenAliases": [],
|
|
31
|
+
"id": "cell:read",
|
|
32
|
+
"pluginAlias": "jupyter-link",
|
|
33
|
+
"pluginName": "jupyter-link",
|
|
34
|
+
"pluginType": "core",
|
|
35
|
+
"strict": true,
|
|
36
|
+
"enableJsonFlag": false,
|
|
37
|
+
"isESM": true,
|
|
38
|
+
"relativePath": [
|
|
39
|
+
"src",
|
|
40
|
+
"commands",
|
|
41
|
+
"cell",
|
|
42
|
+
"read.mjs"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"cell:update": {
|
|
46
|
+
"aliases": [],
|
|
47
|
+
"args": {},
|
|
48
|
+
"description": "Update a code cell (source/outputs/execution_count/metadata)",
|
|
49
|
+
"flags": {},
|
|
50
|
+
"hasDynamicHelp": false,
|
|
51
|
+
"hiddenAliases": [],
|
|
52
|
+
"id": "cell:update",
|
|
53
|
+
"pluginAlias": "jupyter-link",
|
|
54
|
+
"pluginName": "jupyter-link",
|
|
55
|
+
"pluginType": "core",
|
|
56
|
+
"strict": true,
|
|
57
|
+
"enableJsonFlag": false,
|
|
58
|
+
"isESM": true,
|
|
59
|
+
"relativePath": [
|
|
60
|
+
"src",
|
|
61
|
+
"commands",
|
|
62
|
+
"cell",
|
|
63
|
+
"update.mjs"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"check:env": {
|
|
67
|
+
"aliases": [],
|
|
68
|
+
"args": {},
|
|
69
|
+
"description": "Verify connectivity and basic Jupyter Server compatibility",
|
|
70
|
+
"flags": {},
|
|
71
|
+
"hasDynamicHelp": false,
|
|
72
|
+
"hiddenAliases": [],
|
|
73
|
+
"id": "check:env",
|
|
74
|
+
"pluginAlias": "jupyter-link",
|
|
75
|
+
"pluginName": "jupyter-link",
|
|
76
|
+
"pluginType": "core",
|
|
77
|
+
"strict": true,
|
|
78
|
+
"enableJsonFlag": false,
|
|
79
|
+
"isESM": true,
|
|
80
|
+
"relativePath": [
|
|
81
|
+
"src",
|
|
82
|
+
"commands",
|
|
83
|
+
"check",
|
|
84
|
+
"env.mjs"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"close:channels": {
|
|
88
|
+
"aliases": [],
|
|
89
|
+
"args": {},
|
|
90
|
+
"description": "Close a previously opened channel_ref",
|
|
91
|
+
"flags": {},
|
|
92
|
+
"hasDynamicHelp": false,
|
|
93
|
+
"hiddenAliases": [],
|
|
94
|
+
"id": "close:channels",
|
|
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
|
+
"close",
|
|
105
|
+
"channels.mjs"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"collect:outputs": {
|
|
109
|
+
"aliases": [],
|
|
110
|
+
"args": {},
|
|
111
|
+
"description": "Wait for outputs/reply/idle for a parent_msg_id on a channel",
|
|
112
|
+
"flags": {},
|
|
113
|
+
"hasDynamicHelp": false,
|
|
114
|
+
"hiddenAliases": [],
|
|
115
|
+
"id": "collect:outputs",
|
|
116
|
+
"pluginAlias": "jupyter-link",
|
|
117
|
+
"pluginName": "jupyter-link",
|
|
118
|
+
"pluginType": "core",
|
|
119
|
+
"strict": true,
|
|
120
|
+
"enableJsonFlag": false,
|
|
121
|
+
"isESM": true,
|
|
122
|
+
"relativePath": [
|
|
123
|
+
"src",
|
|
124
|
+
"commands",
|
|
125
|
+
"collect",
|
|
126
|
+
"outputs.mjs"
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
"contents:read": {
|
|
130
|
+
"aliases": [],
|
|
131
|
+
"args": {},
|
|
132
|
+
"description": "Read a notebook JSON via Contents API",
|
|
133
|
+
"flags": {},
|
|
134
|
+
"hasDynamicHelp": false,
|
|
135
|
+
"hiddenAliases": [],
|
|
136
|
+
"id": "contents:read",
|
|
137
|
+
"pluginAlias": "jupyter-link",
|
|
138
|
+
"pluginName": "jupyter-link",
|
|
139
|
+
"pluginType": "core",
|
|
140
|
+
"strict": true,
|
|
141
|
+
"enableJsonFlag": false,
|
|
142
|
+
"isESM": true,
|
|
143
|
+
"relativePath": [
|
|
144
|
+
"src",
|
|
145
|
+
"commands",
|
|
146
|
+
"contents",
|
|
147
|
+
"read.mjs"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"contents:write": {
|
|
151
|
+
"aliases": [],
|
|
152
|
+
"args": {},
|
|
153
|
+
"description": "Write notebook JSON via Contents API",
|
|
154
|
+
"flags": {},
|
|
155
|
+
"hasDynamicHelp": false,
|
|
156
|
+
"hiddenAliases": [],
|
|
157
|
+
"id": "contents:write",
|
|
158
|
+
"pluginAlias": "jupyter-link",
|
|
159
|
+
"pluginName": "jupyter-link",
|
|
160
|
+
"pluginType": "core",
|
|
161
|
+
"strict": true,
|
|
162
|
+
"enableJsonFlag": false,
|
|
163
|
+
"isESM": true,
|
|
164
|
+
"relativePath": [
|
|
165
|
+
"src",
|
|
166
|
+
"commands",
|
|
167
|
+
"contents",
|
|
168
|
+
"write.mjs"
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
"execute:code": {
|
|
172
|
+
"aliases": [],
|
|
173
|
+
"args": {},
|
|
174
|
+
"description": "Send execute_request on an open channel and return parent_msg_id",
|
|
175
|
+
"flags": {},
|
|
176
|
+
"hasDynamicHelp": false,
|
|
177
|
+
"hiddenAliases": [],
|
|
178
|
+
"id": "execute:code",
|
|
179
|
+
"pluginAlias": "jupyter-link",
|
|
180
|
+
"pluginName": "jupyter-link",
|
|
181
|
+
"pluginType": "core",
|
|
182
|
+
"strict": true,
|
|
183
|
+
"enableJsonFlag": false,
|
|
184
|
+
"isESM": true,
|
|
185
|
+
"relativePath": [
|
|
186
|
+
"src",
|
|
187
|
+
"commands",
|
|
188
|
+
"execute",
|
|
189
|
+
"code.mjs"
|
|
190
|
+
]
|
|
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
|
+
},
|
|
213
|
+
"config:get": {
|
|
214
|
+
"aliases": [],
|
|
215
|
+
"args": {},
|
|
216
|
+
"description": "Show effective configuration (env vars > config file > defaults)",
|
|
217
|
+
"flags": {},
|
|
218
|
+
"hasDynamicHelp": false,
|
|
219
|
+
"hiddenAliases": [],
|
|
220
|
+
"id": "config:get",
|
|
221
|
+
"pluginAlias": "jupyter-link",
|
|
222
|
+
"pluginName": "jupyter-link",
|
|
223
|
+
"pluginType": "core",
|
|
224
|
+
"strict": true,
|
|
225
|
+
"enableJsonFlag": false,
|
|
226
|
+
"isESM": true,
|
|
227
|
+
"relativePath": [
|
|
228
|
+
"src",
|
|
229
|
+
"commands",
|
|
230
|
+
"config",
|
|
231
|
+
"get.mjs"
|
|
232
|
+
]
|
|
233
|
+
},
|
|
234
|
+
"config:set": {
|
|
235
|
+
"aliases": [],
|
|
236
|
+
"args": {},
|
|
237
|
+
"description": "Save Jupyter connection settings to ~/.config/jupyter-link/config.json",
|
|
238
|
+
"flags": {},
|
|
239
|
+
"hasDynamicHelp": false,
|
|
240
|
+
"hiddenAliases": [],
|
|
241
|
+
"id": "config:set",
|
|
242
|
+
"pluginAlias": "jupyter-link",
|
|
243
|
+
"pluginName": "jupyter-link",
|
|
244
|
+
"pluginType": "core",
|
|
245
|
+
"strict": true,
|
|
246
|
+
"enableJsonFlag": false,
|
|
247
|
+
"isESM": true,
|
|
248
|
+
"relativePath": [
|
|
249
|
+
"src",
|
|
250
|
+
"commands",
|
|
251
|
+
"config",
|
|
252
|
+
"set.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"
|
|
274
|
+
]
|
|
275
|
+
},
|
|
276
|
+
"save:notebook": {
|
|
277
|
+
"aliases": [],
|
|
278
|
+
"args": {},
|
|
279
|
+
"description": "Save notebook (round-trip PUT)",
|
|
280
|
+
"flags": {},
|
|
281
|
+
"hasDynamicHelp": false,
|
|
282
|
+
"hiddenAliases": [],
|
|
283
|
+
"id": "save:notebook",
|
|
284
|
+
"pluginAlias": "jupyter-link",
|
|
285
|
+
"pluginName": "jupyter-link",
|
|
286
|
+
"pluginType": "core",
|
|
287
|
+
"strict": true,
|
|
288
|
+
"enableJsonFlag": false,
|
|
289
|
+
"isESM": true,
|
|
290
|
+
"relativePath": [
|
|
291
|
+
"src",
|
|
292
|
+
"commands",
|
|
293
|
+
"save",
|
|
294
|
+
"notebook.mjs"
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"version": "0.1.0"
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jupyter-link",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI and AgentSkill to execute code in Jupyter kernels and persist outputs to notebooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Roberto Arce",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/rarce/jupyter-link.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/rarce/jupyter-link",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"jupyter",
|
|
15
|
+
"notebook",
|
|
16
|
+
"kernel",
|
|
17
|
+
"agentskills",
|
|
18
|
+
"cli",
|
|
19
|
+
"execute",
|
|
20
|
+
"ipynb",
|
|
21
|
+
"nbformat"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"scripts",
|
|
30
|
+
"oclif.manifest.json"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@oclif/core": "^3.26.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@vitest/coverage-v8": "^1.6.1",
|
|
37
|
+
"vitest": "^1.5.0",
|
|
38
|
+
"yaml": "^2.4.1"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "npx oclif manifest",
|
|
42
|
+
"test": "vitest run --reporter=dot",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"bin": {
|
|
48
|
+
"jupyter-link": "bin/run"
|
|
49
|
+
},
|
|
50
|
+
"oclif": {
|
|
51
|
+
"bin": "jupyter-link",
|
|
52
|
+
"commands": "./src/commands",
|
|
53
|
+
"plugins": []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getConfig, httpJson, ok, fail, assertNodeVersion } from './util.mjs';
|
|
2
|
+
|
|
3
|
+
async function main() {
|
|
4
|
+
assertNodeVersion();
|
|
5
|
+
const { baseUrl, token } = getConfig();
|
|
6
|
+
// Try sessions and contents root
|
|
7
|
+
const sessions = await httpJson('GET', `${baseUrl}/api/sessions`, token).catch(e => ({ error: e.message }));
|
|
8
|
+
const contents = await httpJson('GET', `${baseUrl}/api/contents`, token).catch(e => ({ error: e.message }));
|
|
9
|
+
ok({
|
|
10
|
+
ok: !sessions.error && !contents.error,
|
|
11
|
+
sessions_ok: !sessions.error,
|
|
12
|
+
contents_ok: !contents.error,
|
|
13
|
+
details: { sessions, contents }
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().catch(fail);
|
|
18
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readStdinJson, ok, fail, assertNodeVersion } from './util.mjs';
|
|
2
|
+
import { ensureDaemon, request } from './ipc_client.mjs';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
assertNodeVersion();
|
|
6
|
+
const input = await readStdinJson();
|
|
7
|
+
const ref = input.channel_ref ?? input.ref;
|
|
8
|
+
if (!ref) throw new Error('channel_ref is required');
|
|
9
|
+
await ensureDaemon();
|
|
10
|
+
const out = await request('close', { channel_ref: ref });
|
|
11
|
+
if (out.error) throw new Error(out.error);
|
|
12
|
+
ok(out);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
main().catch(fail);
|
|
16
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readStdinJson, ok, fail, assertNodeVersion } from './util.mjs';
|
|
2
|
+
import { ensureDaemon, request } from './ipc_client.mjs';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
assertNodeVersion();
|
|
6
|
+
const input = await readStdinJson();
|
|
7
|
+
const ref = input.channel_ref ?? input.ref;
|
|
8
|
+
const parent = input.parent_msg_id ?? input.parent_id;
|
|
9
|
+
if (!ref) throw new Error('channel_ref is required');
|
|
10
|
+
if (!parent) throw new Error('parent_msg_id is required');
|
|
11
|
+
await ensureDaemon();
|
|
12
|
+
const out = await request('collect', { channel_ref: ref, parent_msg_id: parent, timeout_s: input.timeout_s || 60 });
|
|
13
|
+
if (out.error) throw new Error(out.error);
|
|
14
|
+
ok(out);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().catch(fail);
|
|
18
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
import { mapIopubToOutput, isStatusIdle, isParent, makeExecuteRequest } from './jupyter_proto.mjs';
|
|
5
|
+
import { nowIso, newSessionId, getConfig } from '../src/lib/common.mjs';
|
|
6
|
+
|
|
7
|
+
const PORT = getConfig().port;
|
|
8
|
+
// helper functions imported from jupyter_proto.mjs
|
|
9
|
+
|
|
10
|
+
// State
|
|
11
|
+
const channels = new Map(); // ref -> { ws, sessionId, kernelId, url, outputsByParent }
|
|
12
|
+
|
|
13
|
+
export function wsUrlFor(baseUrl, token, kernelId, sessionId) {
|
|
14
|
+
const url = new URL(baseUrl);
|
|
15
|
+
const wsScheme = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
16
|
+
const query = new URLSearchParams();
|
|
17
|
+
if (token) query.set('token', token);
|
|
18
|
+
query.set('session_id', sessionId);
|
|
19
|
+
const u = `${wsScheme}//${url.host}${url.pathname.replace(/\/$/, '')}/api/kernels/${kernelId}/channels?${query.toString()}`;
|
|
20
|
+
return u;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function handleOpen({ baseUrl, token, kernelId }) {
|
|
24
|
+
if (!baseUrl || !kernelId) throw new Error('baseUrl and kernelId are required');
|
|
25
|
+
const sessionId = newSessionId();
|
|
26
|
+
const url = wsUrlFor(baseUrl, token, kernelId, sessionId);
|
|
27
|
+
const WS = globalThis.WebSocket;
|
|
28
|
+
if (!WS) throw new Error('WebSocket API not available in Node. Use Node >=20 or enable experimental WebSocket');
|
|
29
|
+
const ws = new WS(url);
|
|
30
|
+
const ref = 'ch-' + crypto.randomBytes(6).toString('hex');
|
|
31
|
+
const state = { ws, sessionId, kernelId, url, outputsByParent: new Map(), ready: false };
|
|
32
|
+
channels.set(ref, state);
|
|
33
|
+
ws.on('open', () => { state.ready = true; });
|
|
34
|
+
ws.on('message', (data) => {
|
|
35
|
+
let msg; try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
36
|
+
const parentId = msg.parent_header && msg.parent_header.msg_id;
|
|
37
|
+
if (!parentId) return;
|
|
38
|
+
let agg = state.outputsByParent.get(parentId);
|
|
39
|
+
if (!agg) return;
|
|
40
|
+
const channel = msg.channel;
|
|
41
|
+
const msgType = msg.header && msg.header.msg_type;
|
|
42
|
+
if (channel === 'iopub' && isParent(msg, parentId)) {
|
|
43
|
+
const out = mapIopubToOutput(msg);
|
|
44
|
+
if (out) agg.outputs.push(out);
|
|
45
|
+
if (isStatusIdle(msg)) agg.gotIdle = true;
|
|
46
|
+
}
|
|
47
|
+
if (channel === 'shell' && msgType === 'execute_reply' && isParent(msg, parentId)) {
|
|
48
|
+
agg.gotReply = true;
|
|
49
|
+
agg.status = (msg.content && msg.content.status) || agg.status;
|
|
50
|
+
agg.execution_count = (msg.content && msg.content.execution_count) || agg.execution_count;
|
|
51
|
+
}
|
|
52
|
+
if (agg.gotReply && agg.gotIdle && !agg.resolved) {
|
|
53
|
+
agg.resolved = true;
|
|
54
|
+
if (agg.resolve) agg.resolve();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
ws.on('close', () => { state.ready = false; state.dead = true; });
|
|
58
|
+
ws.on('error', () => { state.ready = false; state.dead = true; });
|
|
59
|
+
return { channel_ref: ref, session_id: sessionId };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleExec({ channel_ref, code, allow_stdin = false, stop_on_error = true }) {
|
|
63
|
+
const ch = channels.get(channel_ref);
|
|
64
|
+
if (!ch) throw new Error('unknown channel_ref');
|
|
65
|
+
if (ch.dead) throw new Error('channel is closed or errored');
|
|
66
|
+
if (!ch.ready) throw new Error('channel not ready');
|
|
67
|
+
const msg = makeExecuteRequest(code, ch.sessionId, allow_stdin, stop_on_error);
|
|
68
|
+
const parentId = msg.header.msg_id;
|
|
69
|
+
ch.outputsByParent.set(parentId, { outputs: [], execution_count: null, status: 'unknown', gotReply: false, gotIdle: false, resolved: false });
|
|
70
|
+
ch.ws.send(JSON.stringify(msg));
|
|
71
|
+
return { parent_msg_id: parentId };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleCollect({ channel_ref, parent_msg_id, timeout_s = 60 }) {
|
|
75
|
+
const ch = channels.get(channel_ref);
|
|
76
|
+
if (!ch) throw new Error('unknown channel_ref');
|
|
77
|
+
const agg = ch.outputsByParent.get(parent_msg_id);
|
|
78
|
+
if (!agg) throw new Error('unknown parent_msg_id');
|
|
79
|
+
let timedOut = false;
|
|
80
|
+
if (!(agg.gotReply && agg.gotIdle)) {
|
|
81
|
+
await new Promise((resolve, reject) => {
|
|
82
|
+
const timer = setTimeout(() => { if (agg.resolve === resolve) agg.resolve = undefined; timedOut = true; resolve(); }, timeout_s * 1000);
|
|
83
|
+
agg.resolve = () => { clearTimeout(timer); resolve(); };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const status = timedOut ? 'timeout' : (agg.status || (agg.gotReply ? 'ok' : 'unknown'));
|
|
87
|
+
// Clean up to prevent memory leak
|
|
88
|
+
ch.outputsByParent.delete(parent_msg_id);
|
|
89
|
+
return { outputs: agg.outputs, execution_count: agg.execution_count, status };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleClose({ channel_ref }) {
|
|
93
|
+
const ch = channels.get(channel_ref);
|
|
94
|
+
if (!ch) return { ok: true };
|
|
95
|
+
try { ch.ws.close(); } catch {}
|
|
96
|
+
channels.delete(channel_ref);
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleList() {
|
|
101
|
+
const arr = [];
|
|
102
|
+
for (const [ref, st] of channels.entries()) arr.push({ channel_ref: ref, kernel_id: st.kernelId, url: st.url, ready: st.ready });
|
|
103
|
+
return { channels: arr };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const server = net.createServer((socket) => {
|
|
107
|
+
let buf = '';
|
|
108
|
+
socket.on('data', (chunk) => {
|
|
109
|
+
buf += chunk.toString('utf8');
|
|
110
|
+
let idx;
|
|
111
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
112
|
+
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
|
|
113
|
+
let req; try { req = JSON.parse(line); } catch { socket.write(JSON.stringify({ error: 'bad json' }) + '\n'); continue; }
|
|
114
|
+
try {
|
|
115
|
+
let res;
|
|
116
|
+
switch (req.op) {
|
|
117
|
+
case 'ping': res = { ok: true }; break;
|
|
118
|
+
case 'open': res = handleOpen(req.args || {}); break;
|
|
119
|
+
case 'exec': res = handleExec(req.args || {}); break;
|
|
120
|
+
case 'collect': res = handleCollect(req.args || {}); break;
|
|
121
|
+
case 'close': res = handleClose(req.args || {}); break;
|
|
122
|
+
case 'list': res = handleList(); break;
|
|
123
|
+
default: res = { error: 'unknown op' };
|
|
124
|
+
}
|
|
125
|
+
Promise.resolve(res).then((out) => socket.write(JSON.stringify(out) + '\n')).catch((e) => { try { socket.write(JSON.stringify({ error: e.message || String(e) }) + '\n'); } catch {} });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
socket.write(JSON.stringify({ error: e.message || String(e) }) + '\n');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
server.listen(PORT, '127.0.0.1');
|