@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.
- package/.claude-plugin/plugin.json +9 -0
- package/README.md +93 -0
- package/bin/cli.sh +64 -0
- package/bin/postinstall.js +57 -0
- package/commands/share.md +13 -0
- package/icon.svg +35 -0
- package/package.json +38 -0
- package/scripts/auth.sh +184 -0
- package/scripts/share.sh +240 -0
- package/scripts/token.sh +140 -0
- package/skills/share-thread/SKILL.md +180 -0
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
|
+
[](https://www.npmjs.com/package/@openthread/claude-code-plugin)
|
|
6
|
+
[](https://github.com/nicholasgriffintn/openthread/blob/main/LICENSE)
|
|
7
|
+
[](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
|
+
}
|
package/scripts/auth.sh
ADDED
|
@@ -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 "$@"
|
package/scripts/share.sh
ADDED
|
@@ -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
|
package/scripts/token.sh
ADDED
|
@@ -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
|
+
```
|