@openthread/claude-code-plugin 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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "openthread-share",
3
+ "version": "0.1.0",
4
+ "description": "Share Claude Code conversations to OpenThread",
5
+ "icon": "icon.svg",
6
+ "author": {
7
+ "name": "OpenThread"
8
+ }
9
+ }
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @openthread/claude-code-plugin
2
+
3
+ Share Claude Code conversations to [OpenThread](https://openthread.dev) -- a Reddit-like platform for AI conversation threads.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@openthread/claude-code-plugin)](https://www.npmjs.com/package/@openthread/claude-code-plugin)
6
+ [![license](https://img.shields.io/npm/l/@openthread/claude-code-plugin)](https://github.com/nicholasgriffintn/openthread/blob/main/LICENSE)
7
+ [![downloads](https://img.shields.io/npm/dm/@openthread/claude-code-plugin)](https://www.npmjs.com/package/@openthread/claude-code-plugin)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i -g @openthread/claude-code-plugin
13
+ ```
14
+
15
+ The postinstall script registers the plugin with Claude Code automatically.
16
+
17
+ ## Quick Start
18
+
19
+ ```
20
+ You> /share fixing auth bug
21
+
22
+ Sharing conversation to OpenThread...
23
+ Authenticated as @yourname
24
+ Auto-generated title: "Debugging PKCE token refresh in auth middleware"
25
+ Posted to c/ClaudeCode
26
+
27
+ https://openthread.dev/c/ClaudeCode/posts/abc123
28
+ ```
29
+
30
+ ## Features
31
+
32
+ - **One-command sharing** -- run `/share` inside any Claude Code session to publish the conversation to OpenThread.
33
+ - **Quick mode** -- `/share <description>` auto-generates a title, selects a community, and posts immediately.
34
+ - **Interactive mode** -- `/share` with no arguments prompts you to choose a title, community, and tags.
35
+ - **Secure auth** -- PKCE OAuth flow with automatic token refresh. Credentials are stored locally.
36
+ - **CLI management** -- `openthread-claude` binary for install, uninstall, status checks, and updates.
37
+
38
+ ## Usage
39
+
40
+ ### Quick mode
41
+
42
+ ```
43
+ /share fixing auth bug
44
+ ```
45
+
46
+ Provide a short description after `/share`. The plugin generates a title from the conversation context, picks the best-matching community, and posts without further prompts.
47
+
48
+ ### Interactive mode
49
+
50
+ ```
51
+ /share
52
+ ```
53
+
54
+ Run with no arguments to step through each field:
55
+
56
+ 1. Title (suggested from conversation, editable)
57
+ 2. Community (searchable list)
58
+ 3. Tags (optional, comma-separated)
59
+
60
+ ## CLI Commands
61
+
62
+ | Command | Description |
63
+ | --- | --- |
64
+ | `openthread-claude install` | Register the plugin with Claude Code |
65
+ | `openthread-claude uninstall` | Remove the plugin from Claude Code |
66
+ | `openthread-claude status` | Show current auth and plugin state |
67
+ | `openthread-claude update` | Pull the latest plugin version |
68
+
69
+ ## Configuration
70
+
71
+ Environment variables override the default endpoints. Set them in your shell profile or `.env` file.
72
+
73
+ | Variable | Default | Description |
74
+ | --- | --- | --- |
75
+ | `OPENTHREAD_API_URL` | `https://api.openthread.dev` | Backend API base URL |
76
+ | `OPENTHREAD_WEB_URL` | `https://openthread.dev` | Web app base URL (used for post links) |
77
+
78
+ ## Manual Install
79
+
80
+ If you prefer not to use npm:
81
+
82
+ 1. Download the latest release `.zip` from the [GitHub releases page](https://github.com/nicholasgriffintn/openthread/releases).
83
+ 2. Extract it to `~/.claude/plugins/openthread-share/`.
84
+ 3. Restart Claude Code. The plugin will be detected on next launch.
85
+
86
+ ```bash
87
+ mkdir -p ~/.claude/plugins/openthread-share
88
+ unzip openthread-claude-code-plugin.zip -d ~/.claude/plugins/openthread-share/
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT
package/bin/cli.sh ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # CLI for managing the OpenThread Claude Code plugin
3
+ set -euo pipefail
4
+
5
+ PLUGIN_NAME="openthread-share"
6
+ PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ DEST_DIR="$HOME/.claude/plugins/$PLUGIN_NAME"
8
+
9
+ usage() {
10
+ echo "Usage: openthread-claude <command>"
11
+ echo ""
12
+ echo "Commands:"
13
+ echo " install Install plugin to ~/.claude/plugins/"
14
+ echo " uninstall Remove plugin from ~/.claude/plugins/"
15
+ echo " status Check if plugin is installed"
16
+ echo " update Reinstall plugin (update to current version)"
17
+ }
18
+
19
+ install_plugin() {
20
+ mkdir -p "$DEST_DIR"
21
+
22
+ for dir in .claude-plugin commands skills scripts; do
23
+ if [ -d "$PLUGIN_DIR/$dir" ]; then
24
+ cp -r "$PLUGIN_DIR/$dir" "$DEST_DIR/"
25
+ fi
26
+ done
27
+
28
+ if [ -f "$PLUGIN_DIR/icon.svg" ]; then
29
+ cp "$PLUGIN_DIR/icon.svg" "$DEST_DIR/"
30
+ fi
31
+
32
+ chmod +x "$DEST_DIR/scripts/"*.sh 2>/dev/null || true
33
+
34
+ VERSION=$(python3 -c "import json; print(json.load(open('$DEST_DIR/.claude-plugin/plugin.json'))['version'])")
35
+ echo "✓ OpenThread plugin v$VERSION installed to $DEST_DIR"
36
+ echo " Use /share in Claude Code to share conversations."
37
+ }
38
+
39
+ uninstall_plugin() {
40
+ if [ -d "$DEST_DIR" ]; then
41
+ rm -rf "$DEST_DIR"
42
+ echo "✓ OpenThread plugin removed from $DEST_DIR"
43
+ else
44
+ echo "Plugin is not installed."
45
+ fi
46
+ }
47
+
48
+ check_status() {
49
+ if [ -d "$DEST_DIR" ] && [ -f "$DEST_DIR/.claude-plugin/plugin.json" ]; then
50
+ VERSION=$(python3 -c "import json; print(json.load(open('$DEST_DIR/.claude-plugin/plugin.json'))['version'])")
51
+ echo "✓ OpenThread plugin v$VERSION is installed at $DEST_DIR"
52
+ else
53
+ echo "✗ OpenThread plugin is not installed."
54
+ echo " Run: openthread-claude install"
55
+ fi
56
+ }
57
+
58
+ case "${1:-}" in
59
+ install) install_plugin ;;
60
+ uninstall|remove) uninstall_plugin ;;
61
+ status) check_status ;;
62
+ update) install_plugin ;;
63
+ *) usage ;;
64
+ esac
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Auto-install plugin files to ~/.claude/plugins/ on npm install
3
+ const { existsSync, mkdirSync, cpSync, chmodSync, readdirSync, readFileSync, writeFileSync } = require("fs");
4
+ const { join, resolve } = require("path");
5
+ const { homedir } = require("os");
6
+
7
+ const PLUGIN_NAME = "openthread-share";
8
+ const pluginSrc = resolve(__dirname, "..");
9
+ const pluginDest = join(homedir(), ".claude", "plugins", PLUGIN_NAME);
10
+
11
+ const DIRS_TO_COPY = [".claude-plugin", "commands", "skills", "scripts"];
12
+ const FILES_TO_COPY = ["icon.svg"];
13
+
14
+ try {
15
+ mkdirSync(pluginDest, { recursive: true });
16
+
17
+ for (const dir of DIRS_TO_COPY) {
18
+ const src = join(pluginSrc, dir);
19
+ if (existsSync(src)) {
20
+ cpSync(src, join(pluginDest, dir), { recursive: true });
21
+ }
22
+ }
23
+
24
+ for (const file of FILES_TO_COPY) {
25
+ const src = join(pluginSrc, file);
26
+ if (existsSync(src)) {
27
+ cpSync(src, join(pluginDest, file));
28
+ }
29
+ }
30
+
31
+ // Sync version from package.json → plugin.json
32
+ const pkgJsonPath = join(pluginSrc, "package.json");
33
+ const pluginJsonPath = join(pluginDest, ".claude-plugin", "plugin.json");
34
+ if (existsSync(pkgJsonPath) && existsSync(pluginJsonPath)) {
35
+ const version = JSON.parse(readFileSync(pkgJsonPath, "utf8")).version;
36
+ const pluginJson = JSON.parse(readFileSync(pluginJsonPath, "utf8"));
37
+ pluginJson.version = version;
38
+ writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2) + "\n");
39
+ }
40
+
41
+ // Ensure scripts are executable
42
+ const scriptsDir = join(pluginDest, "scripts");
43
+ if (existsSync(scriptsDir)) {
44
+ for (const f of readdirSync(scriptsDir)) {
45
+ if (f.endsWith(".sh")) {
46
+ chmodSync(join(scriptsDir, f), 0o755);
47
+ }
48
+ }
49
+ }
50
+
51
+ const version = JSON.parse(readFileSync(join(pluginSrc, "package.json"), "utf8")).version;
52
+ console.log(`\x1b[32m✓\x1b[0m OpenThread plugin v${version} installed to ${pluginDest}`);
53
+ console.log(` Use \x1b[1m/share\x1b[0m in Claude Code to share conversations.`);
54
+ } catch (err) {
55
+ console.error(`Warning: Could not auto-install plugin: ${err.message}`);
56
+ console.error(`You can manually install by copying plugin files to ${pluginDest}`);
57
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: share
3
+ description: Share the current conversation to OpenThread
4
+ allowed-tools: Bash, Read, Write, AskUserQuestion
5
+ ---
6
+
7
+ Share this Claude Code conversation to OpenThread using the `share-thread` skill.
8
+
9
+ Arguments: $ARGUMENTS
10
+
11
+ If arguments are provided, use quick mode — share directly without asking questions. Otherwise, ask the user for title, community, and tags.
12
+
13
+ Follow the instructions in the `share-thread` skill to complete the sharing process.
package/icon.svg ADDED
@@ -0,0 +1,35 @@
1
+ <svg width="128" height="128" viewBox="70 120 180 180" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Outer ring -->
3
+ <circle cx="160" cy="210" r="72" fill="none" stroke="#3B8BD4" stroke-width="2" opacity="0.18"/>
4
+
5
+ <!-- Chat bubble 1 (AI) -->
6
+ <rect x="100" y="168" width="84" height="52" rx="14" fill="#3B8BD4" opacity="0.15"/>
7
+ <rect x="100" y="168" width="84" height="52" rx="14" fill="none" stroke="#3B8BD4" stroke-width="1.5" opacity="0.55"/>
8
+ <path d="M108 220 L100 234 L120 220Z" fill="#3B8BD4" opacity="0.55"/>
9
+
10
+ <!-- Thread lines in bubble 1 -->
11
+ <line x1="114" y1="184" x2="170" y2="184" stroke="#3B8BD4" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
12
+ <line x1="114" y1="196" x2="158" y2="196" stroke="#3B8BD4" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
13
+ <line x1="114" y1="208" x2="148" y2="208" stroke="#3B8BD4" stroke-width="1.5" stroke-linecap="round" opacity="0.25"/>
14
+
15
+ <!-- Chat bubble 2 (user) -->
16
+ <rect x="140" y="236" width="72" height="44" rx="12" fill="#1D9E75" opacity="0.12"/>
17
+ <rect x="140" y="236" width="72" height="44" rx="12" fill="none" stroke="#1D9E75" stroke-width="1.5" opacity="0.5"/>
18
+ <path d="M204 264 L214 276 L198 264Z" fill="#1D9E75" opacity="0.5"/>
19
+
20
+ <!-- Thread lines in bubble 2 -->
21
+ <line x1="154" y1="251" x2="200" y2="251" stroke="#1D9E75" stroke-width="1.8" stroke-linecap="round" opacity="0.65"/>
22
+ <line x1="154" y1="263" x2="190" y2="263" stroke="#1D9E75" stroke-width="1.5" stroke-linecap="round" opacity="0.35"/>
23
+
24
+ <!-- Connector dots -->
25
+ <circle cx="160" cy="233" r="3" fill="#3B8BD4" opacity="0.5"/>
26
+ <circle cx="160" cy="235" r="1.5" fill="#1D9E75" opacity="0.5"/>
27
+
28
+ <!-- Upvote arrow -->
29
+ <path d="M113 153 L120 142 L127 153Z" fill="#EF9F27" stroke="#EF9F27" stroke-width="0.5" stroke-linejoin="round" opacity="0.75"/>
30
+ <line x1="120" y1="153" x2="120" y2="163" stroke="#EF9F27" stroke-width="2" stroke-linecap="round" opacity="0.75"/>
31
+
32
+ <!-- Score pill -->
33
+ <rect x="130" y="145" width="28" height="16" rx="8" fill="#EF9F27" opacity="0.15"/>
34
+ <rect x="130" y="145" width="28" height="16" rx="8" fill="none" stroke="#EF9F27" stroke-width="1" opacity="0.4"/>
35
+ </svg>
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@openthread/claude-code-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Share Claude Code conversations to OpenThread",
5
+ "bin": {
6
+ "openthread-claude": "bin/cli.sh"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ ".claude-plugin/",
11
+ "commands/",
12
+ "skills/",
13
+ "scripts/auth.sh",
14
+ "scripts/share.sh",
15
+ "scripts/token.sh",
16
+ "icon.svg"
17
+ ],
18
+ "scripts": {
19
+ "prepack": "node -e \"const fs=require('fs');const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;const p=JSON.parse(fs.readFileSync('.claude-plugin/plugin.json','utf8'));p.version=v;fs.writeFileSync('.claude-plugin/plugin.json',JSON.stringify(p,null,2)+'\\n')\"",
20
+ "postinstall": "node bin/postinstall.js"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "claude",
25
+ "openthread",
26
+ "plugin",
27
+ "ai",
28
+ "conversation",
29
+ "share"
30
+ ],
31
+ "author": "OpenThread",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/nicholasgriffintn/openthread.git",
36
+ "directory": "apps/claude-code-plugin"
37
+ }
38
+ }
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bash
2
+ # OpenThread PKCE auth flow for Claude Code plugin
3
+ # Opens browser, runs a local callback server, exchanges code for tokens
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ # shellcheck source=token.sh
8
+ source "$SCRIPT_DIR/token.sh"
9
+
10
+ API_BASE="${OPENTHREAD_API_URL:-https://api.openthread.dev}"
11
+ WEB_BASE="${OPENTHREAD_WEB_URL:-https://openthread.dev}"
12
+ EXTENSION_ID="claude-code"
13
+ CALLBACK_PORT=18923
14
+ REDIRECT_URI="http://localhost:${CALLBACK_PORT}/callback"
15
+
16
+ # --- PKCE generation ---
17
+
18
+ generate_pkce() {
19
+ # Generate a 32-byte random code_verifier (base64url, 43 chars)
20
+ CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
21
+
22
+ # SHA-256 hash → base64url = code_challenge
23
+ CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
24
+ }
25
+
26
+ # --- Main auth flow ---
27
+
28
+ main() {
29
+ echo "Starting OpenThread authentication..." >&2
30
+
31
+ generate_pkce
32
+
33
+ # Create a temp file for server output
34
+ AUTH_OUTPUT=$(mktemp)
35
+ trap 'rm -f "$AUTH_OUTPUT"' EXIT
36
+
37
+ # Start local callback server, capturing output
38
+ python3 -c "
39
+ import http.server
40
+ import urllib.parse
41
+
42
+ class Handler(http.server.BaseHTTPRequestHandler):
43
+ def do_GET(self):
44
+ parsed = urllib.parse.urlparse(self.path)
45
+ params = urllib.parse.parse_qs(parsed.query)
46
+
47
+ if parsed.path == '/callback' and 'code' in params:
48
+ code = params['code'][0]
49
+ self.send_response(200)
50
+ self.send_header('Content-Type', 'text/html')
51
+ self.end_headers()
52
+ self.wfile.write(b'<html><body><h2>Authorization successful!</h2><p>You can close this tab and return to Claude Code.</p><script>window.close()</script></body></html>')
53
+ with open('$AUTH_OUTPUT', 'w') as f:
54
+ f.write(code)
55
+ else:
56
+ self.send_response(404)
57
+ self.end_headers()
58
+
59
+ def log_message(self, format, *args):
60
+ pass
61
+
62
+ try:
63
+ server = http.server.HTTPServer(('127.0.0.1', ${CALLBACK_PORT}), Handler)
64
+ server.timeout = 120 # 2 minute timeout
65
+ server.handle_request()
66
+ except OSError as e:
67
+ with open('$AUTH_OUTPUT', 'w') as f:
68
+ f.write('ERROR:' + str(e))
69
+ " 2>/dev/null &
70
+ SERVER_PID=$!
71
+ sleep 0.3
72
+
73
+ # Check server started
74
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
75
+ echo "ERROR: Could not start callback server on port ${CALLBACK_PORT}." >&2
76
+ echo "FALLBACK: Manual auth required." >&2
77
+ manual_auth
78
+ return $?
79
+ fi
80
+
81
+ # Build authorization URL and open browser
82
+ AUTH_URL="${WEB_BASE}/extension/callback?code_challenge=${CODE_CHALLENGE}&extension_id=${EXTENSION_ID}&redirect_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REDIRECT_URI}', safe=''))")"
83
+
84
+ echo "Opening browser for authorization..." >&2
85
+
86
+ # Open browser (macOS: open, Linux: xdg-open)
87
+ if command -v open &>/dev/null; then
88
+ open "$AUTH_URL"
89
+ elif command -v xdg-open &>/dev/null; then
90
+ xdg-open "$AUTH_URL"
91
+ else
92
+ echo "Please open this URL in your browser:" >&2
93
+ echo "$AUTH_URL" >&2
94
+ fi
95
+
96
+ echo "Waiting for authorization (up to 2 minutes)..." >&2
97
+
98
+ # Wait for the server to receive the callback
99
+ wait "$SERVER_PID" 2>/dev/null || true
100
+
101
+ # Read the auth code from the temp file
102
+ if [ ! -s "$AUTH_OUTPUT" ]; then
103
+ echo "ERROR: No authorization code received (timed out or server error)." >&2
104
+ echo "FALLBACK: Manual auth required." >&2
105
+ manual_auth
106
+ return $?
107
+ fi
108
+
109
+ AUTH_CODE=$(cat "$AUTH_OUTPUT")
110
+
111
+ # Check for errors
112
+ if [[ "$AUTH_CODE" == ERROR:* ]]; then
113
+ echo "ERROR: Server failed: ${AUTH_CODE#ERROR:}" >&2
114
+ manual_auth
115
+ return $?
116
+ fi
117
+
118
+ # Exchange the code for tokens
119
+ exchange_code "$AUTH_CODE"
120
+ }
121
+
122
+ # --- Manual fallback ---
123
+
124
+ manual_auth() {
125
+ generate_pkce
126
+
127
+ AUTH_URL="${WEB_BASE}/extension/callback?code_challenge=${CODE_CHALLENGE}&extension_id=${EXTENSION_ID}&redirect_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REDIRECT_URI}', safe=''))")"
128
+
129
+ echo "" >&2
130
+ echo "Open this URL in your browser:" >&2
131
+ echo "$AUTH_URL" >&2
132
+ echo "" >&2
133
+ echo "After authorizing, copy the 'code' parameter from the redirect URL." >&2
134
+ echo -n "Paste the authorization code here: " >&2
135
+ read -r AUTH_CODE
136
+
137
+ if [ -z "$AUTH_CODE" ]; then
138
+ echo "ERROR: No code provided." >&2
139
+ return 1
140
+ fi
141
+
142
+ exchange_code "$AUTH_CODE"
143
+ }
144
+
145
+ # --- Token exchange ---
146
+
147
+ exchange_code() {
148
+ local auth_code="$1"
149
+
150
+ echo "Exchanging authorization code for tokens..." >&2
151
+
152
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/api/auth/extension/token" \
153
+ -H "Content-Type: application/json" \
154
+ -d "{
155
+ \"code\": \"${auth_code}\",
156
+ \"codeVerifier\": \"${CODE_VERIFIER}\",
157
+ \"extensionId\": \"${EXTENSION_ID}\"
158
+ }")
159
+
160
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
161
+ BODY=$(echo "$RESPONSE" | sed '$d')
162
+
163
+ if [ "$HTTP_CODE" != "200" ]; then
164
+ echo "ERROR: Token exchange failed (HTTP ${HTTP_CODE}):" >&2
165
+ echo "$BODY" >&2
166
+ return 1
167
+ fi
168
+
169
+ # Parse tokens from response
170
+ ACCESS_TOKEN=$(echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print(d['access_token'])")
171
+ REFRESH_TOKEN=$(echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print(d['refresh_token'])")
172
+ EXPIRES_IN=$(echo "$BODY" | python3 -c "import sys,json; d=json.load(sys.stdin)['data']; print(d['expires_in'])")
173
+
174
+ # Calculate expires_at (current epoch + expires_in)
175
+ EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN ))
176
+
177
+ # Save tokens
178
+ save_tokens "$ACCESS_TOKEN" "$REFRESH_TOKEN" "$EXPIRES_AT"
179
+
180
+ echo "Authentication successful!" >&2
181
+ echo "$ACCESS_TOKEN"
182
+ }
183
+
184
+ main "$@"
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env bash
2
+ # Share a Claude Code session to OpenThread
3
+ # Usage:
4
+ # bash share.sh parse <session_file> — output compact markdown from JSONL
5
+ # bash share.sh import <session_file> <title> <community_id> <tags> <token> — parse JSONL & import as compact post
6
+ # bash share.sh import-export <export_file> <title> <community_id> <tags> <token> — send /export text as body & import
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ API_BASE="${OPENTHREAD_API_URL:-https://api.openthread.dev}"
11
+
12
+ # --- Parse JSONL session file into compact markdown body ---
13
+ # Extracts only text blocks from assistant messages, skips thinking/tool_use/tool_result/etc.
14
+ # Formats as markdown with ## User / ## Assistant headings separated by ---
15
+ parse_session() {
16
+ local session_file="$1"
17
+
18
+ if [ ! -f "$session_file" ]; then
19
+ echo "ERROR: Session file not found: $session_file" >&2
20
+ return 1
21
+ fi
22
+
23
+ python3 - "$session_file" <<'PYEOF'
24
+ import json
25
+ import sys
26
+
27
+ session_file = sys.argv[1]
28
+ parts = []
29
+ SKIP_TYPES = {"thinking", "tool_use", "tool_result", "web_fetch", "web_search", "error", "image"}
30
+
31
+ with open(session_file, 'r') as f:
32
+ for line in f:
33
+ line = line.strip()
34
+ if not line:
35
+ continue
36
+ try:
37
+ entry = json.loads(line)
38
+ except json.JSONDecodeError:
39
+ continue
40
+
41
+ # Skip sidechain messages
42
+ if entry.get("isSidechain"):
43
+ continue
44
+
45
+ entry_type = entry.get("type", "")
46
+ if entry_type == "user":
47
+ role = "User"
48
+ elif entry_type == "assistant":
49
+ role = "Assistant"
50
+ else:
51
+ continue
52
+
53
+ message = entry.get("message", {})
54
+ if not isinstance(message, dict):
55
+ if isinstance(message, str) and message.strip():
56
+ parts.append(f"## {role}\n\n{message.strip()}")
57
+ continue
58
+
59
+ msg_content = message.get("content", "")
60
+
61
+ # If content is a string, keep it as-is
62
+ if isinstance(msg_content, str):
63
+ text = msg_content.strip()
64
+ if text:
65
+ parts.append(f"## {role}\n\n{text}")
66
+ continue
67
+
68
+ # Content is a list of blocks — keep only text blocks
69
+ if isinstance(msg_content, list):
70
+ kept = []
71
+ for block in msg_content:
72
+ if isinstance(block, str):
73
+ kept.append(block)
74
+ elif isinstance(block, dict):
75
+ btype = block.get("type", "")
76
+ if btype in SKIP_TYPES:
77
+ continue
78
+ if btype == "text" and block.get("text", "").strip():
79
+ kept.append(block["text"])
80
+ text = "\n\n".join(kept).strip()
81
+ if text:
82
+ parts.append(f"## {role}\n\n{text}")
83
+
84
+ body = "\n\n---\n\n".join(parts)
85
+ # Truncate to 500000 chars
86
+ print(body[:500000])
87
+ PYEOF
88
+ }
89
+
90
+ # --- Import to OpenThread ---
91
+ import_post() {
92
+ local session_file="$1"
93
+ local title="$2"
94
+ local community_id="$3"
95
+ local tags_csv="$4"
96
+ local access_token="$5"
97
+
98
+ # Parse the session into compact markdown
99
+ local compact_body
100
+ compact_body=$(parse_session "$session_file")
101
+
102
+ if [ -z "$compact_body" ]; then
103
+ echo "ERROR: Failed to parse session file" >&2
104
+ return 1
105
+ fi
106
+
107
+ # Build tags array from comma-separated string
108
+ local tags_json="[]"
109
+ if [ -n "$tags_csv" ] && [ "$tags_csv" != "-" ]; then
110
+ tags_json=$(python3 -c "
111
+ import json, sys
112
+ tags = [t.strip() for t in sys.argv[1].split(',') if t.strip()]
113
+ print(json.dumps(tags))
114
+ " "$tags_csv")
115
+ fi
116
+
117
+ # Build the import payload with body (no rawThread)
118
+ local payload
119
+ payload=$(python3 -c "
120
+ import json, sys
121
+ body_text = sys.argv[1]
122
+ payload = {
123
+ 'title': sys.argv[2],
124
+ 'communityId': sys.argv[3],
125
+ 'body': body_text,
126
+ 'tags': json.loads(sys.argv[4]),
127
+ 'source': 'claude-code',
128
+ 'provider': 'claude',
129
+ }
130
+ print(json.dumps(payload))
131
+ " "$compact_body" "$title" "$community_id" "$tags_json")
132
+
133
+ # POST to import endpoint
134
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/api/posts/import" \
135
+ -H "Authorization: Bearer ${access_token}" \
136
+ -H "Content-Type: application/json" \
137
+ -d "$payload")
138
+
139
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
140
+ BODY=$(echo "$RESPONSE" | sed '$d')
141
+
142
+ if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
143
+ echo "ERROR: Import failed (HTTP ${HTTP_CODE}):" >&2
144
+ echo "$BODY" >&2
145
+ return 1
146
+ fi
147
+
148
+ # Output the post data
149
+ echo "$BODY"
150
+ }
151
+
152
+ # --- Import /export text to OpenThread (send as body directly) ---
153
+ import_export_post() {
154
+ local export_file="$1"
155
+ local title="$2"
156
+ local community_id="$3"
157
+ local tags_csv="$4"
158
+ local access_token="$5"
159
+
160
+ if [ ! -f "$export_file" ]; then
161
+ echo "ERROR: Export file not found: $export_file" >&2
162
+ return 1
163
+ fi
164
+
165
+ # Build tags array from comma-separated string
166
+ local tags_json="[]"
167
+ if [ -n "$tags_csv" ] && [ "$tags_csv" != "-" ]; then
168
+ tags_json=$(python3 -c "
169
+ import json, sys
170
+ tags = [t.strip() for t in sys.argv[1].split(',') if t.strip()]
171
+ print(json.dumps(tags))
172
+ " "$tags_csv")
173
+ fi
174
+
175
+ # Build the import payload with body (export text is already compact)
176
+ local payload
177
+ payload=$(python3 -c "
178
+ import json, sys
179
+ with open(sys.argv[1], 'r') as f:
180
+ export_text = f.read()
181
+ # Truncate to 500000 chars
182
+ export_text = export_text[:500000]
183
+ payload = {
184
+ 'title': sys.argv[2],
185
+ 'communityId': sys.argv[3],
186
+ 'body': export_text,
187
+ 'tags': json.loads(sys.argv[4]),
188
+ 'source': 'claude-code',
189
+ 'provider': 'claude',
190
+ }
191
+ print(json.dumps(payload))
192
+ " "$export_file" "$title" "$community_id" "$tags_json")
193
+
194
+ # POST to import endpoint
195
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/api/posts/import" \
196
+ -H "Authorization: Bearer ${access_token}" \
197
+ -H "Content-Type: application/json" \
198
+ -d "$payload")
199
+
200
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
201
+ BODY=$(echo "$RESPONSE" | sed '$d')
202
+
203
+ if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
204
+ echo "ERROR: Import failed (HTTP ${HTTP_CODE}):" >&2
205
+ echo "$BODY" >&2
206
+ return 1
207
+ fi
208
+
209
+ # Output the post data
210
+ echo "$BODY"
211
+ }
212
+
213
+ # --- CLI interface ---
214
+ case "${1:-}" in
215
+ parse)
216
+ if [ -z "${2:-}" ]; then
217
+ echo "Usage: share.sh parse <session_file>" >&2
218
+ exit 1
219
+ fi
220
+ parse_session "$2"
221
+ ;;
222
+ import)
223
+ if [ -z "${6:-}" ]; then
224
+ echo "Usage: share.sh import <session_file> <title> <community_id> <tags> <token>" >&2
225
+ exit 1
226
+ fi
227
+ import_post "$2" "$3" "$4" "${5:--}" "$6"
228
+ ;;
229
+ import-export)
230
+ if [ -z "${6:-}" ]; then
231
+ echo "Usage: share.sh import-export <export_file> <title> <community_id> <tags> <token>" >&2
232
+ exit 1
233
+ fi
234
+ import_export_post "$2" "$3" "$4" "${5:--}" "$6"
235
+ ;;
236
+ *)
237
+ echo "Usage: share.sh {parse|import|import-export} ..." >&2
238
+ exit 1
239
+ ;;
240
+ esac
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # Token management for OpenThread Claude Code plugin
3
+ # Usage:
4
+ # source token.sh — import functions
5
+ # bash token.sh refresh — refresh the access token
6
+ # bash token.sh get — print a valid access token (refreshing if needed)
7
+ # bash token.sh check — exit 0 if authenticated, 1 if not
8
+ set -euo pipefail
9
+
10
+ API_BASE="${OPENTHREAD_API_URL:-https://api.openthread.dev}"
11
+ EXTENSION_ID="claude-code"
12
+ CREDENTIALS_DIR="$HOME/.config/openthread"
13
+ CREDENTIALS_FILE="$CREDENTIALS_DIR/credentials.json"
14
+
15
+ # --- Save tokens to credentials file ---
16
+ save_tokens() {
17
+ local access_token="$1"
18
+ local refresh_token="$2"
19
+ local expires_at="$3"
20
+
21
+ mkdir -p "$CREDENTIALS_DIR"
22
+
23
+ cat > "$CREDENTIALS_FILE" <<EOF
24
+ {
25
+ "access_token": "${access_token}",
26
+ "refresh_token": "${refresh_token}",
27
+ "expires_at": ${expires_at}
28
+ }
29
+ EOF
30
+
31
+ chmod 600 "$CREDENTIALS_FILE"
32
+ }
33
+
34
+ # --- Check if token is expired ---
35
+ is_expired() {
36
+ if [ ! -f "$CREDENTIALS_FILE" ]; then
37
+ return 0 # No file = expired
38
+ fi
39
+
40
+ local expires_at
41
+ expires_at=$(python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['expires_at'])" 2>/dev/null || echo "0")
42
+ local now
43
+ now=$(date +%s)
44
+
45
+ # Consider expired if within 60 seconds of expiry
46
+ if [ "$now" -ge "$((expires_at - 60))" ]; then
47
+ return 0 # Expired
48
+ fi
49
+
50
+ return 1 # Still valid
51
+ }
52
+
53
+ # --- Get access token from credentials ---
54
+ get_access_token() {
55
+ if [ ! -f "$CREDENTIALS_FILE" ]; then
56
+ echo "ERROR: Not authenticated. Run auth.sh first." >&2
57
+ return 1
58
+ fi
59
+
60
+ python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['access_token'])" 2>/dev/null
61
+ }
62
+
63
+ # --- Get refresh token from credentials ---
64
+ get_refresh_token() {
65
+ if [ ! -f "$CREDENTIALS_FILE" ]; then
66
+ echo "ERROR: Not authenticated. Run auth.sh first." >&2
67
+ return 1
68
+ fi
69
+
70
+ python3 -c "import json; print(json.load(open('$CREDENTIALS_FILE'))['refresh_token'])" 2>/dev/null
71
+ }
72
+
73
+ # --- Refresh the access token ---
74
+ refresh_token() {
75
+ local current_refresh
76
+ current_refresh=$(get_refresh_token) || return 1
77
+
78
+ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_BASE}/api/auth/extension/refresh" \
79
+ -H "Content-Type: application/json" \
80
+ -d "{
81
+ \"refreshToken\": \"${current_refresh}\",
82
+ \"extensionId\": \"${EXTENSION_ID}\"
83
+ }")
84
+
85
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
86
+ BODY=$(echo "$RESPONSE" | sed '$d')
87
+
88
+ if [ "$HTTP_CODE" != "200" ]; then
89
+ echo "ERROR: Token refresh failed (HTTP ${HTTP_CODE}):" >&2
90
+ echo "$BODY" >&2
91
+ return 1
92
+ fi
93
+
94
+ local new_access_token
95
+ new_access_token=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])")
96
+ local expires_in
97
+ expires_in=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['expires_in'])")
98
+ local new_expires_at
99
+ new_expires_at=$(( $(date +%s) + expires_in ))
100
+
101
+ # Update credentials file (keep existing refresh token)
102
+ save_tokens "$new_access_token" "$current_refresh" "$new_expires_at"
103
+
104
+ echo "$new_access_token"
105
+ }
106
+
107
+ # --- Get a valid token (refresh if needed) ---
108
+ get_valid_token() {
109
+ if is_expired; then
110
+ echo "Token expired, refreshing..." >&2
111
+ refresh_token
112
+ else
113
+ get_access_token
114
+ fi
115
+ }
116
+
117
+ # --- CLI interface ---
118
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
119
+ case "${1:-}" in
120
+ refresh)
121
+ refresh_token
122
+ ;;
123
+ get)
124
+ get_valid_token
125
+ ;;
126
+ check)
127
+ if [ -f "$CREDENTIALS_FILE" ] && ! is_expired; then
128
+ echo "Authenticated"
129
+ exit 0
130
+ else
131
+ echo "Not authenticated or token expired"
132
+ exit 1
133
+ fi
134
+ ;;
135
+ *)
136
+ echo "Usage: token.sh {refresh|get|check}" >&2
137
+ exit 1
138
+ ;;
139
+ esac
140
+ fi
@@ -0,0 +1,180 @@
1
+ ---
2
+ name: share-thread
3
+ description: Share the current Claude Code conversation to OpenThread
4
+ ---
5
+
6
+ # Share Conversation to OpenThread (v2)
7
+
8
+ This skill shares the current Claude Code conversation to OpenThread in 4 steps.
9
+
10
+ ## Configuration
11
+
12
+ ```
13
+ API_BASE="${OPENTHREAD_API_URL:-http://localhost:3001}"
14
+ WEB_BASE="${OPENTHREAD_WEB_URL:-http://localhost:3000}"
15
+ PLUGIN_DIR=<directory containing this skill — find it relative to this file, e.g. apps/claude-code-plugin or ~/.claude/plugins/openthread-share>
16
+ ```
17
+
18
+ ## Step 1: Authenticate
19
+
20
+ Get a valid access token using `token.sh get` (it auto-refreshes expired tokens):
21
+
22
+ ```bash
23
+ ACCESS_TOKEN=$(bash "PLUGIN_DIR/scripts/token.sh" get 2>/dev/null)
24
+ ```
25
+
26
+ **If that fails** (exit code non-zero, or empty output), the user isn't authenticated yet. Run the full auth flow:
27
+
28
+ ```bash
29
+ ACCESS_TOKEN=$(bash "PLUGIN_DIR/scripts/auth.sh")
30
+ ```
31
+
32
+ After this step you MUST have a non-empty `$ACCESS_TOKEN`. If both commands fail, stop and tell the user: "Authentication failed. Make sure the OpenThread server is running at `$API_BASE` and try again."
33
+
34
+ ## Step 2: Find the Session File
35
+
36
+ Claude Code stores conversation transcripts as JSONL files at:
37
+
38
+ ```
39
+ ~/.claude/projects/<project-slug>/<session-uuid>.jsonl
40
+ ```
41
+
42
+ The project slug is the current working directory with `/` replaced by `-` (e.g. `-Users-yogi-WebstormProjects-agent-post`).
43
+
44
+ **Find your own session file** — the file corresponding to THIS conversation. You know your own session ID from the environment. Look it up directly:
45
+
46
+ ```bash
47
+ SESSION_FILE="$HOME/.claude/projects/<project-slug>/<this-session-uuid>.jsonl"
48
+ ```
49
+
50
+ If you cannot determine your own session ID, fall back to the ranked selection below.
51
+
52
+ ### Fallback: Ranked selection
53
+
54
+ List JSONL files in the project directory, sorted by size descending:
55
+
56
+ ```bash
57
+ ls -lhS "$HOME/.claude/projects/<project-slug>/"*.jsonl
58
+ ```
59
+
60
+ Apply these filters in order:
61
+ 1. **Exclude files smaller than 50KB** — these are re-invocation sessions or sub-agent sessions, not real conversations
62
+ 2. **Prefer the most recently modified file** among the remaining candidates (use `ls -lt` to sort by modification time)
63
+ 3. If only one file remains after filtering, use it
64
+
65
+ ### Sanity check
66
+
67
+ Before proceeding, verify the file has meaningful content — count the number of user/assistant message pairs:
68
+
69
+ ```bash
70
+ python3 -c "
71
+ import json, sys
72
+ count = 0
73
+ with open(sys.argv[1]) as f:
74
+ for line in f:
75
+ try:
76
+ e = json.loads(line)
77
+ if e.get('type') in ('user', 'assistant'):
78
+ count += 1
79
+ except: pass
80
+ print(count)
81
+ " "$SESSION_FILE"
82
+ ```
83
+
84
+ The count should be at least 4 (2 user + 2 assistant messages). If it's less, skip to the next candidate file. If no candidates pass, stop and tell the user: "Could not find a session file with enough content to share."
85
+
86
+ ## Step 3: Prepare Metadata & Summary
87
+
88
+ First, check if the `/share-thread` command was invoked with **arguments** (any text after the command name in the `ARGUMENTS:` field).
89
+
90
+ ### Quick Mode (arguments provided — DO NOT ask questions)
91
+
92
+ **If the `ARGUMENTS:` field contains any text**, use quick mode. **Do NOT call `AskUserQuestion` at all.** Auto-generate everything:
93
+
94
+ 1. **Fetch communities**:
95
+ ```bash
96
+ curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$API_BASE/api/communities?limit=50"
97
+ ```
98
+ Response format: `{"data": [{"id": "uuid", "name": "Name", "slug": "slug"}, ...], "pagination": {...}}`
99
+
100
+ 2. **Auto-generate all metadata using these concrete rules:**
101
+
102
+ - **Title**: Generate a concise title (under 60 characters) summarizing the conversation's main task or topic. Derive it from the overall conversation arc, not just the first message. Use title case. Examples: "Building a Browser Extension Auth Flow", "Debugging Timezone Issues in Test Suite".
103
+
104
+ - **Community**: Pick the most topically relevant community from the fetched list using this decision tree:
105
+ - If the conversation is primarily about writing/debugging code → "Coding with AI"
106
+ - If the conversation is about the OpenThread project specifically → "OpenThread Meta" (if it exists)
107
+ - If the conversation involves creative writing, brainstorming, or non-technical topics → pick the closest match
108
+ - **Default**: "Coding with AI" (it's almost always correct for Claude Code sessions)
109
+
110
+ - **Tags**: Generate 2-4 lowercase tags based on technologies, task type, and topics discussed. Use comma-separated format. Examples: `typescript, api, authentication` or `react, debugging, state-management`. Include the primary programming language and the main activity (debugging, refactoring, feature, etc.).
111
+
112
+ - **Summary**: Generate a 2-3 sentence summary (under 500 characters). Write in past tense. Focus on what was accomplished, not implementation details. **Do NOT include** file paths, credentials, tokens, API keys, email addresses, or PII. Examples:
113
+ - "Implemented a privacy masking utility for shared threads that redacts sensitive data like tokens, API keys, and file paths before storage. Also added a summary field to the import endpoint."
114
+ - "Debugged a failing test suite caused by a timezone mismatch in date comparison logic. The fix involved normalizing timestamps to UTC before comparison."
115
+
116
+ 3. **Proceed directly to Step 4** — no user interaction.
117
+
118
+ ### Interactive Mode (NO arguments — ask the user)
119
+
120
+ **If the `ARGUMENTS:` field is empty or not present**, use interactive mode:
121
+
122
+ 1. **Fetch communities** (same as above).
123
+
124
+ 2. **Ask the user** using `AskUserQuestion` with these questions:
125
+ - **Title**: Suggest a title derived from the conversation topic. Let the user accept or modify.
126
+ - **Community**: Present the fetched communities as selectable options.
127
+ - **Tags**: Suggest some tags and let the user accept, modify, or skip.
128
+
129
+ 3. **Generate a summary** following the same rules as quick mode above.
130
+
131
+ 4. **Proceed to Step 4.**
132
+
133
+ ## Step 4: Import the Post
134
+
135
+ Run the import using `share.sh`:
136
+
137
+ ```bash
138
+ bash "PLUGIN_DIR/scripts/share.sh" import \
139
+ "$SESSION_FILE" \
140
+ "$TITLE" \
141
+ "$COMMUNITY_ID" \
142
+ "$TAGS" \
143
+ "$ACCESS_TOKEN" \
144
+ "$SUMMARY"
145
+ ```
146
+
147
+ Where `$TAGS` is a comma-separated string (e.g. `"typescript, api, auth"`).
148
+
149
+ ### Error Recovery
150
+
151
+ Handle these error cases:
152
+
153
+ - **422 Validation Error** (parse failure): The session file may be corrupt or wrong. Try the next candidate file from Step 2's ranked list. If no more candidates, tell the user.
154
+
155
+ - **401 Unauthorized** (token expired mid-flow): Re-authenticate by running Step 1 again, then retry the import with the new token.
156
+
157
+ - **Special character errors** in title/summary: If the import fails with a JSON parse error, the title or summary may contain characters that break shell escaping. Retry with simplified title/summary (remove quotes, backticks, and special characters).
158
+
159
+ - **Network error**: Tell the user to check that the API server is running at `$API_BASE`.
160
+
161
+ ### On Success
162
+
163
+ The response contains:
164
+ ```json
165
+ {"data": {"id": "post-id", "slug": "post-slug", ...}}
166
+ ```
167
+
168
+ Extract the post ID, then:
169
+
170
+ 1. **Display the URL**:
171
+ ```
172
+ Post shared successfully!
173
+ View it at: $WEB_BASE/post/$POST_ID
174
+ ```
175
+
176
+ 2. **Open in browser**:
177
+ ```bash
178
+ open "$WEB_BASE/post/$POST_ID" # macOS
179
+ # xdg-open on Linux
180
+ ```