@keeroklab/cli 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/package.json +32 -0
- package/src/env-injector.js +32 -0
- package/src/index.js +118 -0
- package/src/pty-capture.js +74 -0
- package/src/ws-client.js +120 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keeroklab/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI wrapper for Keerok Lab β live Claude Code supervision",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"keerok-lab": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/"
|
|
14
|
+
],
|
|
15
|
+
"keywords": ["claude", "lab", "supervision", "terminal", "claude-code", "keerok"],
|
|
16
|
+
"author": "Keerok <hello@keerok.tech> (https://keerok.tech)",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/keerok/lab-cli"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://keerok.tech",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^12.0.0",
|
|
25
|
+
"node-pty": "^1.0.0",
|
|
26
|
+
"ws": "^8.16.0",
|
|
27
|
+
"chalk": "^5.3.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-injector.js β Validates the connection token and retrieves session config.
|
|
3
|
+
* GET /api/lab/validate-token/ with Bearer token.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function validateToken(token, serverUrl) {
|
|
7
|
+
const url = `${serverUrl.replace(/\/$/, '')}/api/lab/validate-token/`;
|
|
8
|
+
|
|
9
|
+
const response = await fetch(url, {
|
|
10
|
+
method: 'GET',
|
|
11
|
+
headers: {
|
|
12
|
+
'Authorization': `Bearer ${token}`,
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
return { valid: false, error: data.error || `HTTP ${response.status}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve relative ws_url to absolute if needed
|
|
23
|
+
if (data.ws_url && data.ws_url.startsWith('ws://localhost')) {
|
|
24
|
+
// Replace with server-relative URL
|
|
25
|
+
const wsScheme = serverUrl.startsWith('https') ? 'wss' : 'ws';
|
|
26
|
+
const host = new URL(serverUrl).host;
|
|
27
|
+
const path = new URL(`http://localhost${data.ws_url.replace(/^wss?:\/\/[^/]+/, '')}`).pathname;
|
|
28
|
+
data.ws_url = `${wsScheme}://${host}${path}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return data;
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { validateToken } from './env-injector.js';
|
|
5
|
+
import { createWsClient } from './ws-client.js';
|
|
6
|
+
import { spawnPty } from './pty-capture.js';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('keerok-lab')
|
|
12
|
+
.description('CLI wrapper for Keerok Lab β live Claude Code supervision')
|
|
13
|
+
.version('0.1.0');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('connect')
|
|
17
|
+
.description('Connect to a lab session')
|
|
18
|
+
.argument('<token>', 'Connection token provided by the instructor')
|
|
19
|
+
.option('-s, --server <url>', 'Server URL', 'https://keerok.tech')
|
|
20
|
+
.option('-c, --command <cmd>', 'Command to run', 'claude')
|
|
21
|
+
.action(async (token, options) => {
|
|
22
|
+
try {
|
|
23
|
+
console.log('\nπ¬ Keerok Lab β Connecting...\n');
|
|
24
|
+
|
|
25
|
+
// Step 1: Validate token and get session config
|
|
26
|
+
const config = await validateToken(token, options.server);
|
|
27
|
+
if (!config.valid) {
|
|
28
|
+
console.error(`β ${config.error || 'Invalid token'}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`β
Session: ${config.session_title}`);
|
|
33
|
+
console.log(`π€ Name: ${config.participant_name}`);
|
|
34
|
+
console.log(`π€ Model: ${config.model_allowed}`);
|
|
35
|
+
if (config.budget_usd > 0) {
|
|
36
|
+
console.log(`π° Budget: $${config.budget_usd.toFixed(2)}`);
|
|
37
|
+
}
|
|
38
|
+
if (config.instructions) {
|
|
39
|
+
console.log(`\nπ Instructions:\n${config.instructions}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Step 2: Connect WebSocket
|
|
43
|
+
const ws = createWsClient(config.ws_url, {
|
|
44
|
+
onSessionConfig: (data) => {
|
|
45
|
+
// API key received β inject into environment
|
|
46
|
+
if (data.api_key) {
|
|
47
|
+
process.env.ANTHROPIC_API_KEY = data.api_key;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
onBudgetWarning: (data) => {
|
|
51
|
+
console.log(`\nβ οΈ Budget warning: $${data.remaining_usd.toFixed(2)} remaining (${data.percentage.toFixed(0)}% used)\n`);
|
|
52
|
+
},
|
|
53
|
+
onBudgetExceeded: () => {
|
|
54
|
+
console.log('\nπ« Budget exceeded β session terminated\n');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
},
|
|
57
|
+
onInstructorMessage: (data) => {
|
|
58
|
+
console.log(`\nπ© Instructor: ${data.message}\n`);
|
|
59
|
+
},
|
|
60
|
+
onSessionPaused: () => {
|
|
61
|
+
console.log('\nβΈοΈ Session paused by instructor\n');
|
|
62
|
+
},
|
|
63
|
+
onSessionEnded: () => {
|
|
64
|
+
console.log('\nπ Session ended by instructor\n');
|
|
65
|
+
process.exit(0);
|
|
66
|
+
},
|
|
67
|
+
onRevoked: () => {
|
|
68
|
+
console.log('\nπ« Access revoked by instructor\n');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Wait for WS connection
|
|
74
|
+
await ws.waitForConnection();
|
|
75
|
+
console.log('π WebSocket connected\n');
|
|
76
|
+
console.log('β'.repeat(50));
|
|
77
|
+
console.log(`Starting: ${options.command}`);
|
|
78
|
+
console.log('β'.repeat(50) + '\n');
|
|
79
|
+
|
|
80
|
+
// Step 3: Spawn PTY with the command
|
|
81
|
+
const pty = spawnPty(options.command, {
|
|
82
|
+
onData: (data) => {
|
|
83
|
+
// Forward terminal output to server
|
|
84
|
+
ws.send({
|
|
85
|
+
type: 'terminal_output',
|
|
86
|
+
data: Buffer.from(data).toString('base64'),
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
onResize: (cols, rows) => {
|
|
90
|
+
ws.send({ type: 'terminal_resize', cols, rows });
|
|
91
|
+
},
|
|
92
|
+
onExit: (code) => {
|
|
93
|
+
console.log(`\n\nProcess exited with code ${code}`);
|
|
94
|
+
ws.close();
|
|
95
|
+
process.exit(code);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Heartbeat every 30s
|
|
100
|
+
const heartbeat = setInterval(() => {
|
|
101
|
+
ws.send({ type: 'heartbeat' });
|
|
102
|
+
}, 30000);
|
|
103
|
+
|
|
104
|
+
// Handle SIGINT gracefully
|
|
105
|
+
process.on('SIGINT', () => {
|
|
106
|
+
clearInterval(heartbeat);
|
|
107
|
+
pty.kill();
|
|
108
|
+
ws.close();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`\nβ Error: ${err.message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
program.parse();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pty-capture.js β Spawns a PTY process and captures output.
|
|
3
|
+
* Uses node-pty to create a pseudo-terminal that captures all output
|
|
4
|
+
* including ANSI escape codes, colors, and cursor movements.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import pty from 'node-pty';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
|
|
10
|
+
export function spawnPty(command, handlers) {
|
|
11
|
+
const shell = os.platform() === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';
|
|
12
|
+
const args = command === shell ? [] : ['-c', command];
|
|
13
|
+
const useShell = command !== shell;
|
|
14
|
+
|
|
15
|
+
const cols = process.stdout.columns || 80;
|
|
16
|
+
const rows = process.stdout.rows || 24;
|
|
17
|
+
|
|
18
|
+
const term = pty.spawn(
|
|
19
|
+
useShell ? shell : command,
|
|
20
|
+
useShell ? args : [],
|
|
21
|
+
{
|
|
22
|
+
name: 'xterm-256color',
|
|
23
|
+
cols,
|
|
24
|
+
rows,
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
TERM: 'xterm-256color',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Forward PTY output to both stdout and the handler
|
|
34
|
+
term.onData((data) => {
|
|
35
|
+
process.stdout.write(data);
|
|
36
|
+
handlers.onData?.(data);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
term.onExit(({ exitCode }) => {
|
|
40
|
+
handlers.onExit?.(exitCode);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Forward stdin to PTY
|
|
44
|
+
if (process.stdin.isTTY) {
|
|
45
|
+
process.stdin.setRawMode(true);
|
|
46
|
+
}
|
|
47
|
+
process.stdin.resume();
|
|
48
|
+
process.stdin.on('data', (data) => {
|
|
49
|
+
term.write(data.toString());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Handle terminal resize
|
|
53
|
+
process.stdout.on('resize', () => {
|
|
54
|
+
const newCols = process.stdout.columns;
|
|
55
|
+
const newRows = process.stdout.rows;
|
|
56
|
+
term.resize(newCols, newRows);
|
|
57
|
+
handlers.onResize?.(newCols, newRows);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Send initial size
|
|
61
|
+
handlers.onResize?.(cols, rows);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
kill() {
|
|
65
|
+
term.kill();
|
|
66
|
+
},
|
|
67
|
+
write(data) {
|
|
68
|
+
term.write(data);
|
|
69
|
+
},
|
|
70
|
+
resize(cols, rows) {
|
|
71
|
+
term.resize(cols, rows);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/ws-client.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ws-client.js β WebSocket client with automatic reconnection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
|
|
7
|
+
export function createWsClient(url, handlers) {
|
|
8
|
+
let ws = null;
|
|
9
|
+
let connected = false;
|
|
10
|
+
let reconnectAttempts = 0;
|
|
11
|
+
const maxReconnectAttempts = 20;
|
|
12
|
+
let reconnectTimer = null;
|
|
13
|
+
let connectionResolve = null;
|
|
14
|
+
|
|
15
|
+
function connect() {
|
|
16
|
+
ws = new WebSocket(url);
|
|
17
|
+
|
|
18
|
+
ws.on('open', () => {
|
|
19
|
+
connected = true;
|
|
20
|
+
reconnectAttempts = 0;
|
|
21
|
+
if (connectionResolve) {
|
|
22
|
+
connectionResolve();
|
|
23
|
+
connectionResolve = null;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
ws.on('close', () => {
|
|
28
|
+
connected = false;
|
|
29
|
+
scheduleReconnect();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
ws.on('error', (err) => {
|
|
33
|
+
// Errors are followed by 'close', so reconnect is handled there
|
|
34
|
+
if (connectionResolve) {
|
|
35
|
+
// If we haven't connected yet, reject
|
|
36
|
+
connectionResolve = null;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
ws.on('message', (raw) => {
|
|
41
|
+
try {
|
|
42
|
+
const data = JSON.parse(raw.toString());
|
|
43
|
+
handleMessage(data, handlers);
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore malformed messages
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scheduleReconnect() {
|
|
51
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
52
|
+
console.error('Max reconnect attempts reached. Exiting.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
|
56
|
+
reconnectAttempts++;
|
|
57
|
+
reconnectTimer = setTimeout(() => connect(), delay);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleMessage(data, handlers) {
|
|
61
|
+
switch (data.type) {
|
|
62
|
+
case 'session_config':
|
|
63
|
+
handlers.onSessionConfig?.(data);
|
|
64
|
+
break;
|
|
65
|
+
case 'budget_warning':
|
|
66
|
+
handlers.onBudgetWarning?.(data);
|
|
67
|
+
break;
|
|
68
|
+
case 'budget_exceeded':
|
|
69
|
+
handlers.onBudgetExceeded?.(data);
|
|
70
|
+
break;
|
|
71
|
+
case 'instructor_message':
|
|
72
|
+
handlers.onInstructorMessage?.(data);
|
|
73
|
+
break;
|
|
74
|
+
case 'session_paused':
|
|
75
|
+
handlers.onSessionPaused?.(data);
|
|
76
|
+
break;
|
|
77
|
+
case 'session_ended':
|
|
78
|
+
handlers.onSessionEnded?.(data);
|
|
79
|
+
break;
|
|
80
|
+
case 'revoked':
|
|
81
|
+
handlers.onRevoked?.(data);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Initial connection
|
|
87
|
+
connect();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
send(data) {
|
|
91
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
92
|
+
ws.send(JSON.stringify(data));
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
close() {
|
|
97
|
+
clearTimeout(reconnectTimer);
|
|
98
|
+
if (ws) {
|
|
99
|
+
ws.close();
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
waitForConnection() {
|
|
104
|
+
if (connected) return Promise.resolve();
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
connectionResolve = resolve;
|
|
107
|
+
// Timeout after 10s
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
if (!connected) {
|
|
110
|
+
reject(new Error('WebSocket connection timeout'));
|
|
111
|
+
}
|
|
112
|
+
}, 10000);
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
get connected() {
|
|
117
|
+
return connected;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|