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.
Files changed (23) hide show
  1. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.claude-plugin/marketplace.json +1 -1
  2. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.claude-plugin/plugin.json +1 -1
  3. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/LAUNCHGUIDE.md +1 -1
  4. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/PKG-INFO +4 -4
  5. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/README.md +2 -2
  6. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/manifest.json +2 -2
  7. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/pyproject.toml +2 -2
  8. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/server.json +3 -3
  9. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/__init__.py +1 -1
  10. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/browser.py +8 -26
  11. threadfox_lite-0.2.4/src/threadfox_lite/node.py +187 -0
  12. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/src/threadfox_lite/server.py +14 -2
  13. threadfox_lite-0.2.4/tests/test_node.py +57 -0
  14. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/tests/test_reddit.py +1 -1
  15. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/uv.lock +2 -2
  16. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.github/workflows/publish.yml +0 -0
  17. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.gitignore +0 -0
  18. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/.mcpbignore +0 -0
  19. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/LICENSE +0 -0
  20. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/glama.json +0 -0
  21. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/icon.png +0 -0
  22. {threadfox_lite-0.2.2 → threadfox_lite-0.2.4}/skills/reddit-rules-first/SKILL.md +0 -0
  23. {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.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.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+, Node.js and Microsoft's Playwright Extension in the Chrome where you are signed in to Reddit.
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.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.23.0
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: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. The first read asks you to pick a tab in Chrome.
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 (Settings > Extensions). It needs [uv](https://docs.astral.sh/uv/) on your machine.
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: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. The first read asks you to pick a tab in Chrome.
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 (Settings > Extensions). It needs [uv](https://docs.astral.sh/uv/) on your machine.
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.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 and the extension; 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.",
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.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.23.0,<2"]
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.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.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.2/threadfox-lite-0.2.2.mcpb",
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"
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`, needs Node.js and the Playwright Extension in Chrome) and runs one small
6
- fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is posted, voted or changed.
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: install Node.js (https://nodejs.org), add Microsoft's Playwright "
21
- "Extension to Chrome (" + EXTENSION_URL + "), sign in to Reddit in that Chrome, then try again.")
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 = find_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 (Node.js and the Playwright Extension), relay them plainly and stop. Nothing here posts or changes anything."))
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.assertIn("nodejs.org", r["error"])
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.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.23.0,<2" }]
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