@16pxh/cli-bridge 1.0.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/README.md +20 -0
- package/bin/bridge.mjs +82 -0
- package/lib/auth.mjs +48 -0
- package/lib/claude.mjs +88 -0
- package/lib/server.mjs +133 -0
- package/package.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @16pxh/cli-bridge
|
|
2
|
+
|
|
3
|
+
Bridge your local Claude CLI to 16pxh AI features.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
- Node.js 18+
|
|
7
|
+
- Claude CLI installed and authenticated (`claude --version`)
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
1. Go to 16pxh Settings → AI Agent → Claude → "Connect CLI Bridge"
|
|
11
|
+
2. Copy the verification token shown
|
|
12
|
+
3. Run: `npx @16pxh/cli-bridge`
|
|
13
|
+
4. Paste the token when prompted
|
|
14
|
+
5. Click "Verify Connection" in your browser
|
|
15
|
+
|
|
16
|
+
The bridge runs on `localhost:1676`. Keep the terminal open while using AI features.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
- `r` + Enter: Reset token (enter a new one)
|
|
20
|
+
- `Ctrl+C`: Shutdown bridge
|
package/bin/bridge.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as readline from 'node:readline';
|
|
3
|
+
import { setToken, clearToken } from '../lib/auth.mjs';
|
|
4
|
+
import { checkClaudeInstalled, killActive } from '../lib/claude.mjs';
|
|
5
|
+
import { startServer } from '../lib/server.mjs';
|
|
6
|
+
|
|
7
|
+
console.log('🔌 16pxh CLI Bridge');
|
|
8
|
+
console.log('');
|
|
9
|
+
|
|
10
|
+
// --- Check Claude CLI ---
|
|
11
|
+
const version = await checkClaudeInstalled();
|
|
12
|
+
if (!version) {
|
|
13
|
+
console.error('Error: Claude CLI is not installed or not in PATH.');
|
|
14
|
+
console.error('Install it from: https://claude.ai/download');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
console.log(`Claude CLI detected: ${version}`);
|
|
18
|
+
|
|
19
|
+
// --- Readline setup ---
|
|
20
|
+
const rl = readline.createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stdout,
|
|
23
|
+
terminal: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Prompt the user for a token and store it.
|
|
28
|
+
*/
|
|
29
|
+
async function promptToken() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
rl.question('Paste your verification token: ', (answer) => {
|
|
32
|
+
const token = answer.trim();
|
|
33
|
+
if (!token) {
|
|
34
|
+
console.log('Token cannot be empty. Try again.');
|
|
35
|
+
resolve(promptToken());
|
|
36
|
+
} else {
|
|
37
|
+
setToken(token);
|
|
38
|
+
console.log('Token accepted.');
|
|
39
|
+
resolve();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await promptToken();
|
|
46
|
+
|
|
47
|
+
// --- Start server ---
|
|
48
|
+
const server = startServer();
|
|
49
|
+
console.log('Bridge ready on port 1676.');
|
|
50
|
+
console.log('Commands: r + Enter to reset token | Ctrl+C to quit');
|
|
51
|
+
|
|
52
|
+
// --- Listen for "r" to reset token ---
|
|
53
|
+
rl.on('line', async (line) => {
|
|
54
|
+
if (line.trim().toLowerCase() === 'r') {
|
|
55
|
+
clearToken();
|
|
56
|
+
console.log('Token cleared. Enter a new token:');
|
|
57
|
+
await promptToken();
|
|
58
|
+
console.log('Token updated. Bridge still running on port 1676.');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- Graceful shutdown ---
|
|
63
|
+
let shuttingDown = false;
|
|
64
|
+
|
|
65
|
+
function shutdown() {
|
|
66
|
+
if (shuttingDown) return;
|
|
67
|
+
shuttingDown = true;
|
|
68
|
+
console.log('\nShutting down...');
|
|
69
|
+
|
|
70
|
+
const forceExit = setTimeout(() => process.exit(0), 3000);
|
|
71
|
+
forceExit.unref();
|
|
72
|
+
|
|
73
|
+
killActive();
|
|
74
|
+
|
|
75
|
+
server.close(() => {
|
|
76
|
+
rl.close();
|
|
77
|
+
process.exit(0);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.on('SIGINT', shutdown);
|
|
82
|
+
process.on('SIGTERM', shutdown);
|
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const SECRET = '16pxh-bridge-v1';
|
|
4
|
+
|
|
5
|
+
/** @type {string | null} */
|
|
6
|
+
let storedHash = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hash a raw token with the shared secret (HMAC-SHA256).
|
|
10
|
+
* @param {string} rawToken
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function hashToken(rawToken) {
|
|
14
|
+
return createHmac('sha256', SECRET).update(rawToken).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hash rawToken and store it in memory.
|
|
19
|
+
* @param {string} rawToken
|
|
20
|
+
*/
|
|
21
|
+
export function setToken(rawToken) {
|
|
22
|
+
storedHash = hashToken(rawToken);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clear the stored token hash.
|
|
27
|
+
*/
|
|
28
|
+
export function clearToken() {
|
|
29
|
+
storedHash = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compare rawToken's hash against the stored hash.
|
|
34
|
+
* @param {string} rawToken
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function verifyToken(rawToken) {
|
|
38
|
+
if (!storedHash) return false;
|
|
39
|
+
return hashToken(rawToken) === storedHash;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Return true if a token has been set.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function hasToken() {
|
|
47
|
+
return storedHash !== null;
|
|
48
|
+
}
|
package/lib/claude.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/** @type {import('node:child_process').ChildProcess | null} */
|
|
7
|
+
let activeProcess = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run `claude --version` and return the version string, or null if not installed.
|
|
11
|
+
* @returns {Promise<string | null>}
|
|
12
|
+
*/
|
|
13
|
+
export async function checkClaudeInstalled() {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await execFileAsync('claude', ['--version']);
|
|
16
|
+
return stdout.trim() || null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spawn `claude -p <prompt> --output-format json`, resolve with parsed result.
|
|
24
|
+
* @param {string} prompt
|
|
25
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
26
|
+
* @returns {Promise<{ result: string, session_id: string, duration_ms: number }>}
|
|
27
|
+
*/
|
|
28
|
+
export function runPrompt(prompt, { timeoutMs = 60000 } = {}) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const args = ['-p', prompt, '--output-format', 'json'];
|
|
31
|
+
const child = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
32
|
+
|
|
33
|
+
activeProcess = child;
|
|
34
|
+
|
|
35
|
+
let stdout = '';
|
|
36
|
+
let stderr = '';
|
|
37
|
+
|
|
38
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
39
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
40
|
+
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
child.kill('SIGKILL');
|
|
43
|
+
reject(new Error(`Claude CLI timed out after ${timeoutMs}ms`));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (activeProcess === child) activeProcess = null;
|
|
49
|
+
|
|
50
|
+
if (code !== 0) {
|
|
51
|
+
return reject(new Error(`Claude CLI exited with code ${code}: ${stderr.trim()}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(stdout.trim());
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return reject(new Error(`Failed to parse Claude CLI output: ${err.message}\nRaw: ${stdout.slice(0, 200)}`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
resolve({
|
|
62
|
+
result: parsed.result ?? parsed.content ?? '',
|
|
63
|
+
session_id: parsed.session_id ?? '',
|
|
64
|
+
duration_ms: parsed.duration_ms ?? 0,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.on('error', (err) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (activeProcess === child) activeProcess = null;
|
|
71
|
+
reject(err);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Kill the currently running claude child process, if any.
|
|
78
|
+
*/
|
|
79
|
+
export function killActive() {
|
|
80
|
+
if (activeProcess) {
|
|
81
|
+
try {
|
|
82
|
+
activeProcess.kill('SIGKILL');
|
|
83
|
+
} catch {
|
|
84
|
+
// already gone
|
|
85
|
+
}
|
|
86
|
+
activeProcess = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { verifyToken, hasToken } from './auth.mjs';
|
|
3
|
+
import { checkClaudeInstalled, runPrompt } from './claude.mjs';
|
|
4
|
+
|
|
5
|
+
const PORT = 1676;
|
|
6
|
+
const HOST = '127.0.0.1';
|
|
7
|
+
|
|
8
|
+
const CORS_HEADERS = {
|
|
9
|
+
'Access-Control-Allow-Origin': '*',
|
|
10
|
+
'Access-Control-Allow-Headers': 'x-bridge-token, content-type',
|
|
11
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send a JSON response.
|
|
16
|
+
* @param {import('node:http').ServerResponse} res
|
|
17
|
+
* @param {number} status
|
|
18
|
+
* @param {object} data
|
|
19
|
+
*/
|
|
20
|
+
function json(res, status, data) {
|
|
21
|
+
const body = JSON.stringify(data);
|
|
22
|
+
res.writeHead(status, {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
'Content-Length': Buffer.byteLength(body),
|
|
25
|
+
...CORS_HEADERS,
|
|
26
|
+
});
|
|
27
|
+
res.end(body);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the full request body as a string.
|
|
32
|
+
* @param {import('node:http').IncomingMessage} req
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
function readBody(req) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let body = '';
|
|
38
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
39
|
+
req.on('end', () => resolve(body));
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Authenticate request via `x-bridge-token` header.
|
|
46
|
+
* Returns true if valid, writes 401/403 and returns false otherwise.
|
|
47
|
+
* @param {import('node:http').IncomingMessage} req
|
|
48
|
+
* @param {import('node:http').ServerResponse} res
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function authenticate(req, res) {
|
|
52
|
+
const token = req.headers['x-bridge-token'];
|
|
53
|
+
if (!token) {
|
|
54
|
+
json(res, 401, { error: 'Missing x-bridge-token header' });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (!verifyToken(token)) {
|
|
58
|
+
json(res, 403, { error: 'Invalid token' });
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create and start the HTTP server.
|
|
66
|
+
* @returns {import('node:http').Server}
|
|
67
|
+
*/
|
|
68
|
+
export function startServer() {
|
|
69
|
+
const server = createServer(async (req, res) => {
|
|
70
|
+
// Handle CORS preflight
|
|
71
|
+
if (req.method === 'OPTIONS') {
|
|
72
|
+
res.writeHead(204, CORS_HEADERS);
|
|
73
|
+
res.end();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const url = new URL(req.url, `http://${HOST}`);
|
|
78
|
+
|
|
79
|
+
// GET /health
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
81
|
+
if (!authenticate(req, res)) return;
|
|
82
|
+
|
|
83
|
+
const version = await checkClaudeInstalled();
|
|
84
|
+
json(res, 200, {
|
|
85
|
+
status: 'ok',
|
|
86
|
+
claude_cli: version !== null,
|
|
87
|
+
version: version ?? null,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// POST /prompt
|
|
93
|
+
if (req.method === 'POST' && url.pathname === '/prompt') {
|
|
94
|
+
if (!authenticate(req, res)) return;
|
|
95
|
+
|
|
96
|
+
let body;
|
|
97
|
+
try {
|
|
98
|
+
const raw = await readBody(req);
|
|
99
|
+
body = JSON.parse(raw);
|
|
100
|
+
} catch {
|
|
101
|
+
json(res, 400, { error: 'Invalid JSON body' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { prompt, images } = body;
|
|
106
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
107
|
+
json(res, 400, { error: 'Missing or invalid "prompt" field' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let fullPrompt = prompt;
|
|
112
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
113
|
+
for (const url of images) {
|
|
114
|
+
fullPrompt += `\n\nAnalyze this image: ${url}`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const { result, session_id, duration_ms } = await runPrompt(fullPrompt);
|
|
120
|
+
json(res, 200, { success: true, result, session_id, duration_ms });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
json(res, 500, { error: err.message });
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 404 fallback
|
|
128
|
+
json(res, 404, { error: 'Not found' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
server.listen(PORT, HOST);
|
|
132
|
+
return server;
|
|
133
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@16pxh/cli-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bridge local Claude CLI to 16pxh AI features",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"16pxh-bridge": "./bin/bridge.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": ["bin/", "lib/", "README.md"],
|
|
10
|
+
"keywords": ["16pxh", "claude", "cli", "bridge"],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"engines": { "node": ">=18" }
|
|
13
|
+
}
|