@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 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
+ }
@@ -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
+ }