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