threadfox-lite 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.claude-plugin/marketplace.json +1 -1
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.claude-plugin/plugin.json +1 -1
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/LAUNCHGUIDE.md +1 -1
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/PKG-INFO +4 -4
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/README.md +2 -2
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/manifest.json +2 -2
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/pyproject.toml +2 -2
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/server.json +3 -3
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/__init__.py +1 -1
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/browser.py +8 -26
- threadfox_lite-0.2.4/src/threadfox_lite/node.py +187 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/server.py +14 -2
- threadfox_lite-0.2.4/tests/test_node.py +57 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/tests/test_reddit.py +1 -1
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/uv.lock +2 -2
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.github/workflows/publish.yml +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.gitignore +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.mcpbignore +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/LICENSE +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/glama.json +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/icon.png +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/skills/reddit-rules-first/SKILL.md +0 -0
- {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/reddit.py +0 -0
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "threadfox-lite",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Free, read-only Reddit tools: subreddit rules before you post, post status after, and the reddit-rules-first skill.",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.4",
|
|
16
16
|
"category": "marketing",
|
|
17
17
|
"homepage": "https://threadfox.vip"
|
|
18
18
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadfox-lite",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Free, read-only Reddit tools: find communities where you may post, read their rules, check your account and whether a post stayed up, plus the reddit-rules-first skill.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "ThreadFox (AMF Indiana)",
|
|
@@ -11,7 +11,7 @@ This server (ThreadFox Lite) runs locally and gives every user four free, read-o
|
|
|
11
11
|
Built from our own run: 978,227 recorded views from two days of posts (Sept 20-21 UTC), with no ad spend. That is our result, not a promise about yours. Made by AMF Indiana; not affiliated with Reddit, OpenAI or Anthropic.
|
|
12
12
|
|
|
13
13
|
## Setup Requirements
|
|
14
|
-
MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The read-only tools work without it. Requires uv (uvx), Python 3.10
|
|
14
|
+
MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The read-only tools work without it. Requires uv (uvx), Python 3.10+ and Microsoft's Playwright Extension in the Chrome where you are signed in to Reddit.
|
|
15
15
|
|
|
16
16
|
## Category
|
|
17
17
|
Marketing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: threadfox-lite
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox.
|
|
5
5
|
Project-URL: Homepage, https://threadfox.vip
|
|
6
6
|
Project-URL: Source, https://github.com/amflimited/threadfox-lite
|
|
@@ -8,7 +8,7 @@ Author-email: "AMF Indiana (ThreadFox)" <support@threadfox.vip>
|
|
|
8
8
|
License: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
|
-
Requires-Dist: mcp<2,>=1.
|
|
11
|
+
Requires-Dist: mcp<2,>=1.28.1
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
|
|
14
14
|
# ThreadFox Lite
|
|
@@ -21,7 +21,7 @@ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP
|
|
|
21
21
|
- `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
|
|
22
22
|
- `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
|
|
23
23
|
|
|
24
|
-
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup:
|
|
24
|
+
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). The first read asks you to pick a tab in Chrome.
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
@@ -34,7 +34,7 @@ Claude Code, the MCP server only:
|
|
|
34
34
|
|
|
35
35
|
claude mcp add threadfox-lite -- uvx threadfox-lite
|
|
36
36
|
|
|
37
|
-
Claude Desktop: download the `.mcpb` file from the [latest release](https://github.com/amflimited/threadfox-lite/releases/latest) and open it
|
|
37
|
+
Claude Desktop: download the `.mcpb` file from the [latest release](https://github.com/amflimited/threadfox-lite/releases/latest) and open it; if Windows asks which app should open it, choose Claude. Claude Desktop sets up Python for it by itself.
|
|
38
38
|
|
|
39
39
|
Codex (`~/.codex/config.toml`):
|
|
40
40
|
|
|
@@ -8,7 +8,7 @@ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP
|
|
|
8
8
|
- `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
|
|
9
9
|
- `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
|
|
10
10
|
|
|
11
|
-
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup:
|
|
11
|
+
**Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. Node.js is fetched for you if you don't have it (the official build from nodejs.org into `~/.threadfox/runtime`, checked against its published SHA-256; nothing installed system-wide). The first read asks you to pick a tab in Chrome.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -21,7 +21,7 @@ Claude Code, the MCP server only:
|
|
|
21
21
|
|
|
22
22
|
claude mcp add threadfox-lite -- uvx threadfox-lite
|
|
23
23
|
|
|
24
|
-
Claude Desktop: download the `.mcpb` file from the [latest release](https://github.com/amflimited/threadfox-lite/releases/latest) and open it
|
|
24
|
+
Claude Desktop: download the `.mcpb` file from the [latest release](https://github.com/amflimited/threadfox-lite/releases/latest) and open it; if Windows asks which app should open it, choose Claude. Claude Desktop sets up Python for it by itself.
|
|
25
25
|
|
|
26
26
|
Codex (`~/.codex/config.toml`):
|
|
27
27
|
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"manifest_version": "0.4",
|
|
3
3
|
"name": "threadfox-lite",
|
|
4
4
|
"display_name": "ThreadFox Lite",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.4",
|
|
6
6
|
"description": "Find subreddits where you may post, read their rules, check your account and whether a post stayed up. Free, read-only.",
|
|
7
|
-
"long_description": "Free, read-only Reddit tools for Claude and any MCP client. find_communities lists subreddits about a topic with their self-promotion rules flagged; subreddit_rules reads one community's rules, size and description; account_check reports an account's age, karma, standing and whether its recent posts are removed or hidden from search; post_status says whether a post is live, removed or deleted. Reddit now sends signed-out requests to a login page, so every read runs in your own Chrome, signed in as you, through Microsoft's Playwright Extension (needs Node.js
|
|
7
|
+
"long_description": "Free, read-only Reddit tools for Claude and any MCP client. find_communities lists subreddits about a topic with their self-promotion rules flagged; subreddit_rules reads one community's rules, size and description; account_check reports an account's age, karma, standing and whether its recent posts are removed or hidden from search; post_status says whether a post is live, removed or deleted. Reddit now sends signed-out requests to a login page, so every read runs in your own Chrome, signed in as you, through Microsoft's Playwright Extension (needs only the extension: Node.js is fetched automatically when missing; the first read asks you to pick a tab). Nothing is posted or changed. The full ThreadFox kit (finding conversations where people ask for what you sell, posting from your own Chrome, an outcome ledger, a 12-module course) is sold separately at https://threadfox.vip.",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "AMF Indiana (ThreadFox)",
|
|
10
10
|
"email": "support@threadfox.vip",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "threadfox-lite"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
8
|
authors = [{ name = "AMF Indiana (ThreadFox)", email = "support@threadfox.vip" }]
|
|
9
|
-
dependencies = ["mcp>=1.
|
|
9
|
+
dependencies = ["mcp>=1.28.1,<2"]
|
|
10
10
|
|
|
11
11
|
[project.urls]
|
|
12
12
|
Homepage = "https://threadfox.vip"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.amflimited/threadfox-lite",
|
|
4
4
|
"title": "ThreadFox Lite",
|
|
5
5
|
"description": "Find subreddits you may post in, read their rules, check your account and posts. Free, read-only.",
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.4",
|
|
7
7
|
"websiteUrl": "https://threadfox.vip",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/amflimited/threadfox-lite",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "pypi",
|
|
15
15
|
"identifier": "threadfox-lite",
|
|
16
|
-
"version": "0.2.
|
|
16
|
+
"version": "0.2.4",
|
|
17
17
|
"runtimeHint": "uvx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"registryType": "mcpb",
|
|
24
|
-
"identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.2.
|
|
24
|
+
"identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.2.4/threadfox-lite-0.2.4.mcpb",
|
|
25
25
|
"fileSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
26
26
|
"transport": {
|
|
27
27
|
"type": "stdio"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""ThreadFox Lite: free, read-only Reddit tools for AI agents."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.4"
|
|
@@ -2,43 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
Reddit now sends every signed-out request, including its public JSON, to a login page. So ThreadFox Lite reads
|
|
4
4
|
the way you do: in your Chrome, signed in as you. It starts Microsoft's Playwright MCP server in extension mode
|
|
5
|
-
(`npx @playwright/mcp --extension
|
|
6
|
-
fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is
|
|
5
|
+
(`npx @playwright/mcp --extension`; node.py fetches Node.js when none is installed, and you add the Playwright
|
|
6
|
+
Extension to Chrome) and runs one small fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is
|
|
7
|
+
posted, voted or changed.
|
|
7
8
|
"""
|
|
8
9
|
import atexit
|
|
9
|
-
import glob
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
-
import shutil
|
|
14
13
|
import subprocess
|
|
15
14
|
import threading
|
|
16
15
|
import time
|
|
17
16
|
|
|
17
|
+
from .node import SetupError, ensure_npx, find_npx # noqa: F401 (find_npx and SetupError are part of this module's API)
|
|
18
|
+
|
|
18
19
|
EXTENSION_URL = "https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm"
|
|
19
20
|
SETUP = ("ThreadFox Lite reads Reddit through your own signed-in Chrome, because Reddit now asks everyone to sign in "
|
|
20
|
-
"before it shows anything. One-time setup:
|
|
21
|
-
"
|
|
21
|
+
"before it shows anything. One-time setup: add Microsoft's Playwright Extension to Chrome (" + EXTENSION_URL + "), "
|
|
22
|
+
"sign in to Reddit in that Chrome, then try again.")
|
|
22
23
|
SPACING = 1.5 # seconds between reads, so the account is never rate-limited
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
class SetupError(RuntimeError):
|
|
26
|
-
"""The browser route is not ready; the message says what to do."""
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def find_npx():
|
|
30
|
-
"""npx on PATH, or in the usual Node.js folders (apps started from the Dock or Start menu get a short PATH)."""
|
|
31
|
-
found = shutil.which("npx")
|
|
32
|
-
if found:
|
|
33
|
-
return found
|
|
34
|
-
home = os.path.expanduser("~")
|
|
35
|
-
candidates = ["/usr/local/bin/npx", "/opt/homebrew/bin/npx", os.path.join(home, ".volta", "bin", "npx")]
|
|
36
|
-
candidates += sorted(glob.glob(os.path.join(home, ".nvm", "versions", "node", "*", "bin", "npx")), reverse=True)
|
|
37
|
-
for base in filter(None, [os.environ.get("ProgramFiles"), os.environ.get("APPDATA")]):
|
|
38
|
-
candidates += [os.path.join(base, "nodejs", "npx.cmd"), os.path.join(base, "npm", "npx.cmd")]
|
|
39
|
-
return next((c for c in candidates if os.path.isfile(c)), None)
|
|
40
|
-
|
|
41
|
-
|
|
42
26
|
class Chrome:
|
|
43
27
|
"""A Playwright MCP child process spoken to over newline-delimited JSON-RPC."""
|
|
44
28
|
|
|
@@ -57,9 +41,7 @@ class Chrome:
|
|
|
57
41
|
cmd = list(self.command or [])
|
|
58
42
|
env = dict(os.environ)
|
|
59
43
|
if not cmd:
|
|
60
|
-
npx =
|
|
61
|
-
if not npx:
|
|
62
|
-
raise SetupError(SETUP)
|
|
44
|
+
npx = ensure_npx() # downloads the official Node.js once when none is installed; raises SetupError if it cannot
|
|
63
45
|
cmd = [npx, "-y", "@playwright/mcp@latest", "--extension"]
|
|
64
46
|
env["PATH"] = os.path.dirname(npx) + os.pathsep + env.get("PATH", "")
|
|
65
47
|
self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Node.js for Playwright MCP, without asking the user to install it.
|
|
2
|
+
|
|
3
|
+
ThreadFox Lite's reads run Microsoft's Playwright MCP (`npx @playwright/mcp`), which needs Node.js 18+. When no usable
|
|
4
|
+
Node.js is installed, the official Node.js LTS is downloaded into ~/.threadfox/runtime, checked against the SHA-256
|
|
5
|
+
nodejs.org publishes (pinned below) and used from there. Nothing is installed system-wide. The full ThreadFox kit uses
|
|
6
|
+
the same folder, so the two share one copy.
|
|
7
|
+
"""
|
|
8
|
+
import glob
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import urllib.request
|
|
19
|
+
import zipfile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SetupError(RuntimeError):
|
|
23
|
+
"""The browser route is not ready; the message says what to do."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# The official Node.js LTS, fetched only when no Node.js 18+ is installed. SHA-256 values are copied from
|
|
27
|
+
# https://nodejs.org/dist/v24.21.0/SHASUMS256.txt; a download that does not match is deleted unrun.
|
|
28
|
+
NODE_VERSION = '24.21.0'
|
|
29
|
+
NODE_SHA256 = {
|
|
30
|
+
'darwin-arm64.tar.xz': '6239d4cf92d864487ec8cd3615038f7b67e7f58b77b21cd2f09ea9fbd68065fe',
|
|
31
|
+
'darwin-x64.tar.xz': '0ae5a24c24bb7d015cd816c5036b3f90f2945aa872fcf54e58da054753b3a299',
|
|
32
|
+
'linux-arm64.tar.xz': '6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2',
|
|
33
|
+
'linux-x64.tar.xz': 'fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6',
|
|
34
|
+
'darwin-arm64.tar.gz': 'bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057',
|
|
35
|
+
'darwin-x64.tar.gz': '1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097',
|
|
36
|
+
'linux-arm64.tar.gz': '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5',
|
|
37
|
+
'linux-x64.tar.gz': '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff',
|
|
38
|
+
'win-arm64.zip': '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921',
|
|
39
|
+
'win-x64.zip': '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541',
|
|
40
|
+
}
|
|
41
|
+
NODE_BASE_URL = 'https://nodejs.org/dist'
|
|
42
|
+
_node_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def node_platform():
|
|
46
|
+
"""('darwin'|'linux'|'win', 'arm64'|'x64'), with None for a platform nodejs.org has no build for."""
|
|
47
|
+
system = {'Darwin': 'darwin', 'Linux': 'linux', 'Windows': 'win'}.get(platform.system())
|
|
48
|
+
machine = platform.machine().lower()
|
|
49
|
+
arch = 'arm64' if machine in ('arm64', 'aarch64') else 'x64' if machine in ('x86_64', 'amd64', 'x64') else None
|
|
50
|
+
return system, arch
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def runtime_dirs():
|
|
54
|
+
"""Where ThreadFox keeps its portable Node.js: THREADFOX_HOME or ~/.threadfox, shared with the full kit."""
|
|
55
|
+
home = os.environ.get('THREADFOX_HOME') or os.path.join(os.path.expanduser('~'), '.threadfox')
|
|
56
|
+
return [os.path.join(home, 'runtime')]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def portable_npx():
|
|
60
|
+
system, arch = node_platform()
|
|
61
|
+
name = f'node-v{NODE_VERSION}-{system}-{arch}'
|
|
62
|
+
for base in runtime_dirs():
|
|
63
|
+
exe = os.path.join(base, name, 'npx.cmd') if system == 'win' else os.path.join(base, name, 'bin', 'npx')
|
|
64
|
+
if os.path.isfile(exe):
|
|
65
|
+
return exe
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_npx():
|
|
70
|
+
"""ThreadFox's own portable Node.js, then npx on PATH, then the usual Node.js install folders. Apps started from the
|
|
71
|
+
Dock or Start menu (Claude Desktop) often get a shorter PATH than a terminal, so a working install can be invisible to them."""
|
|
72
|
+
found = portable_npx() or shutil.which('npx')
|
|
73
|
+
if found:
|
|
74
|
+
return found
|
|
75
|
+
home = os.path.expanduser('~')
|
|
76
|
+
candidates = ['/usr/local/bin/npx', '/opt/homebrew/bin/npx', os.path.join(home, '.volta', 'bin', 'npx')]
|
|
77
|
+
candidates += sorted(glob.glob(os.path.join(home, '.nvm', 'versions', 'node', '*', 'bin', 'npx')), reverse=True)
|
|
78
|
+
candidates += sorted(glob.glob(os.path.join(home, '.fnm', 'node-versions', '*', 'installation', 'bin', 'npx')), reverse=True)
|
|
79
|
+
for base in filter(None, [os.environ.get('ProgramFiles'), os.environ.get('ProgramFiles(x86)'), os.environ.get('APPDATA')]):
|
|
80
|
+
candidates += [os.path.join(base, 'nodejs', 'npx.cmd'), os.path.join(base, 'npm', 'npx.cmd')]
|
|
81
|
+
return next((c for c in candidates if os.path.isfile(c)), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def node_major(npx):
|
|
85
|
+
"""The major version of the node that sits next to this npx, or None when it cannot be asked."""
|
|
86
|
+
folder = os.path.dirname(npx)
|
|
87
|
+
node = next((c for c in (os.path.join(folder, 'node.exe'), os.path.join(folder, 'node')) if os.path.isfile(c)), None) or shutil.which('node')
|
|
88
|
+
try:
|
|
89
|
+
out = subprocess.run([node, '--version'], capture_output=True, text=True, timeout=15).stdout if node else ''
|
|
90
|
+
except (OSError, subprocess.SubprocessError):
|
|
91
|
+
return None
|
|
92
|
+
m = re.match(r'v(\d+)\.', out.strip())
|
|
93
|
+
return int(m.group(1)) if m else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _download(url, dest):
|
|
97
|
+
"""curl where it exists (every current macOS, Windows 10+ and most Linux): it uses the system's certificates, which some
|
|
98
|
+
Python builds on macOS cannot find. Python's own client otherwise."""
|
|
99
|
+
curl = shutil.which('curl')
|
|
100
|
+
if curl:
|
|
101
|
+
done = subprocess.run([curl, '-fsSL', '--retry', '2', '--connect-timeout', '20', '-o', dest, url],
|
|
102
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True)
|
|
103
|
+
if done.returncode == 0:
|
|
104
|
+
return
|
|
105
|
+
with urllib.request.urlopen(url, timeout=60) as r, open(dest, 'wb') as f:
|
|
106
|
+
shutil.copyfileobj(r, f, 1 << 20)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract(archive, dest):
|
|
110
|
+
if archive.endswith('.zip'):
|
|
111
|
+
with zipfile.ZipFile(archive) as z:
|
|
112
|
+
z.extractall(dest) # zipfile drops absolute paths and '..'
|
|
113
|
+
return
|
|
114
|
+
with tarfile.open(archive) as t:
|
|
115
|
+
if hasattr(tarfile, 'data_filter'):
|
|
116
|
+
t.extractall(dest, filter='data')
|
|
117
|
+
return
|
|
118
|
+
root = os.path.realpath(dest)
|
|
119
|
+
for m in t.getmembers():
|
|
120
|
+
target = os.path.realpath(os.path.join(dest, m.name))
|
|
121
|
+
link = os.path.realpath(os.path.join(os.path.dirname(target), m.linkname)) if m.issym() or m.islnk() else target
|
|
122
|
+
if not (target + os.sep).startswith(root + os.sep) or not (link + os.sep).startswith(root + os.sep) or m.isdev():
|
|
123
|
+
raise SetupError('The Node.js archive contains an unexpected path, so it was not unpacked.')
|
|
124
|
+
t.extractall(dest)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_node():
|
|
128
|
+
"""Download the official Node.js LTS into ThreadFox's runtime folder, check its SHA-256 before unpacking, and return its npx.
|
|
129
|
+
Nothing is installed system-wide and no installer runs; deleting the folder removes it."""
|
|
130
|
+
with _node_lock:
|
|
131
|
+
if portable_npx():
|
|
132
|
+
return portable_npx()
|
|
133
|
+
system, arch = node_platform()
|
|
134
|
+
ext = 'zip' if system == 'win' else 'tar.xz'
|
|
135
|
+
if ext == 'tar.xz':
|
|
136
|
+
try:
|
|
137
|
+
import lzma # noqa: F401 (some Python builds leave it out; .tar.gz is twice the size but always readable)
|
|
138
|
+
except ImportError:
|
|
139
|
+
ext = 'tar.gz'
|
|
140
|
+
key = f'{system}-{arch}.{ext}'
|
|
141
|
+
if key not in NODE_SHA256:
|
|
142
|
+
raise SetupError(f'ThreadFox has no portable Node.js for {platform.system()} {platform.machine()}. '
|
|
143
|
+
'Install Node.js 18+ from https://nodejs.org, then quit and reopen your AI app.')
|
|
144
|
+
name = f'node-v{NODE_VERSION}-{system}-{arch}'
|
|
145
|
+
runtime = runtime_dirs()[0]
|
|
146
|
+
os.makedirs(runtime, exist_ok=True)
|
|
147
|
+
work = tempfile.mkdtemp(prefix='.node-', dir=runtime)
|
|
148
|
+
try:
|
|
149
|
+
archive = os.path.join(work, name + '.' + ext)
|
|
150
|
+
try:
|
|
151
|
+
_download(f'{NODE_BASE_URL}/v{NODE_VERSION}/{name}.{ext}', archive)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
raise SetupError(f'Node.js could not be downloaded from nodejs.org ({exc}). Check the internet connection and try again, '
|
|
154
|
+
'or install Node.js 18+ from https://nodejs.org.')
|
|
155
|
+
digest = hashlib.sha256()
|
|
156
|
+
with open(archive, 'rb') as f:
|
|
157
|
+
for chunk in iter(lambda: f.read(1 << 20), b''):
|
|
158
|
+
digest.update(chunk)
|
|
159
|
+
if digest.hexdigest() != NODE_SHA256[key]:
|
|
160
|
+
raise SetupError('The Node.js download did not match the checksum nodejs.org publishes, so it was deleted without '
|
|
161
|
+
'running. Try again, or install Node.js 18+ from https://nodejs.org.')
|
|
162
|
+
try:
|
|
163
|
+
_extract(archive, work)
|
|
164
|
+
except (tarfile.TarError, zipfile.BadZipFile, EOFError) as exc:
|
|
165
|
+
raise SetupError(f'The Node.js archive could not be unpacked safely ({exc}). Try again, or install Node.js 18+ from https://nodejs.org.')
|
|
166
|
+
final = os.path.join(runtime, name)
|
|
167
|
+
if os.path.isdir(final):
|
|
168
|
+
shutil.rmtree(final)
|
|
169
|
+
os.replace(os.path.join(work, name), final)
|
|
170
|
+
finally:
|
|
171
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
172
|
+
exe = portable_npx()
|
|
173
|
+
if not exe:
|
|
174
|
+
raise SetupError('Node.js was unpacked but npx is missing. Install Node.js 18+ from https://nodejs.org.')
|
|
175
|
+
return exe
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ensure_npx(install=True):
|
|
179
|
+
"""An npx whose Node.js is 18 or newer: an installed one when it qualifies, otherwise ThreadFox's portable copy."""
|
|
180
|
+
exe = find_npx()
|
|
181
|
+
if exe and exe != portable_npx():
|
|
182
|
+
major = node_major(exe)
|
|
183
|
+
if major is None or major < 18:
|
|
184
|
+
exe = None # Playwright MCP needs a Node.js 18+ that runs; an old or broken one is left alone and not used
|
|
185
|
+
if exe or not install:
|
|
186
|
+
return exe
|
|
187
|
+
return install_node()
|
|
@@ -5,17 +5,19 @@ which tells a paying customer how to open the full ThreadFox kit (posting from y
|
|
|
5
5
|
the outcome ledger and the course). The full kit is sold at https://threadfox.vip and on MCP Marketplace.
|
|
6
6
|
"""
|
|
7
7
|
import os
|
|
8
|
+
import threading
|
|
8
9
|
|
|
9
10
|
from mcp.server.fastmcp import FastMCP
|
|
10
11
|
|
|
11
|
-
from . import reddit
|
|
12
|
+
from . import node, reddit
|
|
12
13
|
|
|
13
14
|
from . import __version__
|
|
14
15
|
|
|
15
16
|
mcp = FastMCP("threadfox-lite", instructions=(
|
|
16
17
|
"Free, read-only Reddit tools. find_communities and subreddit_rules before writing a post, account_check before posting "
|
|
17
18
|
"from an account, post_status at 1, 6 and 24 hours. Every read runs in the user's own signed-in Chrome; if a tool returns "
|
|
18
|
-
"setup steps (
|
|
19
|
+
"setup steps (the Playwright Extension in Chrome), relay them plainly and stop. Node.js is fetched automatically when missing. "
|
|
20
|
+
"Nothing here posts or changes anything."))
|
|
19
21
|
mcp._mcp_server.version = __version__ # report ThreadFox Lite's version, not the MCP library's
|
|
20
22
|
|
|
21
23
|
|
|
@@ -65,7 +67,17 @@ def threadfox_full_kit() -> dict:
|
|
|
65
67
|
"get_it": "https://threadfox.vip ($49 once, 30-day refund) or ThreadFox on MCP Marketplace"}
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
def _prefetch_node():
|
|
71
|
+
"""Fetch Node.js in the background at start when none is usable, so the first read does not wait for it."""
|
|
72
|
+
try:
|
|
73
|
+
if not os.environ.get("THREADFOX_LITE_BROWSER_CMD"):
|
|
74
|
+
node.ensure_npx()
|
|
75
|
+
except Exception:
|
|
76
|
+
pass # the first read reports the reason
|
|
77
|
+
|
|
78
|
+
|
|
68
79
|
def main():
|
|
80
|
+
threading.Thread(target=_prefetch_node, daemon=True).start()
|
|
69
81
|
mcp.run()
|
|
70
82
|
|
|
71
83
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""No Node.js installed: the official Node.js LTS is downloaded once, checked against its pinned SHA-256, and used."""
|
|
2
|
+
import hashlib, io, os, sys, tarfile, tempfile, unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
6
|
+
from threadfox_lite import node
|
|
7
|
+
|
|
8
|
+
NAME = f"node-v{node.NODE_VERSION}-linux-x64"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PortableNode(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.tmp = tempfile.TemporaryDirectory(); root = Path(self.tmp.name)
|
|
14
|
+
self.home = root / "home"; self.dist = root / "dist" / f"v{node.NODE_VERSION}"; self.dist.mkdir(parents=True)
|
|
15
|
+
for p in (patch.dict(os.environ, {"THREADFOX_HOME": str(self.home)}), patch.object(node, "NODE_BASE_URL", (root / "dist").as_uri()),
|
|
16
|
+
patch.object(node, "node_platform", return_value=("linux", "x64"))):
|
|
17
|
+
p.start(); self.addCleanup(p.stop)
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
self.tmp.cleanup()
|
|
21
|
+
|
|
22
|
+
def publish(self, members):
|
|
23
|
+
archive = self.dist / f"{NAME}.tar.xz"
|
|
24
|
+
with tarfile.open(archive, "w:xz") as tar:
|
|
25
|
+
for name, data in members:
|
|
26
|
+
info = tarfile.TarInfo(name); info.size = len(data); info.mode = 0o755
|
|
27
|
+
tar.addfile(info, io.BytesIO(data))
|
|
28
|
+
return hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
29
|
+
|
|
30
|
+
def test_downloads_checks_and_unpacks_once(self):
|
|
31
|
+
digest = self.publish([(f"{NAME}/bin/node", b"#!/bin/sh\n"), (f"{NAME}/bin/npx", b"#!/bin/sh\n")])
|
|
32
|
+
with patch.dict(node.NODE_SHA256, {"linux-x64.tar.xz": digest}):
|
|
33
|
+
npx = node.install_node()
|
|
34
|
+
self.assertEqual(npx, str(self.home / "runtime" / NAME / "bin" / "npx"))
|
|
35
|
+
(self.dist / f"{NAME}.tar.xz").unlink()
|
|
36
|
+
self.assertEqual(node.install_node(), npx)
|
|
37
|
+
self.assertEqual(os.listdir(self.home / "runtime"), [NAME])
|
|
38
|
+
|
|
39
|
+
def test_a_mismatched_download_is_deleted_unrun(self):
|
|
40
|
+
self.publish([(f"{NAME}/bin/npx", b"tampered")])
|
|
41
|
+
with patch.dict(node.NODE_SHA256, {"linux-x64.tar.xz": "0" * 64}):
|
|
42
|
+
with self.assertRaisesRegex(node.SetupError, "checksum"):
|
|
43
|
+
node.install_node()
|
|
44
|
+
self.assertEqual(os.listdir(self.home / "runtime"), [])
|
|
45
|
+
|
|
46
|
+
def test_an_old_or_broken_installed_node_is_not_used(self):
|
|
47
|
+
with patch.object(node, "find_npx", return_value="/usr/bin/npx"), patch.object(node, "install_node", return_value="PORTABLE"):
|
|
48
|
+
with patch.object(node, "node_major", return_value=16):
|
|
49
|
+
self.assertEqual(node.ensure_npx(), "PORTABLE")
|
|
50
|
+
with patch.object(node, "node_major", return_value=None):
|
|
51
|
+
self.assertEqual(node.ensure_npx(), "PORTABLE")
|
|
52
|
+
with patch.object(node, "node_major", return_value=22):
|
|
53
|
+
self.assertEqual(node.ensure_npx(), "/usr/bin/npx")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
unittest.main()
|
|
@@ -103,7 +103,7 @@ class Setup(unittest.TestCase):
|
|
|
103
103
|
raise browser.SetupError(browser.SETUP)
|
|
104
104
|
for r in (reddit.subreddit_rules("SaaS", get=get), reddit.account_check("someone", get=get),
|
|
105
105
|
reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", get=get), reddit.find_communities("bakery", get=get)):
|
|
106
|
-
self.assertIn("Playwright Extension", r["error"]); self.
|
|
106
|
+
self.assertIn("Playwright Extension", r["error"]); self.assertNotIn("install Node.js", r["error"])
|
|
107
107
|
|
|
108
108
|
def test_chrome_client_reads_through_a_playwright_mcp(self):
|
|
109
109
|
"""The real client against a stand-in Playwright MCP that answers the way the real one formats results."""
|
|
@@ -859,14 +859,14 @@ wheels = [
|
|
|
859
859
|
|
|
860
860
|
[[package]]
|
|
861
861
|
name = "threadfox-lite"
|
|
862
|
-
version = "0.2.
|
|
862
|
+
version = "0.2.4"
|
|
863
863
|
source = { editable = "." }
|
|
864
864
|
dependencies = [
|
|
865
865
|
{ name = "mcp" },
|
|
866
866
|
]
|
|
867
867
|
|
|
868
868
|
[package.metadata]
|
|
869
|
-
requires-dist = [{ name = "mcp", specifier = ">=1.
|
|
869
|
+
requires-dist = [{ name = "mcp", specifier = ">=1.28.1,<2" }]
|
|
870
870
|
|
|
871
871
|
[[package]]
|
|
872
872
|
name = "typing-extensions"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|