tabclaude-host 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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # tabclaude-host
2
+
3
+ Chrome Native Messaging Host for [Tabclaude](https://github.com/sanghun0724/tabclaude) — AI Tab Manager.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js >= 18
8
+ - [Claude CLI](https://github.com/anthropics/claude-code) installed (`npm install -g @anthropic-ai/claude-code`)
9
+ - Tabclaude Chrome extension loaded
10
+
11
+ ## Installation
12
+
13
+ **Step 1 — Install the native host:**
14
+ ```
15
+ npm install -g tabclaude-host
16
+ ```
17
+
18
+ **Step 2 — Register with Chrome:**
19
+ ```
20
+ tabclaude-host setup <your-extension-id>
21
+ ```
22
+
23
+ To find your extension ID: open `chrome://extensions`, enable Developer mode, and copy the ID shown under Tabclaude.
24
+
25
+ ## Uninstall
26
+
27
+ ```
28
+ npm uninstall -g tabclaude-host
29
+ ```
30
+
31
+ ## Troubleshooting
32
+
33
+ - Logs: `/tmp/tabclaude-host.log`
34
+ - If Claude features don't work, verify Claude CLI is installed: `which claude`
35
+ - After updating Node.js or Claude CLI, re-run `tabclaude-host setup <extension-id>`
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,95 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stderr } from "node:process";
3
+
4
+ const claudeBin = process.env.CLAUDE_PATH || "claude";
5
+ const CLAUDE_TIMEOUT = 30000;
6
+
7
+ function runClaude(prompt) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = execFile(
10
+ claudeBin,
11
+ ["--print", "--", prompt],
12
+ { timeout: CLAUDE_TIMEOUT, maxBuffer: 1024 * 1024 },
13
+ (err, stdoutData, stderrData) => {
14
+ if (err) {
15
+ if (stderrData) stderr.write(`claude stderr: ${stderrData}\n`);
16
+ reject(new Error(`Claude CLI failed: ${err.message}`));
17
+ return;
18
+ }
19
+ resolve(stdoutData.trim());
20
+ },
21
+ );
22
+ // Close stdin so claude CLI doesn't wait for input
23
+ child.stdin.end();
24
+ });
25
+ }
26
+
27
+ function parseJsonResponse(text) {
28
+ // Try extracting JSON from markdown code block first
29
+ const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
30
+ const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : text.trim();
31
+ return JSON.parse(jsonStr);
32
+ }
33
+
34
+ export async function analyzeWithClaude(payload) {
35
+ const { tabs, settings, existingGroups = [] } = payload;
36
+
37
+ const existingGroupsSection = existingGroups.length > 0
38
+ ? `\nExisting tab groups (already created by the user):\n${JSON.stringify(existingGroups, null, 2)}\n`
39
+ : "";
40
+
41
+ const prompt = `You are a tab management assistant. Analyze these browser tabs and respond with ONLY valid JSON (no markdown, no explanation).
42
+
43
+ Tabs (groupId -1 means ungrouped):
44
+ ${JSON.stringify(tabs, null, 2)}
45
+ ${existingGroupsSection}
46
+ User settings:
47
+ - Autonomy level: ${settings.autonomyLevel}
48
+ - Tab threshold: ${settings.tabThreshold}
49
+
50
+ Respond with this exact JSON structure:
51
+ {
52
+ "categories": [{"name": "string", "tabIds": [numbers]}],
53
+ "groups": [{"name": "string", "color": "blue|red|yellow|green|pink|purple|cyan|orange|grey", "tabIds": [numbers]}],
54
+ "closeSuggestions": [{"tabId": number, "reason": "string"}]
55
+ }
56
+
57
+ Rules:
58
+ - Group related tabs by topic/project
59
+ - Suggest closing tabs that are clearly inactive or duplicated
60
+ - Be ${settings.autonomyLevel === "conservative" ? "very conservative — only suggest obvious duplicates" : settings.autonomyLevel === "aggressive" ? "aggressive — suggest closing anything not recently used" : "balanced — suggest closing old/duplicate tabs"}
61
+ - Use short, descriptive group names
62
+ - Color-code groups logically (dev=blue, social=pink, docs=green, etc.)
63
+ ${existingGroups.length > 0 ? `- IMPORTANT: Do NOT create duplicate groups with the same or similar names as existing groups above
64
+ - If ungrouped tabs (groupId: -1) fit an existing group, suggest adding them to that group (use the same group name and color)
65
+ - Only propose a NEW group when no existing group covers the topic` : ""}`;
66
+
67
+ const response = await runClaude(prompt);
68
+ return parseJsonResponse(response);
69
+ }
70
+
71
+ export async function askClaude(payload) {
72
+ const { action, query, savedTabs } = payload;
73
+
74
+ if (action === "restore") {
75
+ const prompt = `You are a tab management assistant. The user wants to restore tabs matching this request: "${query}"
76
+
77
+ Saved tabs:
78
+ ${JSON.stringify(savedTabs ?? [], null, 2)}
79
+
80
+ Respond with ONLY valid JSON:
81
+ {"urls": ["url1", "url2"]}
82
+
83
+ Return only URLs that match the user's request. If no tabs match, return {"urls": []}.`;
84
+
85
+ const response = await runClaude(prompt);
86
+ return parseJsonResponse(response);
87
+ }
88
+
89
+ // General query
90
+ const prompt = `You are a tab management assistant. Answer this question concisely: "${query}"
91
+ Respond with ONLY valid JSON: {"answer": "your answer here"}`;
92
+
93
+ const response = await runClaude(prompt);
94
+ return parseJsonResponse(response);
95
+ }
package/host.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { stdin, stdout, stderr } from "node:process";
4
+ import { analyzeWithClaude, askClaude } from "./claude-client.js";
5
+
6
+ // Setup subcommand
7
+ if (process.argv[2] === "setup") {
8
+ const { setup } = await import("./scripts/setup.js");
9
+ await setup();
10
+ process.exit(0);
11
+ }
12
+
13
+ // Chrome Native Messaging uses length-prefixed messages (4-byte LE header).
14
+ // We use a persistent readable listener to keep the process alive.
15
+
16
+ let buffer = Buffer.alloc(0);
17
+
18
+ stdin.on("readable", () => {
19
+ let chunk;
20
+ while ((chunk = stdin.read()) !== null) {
21
+ buffer = Buffer.concat([buffer, chunk]);
22
+ processBuffer();
23
+ }
24
+ });
25
+
26
+ stdin.on("end", () => {
27
+ stderr.write("Tabclaude Native Host: stdin closed\n");
28
+ process.exit(0);
29
+ });
30
+
31
+ function processBuffer() {
32
+ while (buffer.length >= 4) {
33
+ const msgLen = buffer.readUInt32LE(0);
34
+ const MAX_MSG_SIZE = 1024 * 1024; // 1MB
35
+ if (msgLen > MAX_MSG_SIZE) {
36
+ stderr.write(`[Tabclaude] Message too large: ${msgLen} bytes\n`);
37
+ process.exit(1);
38
+ }
39
+ if (buffer.length < 4 + msgLen) break;
40
+
41
+ const body = buffer.subarray(4, 4 + msgLen).toString("utf-8");
42
+ buffer = buffer.subarray(4 + msgLen);
43
+
44
+ try {
45
+ const message = JSON.parse(body);
46
+ handleMessage(message).catch((err) => {
47
+ stderr.write(`[Tabclaude] Unhandled error: ${err.message}\n`);
48
+ });
49
+ } catch (err) {
50
+ stderr.write(`Parse error: ${err.message}\n`);
51
+ }
52
+ }
53
+ }
54
+
55
+ function sendMessage(message) {
56
+ const json = JSON.stringify(message);
57
+ const buf = Buffer.from(json, "utf-8");
58
+ const header = Buffer.alloc(4);
59
+ header.writeUInt32LE(buf.length, 0);
60
+ stdout.write(header);
61
+ stdout.write(buf);
62
+ }
63
+
64
+ async function handleMessage(message) {
65
+ try {
66
+ switch (message.type) {
67
+ case "ANALYZE_TABS": {
68
+ const analysis = await analyzeWithClaude(message.payload);
69
+ sendMessage({ type: "ANALYZE_TABS", success: true, data: analysis });
70
+ break;
71
+ }
72
+ case "ASK_CLAUDE": {
73
+ const result = await askClaude(message.payload);
74
+ sendMessage({ type: "ASK_CLAUDE", success: true, data: result });
75
+ break;
76
+ }
77
+ case "PING": {
78
+ sendMessage({ type: "PING", success: true, data: { pong: true } });
79
+ break;
80
+ }
81
+ default:
82
+ sendMessage({
83
+ type: message.type,
84
+ success: false,
85
+ error: `Unknown message type: ${message.type}`,
86
+ });
87
+ }
88
+ } catch (err) {
89
+ stderr.write(`Error: ${err.message}\n`);
90
+ sendMessage({
91
+ type: message.type,
92
+ success: false,
93
+ error: err.message,
94
+ });
95
+ }
96
+ }
97
+
98
+ stderr.write("Tabclaude Native Host started\n");
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "tabclaude-host",
3
+ "version": "0.1.0",
4
+ "description": "Tabclaude Native Messaging Host with Claude CLI integration",
5
+ "author": "Brady",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "tabclaude-host": "host.js"
10
+ },
11
+ "files": [
12
+ "host.js",
13
+ "claude-client.js",
14
+ "scripts/"
15
+ ],
16
+ "scripts": {
17
+ "build": "echo 'No build needed for native host'",
18
+ "dev": "node host.js",
19
+ "postinstall": "node scripts/register.js",
20
+ "preuninstall": "node scripts/unregister.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "chrome-extension",
27
+ "native-messaging",
28
+ "claude",
29
+ "tabclaude",
30
+ "tab-manager"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/sanghun0724/tabclaude.git"
35
+ },
36
+ "dependencies": {}
37
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ console.log(
4
+ "\n[tabclaude-host] Installed successfully!\n" +
5
+ "To complete setup, run:\n" +
6
+ " tabclaude-host setup <your-extension-id>\n\n" +
7
+ "Find your extension ID at chrome://extensions (Developer mode on).\n"
8
+ );
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { platform, homedir } from "node:os";
7
+ import { execSync } from "node:child_process";
8
+
9
+ const HOST_NAME = "com.tabclaude.host";
10
+
11
+ function findClaude() {
12
+ // 1. which claude
13
+ try {
14
+ const p = execSync("which claude", { encoding: "utf-8" }).trim();
15
+ if (p) return p;
16
+ } catch {}
17
+
18
+ // 2. sibling of node
19
+ const sibling = join(dirname(process.execPath), "claude");
20
+ if (existsSync(sibling)) return sibling;
21
+
22
+ // 3. /usr/local/bin/claude
23
+ if (existsSync("/usr/local/bin/claude")) return "/usr/local/bin/claude";
24
+
25
+ // 4. /opt/homebrew/bin/claude
26
+ if (existsSync("/opt/homebrew/bin/claude")) return "/opt/homebrew/bin/claude";
27
+
28
+ // 5. fallback
29
+ console.warn(
30
+ `[${HOST_NAME}] WARNING: Claude CLI not found. Install it and ensure it's in PATH.`
31
+ );
32
+ return "claude";
33
+ }
34
+
35
+ function getManifestDir() {
36
+ const os = platform();
37
+ const home = homedir();
38
+
39
+ if (os === "darwin") {
40
+ return join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
41
+ }
42
+ if (os === "linux") {
43
+ return join(home, ".config", "google-chrome", "NativeMessagingHosts");
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export async function setup() {
49
+ // argv[3] when called via "host.js setup <id>", argv[2] when called directly
50
+ const extensionId = process.argv[2] === "setup" ? process.argv[3] : process.argv[2];
51
+
52
+ if (!extensionId) {
53
+ console.log(
54
+ `\nUsage: tabclaude-host setup <extension-id>\n\n` +
55
+ ` <extension-id> Your Chrome extension ID (32 lowercase letters).\n` +
56
+ ` Find it at chrome://extensions (Developer mode on).\n`
57
+ );
58
+ return;
59
+ }
60
+
61
+ if (!/^[a-z]{32}$/.test(extensionId)) {
62
+ console.error(
63
+ `[${HOST_NAME}] Invalid extension ID: "${extensionId}"\n` +
64
+ ` Must be exactly 32 lowercase letters (a-z).`
65
+ );
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ const manifestDir = getManifestDir();
71
+ if (!manifestDir) {
72
+ console.error(
73
+ `[${HOST_NAME}] Unsupported platform: ${platform()}.\n` +
74
+ ` Only macOS and Linux are supported for automatic setup.`
75
+ );
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+
80
+ const __dirname = dirname(fileURLToPath(import.meta.url));
81
+ const hostDir = resolve(__dirname, "..");
82
+ const hostScript = resolve(hostDir, "host.js");
83
+ const wrapperPath = resolve(hostDir, "run-host.sh");
84
+
85
+ const nodePath = process.execPath;
86
+ const claudePath = findClaude();
87
+
88
+ // Generate run-host.sh
89
+ const wrapperContent =
90
+ `#!/bin/bash\n` +
91
+ `DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
92
+ `NODE="${nodePath}"\n` +
93
+ `if [ ! -x "$NODE" ]; then NODE=$(which node 2>/dev/null || echo node); fi\n` +
94
+ `export CLAUDE_PATH="${claudePath}"\n` +
95
+ `exec "$NODE" "$DIR/host.js" 2>>/tmp/tabclaude-host.log\n`;
96
+
97
+ writeFileSync(wrapperPath, wrapperContent, "utf-8");
98
+ chmodSync(wrapperPath, 0o755);
99
+
100
+ // Write Chrome NMH manifest
101
+ const manifest = {
102
+ name: HOST_NAME,
103
+ description: "Tabclaude — AI Tab Manager Native Messaging Host",
104
+ path: wrapperPath,
105
+ type: "stdio",
106
+ allowed_origins: [`chrome-extension://${extensionId}/`],
107
+ };
108
+
109
+ mkdirSync(manifestDir, { recursive: true });
110
+ const manifestPath = join(manifestDir, `${HOST_NAME}.json`);
111
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
112
+
113
+ const claudeDisplay =
114
+ claudePath === "claude"
115
+ ? "claude (not found — install Claude CLI)"
116
+ : claudePath;
117
+
118
+ console.log(
119
+ `\n[${HOST_NAME}] Setup complete!\n` +
120
+ ` Extension ID: ${extensionId}\n` +
121
+ ` Manifest: ${manifestPath}\n` +
122
+ ` Wrapper: ${wrapperPath}\n` +
123
+ ` Node: ${nodePath}\n` +
124
+ ` Claude: ${claudeDisplay}\n\n` +
125
+ `Restart Chrome and your extension should connect.\n`
126
+ );
127
+ }
128
+
129
+ // Run only when invoked directly (not imported from host.js)
130
+ const __setupFile = fileURLToPath(import.meta.url);
131
+ if (process.argv[1] === __setupFile || process.argv[1]?.endsWith("/scripts/setup.js")) {
132
+ setup().catch((err) => {
133
+ console.error(`[${HOST_NAME}] Setup failed: ${err.message}`);
134
+ process.exitCode = 1;
135
+ });
136
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { platform, homedir } from "node:os";
6
+
7
+ const HOST_NAME = "com.tabclaude.host";
8
+
9
+ function getManifestDir() {
10
+ const os = platform();
11
+ const home = homedir();
12
+
13
+ if (os === "darwin") {
14
+ return join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
15
+ }
16
+ if (os === "linux") {
17
+ return join(home, ".config", "google-chrome", "NativeMessagingHosts");
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ async function unregister() {
24
+ const manifestDir = getManifestDir();
25
+
26
+ if (manifestDir === null) {
27
+ if (platform() === "win32") {
28
+ console.log(
29
+ `[${HOST_NAME}] Windows detected — remove the registry key manually:\n` +
30
+ ` HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`
31
+ );
32
+ }
33
+ return;
34
+ }
35
+
36
+ const manifestPath = join(manifestDir, `${HOST_NAME}.json`);
37
+
38
+ try {
39
+ await unlink(manifestPath);
40
+ console.log(`[${HOST_NAME}] Native messaging host unregistered.`);
41
+ console.log(` Removed: ${manifestPath}`);
42
+ } catch (err) {
43
+ if (err.code === "ENOENT") {
44
+ console.log(`[${HOST_NAME}] Manifest not found — nothing to remove.`);
45
+ } else {
46
+ console.error(`[${HOST_NAME}] Failed to remove manifest: ${err.message}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ unregister().catch((err) => {
52
+ console.error(`[${HOST_NAME}] Unregistration failed: ${err.message}`);
53
+ process.exitCode = 1;
54
+ });