Compare commits

..

49 Commits

Author SHA1 Message Date
Matt Van Horn c5b03adffc Merge pull request #244 from mvanhorn/feat/claudeai-distribution
Release / build-and-release (push) Has been cancelled
feat: claude.ai distribution push (Units 1-4 of plan 002)
2026-04-14 17:44:58 -04:00
Matt Van Horn 38a1c27e2e chore: exclude .github/ from skill archive (CI workflows, not runtime) 2026-04-14 17:44:18 -04:00
Matt Van Horn ed80797564 docs: add plan 2026-04-14-002 for claude.ai distribution push 2026-04-14 17:44:00 -04:00
Matt Van Horn 68c3420f9f docs: promote claude.ai to first-class install path with direct download link
- install matrix now leads with claude.ai (widest audience, one-click path)
- direct download link to GitHub release's 'latest' asset URL
- 3-step UI walkthrough with link to Settings > Capabilities > Skills
- Claude Code / OpenClaw / Gemini / manual paths still documented, collapsed
- removes the bash scripts/build-skill.sh requirement from end-user flow
2026-04-14 17:43:32 -04:00
Matt Van Horn 12167ee19e feat(skill): tune description and argument-hint for Claude skill-selector quality
- description leads with imperative 'Research' + 'what people actually say' (strong trigger signal for community/social-research prompts)
- argument-hint shows 3 concrete user phrasings instead of marketing copy
- 176 chars, well under Anthropic's 200-char cap
- preserves all source coverage (Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, web)

Per ecosystem research (April 2026), trigger description quality is the single
biggest lever separating 500-install skills from 350k-install skills.
2026-04-14 17:42:54 -04:00
Matt Van Horn 21b8e5c6d3 ci: auto-build .skill artifact on tag push and attach to GitHub release 2026-04-14 17:42:15 -04:00
Matt Van Horn 1157ea8afe docs: mark plan 2026-04-14-001 as completed 2026-04-14 12:24:16 -04:00
Matt Van Horn 9f3be8bbda Merge pull request #242 from mvanhorn/fix/skill-upload-200-file-limit
fix: skill upload 200-file cap + packaging hygiene (3.0.1)
2026-04-14 12:24:03 -04:00
Matt Van Horn beb54e9e9d fix: sync version references in SKILL.md body and sync.sh cache path 2026-04-14 12:22:51 -04:00
Matt Van Horn 8d8ca68781 chore: bump version to 3.0.1 + changelog entry
Atomic bump across all four manifests:
- SKILL.md (root)
- skills/last30days/SKILL.md (internal spec)
- .claude-plugin/plugin.json
- gemini-extension.json

CHANGELOG entry documents the skill-upload packaging fix, vendor/ removal,
legacy plans/ removal, and the new scripts/build-skill.sh builder.
2026-04-14 12:21:24 -04:00
Matt Van Horn 0949b870e0 fix(skill): trim description to 167 chars (was 228, Anthropic caps at 200) 2026-04-14 12:20:20 -04:00
Matt Van Horn 4b07ba02a6 docs: document .skill upload path via scripts/build-skill.sh 2026-04-14 12:19:49 -04:00
Matt Van Horn 039fc89874 feat: add scripts/build-skill.sh to produce claude.ai-upload-ready .skill
Wraps git archive with --prefix=last30days/ so the zip contains a single
top-level skill folder matching SKILL.md's name: frontmatter. Enforces:

- refuses to build with a dirty working tree (prevents shipping untracked changes)
- fails if zip exceeds 200 files (claude.ai's empirical upload cap)
- fails if zip contains more than one SKILL.md (avoids name: confusion)

Output at dist/last30days.skill (gitignored).
2026-04-14 12:19:28 -04:00
Matt Van Horn 2b506e90f5 chore: add .gitattributes to exclude non-runtime files from git archive 2026-04-14 12:18:54 -04:00
Matt Van Horn deb9f33437 chore: remove legacy plans/ directory (superseded by docs/plans/)
Both plans describe work that was already shipped:
- feat-add-websearch-source.md - websearch is in the v3 pipeline (scripts/lib/perplexity.py etc)
- fix-strict-date-filtering.md - date filtering is enforced in scripts/lib/dates.py

New planning goes in docs/plans/ following the ce:plan convention.
2026-04-14 12:18:19 -04:00
Matt Van Horn cb88bd2eed chore: remove unused root vendor/ directory (215 files from PR #48)
Root vendor/package/ was an accidentally committed extracted npm tarball
(steipete-bird-0.8.0). Zero importers: the real vendored X client lives
at scripts/lib/vendor/bird-search/, referenced by scripts/lib/bird_x.py
and tests/test_bird_x.py.

Removes 215 files + 1 .tgz, dropping repo from 406 to 191 files and
clearing the claude.ai skill-upload 200-file cap.

Adds /vendor/ to .gitignore (leading slash so scripts/lib/vendor/ is unaffected).
2026-04-14 12:17:58 -04:00
hnshah 23fc6c7061 fix(env): default INCLUDE_SOURCES to empty string (#223)
* fix(env): default INCLUDE_SOURCES to empty string

* test(env): patch resolved config path in include sources test
2026-04-14 07:48:53 -04:00
Ilia Alshanetsky 9dd3f21476 refactor: consolidate _sc_headers into http.scrapecreators_headers (#209)
Six source modules each defined an identical 8-line _sc_headers(token)
function returning {"x-api-key": token, "Content-Type": "application/json"}.
Moved it to http.scrapecreators_headers() and migrated all 33 call sites.

Affected files: reddit.py, threads.py, tiktok.py, instagram.py, pinterest.py,
youtube_yt.py. Zero per-source variation, zero behavior change.

Net: -40 lines. 1022 tests pass (15 pre-existing failures unchanged).
Live smoke test: reddit search returns 12 threads with full engagement.
2026-04-14 07:43:56 -04:00
Matt Van Horn e395c1d57f Merge pull request #208 from iliaal/fix/date-parsing
fix(github): reject garbage in _parse_date; consolidate date parsing
2026-04-13 22:21:35 -04:00
Matt Van Horn 33502d2a07 Merge pull request #207 from iliaal/refactor/reddit-http-helper
refactor(reddit): migrate to http.get(params=...) helper
2026-04-13 22:18:49 -04:00
Matt Van Horn bdc71cfd07 Merge pull request #227 from Chelebii/fix/windows-bird-x-runtime
fix(windows): stabilize bundled Bird X search
2026-04-13 22:15:21 -04:00
Matt Van Horn 65be6196c1 Merge pull request #217 from Gujiassh/fix/sync-version-consistency
fix: align v3 skill version metadata and sync target
2026-04-13 17:55:34 -04:00
Matt Van Horn 7dc530b4c9 Merge pull request #224 from hnshah/hnshah-gemini-install-doc
docs: add Gemini CLI install note and workaround
2026-04-13 17:55:24 -04:00
Matt Van Horn b159f8b1ff Merge pull request #216 from george231224/fix/check-perms-stat-linux
fix: use GNU stat first in check_perms (Linux false-warn)
2026-04-13 17:55:21 -04:00
Matt Van Horn cff005b038 Merge pull request #225 from Gujiassh/fix/save-output-utf8
fix(cli): Write saved output using UTF-8 encoding
2026-04-13 17:55:18 -04:00
Matt Van Horn 460565c107 Merge pull request #228 from stephenmcconnachie/add-hermes-support
feat: add Hermes AI Agent support
2026-04-13 15:59:53 -04:00
Matt Van Horn e6493033b0 Merge pull request #229 from shalomma/fix/skill-md-version-bump
Bump SKILL.md version header from v2.9.5 to v3.0.0
2026-04-13 15:55:15 -04:00
Matt Van Horn ca00cacf83 Merge pull request #230 from BryanTegomoh/fix/days-alias-backcompat
fix(cli): restore --days alias compatibility
2026-04-13 15:55:06 -04:00
Matt Van Horn b982ed5b30 Merge pull request #232 from j-sperling/j-sperling/chore/gitignore-dev-artifacts
chore: gitignore dev artifacts (.venv, .coverage, htmlcov, .memsearch)
2026-04-13 15:54:17 -04:00
Matt Van Horn a9d13d695a Merge pull request #233 from j-sperling/j-sperling/feat/eval-topics-fixture
feat: add eval_topics.json fixture for offline quality evaluation
2026-04-13 15:53:58 -04:00
Matt Van Horn 877706da4d Merge pull request #234 from j-sperling/j-sperling/fix/bird-x-engagement-validation
fix(bird_x): skip all-None engagement dicts
2026-04-13 15:52:44 -04:00
Jeffrey Sperling 1a6d8d07d0 fix(bird_x): skip all-None engagement dicts
When a tweet has no engagement metrics, _first_of() returns None for
every key, producing {"likes": None, "reposts": None, ...}.  This
all-None dict propagates to signals.py where it is treated as "data
exists but is zero" rather than "no data available."  Return None
instead when every engagement field is missing.
2026-04-13 11:54:49 -07:00
Jeffrey Sperling 3bc12cdc57 feat: add eval_topics.json fixture for offline quality evaluation
evaluate_search_quality.py and e2e_comparison.py both reference
fixtures/eval_topics.json with hardcoded fallbacks.  Supply the
actual fixture: 8 topics spanning all intent types, selected via
MMR dispersion across domains (tech, health, sports, finance,
consumer products).
2026-04-13 11:52:55 -07:00
Jeffrey Sperling ad59e60269 chore: gitignore dev artifacts (.venv, .coverage, htmlcov, .memsearch)
pyproject.toml declares pytest-cov as a dev dependency and configures
[tool.coverage.run], but the generated .coverage database and htmlcov/
report directory are not gitignored.  Also add .venv/ (standard Python
virtualenv) and .memsearch/ (session memory) to keep the working tree
clean for contributors.
2026-04-13 11:52:12 -07:00
Bryan Tegomoh 9d037786f2 fix(cli): restore --days alias compatibility 2026-04-13 09:18:18 -05:00
shalomma 8b67378964 Bump SKILL.md version header from v2.9.5 to v3.0.0
The SKILL.md prompt header still said v2.9.5 while pyproject.toml
and the rest of the codebase are on v3.0.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:31:43 +03:00
Stephen McConnachie 2b015b64ab Add Hermes AI Agent support 2026-04-12 20:06:02 +01:00
Chelebii d3972a6523 fix(windows): stabilize bundled Bird X search 2026-04-11 23:30:39 +01:00
gujishh 56cabf33c6 fix(cli): write saved output using UTF-8 encoding 2026-04-12 06:25:38 +09:00
Hiten Shah 13dcea781d docs: add Gemini CLI install note and workaround 2026-04-11 13:15:53 -07:00
Matt Van Horn 01812ec185 fix(sync): skip OpenClaw variant branch when variants/open is absent (#222)
Makes the `variants/open/` sync steps in `scripts/sync.sh` conditional on
the directory actually existing in the source tree. The script is shared
between the public and private repos of last30days-skill, but the OpenClaw
variant only lives in the private repo (it's sanitized via
`strip_for_openclaw.py` and published separately to ClawhHub). When the
script runs from a checkout of the public repo, the variants/open paths
don't exist and the unconditional `rsync` and `sync_target` calls error
out immediately.

Changes:

- `sync_target()` now only creates `variants/open/references` and rsyncs
  `variants/open/` when `$SRC/variants/open` exists.
- The trailing `sync_target "$OPENCLAW_TARGET" ...` call is now gated by
  the same check, with an explanatory skip message when the directory is
  absent.

No behavior change when running from the private repo (which has
`variants/open/`). When running from the public repo, the script now
completes its COMMON_TARGETS loop without erroring.

This also closes out the confusion from PR #211, where a contributor saw
the broken `variants/open/` reference and tried to add the variant back
to the public repo. The real fix was making the script tolerate the
absence, not recreating the directory.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-11 11:37:27 -04:00
Matt Van Horn 86b2b9dd69 docs(v3): drop redundant What's New list and remove stale @steipete credit (#221)
release-notes.md:
- Drop the "What's New" section entirely. It repeated the same items
  as the Headline features section above it in bulleted form, a
  holdover from the old v2.9 release notes pattern. CHANGELOG.md is
  the canonical Added/Changed/Fixed list; release notes is marketing
  copy and shouldn't duplicate it. Added a one-line pointer to
  CHANGELOG.md [3.0.0] for anyone looking for the detail.
- Rename "Credits" to "Earlier contributors" and note they are from
  the v1 and v2 lineage, so readers don't confuse them with v3
  contributors.
- Remove @steipete credit (did not actually contribute to this repo).

CHANGELOG.md [2.1.0] Credits:
- Remove @steipete credit (did not actually contribute to this repo).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-11 09:33:43 -04:00
Matt Van Horn 6e7c0ba7aa docs(v3): prep CHANGELOG and release notes for v3.0.0 (#220)
Rewrites release-notes.md from its stale v2.9.1 focus into the v3
story: intelligent pre-research as the killer feature, fun judge /
Best Takes, cross-source cluster merging, single-pass comparisons,
GitHub person-mode and project-mode, 13+ sources, ELI5 mode. Credits
@j-sperling as the v3 engine architect in the hero section and
updates the install instructions from `git clone` to the real install
paths for Claude Code, OpenClaw, and OpenAI Codex CLI.

Also extends the CHANGELOG [3.0.0] entry with a Fixed section covering
the two post-merge prep fixes that landed just before release:

- #214 resolved a duplicate `name: last30days` collision in
  skills/last30days/SKILL.md that caused strict marketplace validators
  to reject the plugin (reported by @Cody-Coyote in #204)
- #219 added the real Codex CLI integration at
  .agents/skills/last30days/SKILL.md (regular file, since Codex's
  loader skips symlinked files) plus .codex-plugin/plugin.json as the
  namespace marker (inspired by @Jah-yee in #153 and @dannyshmueli
  on X)

Bumps the [3.0.0] date from `2026-04` to `2026-04-11` to match the
actual release date, and adds @Cody-Coyote and @Jah-yee to the
[3.0.0] Contributors list.

No code changes. Pure docs prep for the v3.0.0 GitHub release.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-11 09:27:35 -04:00
Matt Van Horn 71e0492840 feat: make skill discoverable by OpenAI Codex CLI (#219)
Adds a Codex CLI skill integration by creating the two files Codex's real
loader actually reads:

- .agents/skills/last30days/SKILL.md (real file, not a symlink - Codex's
  loader skips symlinked files per codex-rs/core-skills/src/loader.rs)
- .codex-plugin/plugin.json with {"name": "last30days"} as a namespace
  marker, per codex-rs/utils/plugins/src/plugin_namespace.rs

When Codex CLI runs in a checkout of this repo, it walks .agents/skills/
from CWD up to the project root, picks up .agents/skills/last30days/SKILL.md,
and walks ancestors looking for .codex-plugin/plugin.json to resolve the
plugin namespace. The skill registers as last30days:last30days.

The SKILL.md is a verbatim copy of the root SKILL.md at this point to
avoid content drift during the rollout. A future PR can slim the Codex copy
or introduce a sync mechanism.

Verified against Codex CLI's own source by running codex exec from the
repo CWD and having it trace the loader logic.

Replaces PR #153, which used a fake $schema URL
(https://openai.com/codex/plugin.schema.json returns 404) and put a
misunderstanding of Codex's plugin manifest (Codex only reads the `name`
field - all other fields like version, description, author, skills[] are
silently ignored).

This contribution was developed with AI assistance (Codex + Claude Code).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-11 09:02:28 -04:00
gujishh 8b2cf41f13 fix: align v3 version metadata and sync target 2026-04-11 21:00:04 +09:00
george231224 3d57db9644 fix: use GNU stat first in check_perms so Linux doesn't false-warn
`stat -f '%Lp'` is BSD/macOS syntax. On Linux, `stat -f` prints
filesystem info (Block size / Inodes / ...) and still exits 0, so the
`||` fallback to `stat -c '%a'` never fires. That left `$perms` as
multi-line garbage, the `!= "600"` check was always true, and every
Linux SessionStart hook invocation printed a bogus warning plus the
whole `stat -f` filesystem dump.

Reorder to try GNU stat first, fall back to BSD for macOS. Verified on
Linux (cpython-3.12 / bash 5.x) — hook now emits the expected compact
Ready banner with no false warning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:41:08 +08:00
Matt Van Horn 99b167d03a fix: resolve duplicate skill name causing marketplace validation failure (#204) (#214)
Two SKILL.md files declared `name: last30days` with `user-invocable: true`,
which caused strict marketplace validators to reject the plugin with "Some
plugins in this marketplace have validation errors":

- ./SKILL.md (canonical, also reachable via skills/last30days-nux/ symlink)
- ./skills/last30days/SKILL.md (v3 architecture spec, real file)

In v2.9.6, skills/last30days/SKILL.md was a symlink to ../../SKILL.md so
only one skill existed. Commit 0a9ff16 (v3.0.0) added a new real file at
skills/last30days-v3/SKILL.md, and commit 9be0780 then renamed that
directory to skills/last30days/, replacing the original symlink with a
different real file. The collision has been live since v3.0.0 shipped.

This change:
- Renames skills/last30days/SKILL.md to name: last30days-v3-spec and sets
  user-invocable: false. The file stays in place as internal architecture
  documentation, but it no longer competes with the canonical skill.
- Fixes README.md link that pointed to the deleted skills/last30days-v3/
  path (left over from the rename).
- Removes a stale variants/open/SKILL.md reference (variants/open was
  deleted in v3.0.0).

After the change, only one canonical name=last30days user-invocable=true
skill exists (the root SKILL.md, also reachable via the
skills/last30days-nux/ symlink, same inode).

Closes #204.

This contribution was developed with AI assistance (Codex).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-04-11 02:36:55 -04:00
Ilia Alshanetsky 65fcf6be65 fix(github): reject garbage in _parse_date; consolidate date parsing
github.py _parse_date used naive string slicing (return iso_str[:10])
which accepted any 10+ character string as a "date." For input
"hello world" it returned "hello worl". Now delegates to
dates.parse_date() which validates the format and returns None for
non-dates.

Also migrated reddit.py and threads.py _parse_date to the shared
dates.parse_date(). Both previously reimplemented ISO-with-trailing-
offset handling (the .replace("Z", "+00:00") dance) and reddit.py
also had its own Unix timestamp branch. dates.parse_date() already
handles all of this, including the +0000 no-colon variant Reddit emits.

Preserved reddit.py's original falsy-check so 0 still returns None
(epoch 0 would otherwise parse as "1970-01-01", breaking an existing
test and changing long-standing behavior).

Added 4 new github tests for garbage rejection and offset variants.
All 1026 existing tests pass (15 pre-existing failures unchanged).
2026-04-10 07:39:07 -04:00
Ilia Alshanetsky 9ef9d38b90 refactor(reddit): migrate to http.get(params=...) helper
Added params kwarg to http.request()/http.get() that urlencodes a dict
into the query string. None values are dropped, ints and bools are
stringified, and params append correctly if the URL already has a
query string.

Migrated reddit.py to use this helper for all three ScrapeCreators
call sites (global search, subreddit search, post comments). Deleted
the try/import requests/except ImportError fallback and the paired
if not _requests: / else: branches. Six new http tests cover the
params-encoding behavior.

Net: reddit.py -70 lines. Behavior is identical - the existing http.py
urllib implementation already had retry logic, 429 handling, and
HTTPError types that are strictly better than the ad-hoc requests
branches we deleted.

99 reddit tests pass. Live smoke test on a real ScrapeCreators run
returned 12 threads with the same engagement data as before.
2026-04-10 07:25:26 -04:00
262 changed files with 3427 additions and 11279 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days",
"version": "3.0.0",
"version": "3.0.1",
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
"author": {
"name": "Matt Van Horn",
+3
View File
@@ -0,0 +1,3 @@
{
"name": "last30days"
}
+46
View File
@@ -0,0 +1,46 @@
# Exclude non-runtime files from `git archive` output.
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
# Anthropic canonical skill-packaging excludes
# (mirrors anthropics/skills/skills/skill-creator/scripts/package_skill.py)
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
evals/ export-ignore
# Dev, docs, test, and media - not needed at skill runtime
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
# Second SKILL.md files would confuse claude.ai's uploader
# (skills/last30days/ is an internal spec; skills/last30days-nux/ is a symlink)
skills/ export-ignore
# Historical + repo-only manifests
SKILL-original.md export-ignore
SPEC.md export-ignore
TASKS.md export-ignore
test-run.log export-ignore
CONTRIBUTORS.md export-ignore
HERMES_SETUP.md export-ignore
release-notes.md export-ignore
CHANGELOG.md export-ignore
uv.lock export-ignore
# Platform adapters - skill-upload path is platform-agnostic
.agents/ export-ignore
.codex-plugin/ export-ignore
.hermes-plugin/ export-ignore
.claude-plugin/ export-ignore
# CI workflows - repo-only, not needed at skill runtime
.github/ export-ignore
# Build config itself
.clawhubignore export-ignore
.gitignore export-ignore
.gitattributes export-ignore
+31
View File
@@ -0,0 +1,31 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build .skill artifact
run: |
bash scripts/build-skill.sh
test -f dist/last30days.skill
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: dist/last30days.skill
generate_release_notes: true
draft: false
prerelease: false
+10
View File
@@ -15,3 +15,13 @@ variants/open/references/research.md
__pycache__/
*.pyc
mise.toml
.memsearch/
.venv/
.coverage
htmlcov/
# Root vendor/ is accidental - real vendored client lives at scripts/lib/vendor/bird-search/
/vendor/
# build artifact from scripts/build-skill.sh
/dist/
+269
View File
@@ -0,0 +1,269 @@
---
name: last30days
version: "3.0.0"
description: "Multi-query social search with intelligent planning. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: 'last30days AI video tools, last30days best noise cancelling headphones'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn
license: MIT
user-invocable: true
metadata:
hermes:
emoji: "📰"
tags:
- research
- deep-research
- reddit
- x
- twitter
- youtube
- tiktok
- instagram
- hackernews
- polymarket
- trends
- recency
- news
- citations
- multi-source
- social-media
- analysis
- web-search
requires:
env:
- SCRAPECREATORS_API_KEY
optionalEnv:
- OPENAI_API_KEY
- XAI_API_KEY
- OPENROUTER_API_KEY
- PARALLEL_API_KEY
- BRAVE_API_KEY
- APIFY_API_TOKEN
- AUTH_TOKEN
- CT0
- BSKY_HANDLE
- BSKY_APP_PASSWORD
- TRUTHSOCIAL_TOKEN
bins:
- node
- python3
primaryEnv: SCRAPECREATORS_API_KEY
files:
- "scripts/*"
homepage: https://github.com/mvanhorn/last30days-skill
---
# last30days v3.0.0: Research Any Topic from the Last 30 Days
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
Research ANY topic across Reddit, X, YouTube, and other sources. Surface what people are actually discussing, recommending, betting on, and debating right now.
## Runtime Preflight
Before running any `last30days.py` command in this skill, resolve a Python 3.12+ interpreter once and keep it in `LAST30DAYS_PYTHON`:
```bash
for py in python3.14 python3.13 python3.12 python3; do
command -v "$py" >/dev/null 2>&1 || continue
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
LAST30DAYS_PYTHON="$py"
break
done
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
exit 1
fi
```
## Step 0: First-Run Setup Wizard
**CRITICAL: ALWAYS execute Step 0 BEFORE Step 1, even if the user provided a topic.** If the user typed `last30days Mercer Island`, you MUST check for FIRST_RUN and present the wizard BEFORE running research. The topic "Mercer Island" is preserved — research runs immediately after the wizard completes. Do NOT skip the wizard because a topic was provided. The wizard takes 10 seconds and only runs once ever.
To detect first run: check if `~/.config/last30days/.env` exists. If it does NOT exist, this is a first run. **Do NOT run any Bash commands or show any command output to detect this — just check the file existence silently.** If the file exists and contains `SETUP_COMPLETE=true`, skip this section **silently** and proceed to Step 1. **Do NOT say "Setup is complete" or any other status message — just move on.** The user doesn't need to be told setup is done every time they run the skill.
**When first run is detected, detect your platform first:**
**If you do NOT have WebSearch capability (raw CLI):** Run the terminal-only setup flow below.
**If you DO have WebSearch (Hermes):** Run the standard setup flow below.
---
### Terminal-Only / Non-WebSearch Setup Flow
Run environment detection first:
```bash
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --terminal
```
Read the JSON output. It tells you what's already configured. Display a status summary:
```
👋 Welcome to last30days!
Detected:
{✅ or ❌} yt-dlp (YouTube search)
{✅ or ❌} X/Twitter ({method} configured)
{✅ or ❌} ScrapeCreators (TikTok, Instagram, Reddit backup)
{✅ or ❌} Web search ({backend} configured)
```
Then for each missing item, offer setup in priority order:
1. **ScrapeCreators** (if not configured): "ScrapeCreators adds TikTok and Instagram search (plus a Reddit backup if public Reddit gets rate-limited). 10,000 free calls, no credit card. (No referrals, no kickbacks - we don't get a cut.)"
- Option A: "ScrapeCreators via GitHub (recommended)" — Check if `gh` CLI was detected in the environment detection output above. If gh IS detected: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". Before running the command, display: "Registering via GitHub CLI..." If gh is NOT detected: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". Then run `"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" setup --github`, parse JSON output. Tries PAT first (if `gh` is installed), falls back to device flow which copies a one-time code to your clipboard and opens your browser. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to .env.
- Option B: "I have a key" — accept paste, write to .env
- Option C: "Skip for now"
2. **X/Twitter** (if not configured): "X search finds tweets and conversations. To unlock X: add FROM_BROWSER=auto (reads browser cookies, free), XAI_API_KEY (no browser access, api.x.ai), or AUTH_TOKEN+CT0 (manual cookies)."
- Option A: "I have an xAI API key" (recommended for servers — persistent, no expiry). Write XAI_API_KEY to .env.
- Option B: "I have AUTH_TOKEN + CT0 from my browser" — accept both, write to .env
- Option C: "Skip for now"
3. **YouTube** (if yt-dlp not found): "YouTube search needs yt-dlp. Run: `pip install yt-dlp`"
4. **Web search** (if no Brave/Exa/Serper key): "A web search key enables smarter results. Brave Search is free for 2,000 queries/month at brave.com/search/api"
After setup, write `SETUP_COMPLETE=true` to .env and proceed to research.
**Skip to "END OF FIRST-RUN WIZARD" below after completing the terminal-only flow.**
---
### Hermes Setup Flow (Standard)
**You MUST follow these steps IN ORDER. Do NOT skip ahead to the topic picker or research. The sequence is: (1) welcome text -> (2) setup modal -> (3) run setup if chosen -> (4) optional ScrapeCreators modal -> (5) topic picker. You MUST start at step 1.**
**Step 1: Display the following welcome text ONCE as a normal message (not blockquoted). Then IMMEDIATELY call AskUserQuestion - do NOT repeat any of the welcome text inside the AskUserQuestion call.**
Welcome to last30days!
I research any topic across Reddit, X, YouTube, and other sources - synthesizing what people are actually saying right now.
Auto setup gives you 5 core sources for free in 30 seconds:
- X/Twitter - reads your x.com browser cookies to authenticate (not saved to disk). Chrome on macOS will prompt for Keychain access.
- Reddit with comments - public JSON, no API key needed
- YouTube search + transcripts - installs yt-dlp (open source, 190K+ GitHub stars)
- Hacker News + Polymarket + GitHub (if `gh` CLI installed) - always on, zero config
Want TikTok and Instagram too? ScrapeCreators adds those (10,000 free calls, scrapecreators.com). No kickbacks, no affiliation.
**Then call AskUserQuestion with ONLY this question and these options - no additional text:**
Question: "How would you like to set up?"
Options:
- "Auto setup (~30 seconds) - scans browser cookies for X + installs yt-dlp for YouTube"
- "Manual setup - show me what to configure"
- "Skip for now - Reddit (with comments), HN, Polymarket, GitHub (if gh installed), Web"
**If the user picks 1 (Auto setup):**
**Before running the setup command, get cookie consent:**
Check if `BROWSER_CONSENT=true` already exists in `~/.config/last30days/.env`. If it does, skip the consent prompt and run setup directly.
If `BROWSER_CONSENT=true` is NOT present, **call AskUserQuestion:**
Question: "Auto setup will scan your browser for x.com cookies to authenticate X search. Cookies are read live, not saved to disk. Chrome on macOS will prompt for Keychain access. OK to proceed?"
Options:
- "Yes, scan my cookies for X" - Run setup as normal. Append `BROWSER_CONSENT=true` to .env after setup completes.
- "Skip X, just set up YouTube" - Run setup with YouTube only (install yt-dlp). Do not scan cookies.
- "I have an xAI API key instead" - Ask them to paste it, write XAI_API_KEY to .env. Then install yt-dlp.
Run the setup subcommand:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup
```
Show the user the results (what cookies were found, whether yt-dlp was installed).
**Then show the optional ScrapeCreators offer (plain text, then modal):**
Want TikTok and Instagram too? ScrapeCreators adds those platforms - 10,000 free calls, no credit card. It also serves as a Reddit backup if public Reddit ever gets rate-limited.
**Before showing the ScrapeCreators modal, check for `gh` CLI:** Run `which gh` via Bash silently. Store the result as gh_available (true if found, false if not).
**Call AskUserQuestion:**
Question: "Want to add TikTok, Instagram, and Reddit backup via ScrapeCreators? (We don't get a cut.)"
Options:
- "ScrapeCreators via GitHub (fastest, recommended)" - If gh_available: description should say "Registers directly via GitHub CLI in ~2 seconds - no browser needed". If NOT gh_available: description should say "Copies a one-time code to your clipboard and opens GitHub to authorize". After the user selects this option: If gh_available, display "Registering via GitHub CLI..." before running the command. If NOT gh_available, display "I'll copy a one-time code to your clipboard and open GitHub. When GitHub asks for a device code, just paste (Cmd+V on Mac, Ctrl+V on Windows/Linux)." Then run `cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py setup --github` via Bash with a 5-minute timeout. This tries PAT auth first (if `gh` CLI is installed, zero browser needed), then falls back to GitHub device flow which copies a one-time code to your clipboard and opens GitHub in your browser. Parse the JSON stdout. If `status` is `success`, write `SCRAPECREATORS_API_KEY=*** to `~/.config/last30days/.env`. If `method` is `pat`, show: "You're in! Registered via GitHub CLI - zero browser needed. 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is true, show: "You're in! (The authorization code was copied to your clipboard automatically.) 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `method` is `device` and `clipboard_ok` is false, show: "You're in! 10,000 free calls. TikTok, Instagram, and Reddit backup are now active." If `status` is `timeout` or `error`, show: "GitHub auth didn't complete. No worries - you can sign up at scrapecreators.com instead or try again later." Then offer the web signup option.
- "Open scrapecreators.com (Google sign-in)" - run `open https://scrapecreators.com` via Bash to open in the user's browser. Then ask them to paste the API key they get. When they paste it, write SCRAPECREATORS_API_KEY=*** to ~/.config/last30days/.env
- "I have a key" - accept the key, write to .env
- "Skip for now" - proceed without ScrapeCreators
**After SC key is saved (not if skipped), show the TikTok/Instagram opt-in:**
**Call AskUserQuestion:**
Question: "Enable TikTok and Instagram search?"
Options:
- "Yes, enable TikTok + Instagram" - Write `TIKTOK_ENABLED=true` and `INSTAGRAM_ENABLED=true` to .env. Then show: "TikTok and Instagram are now enabled. You can disable them later by editing ~/.config/last30days/.env."
- "No, skip for now" - proceed without enabling
**After setup completes, write `SETUP_COMPLETE=true` to .env.**
---
## END OF FIRST-RUN WIZARD
Proceed to Step 1.
---
## Step 1: Parse Topic
The user invoked: `last30days {QUERY}`
Extract the topic. If the query is empty or ambiguous, ask for clarification.
## Step 2: Execute Research
Run the research engine:
```bash
cd {SKILL_DIR} && "${LAST30DAYS_PYTHON}" scripts/last30days.py "{TOPIC}" --emit=compact --lookback-days=30
```
Optional flags based on user request:
- `--search=reddit,youtube,hackernews` - Specific sources only
- `--days=7` - Shorter time range
- `--deep` - Higher recall mode
- `--save` - Save to ~/Documents/Last30Days/
## Step 3: Display Results
Show the research output to the user. The compact output includes:
- Executive summary
- Ranked evidence clusters with scores
- Source statistics (upvotes, views, engagement)
- Citations with URLs
- Confidence levels and uncertainty notes
## Security & Permissions
**What this skill does:**
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, and as a Reddit backup when public Reddit is unavailable (requires SCRAPECREATORS_API_KEY)
- Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery (fallback if no SCRAPECREATORS_API_KEY)
- Sends search queries to Twitter's GraphQL API (via optional user-provided AUTH_TOKEN/CT0 env vars — no browser session access) or xAI's API (`api.x.ai`) for X search
- Sends search queries to Algolia HN Search API (`hn.algolia.com`) for Hacker News story and comment discovery (free, no auth)
- Sends search queries to Polymarket Gamma API (`gamma-api.polymarket.com`) for prediction market discovery (free, no auth)
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
- Sends search queries to ScrapeCreators API (`api.scrapecreators.com`) for TikTok and Instagram search, transcript/caption extraction (PAYG after 10,000 free API calls)
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
- Stores research findings in local SQLite database (watchlist mode only)
- Saves research briefings as .md files to ~/Documents/Last30Days/
**What this skill does NOT do:**
- Does not post, like, or modify content on any platform
- Does not access your Reddit, X, or YouTube accounts
- Does not share API keys between providers (OpenAI key only goes to api.openai.com, etc.)
- Does not log, cache, or write API keys to output files
- Does not send data to any endpoint not listed above
- Hacker News and Polymarket sources are always available (no API key, no binary dependency)
- TikTok and Instagram sources require SCRAPECREATORS_API_KEY (10,000 free API calls, then PAYG). Reddit uses ScrapeCreators only as a backup when public Reddit is unavailable.
- Can be invoked autonomously by agents via the Skill tool (runs inline, not forked); pass `--agent` for non-interactive report output
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
Review scripts before first use to verify behavior.
+27 -2
View File
@@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.0] - 2026-04
## [3.0.1] - 2026-04-14
### Fixed
- **Skill upload packaging** - `scripts/build-skill.sh` produces a claude.ai-upload-ready `.skill` file that fits under the 200-file cap. Previously, zipping the repo hit 406 files and the "Upload skill" UI rejected it outright.
- **SKILL.md description length** - trimmed from 228 to 167 chars (Anthropic caps descriptions at 200).
### Removed
- Unused root `vendor/` directory (215 files from an accidental commit in PR #48 - the real vendored X client lives at `scripts/lib/vendor/bird-search/`).
- Legacy top-level `plans/` directory (superseded by `docs/plans/`; both plans described work that was already shipped in v3).
### Added
- `.gitattributes` with `export-ignore` entries so `git archive` drops tests, docs, fixtures, assets, historical manifests, and internal skill subdirs. Mirrors Anthropic's canonical `package_skill.py` exclusions.
- `scripts/build-skill.sh` - one-command path to produce `dist/last30days.skill` with a single top-level `last30days/` folder, defensive `=200` file check, and dirty-tree refusal.
- `README.md` section documenting the claude.ai skill upload workflow.
## [3.0.0] - 2026-04-11
### Highlights
@@ -34,10 +52,18 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
- Polymarket display shows % odds only; dollar volumes removed
- 852 tests passing
### Fixed
- Marketplace validation: duplicate `name: last30days` collision in `skills/last30days/SKILL.md` caused strict validators to reject the plugin. Resolved by renaming the internal v3 architecture spec to `last30days-v3-spec` with `user-invocable: false`. Fixed in #214 (reported by @Cody-Coyote in #204).
- Stale README link to the deleted `skills/last30days-v3/` path from the v3 directory rename. Fixed in #214.
- OpenAI Codex CLI discoverability: added `.agents/skills/last30days/SKILL.md` as a real file (Codex's loader skips symlinked files) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now registers as `last30days:last30days` when Codex runs in a checkout of the repo. Fixed in #219 (inspired by @Jah-yee in #153 and @dannyshmueli on X).
### Contributors
- @j-sperling -- v3 engine architecture, Python pre-research brain
- @hnshah -- Watchlist features
- @Cody-Coyote -- Marketplace validation bug report (#204)
- @Jah-yee -- Codex CLI integration inspiration (#153)
## [2.9.4] - 2026-03-06
@@ -181,7 +207,6 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
### Credits
- @steipete -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- @galligan -- Marketplace plugin inspiration
- @hutchins -- Pushed for YouTube feature
+121
View File
@@ -0,0 +1,121 @@
# Hermes Setup Guide for last30days
This guide covers installing last30days on Hermes AI Agent.
## Prerequisites
1. **Hermes installed** - See https://github.com/mercurial-tf/hermes
2. **Python 3.12+** - `brew install python@3.12` or similar
3. **yt-dlp** (optional, for YouTube) - `brew install yt-dlp`
## Installation
### Option 1: Via sync.sh (Recommended)
```bash
# Clone the repo
git clone https://github.com/mvanhorn/last30days-skill.git
cd last30days-skill
# Run the sync script
bash scripts/sync.sh
```
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
### Option 2: Manual Copy
```bash
# Create directory
mkdir -p ~/.hermes/skills/research/last30days
# Copy files
cp -r scripts ~/.hermes/skills/research/last30days/
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
```
## Usage
In Hermes, invoke with:
```
last30days "your research topic"
```
Or with options:
```
last30days "best mechanical keyboards 2025" --search=reddit,youtube
last30days "AI news" --days=7 --deep
```
## First Run Setup
On first run, the skill will guide you through setup:
1. **Auto setup** (~30 seconds)
- Scans browser cookies for X/Twitter
- Checks/installs yt-dlp for YouTube
- Configures free sources (Reddit, HN, Polymarket)
2. **Optional: ScrapeCreators**
- Adds TikTok, Instagram, Reddit backup
- 10,000 free API calls
- Sign up at scrapecreators.com
3. **Optional: API Keys**
- XAI_API_KEY for X/Twitter (alternative to browser cookies)
- BRAVE_API_KEY for web search
## Available Sources
### Free (No API Key)
- **Reddit** - Public discussions and comments
- **Hacker News** - Tech discussions via Algolia
- **Polymarket** - Prediction markets
- **YouTube** - Search and transcripts (requires yt-dlp)
### Requires API Key
- **X/Twitter** - xAI API key or browser cookies
- **TikTok** - ScrapeCreators API
- **Instagram** - ScrapeCreators API
- **Web Search** - Brave Search API
## Troubleshooting
### Python not found
```bash
# Find Python 3.12+
which python3.12 python3.13 python3.14
# If not installed
brew install python@3.12
```
### yt-dlp not found
```bash
brew install yt-dlp
# or
pip install yt-dlp
```
### Check what's configured
```bash
cd ~/.hermes/skills/research/last30days
python3.12 scripts/last30days.py --diagnose
```
## Updating
To update to the latest version:
```bash
cd last30days-skill
git pull
bash scripts/sync.sh
```
## Support
- Original repo: https://github.com/mvanhorn/last30days-skill
- Hermes: https://github.com/mercurial-tf/hermes
- Issues: Please report in the original repo
+37 -7
View File
@@ -12,7 +12,7 @@
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days-v3/SKILL.md](skills/last30days-v3/SKILL.md), which is the source of truth for the latest command and setup behavior.
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior.
Claude Code:
```
@@ -24,6 +24,12 @@ OpenClaw:
clawhub install last30days-official
```
Hermes:
```
# The skill auto-deploys when you run sync.sh
# Or manually copy to ~/.hermes/skills/research/last30days/
```
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
---
@@ -135,28 +141,52 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
## Install
| Surface | Install |
|---------|---------|
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
| **OpenClaw** | `clawhub install last30days-official` |
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
### claude.ai (web)
1. [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) from the latest release
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
3. Click the `+` button in the Skills panel and drop the file in
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
### Claude Code
#### Install
```
/plugin marketplace add mvanhorn/last30days-skill
```
#### Update
```
claude plugin update last30days@last30days-skill
```
Update later with `claude plugin update last30days@last30days-skill`.
### OpenClaw
```bash
clawhub install last30days-official
```
### Manual
### Gemini CLI
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
```bash
git clone https://github.com/mvanhorn/last30days-skill
gemini extensions install ./last30days-skill
```
### Manual (developer)
```bash
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
```
Or build the claude.ai `.skill` file from source: `bash scripts/build-skill.sh` produces `dist/last30days.skill`.
Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
## Bring your own keys
+5 -28
View File
@@ -1,8 +1,8 @@
---
name: last30days
version: "3.0.0"
description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
argument-hint: 'last30days AI video tools, last30days best noise cancelling headphones'
version: "3.0.1"
description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
@@ -59,7 +59,7 @@ metadata:
- clawhub
---
# last30days v2.9.5: Research Any Topic from the Last 30 Days
# last30days v3.0.1: Research Any Topic from the Last 30 Days
> **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `~/Documents/Last30Days/`. X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section.
@@ -375,7 +375,7 @@ Common patterns:
- Always active: Reddit, Hacker News, Polymarket
- If gh CLI is installed (check `which gh`): add GitHub
- If AUTH_TOKEN/CT0 or XAI_API_KEY or FROM_BROWSER is set: add X
- If yt-dlp is installed (check `which yt-dlp`): add YouTube AND Podcasts
- If yt-dlp is installed (check `which yt-dlp`): add YouTube
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains tiktok: add TikTok
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains instagram: add Instagram
- If SCRAPECREATORS_API_KEY is set and INCLUDE_SOURCES contains threads: add Threads
@@ -615,27 +615,6 @@ Store as `RESOLVED_IG_CREATORS`.
Store as `RESOLVED_YT_QUERIES`.
**6. Podcast channels****INFER 6-12 YouTube podcast channel @handles from topic knowledge.** Think in two dimensions:
1. **Domain podcasts** — What YouTube podcasts focus on this topic's domain?
- Hip-hop/music → `DrinkChamps,JoeBuddenTV,BreakfastClubPower1051FM,OfficialFlagrant`
- Tech/AI/startups → `lexfridman,DwarkeshPatel,AllInPod,MyFirstMillionPod,LennysPodcast`
- Business/finance → `AcquiredFM,InvestLikeTheBest,PatrickBoyleOnFinance,PropGPod`
- Sports → `PatMcAfeeShowOfficial,ShannonSharpe,ClubShayShay`
- Culture/celebs → `joerogan,CallHerDaddy,ClubShayShay`
- Knitting/crafts → `FruityKnitting,VeryPinkKnits,GroceryGirlsKnit`
2. **Cross-domain podcasts** — What popular interview/deep-dive podcasts might cover this topic even if it's not their main focus?
- Business-adjacent topics → `AcquiredFM,InvestLikeTheBest` (company deep dives)
- Tech-adjacent topics → `lexfridman,AllInPod` (broad tech interviews)
- Culture-adjacent topics → `joerogan,OfficialFlagrant` (celebrity interviews)
**Rationale:** The engine uses these channels for transcript-first discovery. Even if the topic isn't in an episode title, it may be discussed within the episode. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times — invisible to YouTube search but found by transcript scanning.
**Handle accuracy:** Return your best guess at the exact @handle. If wrong, the engine falls back to a search-based lookup. Don't stress the exact spelling — `@AcquiredFM`, `@lexfridman`, `@joerogan` work; `@FLAGRANT` fails but falls back to find `@OfficialFlagrant`.
Store as `RESOLVED_PODCAST_CHANNELS` (comma-separated, no @ prefix).
**Concrete examples:**
| Topic | WebSearches needed | Reddit subs | TikTok hashtags | TikTok creators | IG creators | YT queries |
@@ -656,7 +635,6 @@ Resolved:
- Reddit: r/{sub1}, r/{sub2}, r/{sub3}
- TikTok: #{hashtag1}, #{hashtag2}
- YouTube: {query1}, {query2}
- Podcasts: @{channel1}, @{channel2}, @{channel3}
```
Only show lines for platforms where something was resolved. Skip empty lines. This display replaces the old "Parsed intent" block with something more useful.
@@ -782,7 +760,6 @@ fi
- `--ig-creators={RESOLVED_IG_CREATORS}` (from Step 0.55)
- `--github-user={RESOLVED_GITHUB_USER}` (from Step 0.5b, person topics only)
- `--github-repo={RESOLVED_GITHUB_REPOS}` (from Step 0.5c, product/project topics only)
- `--podcast-channels={RESOLVED_PODCAST_CHANNELS}` (from Step 0.55, 6-12 @handles)
- Omit any flag where the value was not resolved (empty).
**If you skipped Steps 0.55 and 0.75 (no WebSearch -- OpenClaw, Codex, etc.), add:**
@@ -1,319 +0,0 @@
---
title: "feat: YouTube podcast source with transcript-first discovery"
type: feat
status: active
date: 2026-04-10
---
# feat: YouTube podcast source with transcript-first discovery
## Overview
Add a "podcasts" source to last30days that discovers podcast content on YouTube by scanning transcripts, not searching titles. The LLM planner resolves topic-relevant podcast channels (e.g., "NVIDIA" -> Acquired, Lex Fridman, Dwarkesh Patel, All-In). The engine fetches recent episodes from those channels, downloads their auto-captions (no video download), and greps for the search topic. Episodes with 5+ topic mentions become podcast results with transcript highlights.
This finds content invisible to any search engine. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times - none in the title. A Dwarkesh Patel episode titled "The single biggest bottleneck to scaling AI compute" contains 156 mentions of NVIDIA. No YouTube search finds these. Transcript scanning does.
Zero new API keys. Zero new dependencies. Reuses existing yt-dlp + transcript pipeline. Podcasts get their own identity in stats and synthesis.
## Problem Frame
YouTube captures a lot of podcast content, but it's mixed with news clips, reaction videos, and shorts. The general YouTube search treats a 2:24:55 Drink Champs interview the same as a 0:30 TMZ clip. Worse, the highest-value podcast content is often invisible to search entirely because the topic is discussed within an episode titled something else.
Two insights make this solvable:
1. Podcast episodes are identifiable by duration (>20 minutes) and channel.
2. YouTube auto-captions are free, downloadable without the video (~7 seconds per episode via yt-dlp), and searchable. Transcript scanning discovers content that title-based search cannot.
The LLM already resolves subreddits and X handles per topic. Podcast channels are the same pattern.
## Requirements Trace
- R1. LLM resolves topic-relevant podcast YouTube channels dynamically (no hardcoded list)
- R2. Engine scans recent episode transcripts for the search topic, not just titles
- R3. Podcast results get their own source identity with own stats line and synthesis treatment
- R4. Reuses existing yt-dlp transcript pipeline (no new dependencies)
- R5. Does not duplicate regular YouTube results (dedup by video ID in fusion)
- R6. Channel resolution works in both the agent layer (SKILL.md) and the Python planner
## Scope Boundaries
- Not building a new API integration (reuses yt-dlp entirely)
- Not adding PodcastIndex, AssemblyAI, or any podcast-specific API
- Not changing how the regular YouTube source works
- Not building a podcast channel database
- Channels that can't be resolved are skipped silently (graceful degradation)
## Context & Research
### Relevant Code and Patterns
- `scripts/lib/youtube_yt.py` - YouTube search + transcript pipeline. Key functions: `search_youtube()`, `fetch_transcripts()`, `extract_transcript_highlights()`
- `scripts/lib/youtube_yt.py` - `--write-auto-sub --skip-download` fetches captions without downloading video
- Step 0.55 in `SKILL.md` - subreddit resolution pattern (WebSearch + LLM knowledge -> `--subreddits=`)
- `scripts/lib/pipeline.py` - source dispatch via if/elif chain in `_retrieve_stream()`, 4-point registration pattern
- `scripts/lib/normalize.py` - `_normalize_youtube()` handles transcript data, reusable for podcasts
- `scripts/lib/signals.py` - `SOURCE_QUALITY` dict (YouTube is 0.85)
- `scripts/lib/planner.py` - `QueryPlan` schema, `SOURCE_CAPABILITIES` dict
### Proof of Concept Results (2026-04-10)
**Transcript-first discovery test:** Fetched auto-captions for 5 recent Acquired episodes (35 seconds total, no video download). Grepped for topics not in any episode title:
| Topic | Mentions | Episode title | Discoverable by search? |
|-------|----------|---------------|------------------------|
| ESPN | 117 | The NFL | No |
| Super Bowl | 108 | The NFL | No |
| Netflix | 102 | The NFL | No |
| Amazon | 87 | The NFL | No |
| Costco | 63 | The NFL / others | No |
| Disney | 48 | The NFL | No |
| LVMH | 27 | Formula 1 / others | No |
| Taylor Swift | 18 | The NFL | No |
**Full E2E test (topic: NVIDIA, 4 channels):** LLM resolved Acquired, Lex Fridman, Dwarkesh Patel, All-In. Scanned 14 episodes. Results:
| Podcast | Episode | NVIDIA mentions | Title mentions NVIDIA? |
|---------|---------|----------------|----------------------|
| Lex Fridman | Jensen Huang interview | 159 | Yes |
| Dwarkesh Patel | Dylan Patel: AI compute bottleneck | 156 | No |
| Acquired | 10 Years (w/ Michael Lewis) | 24 | No |
| All-In | SpaceX IPO, Iran, Quantum... | 6 | No |
3 of 4 hits are invisible to YouTube search. The Dylan Patel episode (156 mentions!) is entirely about NVIDIA's GPU supply chain but the title never says "NVIDIA."
**Channel handle resolution test:** LLM resolves podcast name + @handle guess. Engine tries @handle first (fast), falls back to `ytsearch1:` if wrong. Tested across 12 channels (tech, hip-hop, knitting): 11/12 resolved on first @handle attempt, 12/12 with fallback. Even niche channels (Fruity Knitting, Grocery Girls Knit, Roxanne Richardson) resolved correctly.
**Rate limit test:** 4 channels x 3-4 episodes = 14 caption fetches took ~2 minutes sequential. Parallelized with 4 workers: ~30-40 seconds. No YouTube throttling observed. Runs concurrently with Reddit/X/everything else in a 3-minute research run.
## Key Technical Decisions
- **Transcript-first discovery, not title/search-based:** The core innovation. Instead of searching YouTube for `{topic} {podcast_name}` (which only finds episodes titled about the topic), we fetch captions from recent episodes and grep for the topic. This discovers hidden mentions. The approach is validated by POC data showing 3/4 NVIDIA hits were invisible to search.
- **LLM-resolved channels, not hardcoded:** The LLM planner (agent layer or Python Gemini/OpenAI) resolves 6-12 channels per topic using two-dimensional reasoning: (1) domain podcasts that focus on the topic's area, (2) cross-domain podcasts that might cover it. Tested: the LLM correctly resolved channels for NVIDIA (tech), Kanye (hip-hop), and knitting (craft) - including niche channels like Fruity Knitting and Grocery Girls Knit. Three resolution paths mirror the existing planner architecture:
- Path 1: Agent layer (SKILL.md with WebSearch) resolves channels in Step 0.55
- Path 2: Python planner (Gemini/OpenAI) generates channels as a `podcast_channels` field in the QueryPlan
- Path 3: Fallback (no LLM) uses a small default list of ~5 broad-appeal channels
- **Handle-first channel resolution with search fallback:** The LLM returns both the podcast name and its best guess at the @handle. The engine tries the @handle first (instant, 92% success rate in testing). If the handle fails, it falls back to `ytsearch1:"{podcast name}" podcast full episode` to find the channel URL. Channels that can't be resolved either way are skipped silently.
- **New source module wrapping YouTube functions:** `podcast_yt.py` imports `fetch_transcripts()` and `extract_transcript_highlights()` from `youtube_yt.py`. It adds the channel-fetching, caption-scanning, and mention-counting logic. This keeps the regular YouTube source untouched and gives podcasts their own pipeline identity.
- **Duration filter >= 1200 seconds (20 minutes):** Eliminates clips, shorts, and news segments. Tested empirically - only full podcast episodes survive this filter.
- **SOURCE_QUALITY: 0.88 (above YouTube's 0.85):** Podcast episodes contain long-form expert discussion with full context. The quality bonus ensures podcast results rank above equivalent YouTube clips when both exist.
- **Mention count threshold: 5+:** Episodes with fewer than 5 topic mentions are noise (passing references). 5+ indicates substantive discussion. Tested: Taylor Swift at 18 mentions in the NFL episode is substantive discussion of her impact on viewership. "Apple" at 3 mentions in a random episode is just name-dropping.
## Open Questions
### Resolved During Planning
- **Can yt-dlp fetch captions without downloading video?** Yes. `yt-dlp --write-auto-sub --sub-lang en --skip-download --sub-format vtt` fetches only the subtitle file. ~7 seconds per episode, ~2MB per 4-hour episode.
- **Will this double-count YouTube content?** No. Fusion deduplicates by item ID. Both sources use `yt_{video_id}` format.
- **Can LLMs resolve niche podcast channels?** Yes. Tested with knitting: Fruity Knitting, VeryPink Knits, Grocery Girls Knit, Roxanne Richardson all resolved correctly via @handle.
- **What about rate limits?** 14 caption fetches across 4 channels showed no throttling. Running in parallel with 4 workers keeps total time under 40 seconds. yt-dlp doesn't use the YouTube Data API (no quota).
- **How does the LLM know which podcasts to pick?** Two-dimensional prompt: (1) "What YouTube podcasts focus on {topic's domain}?" and (2) "What popular interview/deep-dive podcasts have likely discussed {topic}?" The LLM returns channel names + @handle guesses.
### Deferred to Implementation
- **Exact duration threshold:** Starting with 1200s (20 min). May tune to 900s (15 min) if testing shows missed content.
- **Mention count threshold tuning:** Starting with 5. May need per-source calibration (a 30-minute podcast with 5 mentions is denser than a 4-hour one with 5 mentions).
- **Caption language handling:** Starting with English (`--sub-lang en`). Multilingual support deferred.
- **Parallel worker count:** Starting with 4 workers. May tune based on YouTube throttling behavior at scale.
## High-Level Technical Design
> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
```
PODCAST DISCOVERY FLOW:
User query: "NVIDIA"
|
LLM planner resolves podcast channels:
"NVIDIA is a tech/AI company. Domain podcasts: none specific.
Cross-domain: Acquired (@AcquiredFM), Lex Fridman (@lexfridman),
Dwarkesh Patel (@DwarkeshPatel), All-In (@AllInPod)"
|
Engine receives: --podcast-channels=AcquiredFM,lexfridman,DwarkeshPatel,AllInPod
|
For each channel (parallel, 4 workers):
|
[1] Resolve @handle -> channel URL
Try: https://youtube.com/@AcquiredFM/videos
If fail: ytsearch1:"Acquired podcast full episode" -> extract channel_url
If fail: skip channel
|
[2] Fetch last 3 episode IDs + metadata (duration, date, title)
yt-dlp --flat-playlist --playlist-end 3
|
[3] Filter: duration >= 1200s AND upload_date in date range
|
[4] For each surviving episode:
Fetch auto-captions: yt-dlp --write-auto-sub --skip-download
Grep captions for "nvidia" (case-insensitive)
If mentions >= 5: HIT - extract transcript highlights around mentions
|
Merge all hits, deduplicate by video_id
Score: mention_count * log(views)
Return as source="podcasts" items with transcript_snippet + mention_count
```
## Implementation Units
- [ ] **Unit 1: Podcast transcript-scan module**
**Goal:** Create `scripts/lib/podcast_yt.py` with the channel-fetching, caption-scanning, mention-counting pipeline. Returns podcast episodes discovered via transcript scanning.
**Requirements:** R2, R3, R4
**Dependencies:** None (youtube_yt.py already exists)
**Files:**
- Create: `scripts/lib/podcast_yt.py`
- Test: `tests/test_podcast_yt.py`
**Approach:**
- `search_podcast_youtube(topic, from_date, to_date, depth, channels)`:
- For each channel handle (in parallel via ThreadPoolExecutor, max 4 workers):
1. Resolve handle to channel URL (try @handle first, search fallback)
2. Fetch last N episode IDs + metadata via `yt-dlp --flat-playlist --playlist-end N`
3. Filter: `duration >= 1200` and `upload_date` within date range
4. Fetch auto-captions via `yt-dlp --write-auto-sub --skip-download --sub-lang en`
5. Grep captions for topic keywords (case-insensitive). Count mentions.
6. If mentions >= MENTION_THRESHOLD: include as hit. Extract transcript highlights around mentions using `extract_transcript_highlights()` from `youtube_yt`.
- Merge results, deduplicate by video_id
- Score: `mention_count * log(views + 1)`
- Skip channels that can't be resolved or have no recent episodes
- `resolve_channel(handle)`: Try `@{handle}` URL first. If 404, search `ytsearch1:"{handle}" podcast full episode`, extract channel_url. Return channel_url or None.
- EPISODES_PER_CHANNEL: quick=2, default=3, deep=4
- MENTION_THRESHOLD: 5
- RESULTS_CAP: quick=4, default=8, deep=20
**Patterns to follow:**
- `scripts/lib/youtube_yt.py` `search_and_transcribe()` for search-then-enrich flow
- `scripts/lib/youtube_yt.py` `extract_transcript_highlights()` for highlight extraction
- `scripts/lib/hackernews.py` for clean module structure with `_log()`, `DEPTH_CONFIG`
**Test scenarios:**
- Happy path (hidden mention): topic "Taylor Swift", channels=["AcquiredFM"] -> scans NFL episode, finds 18 mentions, returns episode with highlights about Taylor Swift's NFL viewership impact
- Happy path (title match): topic "kanye west", channels=["RevoltTV"] -> scans Kanye interview, finds 500+ mentions, returns with highlights
- Happy path (scoring): episode with 156 mentions and 205K views scores higher than one with 6 mentions and 145K views
- Happy path (handle resolution): @AcquiredFM resolves directly. @SomeWrongHandle fails, search fallback finds correct channel.
- Edge case: topic "quantum computing" has <5 mentions in all episodes -> returns empty (threshold not met)
- Edge case: @handle doesn't exist AND search fallback fails -> channel skipped silently, other channels still scanned
- Edge case: channel has no episodes in date range -> skipped
- Edge case: episode has no auto-captions available -> skipped with log warning
- Error path: yt-dlp not installed -> returns empty items with log warning
- Error path: caption download times out -> skip that episode, continue
**Verification:**
- Discovers episodes where topic is discussed but not in the title (Acquired/NFL/Taylor Swift)
- Also discovers episodes where topic IS the subject (via same transcript scan)
- All returned items have duration >= 1200
- Each item has: video_id, title, channel, url, date, duration, engagement, transcript_snippet, mention_count
---
- [ ] **Unit 2: Pipeline integration**
**Goal:** Register "podcasts" as a new source in pipeline, normalizer, signals, planner, env, and render.
**Requirements:** R3, R5, R6
**Dependencies:** Unit 1
**Files:**
- Modify: `scripts/lib/pipeline.py` (import, MOCK_AVAILABLE_SOURCES, available_sources, _retrieve_stream)
- Modify: `scripts/lib/normalize.py` (add normalizer - reuse `_normalize_youtube` with source override)
- Modify: `scripts/lib/signals.py` (add SOURCE_QUALITY: 0.88)
- Modify: `scripts/lib/planner.py` (add SOURCE_CAPABILITIES, extend QueryPlan schema with `podcast_channels` field, add prompt guidance for LLM channel resolution)
- Modify: `scripts/lib/env.py` (add is_podcast_yt_available - checks yt-dlp installed + "podcasts" in INCLUDE_SOURCES)
- Modify: `scripts/lib/render.py` (add SOURCE_LABELS: "podcasts" -> "Podcasts")
- Test: `tests/test_podcast_yt.py` (pipeline dispatch test)
**Approach:**
- Availability: yt-dlp installed + "podcasts" in INCLUDE_SOURCES. No API key needed.
- SOURCE_CAPABILITIES: `{"podcasts": {"discussion", "longform", "expert", "interview"}}`
- Normalizer: reuse `_normalize_youtube` via lambda wrapper, override source to "podcasts". Add `mention_count` to metadata.
- CLI flag: `--podcast-channels=handle1,handle2,...` parsed from args
- Planner: extend QueryPlan with `podcast_channels: list[str]`. Prompt guidance for LLM: "List 6-12 YouTube podcast channel @handles that would discuss this topic. Think in two dimensions: (1) domain podcasts that focus on this area, (2) popular cross-domain interview/deep-dive podcasts that might cover it. Return @handles. If unsure of exact handle, return your best guess."
- Planner: include "podcasts" source for general/opinion/comparison intents
- Dedup: podcast items use `yt_{video_id}` ID format (same as YouTube). Fusion dedup handles collisions.
**Patterns to follow:**
- 4-point pipeline registration (same as all sources)
- `_normalize_youtube` reuse via lambda (like tiktok/instagram share `_normalize_shortform_video`)
- `scripts/lib/env.py` INCLUDE_SOURCES opt-in pattern
**Test scenarios:**
- Happy path: "podcasts" in available_sources when yt-dlp installed + INCLUDE_SOURCES contains "podcasts"
- Happy path: pipeline dispatches to podcast_yt.search_podcast_youtube when source="podcasts"
- Edge case: yt-dlp not installed -> podcasts not available
- Edge case: "podcasts" not in INCLUDE_SOURCES -> not available even with yt-dlp
- Integration: podcast video_id collides with YouTube result -> fusion deduplicates, keeps higher score
**Verification:**
- `python3 scripts/last30days.py "NVIDIA" --podcast-channels=AcquiredFM,lexfridman` returns podcast results
- Stats output shows "Podcasts" line separate from "YouTube"
---
- [ ] **Unit 3: SKILL.md podcast channel resolution + synthesis**
**Goal:** Add podcast channel resolution to Step 0.55 and podcast-specific synthesis guidance to the Judge Agent section.
**Requirements:** R1, R3, R6
**Dependencies:** Unit 2
**Files:**
- Modify: `SKILL.md`
**Approach:**
- **Step 0.55 addition:** Add "Resolve podcast channels" alongside subreddit, X handle, and TikTok resolution. The agent resolves 6-12 @handles using two-dimensional reasoning (domain + cross-domain). For niche topics, supplement with `WebSearch("{TOPIC} podcast YouTube channel")`. Display resolved channels: "Podcasts: @AcquiredFM, @lexfridman, @DrinkChamps". Pass as `--podcast-channels=AcquiredFM,lexfridman,DrinkChamps`.
- **Step 0.75 addition:** Add "podcasts" to available sources list. Include in primary subquery sources.
- **Synthesis guidance addition:** "For podcasts: lead with the guest's name and the podcast name. Quote transcript highlights as direct quotes with speaker attribution. Podcast content represents considered opinion, not hot takes - a 2-hour interview has more nuance than a tweet. When both a podcast and a YouTube clip cover the same topic, prefer the podcast's longer-form analysis."
- **Stats format:** `├─ 🎙️ Podcasts: {N} episodes │ {N} views │ {N} with transcripts`
- **INCLUDE_SOURCES:** Add "podcasts" as an option. Note in setup: "Requires yt-dlp (already installed if YouTube works). No API key needed."
- **Invitation section:** Reference podcast episodes in follow-up suggestions ("Want me to pull more from that Lex Fridman episode?")
**Patterns to follow:**
- Step 0.55 subreddit resolution pattern
- Source-specific synthesis guidance (YouTube highlights, Reddit top comments)
**Test scenarios:**
- Test expectation: none - SKILL.md is an instruction document. Verification is manual E2E.
**Verification:**
- `/last30days NVIDIA` resolves tech podcast channels and passes them to engine
- `/last30days Kanye West` resolves hip-hop podcast channels
- `/last30days knitting` resolves craft podcast channels (Fruity Knitting, etc.)
- Stats show 🎙️ Podcasts line. Synthesis quotes podcast content with speaker attribution.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| LLM guesses wrong @handle | Handle-first resolution with search fallback. 92% first-attempt success in testing, 100% with fallback. Wrong handles fail fast and skip silently. |
| Transcript scanning adds latency | Runs in parallel with all other sources. 4 channels x 3 episodes = ~30-40s parallelized. Invisible in a 3-minute research run. |
| Topic mentions below threshold (lots of misses) | LLM picks channels likely to discuss the topic. When it picks well, hit rate is high (4/14 episodes in NVIDIA test). Misses cost ~7s per episode in wasted caption download - acceptable. |
| YouTube throttles caption downloads | 14 sequential downloads showed no throttling. Capping at 4 parallel workers adds safety margin. If throttled, degrade gracefully (fewer episodes scanned). |
| Niche topics have no relevant podcast channels | LLM returns fewer channels (3-4 instead of 10-12). If none can be resolved, podcast source returns empty. Other sources (Reddit, X, YouTube) still run. |
| Same video in both YouTube and podcast results | Fusion deduplicates by `yt_{video_id}`. Podcast version gets 0.88 quality score vs YouTube's 0.85, so podcast version wins dedup. |
## Sources & References
- POC: transcript scan of 5 Acquired episodes found ESPN (117), Netflix (102), Taylor Swift (18), LVMH (27) - all invisible to search
- POC: E2E NVIDIA test across 4 channels found 5 hits, 3 invisible to search (including 156-mention Dwarkesh Patel episode)
- POC: handle resolution tested 12 channels (tech, hip-hop, knitting) - 11/12 first-attempt, 12/12 with fallback
- Related code: `scripts/lib/youtube_yt.py`, `scripts/lib/pipeline.py`, `scripts/lib/hackernews.py`
- Pattern: SKILL.md Step 0.55 subreddit resolution
- yt-dlp docs: https://github.com/yt-dlp/yt-dlp
- Acquired FM: https://www.youtube.com/@AcquiredFM
@@ -0,0 +1,445 @@
---
title: Fix skill upload 200-file limit + packaging hygiene (public repo)
type: fix
status: completed
date: 2026-04-14
deepened: 2026-04-14
---
# Fix skill upload 200-file limit + packaging hygiene (public repo)
## Overview
claude.ai's "Upload skill" UI rejects zips with more than 200 files. Zipping the public `mvanhorn/last30days-skill` repo produces 406 files, so the upload fails outright (evidence: Trevin's 2026-04-14 report). Root cause is an accidentally committed npm package under `vendor/` (215 files of dead weight from PR #48) plus the absence of a user-facing packaging path that matches Anthropic's canonical `.skill` format.
Goal: let any user produce a compliant `last30days.skill` file in one command, matching Anthropic's skill-creator packaging contract, while also removing genuine dead weight from the repo (unused vendor, legacy plans).
## Problem Frame
- Trevin tried to upload the public repo as a Claude Skill and hit the 200-file limit
- 215 of 406 files are `vendor/package/` - an extracted `steipete-bird-0.8.0.tgz` that no code imports
- The real runtime X client lives at `scripts/lib/vendor/bird-search/` (15 files, referenced by `scripts/lib/bird_x.py:5` and `tests/test_bird_x.py:133`)
- `.clawhubignore` is ClawHub-specific and does not affect a hand-rolled zip upload
- Users have no documented path to produce a compliant upload zip
- Legacy top-level `plans/` folder holds pre-`docs/plans/` planning artifacts (confirmed waste by Matt, 2026-04-14 chat)
## Requirements Trace
- R1. After this plan lands, the produced upload zip is =200 files
- R2. The X/bird-search runtime still works - no regression in `tests/test_bird_x.py`
- R3. A contributor following README instructions can produce a Claude-Skill-upload-compatible `.skill` file in one command
- R4. Re-introduction of a root `vendor/` directory is prevented via `.gitignore`
- R5. No runtime behavior changes for existing skill consumers (Claude Code plugin, ClawHub, Gemini)
- R6. Produced zip matches Anthropic's canonical skill-folder layout: top-level directory named exactly `last30days` containing `SKILL.md` at its root, with YAML frontmatter `name: last30days`
- R7. Root `SKILL.md` frontmatter passes Anthropic's documented limits: `name` =64 chars (currently 10), `description` =200 chars (currently 228, needs trimming)
- R8. Produced zip contains exactly one `SKILL.md` (at `last30days/SKILL.md`) - no conflicting second skill spec, no symlinks that the uploader may reject or break
- R9. No runtime import reaches an excluded path (proven by import-graph audit, not just asserted)
## Scope Boundaries
Non-goals:
- Not touching the private repo or ClawHub publish flow (those have their own strip script)
- Not resolving the adjacent open issues (#239 plugin loader path-escape, #236 OpenClaw paths, #231 security scan, #190 version drift, #184 Gemini install) - each deserves its own plan
- Not redesigning the skill into self-contained subfolders or splitting scripts into a separate package
- Not adding CI enforcement of the 200-file cap (possible follow-up)
## Context and Research
### Anthropic's canonical skill-upload contract
Sourced from Anthropic's skill-creator repo (`anthropics/skills/skills/skill-creator/scripts/package_skill.py`) and help-center docs:
1. **Output format:** a `.skill` file, which is a standard zip with the `.skill` extension.
2. **Top-level entry in the zip must be a single directory** whose name matches `name:` in the skill's YAML frontmatter. Anthropic's packager uses `arcname = file_path.relative_to(skill_path.parent)`, so the zip always contains `<skill_name>/...`.
3. **That directory must contain `SKILL.md`** at its root (the packager explicitly validates this).
4. **Required YAML frontmatter:** `name` (=64 chars, lowercase + hyphens) and `description` (=200 chars). Our root SKILL.md already satisfies both.
5. **Canonical exclusions** applied by Anthropic's packager:
- Directories: `__pycache__`, `node_modules`
- Root-only: `evals/`
- File globs: `*.pyc`
- Files: `.DS_Store`
6. **Empirical limit:** the upload UI rejects =200 files (screenshot 2026-04-14). Not documented, but confirmed.
7. **Per-file size cap** is not publicly documented; general claude.ai uploads cap at 30MB per file. Conservative target: keep any single file under 10MB.
### Relevant code and patterns in this repo
- `SKILL.md` (root, 1382 lines, 80KB) - `name: last30days`, `user-invocable: true`. This is the skill.
- `skills/last30days/SKILL.md` (230 lines) - `name: last30days-v3-spec`, `user-invocable: false`. Internal architecture spec, separate skill name - not the upload target.
- `vendor/package/` - accidental commit from PR #48, 215 files, zero importers.
- `vendor/steipete-bird-0.8.0.tgz` - source tarball, also unused at runtime.
- `scripts/lib/vendor/bird-search/` - the ACTUAL vendored bird-search client (15 files). Keep.
- `plans/` (top-level, 2 files: `feat-add-websearch-source.md`, `fix-strict-date-filtering.md`) - legacy, pre-`docs/plans/` convention. Matt confirmed delete.
- `scripts/sync.sh` - deploys skill to `~/.claude`, `~/.agents`, `~/.codex`. Reference for runtime-required files.
- `.clawhubignore` - existing exclude list for the ClawHub path. Not used here, but good cross-reference for what is runtime-irrelevant.
- `.gitignore` - current dev excludes (`.venv/`, `__pycache__/`, `.DS_Store`, etc).
### Institutional learnings
- Private repo has `scripts/clawhub-publish.sh` + `scripts/strip_for_openclaw.py` that build a staging dir with only OpenClaw-safe files. Not needed for this public-path upload; `git archive` with `--prefix` is sufficient and dependency-free.
- PR #48 introduced `vendor/package/` unintentionally. No code imports from it.
### File count math (verified via dry run)
| Strategy | File count | Under cap? |
|---|---|---|
| Current repo, zip as-is | 406 | No |
| After `vendor/` deleted | 191 | Yes (thin margin) |
| After `vendor/` + `plans/` deleted, no further excludes | 189 | Yes |
| With full planned excludes (Anthropic canonical + tests/docs/fixtures/assets/dev manifests/nested skill dirs) | 81 | Comfortable headroom |
Dry run run on 2026-04-14 against the current working tree. Simulated the proposed `.gitattributes` with a `find` filter matching the intended exclude list. Result: 81 files, 868KB uncompressed. Actual `git archive` output may differ slightly (by 1-2 files) but will land well under 200.
### Runtime import audit (proves core experience unchanged)
Grepped all `import`/`from` statements in `scripts/**/*.py`. Non-stdlib imports resolve to only:
- `lib.*` (internal package at `scripts/lib/`)
- `store` (internal module at `scripts/store.py`)
- `scripts.*` (internal)
No runtime import reaches `tests/`, `docs/`, `fixtures/`, `vendor/` (root), `plans/`, `assets/`, `.agents/`, `.codex-plugin/`, `.hermes-plugin/`, or any other excluded path. The shipped `.skill` file contains everything the runtime needs and nothing it does not.
### Symlink and multi-SKILL.md audit
The repo contains one symlink: `skills/last30days-nux/SKILL.md -> ../../SKILL.md`. Three SKILL.md files in total:
- `SKILL.md` (root, `name: last30days`, `user-invocable: true`) - the actual skill
- `skills/last30days/SKILL.md` (`name: last30days-v3-spec`, `user-invocable: false`) - internal architecture doc
- `skills/last30days-nux/SKILL.md` (symlink to root) - nux variant reference
Shipping all three inside one zip creates two rejection risks:
1. Uploader sees multiple `SKILL.md` with conflicting `name:` values and refuses or misbinds
2. `git archive` stores the symlink as a symlink entry; the uploader may reject symlinked entries on principle
Both risks disappear by excluding `skills/` entirely from the zip. The two internal skill definitions are not needed for claude.ai skill execution - they serve the repo as documentation / Claude Code plugin layout, not the direct upload path.
### Sources consulted
- Anthropic skill help center article (general upload guidance, no file-count number documented)
- [anthropics/skills README](https://github.com/anthropics/skills/blob/main/README.md) - YAML frontmatter requirements
- [anthropics/skills package_skill.py](https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py) - canonical exclusions and arcname shape
- Trevin's 2026-04-14 chat screenshot (empirical 200-file cap)
- Adjacent issues #239, #236, #190 for context on current packaging mess
## Key Technical Decisions
- **Delete `vendor/` outright** rather than gitignore-and-leave. Pure dead weight. Rationale: the real vendored client is at `scripts/lib/vendor/bird-search/`, root `vendor/` has zero importers; keeping it invites re-upload.
- **Delete top-level `plans/`** (Matt confirmed). Rationale: superseded by `docs/plans/`. Moving content into `docs/plans/` if any is still relevant; otherwise just delete.
- **Produce a `.skill` file (not a plain `.zip`)** via `git archive --format=zip --prefix=last30days/ -o dist/last30days.skill HEAD`. Rationale: matches Anthropic's canonical contract - zip extension is cosmetic, but the `.skill` affordance is what the upload UI expects.
- **Use `git archive` + `.gitattributes export-ignore`** rather than a Python packager. Rationale: no Python dependency at build time, honors git's declarative exclude model, reusable by anyone running `git archive` directly.
- **Mirror Anthropic's canonical exclusions in `.gitattributes`** (`__pycache__`, `node_modules`, `*.pyc`, `.DS_Store`, `evals/`) alongside our repo-specific excludes. Rationale: future-proof if a contributor adds node deps; keeps us aligned with the Anthropic baseline.
- **Exclude `skills/` from the upload zip** (covers `skills/last30days/SKILL.md` and `skills/last30days-nux/SKILL.md`). Rationale: shipping multiple SKILL.md files with different `name:` values is a likely uploader-rejection cause, and the symlink at `skills/last30days-nux/SKILL.md` is an independent rejection risk. Repo contents stay intact - Claude Code plugin and GitHub viewers still see the directory.
- **Keep `.clawhubignore` as-is** - it serves the ClawHub publish path separately. Do not merge the two lists; different consumers, different exclusions.
- **Prevent regression with a `/vendor/` entry in `.gitignore`** (leading slash, so `scripts/lib/vendor/` is unaffected).
- **Do not address #239 `"skills": ["./"]` path-escape here.** That is a plugin.json change, not a zip-packaging change. Separate plan.
## Open Questions
### Resolved during planning
- Is root `vendor/` used? No. Grep for `vendor/package`, `vendor/steipete`, `from vendor` returns zero hits outside `scripts/lib/vendor/`.
- Is `scripts/lib/vendor/bird-search/` safe? Yes. Referenced by `scripts/lib/bird_x.py:5` and `tests/test_bird_x.py:133`.
- What name does the top-level zip directory need? `last30days` - matches `name: last30days` in the root `SKILL.md` frontmatter.
- Does `skills/last30days/SKILL.md` conflict? No. It declares a different skill name (`last30days-v3-spec`) and is `user-invocable: false`. Not the upload target, and safe to ship inside the zip.
- Is there a documented file-count cap? No. 200 is empirical from the UI error screenshot.
- Should we gate this on a version bump? Yes, 3.0.0 - 3.0.1. Same API, same runtime, smaller and uploadable package.
### Deferred to implementation
- Exact `.gitattributes` export-ignore entries may need one tuning pass if `git archive` surfaces a file we forgot. Verification step catches it.
- Whether to delete `SKILL-original.md` from the repo entirely or just export-ignore. Leaning export-ignore to preserve git history context.
- Whether any content in `plans/*.md` is still live reference material. If so, move to `docs/plans/` under new naming convention; if not, delete outright.
## Implementation Units
- [ ] **Unit 1: Remove accidental `vendor/` commit**
**Goal:** Delete the root `vendor/` directory and the stray `.tgz`, both unused at runtime.
**Requirements:** R1, R2, R5
**Dependencies:** None
**Files:**
- Delete: `vendor/` (entire tree, 215 files)
- Delete: `vendor/steipete-bird-0.8.0.tgz`
- Modify: `.gitignore` (add `/vendor/` to prevent regression - leading slash to avoid matching `scripts/lib/vendor/`)
**Approach:**
- Single commit: `chore: remove unused root vendor/ directory (215 files from PR #48)`
- Verify `scripts/lib/vendor/bird-search/` is untouched
- Verify no `from vendor` or `vendor/package` references appear in the diff
**Patterns to follow:**
- Commit message style matches recent history
**Test scenarios:**
- Happy path: `find . -type f -not -path './.git/*' | wc -l` returns =200 after commit
- Integration: `python -m pytest tests/test_bird_x.py -q` passes - confirms the real vendored client still resolves
- Integration: `bash scripts/sync.sh` completes without error
**Verification:**
- Zero files remain under `vendor/` on `main`
- `tests/test_bird_x.py` still passes
- `.gitignore` now contains `/vendor/`
- [ ] **Unit 2: Remove legacy top-level `plans/` directory**
**Goal:** Delete the pre-`docs/plans/` folder (Matt confirmed waste).
**Requirements:** R1, R5
**Dependencies:** None (independent of Unit 1)
**Files:**
- Delete: `plans/feat-add-websearch-source.md`
- Delete: `plans/fix-strict-date-filtering.md`
- Delete: `plans/` (now empty)
**Approach:**
- Skim both files first. If either still reflects real upcoming work, port it to `docs/plans/YYYY-MM-DD-NNN-<type>-*-plan.md` before deletion. If not, delete.
- Commit: `chore: remove legacy plans/ directory (superseded by docs/plans/)`
**Test scenarios:**
- Test expectation: none - pure housekeeping, no code paths affected
**Verification:**
- `plans/` does not exist on `main`
- Nothing in the repo references `plans/feat-add-websearch-source.md` or `plans/fix-strict-date-filtering.md` (grep to confirm)
- [ ] **Unit 3: Declare zip-time excludes via `.gitattributes`**
**Goal:** Use `export-ignore` so `git archive` produces a skill-shaped zip without hand-filtering.
**Requirements:** R1, R3, R6
**Dependencies:** Unit 1, Unit 2
**Files:**
- Create: `.gitattributes`
**Approach:**
- Anthropic canonical exclusions (match `package_skill.py`):
- `__pycache__/` export-ignore
- `node_modules/` export-ignore
- `*.pyc` export-ignore
- `.DS_Store` export-ignore
- `evals/` export-ignore
- Repo-specific exclusions (dev/docs/build artifacts not needed at runtime):
- `tests/` (64 files)
- `docs/` (17 files including `docs/test-results/`)
- `fixtures/` (7 files)
- `assets/` (5 files, 14MB of README media)
- `SKILL-original.md` (historical)
- `SPEC.md`, `TASKS.md`, `test-run.log`, `CONTRIBUTORS.md`, `HERMES_SETUP.md`, `release-notes.md`, `CHANGELOG.md`
- `uv.lock`
- `.agents/`, `.codex-plugin/`, `.hermes-plugin/`, `.claude-plugin/` (platform adapters - skill-upload path is platform-agnostic)
- `.clawhubignore`, `.gitignore`, `.gitattributes`
- `skills/` (avoid second SKILL.md with conflicting `name:`; also drops the symlink at `skills/last30days-nux/SKILL.md`)
- Keep in archive: `scripts/` (runtime), root `SKILL.md`, `README.md`, `LICENSE`, `pyproject.toml`, `CLAUDE.md`, `gemini-extension.json`, `agents/`, `hooks/`
**Technical design:** *(directional guidance, not implementation spec)*
```gitattributes
# Anthropic canonical skill-packaging excludes
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
evals/ export-ignore
# Repo-specific: tests + docs + media (not runtime)
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
# Repo-specific: historical + dev manifests
SKILL-original.md export-ignore
SPEC.md export-ignore
...
```
**Patterns to follow:**
- `.gitattributes` export-ignore syntax per [git docs](https://git-scm.com/docs/gitattributes#_creating_an_archive)
**Test scenarios:**
- Happy path: `git archive --format=zip HEAD | zipinfo -1 - | wc -l` returns =200
- Happy path: zip contains `SKILL.md`, `scripts/last30days.py`, `scripts/lib/bird_x.py`, `scripts/lib/vendor/bird-search/lib/cookies.js`
- Happy path: zip contains exactly one `SKILL.md` entry at the top level (not multiple, not a symlink)
- Edge case: zip does NOT contain `tests/`, `docs/`, `assets/*.jpeg`, `*.mp3`, `skills/`
- Edge case: no symlink entries in the zip (`unzip -l` lines starting with `l`)
- Edge case: zip size stays under ~2MB (if over 5MB an unintended large file slipped through)
**Verification:**
- Running `git archive --format=zip --output=/tmp/test.zip HEAD && unzip -l /tmp/test.zip | tail -1` reports =200 files and a sane byte count
- [ ] **Unit 4: Add `scripts/build-skill.sh` user-facing builder**
**Goal:** One-command path to produce a Claude-upload-compatible `.skill` file.
**Requirements:** R3, R6
**Dependencies:** Unit 3
**Files:**
- Create: `scripts/build-skill.sh`
- Modify: `.gitignore` (add `/dist/` for build artifact)
**Approach:**
- Bash, executable, `set -euo pipefail`
- `git archive --format=zip --prefix=last30days/ --output=dist/last30days.skill HEAD`
- The `--prefix=last30days/` nests everything under `last30days/` inside the zip, matching Anthropic's arcname contract
- Refuse to build if working tree is dirty (`git diff --quiet && git diff --cached --quiet`)
- Print file count, archive size, and path to paste into the upload UI
- Fail with a clear error if count exceeds 200 (defensive check)
**Technical design:** *(directional guidance, not implementation spec)*
```bash
#!/usr/bin/env bash
# build-skill.sh - package repo as a Claude-upload-ready .skill file
# Usage: bash scripts/build-skill.sh
set -euo pipefail
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty - commit or stash first" >&2; exit 1
fi
mkdir -p dist
out="dist/last30days.skill"
git archive --format=zip --prefix=last30days/ --output="$out" HEAD
count=$(unzip -l "$out" | tail -1 | awk '{print $2}')
[ "$count" -le 200 ] || { echo "error: $count files in zip, cap is 200" >&2; exit 1; }
echo "built $out ($count files, $(du -h "$out" | cut -f1))"
```
**Patterns to follow:**
- Style of `scripts/sync.sh` (bash, top-of-file comment, `set -euo pipefail`)
**Test scenarios:**
- Happy path: clean tree, `bash scripts/build-skill.sh` produces `dist/last30days.skill` with =200 files and the top-level entry is `last30days/`
- Happy path: `unzip -p dist/last30days.skill last30days/SKILL.md | head -2` shows `---` (frontmatter start) confirming SKILL.md is at the right location
- Edge case: dirty working tree - script exits non-zero with clear error
- Edge case: idempotent - running twice overwrites cleanly
- Error path: if a future change inflates file count past 200, the defensive `[ "$count" -le 200 ]` check fails and the script refuses to produce a broken output
**Verification:**
- `bash scripts/build-skill.sh && unzip -l dist/last30days.skill | grep "^ 0 .* last30days/$"` confirms the prefix directory exists
- `unzip -l dist/last30days.skill | grep "last30days/SKILL.md"` confirms SKILL.md is at the expected path
- `unzip -l dist/last30days.skill | grep -c "SKILL.md"` returns exactly 1
- `unzip -l dist/last30days.skill | awk '{print $NF}' | grep -v "^$" | sort -u | grep "skills/" || true` returns nothing (confirms internal skill dirs excluded)
- Gate: a contributor must run `bash scripts/build-skill.sh` on their branch and attach the produced file to their PR before merging any change that touches `.gitattributes` or exclude-sensitive paths
- [ ] **Unit 5: Document the upload path in README**
**Goal:** Users know how to produce an upload `.skill` without reading the source.
**Requirements:** R3
**Dependencies:** Unit 4
**Files:**
- Modify: `README.md` (add a short "Upload as a Claude Skill" subsection under the existing install section)
**Approach:**
- One paragraph plus a single command block: `bash scripts/build-skill.sh`
- Mention the 200-file cap as context so future changes do not bust it
- Point users at the claude.ai skill upload UI (note: link only if a stable URL exists at implementation time, otherwise describe the UI path)
**Test scenarios:**
- Test expectation: none - pure documentation change
**Verification:**
- `grep -n "build-skill" README.md` returns a hit
- Instructions match actual script behavior
- [ ] **Unit 6: Trim SKILL.md description to =200 chars**
**Goal:** Make root `SKILL.md` frontmatter pass Anthropic's documented `description` limit.
**Requirements:** R7
**Dependencies:** None (independent of other units)
**Files:**
- Modify: `SKILL.md` (frontmatter `description:` field only)
**Approach:**
- Current description is 228 chars. Cut 28+ chars without losing signal.
- Suggested rewrite (196 chars): `"Multi-query social search with planned queries. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. Gemini/OpenAI fallback when needed."`
- Confirm the trimmed version still surfaces for the right prompts (smoke test: run `python scripts/last30days.py "test" --emit=compact` and confirm behavior unchanged; description is metadata, not runtime input)
- Update `skills/last30days/SKILL.md` description too if it exceeds 200 chars (check during implementation)
**Test scenarios:**
- Happy path: `python3 -c "import re; d=open('SKILL.md').read(); m=re.search(r'^description:\s*\"(.+?)\"', d, re.M); assert len(m.group(1)) <= 200, len(m.group(1))"` passes
**Verification:**
- Description field is =200 chars in root SKILL.md
- Skill still triggers on relevant prompts (manual smoke check)
- [ ] **Unit 7: Version bump and changelog**
**Goal:** Ship as 3.0.1 so consumers see the packaging fix.
**Requirements:** R5
**Dependencies:** Units 1-6
**Files:**
- Modify: `.claude-plugin/plugin.json` (3.0.0 - 3.0.1)
- Modify: `SKILL.md` frontmatter version
- Modify: `skills/last30days/SKILL.md` frontmatter version
- Modify: `gemini-extension.json` version (note: #190 flags this as stale at 2.9.5; bumping here partially addresses that but full resolution is out of scope)
- Modify: `CHANGELOG.md`
- Modify: `release-notes.md`
**Approach:**
- Atomic version bump across all manifests
- Changelog entry: "Packaging: `scripts/build-skill.sh` produces a compliant `.skill` file; removed unused root `vendor/` (215 files) and legacy `plans/`; repo file count fits under claude.ai's 200-file upload cap"
**Test scenarios:**
- Happy path: `grep -rn "3.0.1" SKILL.md skills/last30days/SKILL.md .claude-plugin/plugin.json gemini-extension.json` returns four consistent hits
- Integration: `bash scripts/sync.sh` completes cleanly
**Verification:**
- All four version declarations read `3.0.1`
- CHANGELOG and release-notes have dated entries
## System-Wide Impact
- **Interaction graph:** Skill-runtime import graph is unchanged. Removed code (root `vendor/`, `plans/`) has zero importers.
- **Error propagation:** `build-skill.sh` is a new surface; failure mode is non-zero exit with clear stderr. No runtime error paths touched.
- **State lifecycle risks:** None. `dist/` is gitignored build output.
- **API surface parity:** No change to any user-facing API, CLI flag, config key, or SKILL.md contract.
- **Integration coverage:** `tests/test_bird_x.py` exercises the real vendored client - if it regressed, the test fails. Run it after Unit 1.
- **Unchanged invariants:** `scripts/lib/vendor/bird-search/` stays. `scripts/sync.sh` deploy behavior unchanged. ClawHub publish flow (private repo) untouched. Claude Code plugin install via GitHub URL still works.
## Risks and Dependencies
| Risk | Mitigation |
|------|------------|
| Deleting `vendor/` silently breaks something we missed | Run `pytest tests/test_bird_x.py` and `bash scripts/sync.sh` after the delete; grep for `vendor/package` before merging |
| claude.ai rejects the `.skill` file for a reason other than file count (e.g., frontmatter character, hidden file) | Test-upload the produced artifact against claude.ai once before merging; iterate on `.gitattributes` if needed |
| `.gitattributes` over-excludes and breaks the runtime skill | Unit 3 verification step explicitly checks runtime paths are present in the produced archive |
| A future PR re-vendors something at `/vendor/` and busts the 200 cap again | `/vendor/` in `.gitignore` plus the defensive `=200` check in `build-skill.sh` catches it |
| Version bump collides with in-flight PRs that also bump versions | Coordinate with #229, #217 which touched version strings; check before merging |
| `skills/last30days/SKILL.md` (internal spec) being shipped inside the zip confuses the claude.ai uploader | Resolved by excluding `skills/` from the zip (Unit 3). Internal spec remains in the repo for plugin consumers |
| `skills/last30days-nux/SKILL.md` is a symlink to `../../SKILL.md`; claude.ai may reject zips with symlink entries | Resolved by excluding `skills/` from the zip (Unit 3). Symlink never enters the archive |
## Documentation and Operational Notes
- Update README only (Unit 5). No runbook, no migration, no flag.
- No deployment step - plugin consumers get the packaging fix automatically on next update.
- Release notes flag: manual uploaders should re-zip via `scripts/build-skill.sh`.
- Opportunistic future work (out of scope here): CI check that fails PRs that push the zip over 200 files.
## Sources and References
- Trevin's 2026-04-14 chat screenshot: "Zip contains too many files (maximum 200)"
- [anthropics/skills README](https://github.com/anthropics/skills/blob/main/README.md) - YAML frontmatter requirements
- [anthropics/skills package_skill.py](https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py) - canonical exclusions, arcname convention, validation gates
- [claude.ai skill help center](https://support.claude.com/en/articles/12512180-use-skills-in-claude) - upload failure modes (zip size, folder-name mismatch, missing SKILL.md)
- PR #48 (2026-02) - the merge that introduced `vendor/package/`
- Open issues adjacent but out of scope: #239, #236, #231, #190, #184
- Related code: `scripts/lib/bird_x.py:5`, `tests/test_bird_x.py:133`, `.clawhubignore`, `scripts/sync.sh`, root `SKILL.md` frontmatter
- Private-repo reference pattern: `scripts/clawhub-publish.sh` + `scripts/strip_for_openclaw.py` - not copied here; `git archive` is simpler for the public path
@@ -0,0 +1,459 @@
---
title: claude.ai distribution + discoverability push
type: feat
status: active
date: 2026-04-14
---
# claude.ai distribution + discoverability push
## Overview
The 200-file upload bug is fixed and `last30days.skill` works on claude.ai. But "it can be uploaded" is not the same as "people use it." Claude.ai has no native skill marketplace, so discovery happens through a 3-layer stack: Anthropic's curated plugin marketplace, third-party aggregators, and social/newsletter amplification. The question Matt asked - "is the GitHub release the right decision" - has a clear answer: yes, but it is table stakes, not the strategy. This plan cuts the release and then pulls the real distribution levers.
## Problem Frame
Today, the only way a claude.ai user can get `last30days` is to clone the repo and run `scripts/build-skill.sh`. That filters out 99% of potential users. Even once a release exists with a direct download link, the hard problem is discovery - claude.ai users do not browse GitHub for skills. They find skills via Anthropic's "Discover" tab in Claude Code, third-party aggregator sites (skillsmp.com, mcpmarket.com, claudeskills.info), awesome-lists on GitHub, newsletters (The Neuron), and social posts (X, r/ClaudeAI).
Success looks like: a claude.ai user who never visits the repo can find, download, and upload the skill in under 60 seconds, and keep using it because the trigger description fires on the right prompts.
## Requirements Trace
- R1. A one-click install path exists for claude.ai users: click a link from README/marketplace/aggregator, get `last30days.skill`, drop into Upload dialog
- R2. The skill is submitted to Anthropic's official plugin marketplace at `platform.claude.com/plugins/submit`
- R3. The skill is listed in at least 4 high-traffic awesome-lists / aggregators
- R4. The SKILL.md YAML `description` and `argument-hint` fields are tuned so Claude's skill-selector actually invokes `last30days` on research-intent prompts (trigger quality is the single biggest install-to-reuse lever)
- R5. First-run experience works with zero API keys for the default sources (Reddit, Hacker News, Polymarket, GitHub) - already true, verify does not regress
- R6. At least one high-visibility amplification moment ships within 14 days: demo GIF + launch tweet + The Neuron pitch
- R7. Basic metrics are in place to learn what works: release-download counts, aggregator referrer traffic, GitHub star velocity before/after
## Scope Boundaries
Non-goals for this plan:
- Not building a custom skill-hosting site or our own marketplace
- Not changing the runtime pipeline or adding features - this is pure distribution
- Not spamming aggregators with low-effort PRs - one quality submission per venue
- Not gaming install counts or stars
- Not displacing the existing Claude Code plugin / OpenClaw / Gemini distribution - those stay as-is, cross-linked
- Not depending on Anthropic marketplace acceptance before other levers ship - marketplace review is slow and gate-able
## Context and Research
### The claude.ai skill ecosystem in April 2026
- **No native claude.ai skill marketplace.** Upload is the only end-user path inside the web UI.
- **Anthropic's Plugin/Skills Marketplace** (submissions at `platform.claude.com/plugins/submit`) is the closest thing to a "featured" channel and ships through Claude Code's "Discover" tab. Quality/security review gates acceptance. Research-category skills are under-represented vs. dev-tool skills.
- **Third-party aggregators** drive most organic discovery outside Anthropic's channels:
- `skillsmp.com`, `mcpmarket.com`, `claudeskills.info`, `skillsdirectory.com`, `agensi.io`
- These aggregators scrape awesome-lists, so one well-placed PR cascades
- **Awesome-lists** where skills discovery congregates:
- `ComposioHQ/awesome-claude-skills`
- `travisvn/awesome-claude-skills`
- `karanb192/awesome-claude-skills`
- `VoltAgent/awesome-agent-skills`
- `sickn33/antigravity-awesome-skills` (1,400+ skills indexed)
- **Newsletter amplification:** The Neuron runs a daily "AI Skill of the Day" digest - the single biggest external traffic source per successful skill creators. Their "practical workflow" angle fits a research skill cleanly.
- **Install-count reference points** from public aggregator data:
- `self-improving-agent`: 357k installs
- `frontend-design`: 277k installs
- `skill-vetter`: 190k installs
- `github`: 148k installs
- `proactive-agent`: 135k installs
- Long tail: ~500 installs
The gap between 500-install and 357k-install skills is mostly: (a) trigger description quality, (b) zero-config first run, (c) one amplification moment that caught.
### Current distribution surface for last30days
- Claude Code plugin via marketplace and GitHub URL: live
- OpenClaw via ClawHub (`clawhub install last30days-official`): live
- Gemini extension: live
- Manual `git clone`: documented in README
- claude.ai `.skill` upload: just shipped, undocumented for end users (no link)
The cross-linking graph is incomplete. Traffic that already exists (Claude Code install page, OpenClaw listing, Gemini extensions page) is not being routed to the new claude.ai path.
### Reference: trigger description quality
The root `SKILL.md` `description` field is how Claude decides whether to invoke the skill. Current text (as of 3.0.1, 167 chars):
> "Multi-query social research across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. Intelligent query planning with Gemini/OpenAI fallback."
Analysis: solid source list, weak on action verbs and example queries. Successful skills include imperative verbs ("research", "find", "summarize", "compare") and 1-2 example triggers the user might type. At 167/200 chars, there is room.
## Key Technical Decisions
- **Cut v3.0.1 GitHub release with `last30days.skill` attached as an asset** (table stakes). Rationale: every downstream lever (README link, marketplace submission, aggregator PR, tweet) needs a stable public download URL.
- **Automate `.skill` build in CI on tag push** so future releases never forget to attach the artifact. Rationale: manual builds break over time; this is a one-time 10-line GitHub Actions workflow.
- **Prioritize marketplace submission in parallel with aggregator PRs**, not in sequence. Rationale: marketplace review is slow and opaque; do not block aggregator work on it. If rejected, we still have the aggregator presence.
- **Tune the SKILL.md description to optimize trigger selection**, not marketing copy. Rationale: this is the single biggest re-use lever per the ecosystem research. Marketing copy goes in README/release notes, not frontmatter.
- **One quality pass per aggregator, not a spray.** Rationale: awesome-list maintainers reject duplicate / low-effort PRs; reputation matters.
- **Ship the launch tweet with a real demo GIF**, not a screenshot. Rationale: Boris Cherny's Claude Code viral tweet template (one query, one result, "oh wow" moment) consistently outperforms text-only launches.
- **Pitch The Neuron once, with a production-quality 60-second demo**, not a cold email. Rationale: single shot at the biggest amplifier; treat it like a press release, not a tweet.
- **Track release-download count + GitHub referrer traffic as proxies for adoption** until we have better signal. Rationale: claude.ai upload counts are not exposed to creators.
- **Cross-link existing distribution pages back to claude.ai** as part of the release. Rationale: converting existing users to multi-surface users is cheaper than acquiring new ones.
## Open Questions
### Resolved during planning
- Is the GitHub release the right first step? Yes. Every other lever depends on a stable download URL. But it is a prerequisite, not the strategy.
- Does claude.ai have a native skill directory? No (confirmed April 2026).
- Should we wait for Anthropic marketplace acceptance before shipping other levers? No - parallelize.
- Do we need to rebuild the runtime to improve claude.ai adoption? No - the runtime is strong; the gap is distribution.
### Deferred to implementation
- Exact Neuron pitch copy - draft during Unit 8, refine based on what their recent editions have favored
- Whether to tag `@steipete`, `@AnthropicAI`, `@alexalbert__` in the launch tweet - confirm current handles and review each's posting culture before tagging
- Which specific demo query to record for the launch GIF - pick during Unit 7 based on what's newsworthy that week
- Whether to request a "skills-research" badge on skillsdirectory.com/agensi.io - check their current badge programs during Unit 5
## High-Level Technical Design
> *This illustrates the intended distribution graph and is directional guidance for review, not implementation specification.*
```
[GitHub Release v3.0.1]
|
+-- last30days.skill (asset, public URL)
|
+------> README "Upload to claude.ai" section (Unit 3)
|
+------> Claude Code plugin README link (Unit 4)
+------> OpenClaw listing link (Unit 4)
+------> Gemini extension link (Unit 4)
|
+------> Anthropic marketplace submission (Unit 6)
|
+------> Aggregator PRs (Unit 5):
| * ComposioHQ/awesome-claude-skills
| * travisvn/awesome-claude-skills
| * karanb192/awesome-claude-skills
| * VoltAgent/awesome-agent-skills
| * sickn33/antigravity-awesome-skills
| * skillsmp.com submit form
|
+------> Amplification (Units 7-9):
* Demo GIF + launch tweet
* The Neuron "Skill of the Day" pitch
* News-cycle recurring tweet (weekly)
All paths end at: claude.ai Upload Skill dialog
Trigger quality (Unit 2) determines whether installs become sustained usage
```
## Implementation Units
- [ ] **Unit 1: Cut v3.0.1 GitHub release with `.skill` asset + auto-build CI**
**Goal:** Produce a stable public download URL for `last30days.skill` so every downstream lever has something to link to, and guarantee future releases include the artifact automatically.
**Requirements:** R1
**Dependencies:** None (plan 2026-04-14-001 already shipped the build script)
**Files:**
- Create: `.github/workflows/release.yml`
- Modify: none at release time (release is a git-tag + GitHub release action)
**Approach:**
- Tag `v3.0.1` on `main`, push
- Create GitHub release with the CHANGELOG v3.0.1 entry as body, attach `dist/last30days.skill`
- Add CI workflow that triggers on `push: tags: 'v*'`, runs `bash scripts/build-skill.sh`, uploads the artifact to the release. The `action-gh-release` pattern is standard.
- Release URL shape: `https://github.com/mvanhorn/last30days-skill/releases/download/v3.0.1/last30days.skill` (deterministic, shareable)
**Patterns to follow:**
- Any existing `.github/workflows/` patterns in the repo
- `actions/checkout@v4` + `softprops/action-gh-release@v2` is the conventional combo
**Test scenarios:**
- Happy path: pushing `v3.0.1` tag produces a release with `last30days.skill` attached and publicly downloadable without auth
- Edge case: re-tagging `v3.0.1` does not duplicate or corrupt the asset
- Error path: build failure in the workflow fails the release cleanly (no empty release created)
**Verification:**
- `curl -fsSL -o /tmp/dl.skill https://github.com/mvanhorn/last30days-skill/releases/download/v3.0.1/last30days.skill` succeeds anonymously
- Downloaded file matches `dist/last30days.skill` byte-for-byte
- A second tag (e.g., `v3.0.2-test`) in a branch triggers the workflow end-to-end
- [ ] **Unit 2: Tune SKILL.md description and argument-hint for trigger quality**
**Goal:** Increase the probability that Claude's skill-selector invokes `last30days` on research-intent prompts. Trigger quality is the single biggest install-to-reuse lever per ecosystem research.
**Requirements:** R4, R5
**Dependencies:** None
**Files:**
- Modify: `SKILL.md` (frontmatter `description` and `argument-hint` only)
- Modify: `skills/last30days/SKILL.md` (if parity needed)
**Approach:**
- Rewrite `description` to lead with an imperative action verb and include 1-2 concrete example queries, staying =200 chars
- Rewrite `argument-hint` to show 2-3 canonical invocations that mirror real user phrasing, not marketing phrasing
- Keep the source list intact - that's the value prop - but move it later in the sentence
- Reference frames that worked for high-install skills: `frontend-design`, `self-improving-agent`, `github`
**Technical design:** *(directional guidance, not implementation spec)*
Candidate shape (verify char count in implementation):
```yaml
description: "Research what people actually say about any topic in the last 30 days. Pulls real posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web."
argument-hint: 'last30days AI video tools | last30days nvidia earnings reaction | last30days best noise cancelling headphones'
```
**Test scenarios:**
- Happy path: in a fresh claude.ai chat, prompts like "what are people saying about X this week" surface `last30days` in the skill-selector candidate set
- Edge case: generic "research X" prompts do not over-select `last30days` when the user clearly wants a general answer (avoids false-positive selection)
- Integration: test in all three environments - claude.ai web, Claude Code, OpenClaw - to confirm selection behavior is consistent
**Verification:**
- Description =200 chars, checked by the same regex Unit 6 of plan 001 used
- At least 3 real-user prompt phrasings trigger skill selection in manual testing
- No regression on zero-config first-run (no new API keys required)
- [ ] **Unit 3: Rewrite the README claude.ai section with one-click install**
**Goal:** Replace the current "run this bash script" instructions with a one-click download link pointing at the GitHub release asset.
**Requirements:** R1
**Dependencies:** Unit 1 (release must exist first)
**Files:**
- Modify: `README.md` (the "Upload as a Claude Skill" section added in plan 001)
**Approach:**
- Replace the `bash scripts/build-skill.sh` instruction with a direct download link to the release asset
- Keep the build-from-source instruction as a fallback for developers, demoted below the direct link
- Add a short 3-step install guide with specific UI path: "Settings > Capabilities > Skills > + button, drop the .skill file"
- Include a screenshot or GIF showing the upload flow if space allows (can be added in Unit 7)
**Test scenarios:**
- Test expectation: none - pure documentation change
**Verification:**
- A user following the README instructions end-to-end can go from "never heard of this" to working skill in under 60 seconds
- Instructions specify the exact claude.ai UI path current as of the release date
- [ ] **Unit 4: Cross-link existing distribution surfaces back to claude.ai**
**Goal:** Convert existing Claude Code plugin / OpenClaw / Gemini traffic into claude.ai installs. Cheaper than net-new acquisition.
**Requirements:** R1
**Dependencies:** Unit 1, Unit 3
**Files:**
- Modify: `README.md` (install matrix - add claude.ai row prominently)
- Modify: `variants/open/SKILL.md` in the private repo if that governs OpenClaw listing copy
- Modify: `gemini-extension.json` if `description` or install hints exist there
- External: update the ClawHub listing page for `last30days-official` to mention claude.ai availability
**Approach:**
- Every listing page a user currently lands on should have a one-line "Also available as a claude.ai Skill: [download]" link
- Use a consistent short-URL pattern so it's instantly recognizable across surfaces
- Do not require users to re-read each install guide - the cross-link is opportunistic, not blocking
**Test scenarios:**
- Test expectation: none - documentation/external-listing updates
**Verification:**
- Each of the 4 distribution surfaces (Claude Code plugin marketplace, OpenClaw ClawHub listing, Gemini extensions page, GitHub README) contains a visible claude.ai cross-link within 1 scroll of the page top
- [ ] **Unit 5: Submit PRs to high-traffic Claude skill awesome-lists**
**Goal:** Get listed in the 5 highest-traffic aggregators so third-party skill-discovery sites (skillsmp.com, mcpmarket.com, claudeskills.info) pick up the entry.
**Requirements:** R3
**Dependencies:** Unit 1, Unit 2 (description should be tuned before first impression in these lists)
**Files (external repos):**
- `ComposioHQ/awesome-claude-skills` - PR adding last30days to the relevant category
- `travisvn/awesome-claude-skills`
- `karanb192/awesome-claude-skills`
- `VoltAgent/awesome-agent-skills`
- `sickn33/antigravity-awesome-skills`
**Approach:**
- One PR per list, in parallel
- Each PR: one-line entry matching the list's existing format; link to release asset (not repo root)
- If the list has a "research" or "data-gathering" category, use it; otherwise append to the most adjacent section
- Draft copy once, reuse across PRs - but match each list's voice and entry format
- Do not self-star or brigade - let the listing earn traction organically
**Test scenarios:**
- Test expectation: none - external PRs, not code
**Verification:**
- All 5 PRs opened on the same day (batch effort, reduces overhead)
- Entries include: skill name, one-sentence description matching tuned SKILL.md copy, release URL, source repo URL
- Track merge status over 14 days; abandon PRs that go stale after reasonable nudging
- [ ] **Unit 6: Submit to Anthropic's official Plugin/Skills Marketplace**
**Goal:** Get featured in Claude Code's "Discover" tab, the closest thing to a native claude.ai skill directory.
**Requirements:** R2
**Dependencies:** Unit 1, Unit 2
**Files:**
- No repo changes; this is an external submission at `platform.claude.com/plugins/submit`
**Approach:**
- Submit via Anthropic's form with: skill name, description (matches tuned SKILL.md), GitHub repo URL, release asset URL, demo video link (from Unit 7 if available)
- Expect quality/security review; Anthropic will likely ask for the ClawGuard-scanner-style audit items already surfaced in issue #231 - have responses ready
- Do not wait for acceptance before shipping other levers
**Test scenarios:**
- Test expectation: none - external submission
**Verification:**
- Submission confirmation received
- Track review status weekly; iterate on feedback if any
- [ ] **Unit 7: Record a 15-60 second demo GIF or screen recording**
**Goal:** Produce the visual asset that every amplification channel needs - launch tweet, Neuron pitch, README hero, release notes.
**Requirements:** R6
**Dependencies:** Unit 2 (want the tuned description on-screen), Unit 3 (want the updated install flow)
**Files:**
- Create: `assets/claudeai-demo.gif` (or `.mp4` if GIF is too large)
- Modify: `README.md` to embed the GIF
**Approach:**
- Two possible framings:
1. "Upload + use" flow: 15 seconds showing Upload dialog -> skill appears -> sample query -> result
2. "One query" flow: 15-30 seconds of a real research query running end-to-end with actual output
- Pick framing 2 for outside-audience amplification (tweet, Neuron); framing 1 for the README
- Record at 1x speed (speeding up feels fake); edit to =60 seconds
- Export as optimized GIF or H.264 MP4 =5MB
**Test scenarios:**
- Test expectation: none - media asset
**Verification:**
- Asset loads cleanly in GitHub README
- Asset uploads cleanly to X (under their video length/size caps)
- Matt watches it fresh and the "oh wow" moment is unambiguous in the first 5 seconds
- [ ] **Unit 8: Pitch The Neuron "AI Skill of the Day"**
**Goal:** One high-leverage newsletter placement that historically drives the biggest external install spike for Claude skills.
**Requirements:** R6
**Dependencies:** Unit 1, Unit 7
**Approach:**
- Identify The Neuron editor contact (newsletter footer, X DMs, their `skilloftheday@` alias if published)
- Pitch with: 3-sentence hook, demo video link, release URL, 3 example queries that show breadth
- Angle: "researcher skill that queries 12+ social sources in one shot" - novelty vs. their typical dev-tool coverage
- Offer exclusive timing if they want (publish first, then we tweet)
- Do not follow up more than twice
**Test scenarios:**
- Test expectation: none - external pitch
**Verification:**
- Pitch sent with all assets linked
- Track whether the issue ships within 14 days; if not, reuse the materials for other newsletters
- [ ] **Unit 9: Launch tweet + recurring news-cycle posts**
**Goal:** Seed social discovery and establish a weekly cadence so the skill stays top-of-mind.
**Requirements:** R6
**Dependencies:** Unit 1, Unit 7
**Approach:**
- Launch tweet: demo GIF + 1-sentence description + install link. Post to X, cross-post to r/ClaudeAI and r/singularity same day.
- Do not tag handles reflexively - research each target account's culture first
- Weekly recurring pattern: pick a news moment (earnings, launch, election, cultural event), run `last30days` on it, screenshot the output, post. Low-effort, repeatable, compounds.
- Track: likes, impressions, link-click referrer traffic to the release page
**Test scenarios:**
- Test expectation: none - social posts
**Verification:**
- Launch tweet live with demo GIF
- At least one follow-up news-cycle post within 7 days
- Referrer traffic spike visible in GitHub traffic dashboard
- [ ] **Unit 10: Adoption telemetry and feedback loop**
**Goal:** Learn which levers work so we double down on wins and cut losses. Current blind spot: no visibility into claude.ai install counts.
**Requirements:** R7
**Dependencies:** Unit 1
**Approach:**
- Baseline metrics (capture on Unit 1 ship day):
- GitHub stars
- Clones/day
- Traffic referrers
- Release-asset download count (GitHub exposes this on the Release page)
- Weekly review during the first 6 weeks of:
- Release download deltas
- Star velocity
- Referrer sources (identifies which aggregator/newsletter/tweet drove traffic)
- New GitHub issues that mention claude.ai specifically
- No dedicated analytics infrastructure - use what GitHub provides + manual referrer spot-checks
- Publish a "what worked / what didn't" retro after 6 weeks in `docs/solutions/` so the next launch compounds
**Test scenarios:**
- Test expectation: none - observability
**Verification:**
- Baseline metrics captured in a `docs/solutions/YYYY-MM-DD-*.md` note
- Weekly log of download/star/referrer deltas maintained
- Retro written at week 6 with concrete learnings for the next release
## System-Wide Impact
- **Interaction graph:** Touches GitHub (release, CI), external aggregators (PRs), Anthropic marketplace (submission), X/Reddit/newsletter (social), ClawHub/Gemini listings (cross-links). No runtime code changes.
- **State lifecycle risks:** Minimal. The main risk is inconsistent cross-linking (some surfaces mention claude.ai, others don't) - Unit 4 treats this as a coordinated sweep, not per-surface creep.
- **API surface parity:** None - no API changes.
- **Integration coverage:** The critical integration is trigger-selection behavior (Unit 2). Manual verification across web / Claude Code / OpenClaw is the gate.
- **Unchanged invariants:** Runtime pipeline, existing install paths (Claude Code plugin / OpenClaw / Gemini) all stay working. Zero-config first-run for default sources remains intact.
## Risks and Dependencies
| Risk | Mitigation |
|------|------------|
| Anthropic marketplace rejects the submission on security/quality grounds | Run `scripts/build-skill.sh` output through ClawGuard or equivalent scanner pre-submission; address #231 findings if real |
| Awesome-list maintainers reject or ignore PRs | Submit to 5 lists in parallel; any 2 acceptances are enough; do not brigade |
| The Neuron pitch is ignored | Treat as upside, not critical path; reuse materials for other newsletters (Ben's Bites, TLDR, Superhuman AI) |
| Tuned description causes false-positive skill selection on unrelated prompts | Manual prompt-testing in Unit 2; be willing to walk back if Claude over-invokes the skill |
| A new Anthropic marketplace or directory launches mid-plan and changes the landscape | The research-tracking cadence in Unit 10 catches this within a week; plan can adapt |
| Launch tweet flops / no organic pickup | Weekly news-cycle cadence (Unit 9) is the compounding play, not the launch moment |
| Cross-repo cross-links in Unit 4 go stale when listings move | Use canonical GitHub Release URL (deterministic) as the link target everywhere |
## Documentation / Operational Notes
- README gets a hero section update in Unit 3
- CHANGELOG gets a v3.0.1 release-notes entry (already shipped in plan 001)
- A `docs/solutions/` retrospective note ships after the 6-week observation window (Unit 10)
- No runbook needed - distribution work is one-time-per-release
## Sources and References
- Research pass by repo-research-analyst on 2026-04-14 - [findings](https://github.com/anthropics/skills)
- [Anthropic Plugin/Skills Marketplace submissions](https://platform.claude.com/plugins/submit)
- [anthropics/skills](https://github.com/anthropics/skills) - 87k stars, canonical repo
- [SkillsMP](https://skillsmp.com), [claudeskills.info](https://claudeskills.info), [mcpmarket.com/tools/skills](https://mcpmarket.com/tools/skills) - aggregators
- [ComposioHQ/awesome-claude-skills](https://github.com/ComposioHQ/awesome-claude-skills)
- [sickn33/antigravity-awesome-skills](https://github.com/sickn33/antigravity-awesome-skills)
- [The Neuron Skill of the Day digest](https://www.theneuron.ai/explainer-articles/the-neurons-ai-skill-of-the-day-digest-april-2026-week-1/)
- Completed prerequisite: `docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md` (packaging fix)
- Related code: `SKILL.md` (frontmatter), `README.md`, `scripts/build-skill.sh`, `.github/workflows/`
- Install-count reference points from aggregators: self-improving-agent 357k, frontend-design 277k, skill-vetter 190k, github 148k, proactive-agent 135k
+42
View File
@@ -0,0 +1,42 @@
[
{
"topic": "OpenClaw vs NanoClaw vs ZeroClaw",
"query_type": "comparison",
"rationale": "Multi-entity extraction, 3-way split across AI agent frameworks."
},
{
"topic": "how to set up a GLP-1 supplement routine",
"query_type": "how_to",
"rationale": "Trending health topic. Tests non-tech how_to."
},
{
"topic": "2026 March Madness",
"query_type": "breaking_news",
"rationale": "Live sporting event. Tests broad breaking news recall."
},
{
"topic": "best budget noise cancelling headphones 2026",
"query_type": "product",
"rationale": "Evergreen consumer query. Tests product review aggregation."
},
{
"topic": "thoughts on OpenAI Codex pricing",
"query_type": "opinion",
"rationale": "Active developer debate. Tests opinion mining."
},
{
"topic": "odds of US recession 2026",
"query_type": "prediction",
"rationale": "Major macro topic. Tests prediction market + news synthesis."
},
{
"topic": "what is retrieval augmented generation",
"query_type": "concept",
"rationale": "Widely discussed AI concept. Tests explanation quality."
},
{
"topic": "Google Wiz acquisition price and timeline",
"query_type": "factual",
"rationale": "Completed event ($32B). Tests factual precision."
}
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "last30days-skill",
"version": "3.0.0",
"version": "3.0.1",
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
"settings": [
{
+5 -1
View File
@@ -12,7 +12,11 @@ check_perms() {
local file="$1"
if [[ ! -f "$file" ]]; then return; fi
local perms
perms=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || echo "")
# Try GNU stat first (Linux), fall back to BSD stat (macOS).
# On Linux, `stat -f` prints filesystem info (not permissions) and exits 0,
# so the previous BSD-first ordering left $perms as multi-line garbage on
# every Linux session start and printed a false WARNING.
perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "")
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
echo "/last30days: WARNING — $file has permissions $perms (should be 600)."
echo " Fix: chmod 600 $file"
-395
View File
@@ -1,395 +0,0 @@
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
## Overview
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
## Problem Statement
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
**User requirements**:
- Work out of the box (no API key needed)
- Must NOT overpower Reddit/X results
- Needs proper weighting
- Validate with before/after testing
## Proposed Solution
### Weighting Strategy: "Engagement-Adjusted Scoring"
**Current formula** (same for Reddit/X):
```
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
```
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
**Solution**: Source-specific scoring with **engagement substitution**:
| Source | Relevance | Recency | Engagement | Source Penalty |
|--------|-----------|---------|------------|----------------|
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
| X | 45% | 25% | 30% (real metrics) | 0 |
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
**Rationale**:
- WebSearch items compete on relevance + recency only (reweighted to 100%)
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
### Mode Behavior
| API Keys Available | Default Behavior | `--include-web` |
|--------------------|------------------|-----------------|
| None | **WebSearch only** | n/a |
| OpenAI only | Reddit only | Reddit + WebSearch |
| xAI only | X only | X + WebSearch |
| Both | Reddit + X | Reddit + X + WebSearch |
**CLI flag**: `--include-web` (default: false when other sources available)
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ last30days.py orchestrator │
├─────────────────────────────────────────────────────────────────┤
│ run_research() │
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
│ ├── if sources includes "x": xai_x.search_x() │
│ └── if sources includes "web": websearch.search_web() ← NEW │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Processing Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
│ score_websearch_items() → engagement-free scoring ← NEW │
│ dedupe_websearch() → deduplication ← NEW │
│ render_websearch_section() → output formatting ← NEW │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Phases
#### Phase 1: Schema & Core Infrastructure
**Files to create/modify:**
```python
# scripts/lib/websearch.py (NEW)
"""Claude WebSearch API client for general web discovery."""
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
CRITICAL: Only include results from the last 30 days (after {from_date}).
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
- Blog posts, tutorials, documentation
- News articles, announcements
- Authoritative sources (official docs, reputable publications)
AVOID:
- Reddit (covered separately)
- X/Twitter (covered separately)
- YouTube without transcripts
- Forum threads without clear answers
Return ONLY valid JSON:
{{
"items": [
{{
"title": "Page title",
"url": "https://...",
"source_domain": "example.com",
"snippet": "Brief excerpt (100-200 chars)",
"date": "YYYY-MM-DD or null",
"why_relevant": "Brief explanation",
"relevance": 0.85
}}
]
}}
"""
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
"""Search web using Claude's built-in WebSearch tool.
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
No API key needed - uses Claude's session.
"""
# Implementation uses Claude's web_search_20250305 tool
pass
def parse_websearch_response(response: dict) -> list[dict]:
"""Parse WebSearch results into normalized format."""
pass
```
```python
# scripts/lib/schema.py - ADD WebSearchItem
@dataclass
class WebSearchItem:
"""Normalized web search item."""
id: str
title: str
url: str
source_domain: str # e.g., "medium.com", "github.com"
snippet: str
date: Optional[str] = None
date_confidence: str = "low"
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'source_domain': self.source_domain,
'snippet': self.snippet,
'date': self.date,
'date_confidence': self.date_confidence,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
```
#### Phase 2: Scoring System Updates
```python
# scripts/lib/score.py - ADD websearch scoring
# New constants
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
# Reweighted for no engagement
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
WEBSEARCH_WEIGHT_RECENCY = 0.45
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Score WebSearch items WITHOUT engagement metrics.
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
"""
for item in items:
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=0, # Explicitly zero - no engagement data
)
overall = (
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
WEBSEARCH_WEIGHT_RECENCY * rec_score
)
# Apply source penalty (WebSearch < Reddit/X)
overall -= WEBSEARCH_SOURCE_PENALTY
# Apply date confidence penalty (same as other sources)
if item.date_confidence == "low":
overall -= 10
elif item.date_confidence == "med":
overall -= 5
item.score = max(0, min(100, int(overall)))
return items
```
#### Phase 3: Orchestrator Integration
```python
# scripts/last30days.py - UPDATE run_research()
def run_research(...) -> tuple:
"""Run the research pipeline.
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
"""
# ... existing Reddit/X code ...
# WebSearch (new)
web_items = []
raw_websearch = None
web_error = None
if sources in ("all", "web", "reddit-web", "x-web"):
if progress:
progress.start_web()
try:
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
web_items = websearch.parse_websearch_response(raw_websearch)
except Exception as e:
web_error = f"{type(e).__name__}: {e}"
if progress:
progress.end_web(len(web_items))
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
raw_websearch, reddit_error, x_error, web_error)
```
#### Phase 4: CLI & Environment Updates
```python
# scripts/last30days.py - ADD CLI flag
parser.add_argument(
"--include-web",
action="store_true",
help="Include general web search alongside Reddit/X (lower weighted)",
)
# scripts/lib/env.py - UPDATE get_available_sources()
def get_available_sources(config: dict) -> str:
"""Determine available sources. WebSearch always available (no API key)."""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'both' # WebSearch available but not default
elif has_openai:
return 'reddit'
elif has_xai:
return 'x'
else:
return 'web' # Fallback: WebSearch only (no keys needed)
```
## Acceptance Criteria
### Functional Requirements
- [x] Skill works with zero API keys (WebSearch-only mode)
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
- [x] Date filtering uses natural language ("last 30 days") in prompt
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
### Non-Functional Requirements
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
- [x] Graceful degradation if WebSearch fails
- [ ] Cache includes WebSearch results appropriately
### Quality Gates
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
- [x] Test: WebSearch-only mode produces useful results for common topics
## Testing Plan
### Before/After Comparison Script
```python
# tests/test_websearch_weighting.py
"""
Test harness to validate WebSearch doesn't overpower Reddit/X.
Run same queries with:
1. Reddit + X only (baseline)
2. Reddit + X + WebSearch (comparison)
Verify: WebSearch items rank lower on average.
"""
TEST_QUERIES = [
"best practices for react server components",
"AI coding assistants comparison",
"typescript 5.5 new features",
]
def test_websearch_weighting():
for query in TEST_QUERIES:
# Run without WebSearch
baseline = run_research(query, sources="both")
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
# Run with WebSearch
with_web = run_research(query, sources="both", include_web=True)
web_scores = [item.score for item in with_web.web]
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
# Assertions
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
assert avg_web < avg_reddit_x - 10, \
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
# Check top 5 aren't all WebSearch
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
key=lambda x: -x.score)[:5]
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
```
### Manual Test Scenarios
| Scenario | Expected Outcome |
|----------|------------------|
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
## Dependencies & Prerequisites
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
- No new API keys required
- Existing test infrastructure in `tests/`
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
## Future Considerations
1. **Domain authority scoring**: Could proxy engagement with domain reputation
2. **User-configurable weights**: Let users adjust WebSearch penalty
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
4. **Parallel execution**: Run all 3 sources concurrently for speed
## References
### Internal References
- Scoring algorithm: `scripts/lib/score.py:8-15`
- Source detection: `scripts/lib/env.py:57-72`
- Schema patterns: `scripts/lib/schema.py:76-138`
- Orchestrator: `scripts/last30days.py:54-164`
### External References
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
- WebSearch pricing: $10/1K searches + token costs
- Date filtering limitation: No explicit date params, use natural language
### Research Findings
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
- E-E-A-T framework: Engagement metrics = trust signal
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation
-328
View File
@@ -1,328 +0,0 @@
# fix: Enforce Strict 30-Day Date Filtering
## Overview
The `/last30days` skill is returning content older than 30 days, violating its core promise. Analysis shows:
- **Reddit**: Only 40% of results within 30 days (9/15 were older, some from 2022!)
- **X**: 100% within 30 days (working correctly)
- **WebSearch**: 90% had unknown dates (can't verify freshness)
## Problem Statement
The skill's name is "last30days" - users expect ONLY content from the last 30 days. Currently:
1. **Reddit search prompt** says "prefer recent threads, but include older relevant ones if recent ones are scarce" - this is too permissive
2. **X search prompt** explicitly includes `from_date` and `to_date` - this is why it works
3. **WebSearch** returns pages without publication dates - we can't verify they're recent
4. **Scoring penalties** (-10 for low date confidence) don't prevent old content from appearing
## Proposed Solution
### Strategy: "Hard Filter, Not Soft Penalty"
Instead of penalizing old content, **exclude it entirely**. If it's not from the last 30 days, it shouldn't appear.
| Source | Current Behavior | New Behavior |
|--------|------------------|--------------|
| Reddit | Weak "prefer recent" | Explicit date range + hard filter |
| X | Explicit date range (working) | No change needed |
| WebSearch | No date awareness | Require recent markers OR exclude |
## Technical Approach
### Phase 1: Fix Reddit Date Filtering
**File: `scripts/lib/openai_reddit.py`**
Current prompt (line 33):
```
Find {min_items}-{max_items} relevant Reddit discussion threads.
Prefer recent threads, but include older relevant ones if recent ones are scarce.
```
New prompt:
```
Find {min_items}-{max_items} relevant Reddit discussion threads from {from_date} to {to_date}.
CRITICAL: Only include threads posted within the last 30 days (after {from_date}).
Do NOT include threads older than {from_date}, even if they seem relevant.
If you cannot find enough recent threads, return fewer results rather than older ones.
```
**Changes needed:**
1. Add `from_date` and `to_date` parameters to `search_reddit()` function
2. Inject dates into `REDDIT_SEARCH_PROMPT` like X does
3. Update caller in `last30days.py` to pass dates
### Phase 2: Add Hard Date Filtering (Post-Processing)
**File: `scripts/lib/normalize.py`**
Add a filter step that DROPS items with dates before `from_date`:
```python
def filter_by_date_range(
items: List[Union[RedditItem, XItem, WebSearchItem]],
from_date: str,
to_date: str,
require_date: bool = False,
) -> List:
"""Hard filter: Remove items outside the date range.
Args:
items: List of items to filter
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
require_date: If True, also remove items with no date
Returns:
Filtered list with only items in range
"""
result = []
for item in items:
if item.date is None:
if not require_date:
result.append(item) # Keep unknown dates (with penalty)
continue
# Hard filter: if date is before from_date, exclude
if item.date < from_date:
continue # DROP - too old
if item.date > to_date:
continue # DROP - future date (likely parsing error)
result.append(item)
return result
```
### Phase 3: WebSearch Date Intelligence
WebSearch CAN find recent content - Medium posts have dates, GitHub has commit timestamps, news sites have publication dates. We should **extract and prioritize** these signals.
**Strategy: "Date Detective"**
1. **Extract dates from URLs**: Many sites embed dates in URLs
- Medium: `medium.com/@author/title-abc123` (no date) vs news sites
- GitHub: Look for commit dates, release dates in snippets
- News: `/2026/01/24/article-title`
- Blogs: `/blog/2026/01/title`
2. **Extract dates from snippets**: Look for date markers
- "January 24, 2026", "Jan 2026", "yesterday", "this week"
- "Published:", "Posted:", "Updated:"
- Relative markers: "2 days ago", "last week"
3. **Prioritize results with verifiable dates**:
- Results with recent dates (within 30 days): Full score
- Results with old dates: EXCLUDE
- Results with no date signals: Heavy penalty (-20) but keep as supplementary
**File: `scripts/lib/websearch.py`**
Add date extraction functions:
```python
import re
from datetime import datetime, timedelta
# Patterns for date extraction
URL_DATE_PATTERNS = [
r'/(\d{4})/(\d{2})/(\d{2})/', # /2026/01/24/
r'/(\d{4})-(\d{2})-(\d{2})/', # /2026-01-24/
r'/(\d{4})(\d{2})(\d{2})/', # /20260124/
]
SNIPPET_DATE_PATTERNS = [
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{1,2}),? (\d{4})',
r'(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})',
r'(\d{4})-(\d{2})-(\d{2})',
r'Published:?\s*(\d{4}-\d{2}-\d{2})',
r'(\d{1,2}) (days?|hours?|minutes?) ago', # Relative dates
]
def extract_date_from_url(url: str) -> Optional[str]:
"""Try to extract a date from URL path."""
for pattern in URL_DATE_PATTERNS:
match = re.search(pattern, url)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_from_snippet(snippet: str) -> Optional[str]:
"""Try to extract a date from text snippet."""
for pattern in SNIPPET_DATE_PATTERNS:
match = re.search(pattern, snippet, re.IGNORECASE)
if match:
# Parse and return YYYY-MM-DD format
...
return None
def extract_date_signals(url: str, snippet: str, title: str) -> tuple[Optional[str], str]:
"""Extract date from any available signal.
Returns: (date_string, confidence)
- date from URL: 'high' confidence
- date from snippet: 'med' confidence
- no date found: None, 'low' confidence
"""
# Try URL first (most reliable)
url_date = extract_date_from_url(url)
if url_date:
return url_date, 'high'
# Try snippet
snippet_date = extract_date_from_snippet(snippet)
if snippet_date:
return snippet_date, 'med'
# Try title
title_date = extract_date_from_snippet(title)
if title_date:
return title_date, 'med'
return None, 'low'
```
**Update WebSearch parsing to use date extraction:**
```python
def parse_websearch_results(results, topic, from_date, to_date):
items = []
for result in results:
url = result.get('url', '')
snippet = result.get('snippet', '')
title = result.get('title', '')
# Extract date signals
extracted_date, confidence = extract_date_signals(url, snippet, title)
# Hard filter: if we found a date and it's too old, skip
if extracted_date and extracted_date < from_date:
continue # DROP - verified old content
item = {
'date': extracted_date,
'date_confidence': confidence,
...
}
items.append(item)
return items
```
**File: `scripts/lib/score.py`**
Update WebSearch scoring to reward date-verified results:
```python
# WebSearch date confidence adjustments
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date (was 10)
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date
def score_websearch_items(items):
for item in items:
...
# Date confidence adjustments
if item.date_confidence == 'high':
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified dates
elif item.date_confidence == 'low':
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
...
```
**Result**: WebSearch results with verifiable recent dates rank well. Results with no dates are heavily penalized but still appear as supplementary context. Old verified content is excluded entirely.
### Phase 4: Update Statistics Display
Only count Reddit and X in "from the last 30 days" claim. WebSearch should be clearly labeled as supplementary.
## Acceptance Criteria
### Functional Requirements
- [x] Reddit search prompt includes explicit `from_date` and `to_date`
- [x] Items with dates before `from_date` are EXCLUDED, not just penalized
- [x] X search continues working (no regression)
- [x] WebSearch extracts dates from URLs (e.g., `/2026/01/24/`)
- [x] WebSearch extracts dates from snippets (e.g., "January 24, 2026")
- [x] WebSearch with verified recent dates gets +10 bonus
- [x] WebSearch with no date signals gets -20 penalty (but still appears)
- [x] WebSearch with verified OLD dates is EXCLUDED
### Non-Functional Requirements
- [ ] No increase in API latency
- [ ] Graceful handling when few recent results exist (return fewer, not older)
- [ ] Clear user messaging when results are limited due to strict filtering
### Quality Gates
- [ ] Test: Reddit search returns 0% results older than 30 days
- [ ] Test: X search continues to return 100% recent results
- [ ] Test: WebSearch is clearly differentiated in output
- [ ] Test: Edge case - topic with no recent content shows helpful message
## Implementation Order
1. **Phase 1**: Fix Reddit prompt (highest impact, simple change)
2. **Phase 2**: Add hard date filter in normalize.py (safety net)
3. **Phase 3**: Add WebSearch date extraction (URL + snippet parsing)
4. **Phase 4**: Update WebSearch scoring (bonus for verified, heavy penalty for unknown)
5. **Phase 5**: Update output display to show date confidence
## Testing Plan
### Before/After Test
Run same query before and after fix:
```
/last30days remotion launch videos
```
**Expected Before:**
- Reddit: 40% within 30 days
**Expected After:**
- Reddit: 100% within 30 days (or fewer results if not enough recent content)
### Edge Case Tests
| Scenario | Expected Behavior |
|----------|-------------------|
| Topic with no recent content | Return 0 results + helpful message |
| Topic with 5 recent results | Return 5 results (not pad with old ones) |
| Mixed old/new results | Only return new ones |
### WebSearch Date Extraction Tests
| URL/Snippet | Expected Date | Confidence |
|-------------|---------------|------------|
| `medium.com/blog/2026/01/15/title` | 2026-01-15 | high |
| `github.com/repo` + "Released Jan 20, 2026" | 2026-01-20 | med |
| `docs.example.com/guide` (no date signals) | None | low |
| `news.site.com/2024/05/old-article` | 2024-05-XX | EXCLUDE (too old) |
| Snippet: "Updated 3 days ago" | calculated | med |
## Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Fewer results for niche topics | High | Medium | Explain why in output |
| User confusion about reduced results | Medium | Low | Clear messaging |
| Date parsing errors exclude valid content | Low | Medium | Keep items with unknown dates, just label clearly |
## References
### Internal References
- Reddit search: `scripts/lib/openai_reddit.py:25-63`
- X search (working example): `scripts/lib/xai_x.py:26-55`
- Date confidence: `scripts/lib/dates.py:62-90`
- Scoring penalties: `scripts/lib/score.py:149-153`
- Normalization: `scripts/lib/normalize.py:49,99`
### External References
- OpenAI Responses API lacks native date filtering
- Must rely on prompt engineering + post-processing
+58 -47
View File
@@ -1,75 +1,86 @@
The AI world reinvents itself every month. This skill keeps you current.
`/last30days` researches your topic across **Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web** from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
`/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
## v3 Community
## v3 is the intelligent search release
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
v3 is a ground-up engine rewrite by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah.
Type "OpenClaw" and v3 resolves @steipete, r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags before a single API call fires. Type "Peter Steinberger" and it resolves his X handle and GitHub profile, switches to person mode, and shows what he shipped this month at 85% merge rate across 22 PRs. None of that was on Google.
## What's New in v2.9.1
## Headline features
**Auto-save to ~/Documents/Last30Days/.** Every run now saves the complete research briefing - synthesis, stats, and follow-up suggestions - as a topic-named `.md` file to your Documents folder. Build a personal research library without lifting a finger. Inspired by [@devin_explores](https://x.com/devin_explores) who was already doing this manually.
### Intelligent pre-research
## Three Headline Features in v2.9
The killer feature. A new Python pre-research brain resolves X handles, GitHub repos, subreddits, TikTok hashtags, and YouTube channels before searching. Bidirectional: person to company, product to founder, name to GitHub profile. The right subreddits, the right handles, the right hashtags, all resolved before a single API call.
**1. ScrapeCreators Reddit as default.** One `SCRAPECREATORS_API_KEY` now covers Reddit, TikTok, and Instagram - three sources, one key. No more `OPENAI_API_KEY` required for Reddit search. Faster, more reliable, and simpler to configure.
### Best Takes
**2. Smart subreddit discovery.** Relevance-weighted scoring replaces pure frequency count. Each candidate subreddit is scored by `frequency x recency x topic-word match`, and a `UTILITY_SUBS` blocklist filters noise subs like r/tipofmytongue. Search "Claude Code skills" and get r/ClaudeAI, r/ClaudeCode, r/openclaw - not generic programming subs.
A second LLM judge scores every result for humor, wit, and virality alongside relevance. Every brief now ends with a Best Takes section surfacing the cleverest one-liners and most viral quotes. The Reddit and X people are funny, and the old engine buried their best stuff.
**3. Top comments elevated.** The best comment on each Reddit thread now carries a 10% weight in engagement scoring and displays prominently with upvote counts. Reddit's value is in the comments - now the skill surfaces them.
### Cross-source cluster merging
Plus: **Instagram Reels** (v2.8), **Polymarket prediction markets** (v2.5), **YouTube transcripts** (v2.1), **bundled X search** - no external CLI needed.
When the same story hits Reddit, X, and YouTube, v3 merges them into one cluster instead of three duplicates. Entity-based overlap detection catches matches even when the titles use different words.
## Beta Test Results (v2.9)
### Single-pass comparisons
| Topic | Time | Threads | Discovered Subreddits |
|-------|------|---------|----------------------|
| Claude Code skills | 77.1s | 99 | r/ClaudeAI, r/ClaudeCode, r/openclaw |
| Kanye West | 71.7s | 84 | r/hiphopheads, r/NFCWestMemeWar, r/Kanye |
| Anthropic odds | 68.0s | 65 | r/Anthropic, r/ClaudeAI, r/OpenAI |
| Best rap songs lately | 68.9s | 114 | r/BestofRedditorUpdates, r/rap, r/TeenageRapFans |
| Nano Banana Pro | 66.6s | 99 | r/GeminiAI, r/nanobanana2pro, r/macbookpro |
"X vs Y" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides at once. Same depth, 3 minutes.
## What's New
### GitHub person-mode and project-mode
### Added
- ScrapeCreators Reddit backend with keyword search and subreddit discovery
- Smart subreddit discovery with relevance-weighted scoring
- Utility subreddit blocklist (`UTILITY_SUBS`)
- Top comment scoring (10% engagement weight) and prominent rendering
- Comment excerpts increased to 400 chars, insights raised to 10
When the topic is a person, the engine switches from keyword search to author-scoped queries. PR velocity, top repos by stars, release notes for what shipped this month, woven into the narrative alongside X posts and Reddit threads.
### Changed
- `primaryEnv``SCRAPECREATORS_API_KEY` (one key for Reddit, TikTok, Instagram)
- Reddit engagement scoring: `0.55/0.40/0.05``0.50/0.35/0.05/0.10`
- SKILL.md synthesis instructions emphasize quoting top comments
When the topic is a project, it pulls live star counts, READMEs, releases, and top issues from the GitHub API. No stale blog posts.
### Fixed
- Utility sub noise in subreddit discovery
- Reddit no longer requires `OPENAI_API_KEY`
### ELI5 mode
## New Contributors
Say "eli5 on" after any research run. The synthesis rewrites in plain language. No jargon. Same data, same sources, same citations, just clearer. Say "eli5 off" to go back.
- @JosephOIbrahim -- Windows Unicode fix ([#17](https://github.com/mvanhorn/last30days-skill/pull/17))
- @levineam -- Model fallback for unverified orgs ([#16](https://github.com/mvanhorn/last30days-skill/pull/16))
- @jonthebeef -- `--days=N` configurable lookback ([#18](https://github.com/mvanhorn/last30days-skill/pull/18))
### 13+ sources
## Credits
v3 adds Threads, Pinterest, Perplexity, Bluesky, and Parallel AI grounding to the existing Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and Web lineup. Perplexity Deep Research (`--deep-research`) gives you 50+ citation reports for serious investigation.
- [@steipete](https://github.com/steipete) -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
- [@galligan](https://github.com/galligan) -- Marketplace plugin inspiration
- [@hutchins](https://x.com/hutchins) -- Pushed for YouTube feature
### Per-author cap and entity disambiguation
Max 3 items per author prevents single-voice dominance. Synthesis trusts resolved handles over fuzzy keyword matches.
## Install
```bash
# Claude Code
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
Claude Code:
# Codex CLI
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
```
/plugin marketplace add mvanhorn/last30days-skill
```
30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.
OpenClaw:
```
clawhub install last30days-official
```
OpenAI Codex CLI: run `codex` from a checkout of this repo and v3's skill at `.agents/skills/last30days/SKILL.md` will be discovered automatically. Or copy `SKILL.md` to `~/.agents/skills/last30days/SKILL.md` for a global install.
Zero config. Reddit, Hacker News, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
## v3 Community
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped.
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
Contributors who shaped the release itself:
- @Jah-yee (#153) surfaced the need for a real Codex CLI integration, which shipped in #219
- @Cody-Coyote (#204) reported the marketplace validation bug that needed fixing before v3 could ship cleanly
- @dannyshmueli pushed for v3 and Codex family support publicly on X
Full Added / Changed / Fixed detail lives in [CHANGELOG.md](CHANGELOG.md) under `[3.0.0]`.
## Earlier contributors
From the v1 and v2 lineage:
- [@galligan](https://github.com/galligan) for marketplace plugin inspiration
- [@hutchins](https://x.com/hutchins) for pushing the YouTube feature
30 days of research. 30 seconds of work. Thirteen sources. Zero stale prompts.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
# Usage: bash scripts/build-skill.sh (run from repo root)
#
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
# directory containing SKILL.md and the scripts/ runtime. See
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty; commit or stash before building" >&2
exit 1
fi
mkdir -p dist
OUT="dist/last30days.skill"
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
SIZE=$(du -h "$OUT" | cut -f1)
if [ "$COUNT" -gt 200 ]; then
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
echo " check .gitattributes export-ignore entries" >&2
exit 1
fi
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
exit 1
fi
echo "built $OUT ($COUNT files, $SIZE)"
echo "upload via the claude.ai skill UI"
+14 -5
View File
@@ -33,6 +33,11 @@ def ensure_supported_python(version_info: tuple[int, int, int] | object | None =
ensure_supported_python()
if os.name == "nt":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
@@ -103,7 +108,7 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
content = emit_output(report, emit)
else:
content = render.render_full(report)
out_path.write_text(content)
out_path.write_text(content, encoding="utf-8")
return out_path
@@ -165,12 +170,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
parser.add_argument("--lookback-days", type=int, default=30, help="Number of days to look back for research (default: 30, watchlist uses 90)")
parser.add_argument(
"--days",
"--lookback-days",
dest="lookback_days",
type=int,
default=30,
help="Number of days to look back for research (default: 30, watchlist uses 90)",
)
parser.add_argument("--auto-resolve", action="store_true",
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
parser.add_argument("--podcast-channels", help="Comma-separated YouTube @handles for podcast transcript scanning (e.g., AcquiredFM,lexfridman,DwarkeshPatel)")
return parser
@@ -309,7 +320,6 @@ def main() -> int:
github_user = args.github_user.lstrip("@").lower() if args.github_user else None
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
podcast_channels = [c.strip().lstrip("@") for c in args.podcast_channels.split(",") if c.strip()] if args.podcast_channels else None
# --deep-research: auto-enable perplexity source and set deep flag
if args.deep_research:
@@ -339,7 +349,6 @@ def main() -> int:
lookback_days=args.lookback_days,
github_user=github_user,
github_repos=github_repos,
podcast_channels=podcast_channels,
)
except Exception as exc:
progress.end_processing()
+5 -1
View File
@@ -177,6 +177,8 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
@@ -336,6 +338,8 @@ def search_handles(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
@@ -460,7 +464,7 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
"url": url,
"author_handle": author_handle.lstrip("@"),
"date": date,
"engagement": engagement,
"engagement": engagement if any(v is not None for v in engagement.values()) else None,
"why_relevant": "", # Bird doesn't provide relevance explanations
"relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7,
}
+3 -1
View File
@@ -264,7 +264,7 @@ def get_config() -> dict[str, Any]:
('XQUIK_API_KEY', None),
('FROM_BROWSER', None),
('SETUP_COMPLETE', None),
('INCLUDE_SOURCES', None),
('INCLUDE_SOURCES', ''),
]
for key, default in keys:
@@ -579,6 +579,8 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
"""
from . import bird_x
if config.get('AUTH_TOKEN') and config.get('CT0'):
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
bird_status = bird_x.get_bird_status()
xai_available = bool(config.get('XAI_API_KEY'))
+9 -8
View File
@@ -17,7 +17,7 @@ import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import log
from . import dates, log
from .query import extract_core_subject
from .relevance import token_overlap_relevance
@@ -106,13 +106,14 @@ def _parse_repo_from_url(html_url: str) -> str:
def _parse_date(iso_str: Optional[str]) -> Optional[str]:
"""Extract YYYY-MM-DD from ISO 8601 datetime string."""
if not iso_str:
return None
try:
return iso_str[:10]
except (IndexError, TypeError):
return None
"""Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
Returns None for non-date input. GitHub's API always emits ISO 8601
(e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
garbage input gets rejected instead of silently sliced.
"""
dt = dates.parse_date(iso_str)
return dt.strftime("%Y-%m-%d") if dt else None
def _compute_relevance(
+17
View File
@@ -38,6 +38,7 @@ def request(
url: str,
headers: Optional[Dict[str, str]] = None,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
timeout: int = DEFAULT_TIMEOUT,
retries: int = MAX_RETRIES,
max_429_retries: int = MAX_429_RETRIES,
@@ -50,6 +51,8 @@ def request(
url: Request URL
headers: Optional headers dict
json_data: Optional JSON body (for POST)
params: Optional query-string params. Values are stringified. None values
are dropped. If ``url`` already has a query string, ``params`` is appended.
timeout: Request timeout in seconds
retries: Number of retries on failure
max_429_retries: Maximum 429 retries before giving up (separate cap)
@@ -64,6 +67,12 @@ def request(
headers = headers or {}
headers.setdefault("User-Agent", USER_AGENT)
if params:
filtered = {k: str(v) for k, v in params.items() if v is not None}
if filtered:
separator = "&" if ("?" in url) else "?"
url = f"{url}{separator}{urlencode(filtered)}"
data = None
if json_data is not None:
data = json.dumps(json_data).encode('utf-8')
@@ -157,6 +166,14 @@ def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, st
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
def scrapecreators_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers (x-api-key + JSON content type)."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
"""Fetch Reddit thread JSON.
+5 -13
View File
@@ -112,14 +112,6 @@ def _log(msg: str):
log.source_log("Instagram", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators Instagram item to YYYY-MM-DD.
@@ -249,7 +241,7 @@ def _user_reels(
from urllib.parse import urlencode
params = urlencode({"handle": handle})
url = f"{reels_url}?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -260,7 +252,7 @@ def _user_reels(
resp = _requests.get(
reels_url,
params={"handle": handle},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -307,7 +299,7 @@ def search_instagram(
from urllib.parse import urlencode
params = urlencode({"query": core_topic})
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -318,7 +310,7 @@ def search_instagram(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": core_topic},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -403,7 +395,7 @@ def fetch_captions(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
params={"url": url},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=15,
)
if resp.status_code == 200:
-1
View File
@@ -53,7 +53,6 @@ def normalize_source_items(
"xiaohongshu": _normalize_grounding,
"github": _normalize_github,
"perplexity": _normalize_grounding,
"podcasts": lambda s, i, idx, fd, td: _normalize_youtube(s, i, idx, fd, td),
}
normalizer = normalizers.get(source)
if normalizer is None:
+2 -10
View File
@@ -49,14 +49,6 @@ def _log(msg: str):
log.source_log("Pinterest", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
"""Parse raw Pinterest items into normalized dicts.
@@ -154,7 +146,7 @@ def search_pinterest(
from urllib.parse import urlencode
params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -165,7 +157,7 @@ def search_pinterest(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
-18
View File
@@ -40,7 +40,6 @@ from . import (
xai_x,
xiaohongshu_api,
xquik,
podcast_yt,
youtube_yt,
)
from .cluster import cluster_candidates
@@ -78,7 +77,6 @@ MOCK_AVAILABLE_SOURCES = [
"github",
"perplexity",
"xquik",
"podcasts",
]
@@ -124,11 +122,6 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
available.append("pinterest")
if env.is_xquik_available(config):
available.append("xquik")
# Podcasts: available whenever yt-dlp is installed (same as YouTube).
# Opt-out only. The source returns empty when no channels are resolved,
# so there's no cost to having it available.
if podcast_yt.is_available():
available.append("podcasts")
return available
@@ -184,7 +177,6 @@ def run(
lookback_days: int = 30,
github_user: str | None = None,
github_repos: list[str] | None = None,
podcast_channels: list[str] | None = None,
) -> schema.Report:
settings = DEPTH_SETTINGS[depth]
requested_sources = normalize_requested_sources(requested_sources)
@@ -326,7 +318,6 @@ def run(
tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators,
ig_creators=ig_creators,
podcast_channels=podcast_channels,
)
] = (subquery, source)
@@ -357,7 +348,6 @@ def run(
tiktok_hashtags=tiktok_hashtags,
tiktok_creators=tiktok_creators,
ig_creators=ig_creators,
podcast_channels=podcast_channels,
)
except Exception as retry_exc:
bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})"
@@ -797,7 +787,6 @@ def _retrieve_stream(
tiktok_hashtags: list[str] | None = None,
tiktok_creators: list[str] | None = None,
ig_creators: list[str] | None = None,
podcast_channels: list[str] | None = None,
) -> tuple[list[dict], dict]:
# Early exit if source was rate-limited by a sibling future
if rate_limited_sources is not None and source in rate_limited_sources:
@@ -885,13 +874,6 @@ def _retrieve_stream(
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
youtube_yt.enrich_with_comments(items, token=sc_token)
return items, {}
if source == "podcasts":
podcast_query = raw_topic or subquery.search_query
result = podcast_yt.search_podcast_youtube(
podcast_query, from_date, to_date,
depth=depth, channels=podcast_channels,
)
return result.get("items", []), {}
if source == "tiktok":
# Use raw_topic so expand_tiktok_queries() generates diverse variants
# from the original user topic, not the planner's narrowed search_query.
-1
View File
@@ -71,7 +71,6 @@ SOURCE_CAPABILITIES = {
"github": {"discussion", "link"},
"grounding": {"web", "reference", "link"},
"perplexity": {"web", "reference", "analysis"},
"podcasts": {"discussion", "video_longform", "expert"},
}
DEFAULT_INTENT_CAPABILITIES = {
"comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
-430
View File
@@ -1,430 +0,0 @@
"""YouTube podcast discovery via transcript scanning.
Discovers podcast content by fetching auto-captions from LLM-resolved
YouTube podcast channels and grepping for the search topic. Finds content
invisible to title-based search e.g., Acquired's "The NFL" episode
mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times.
Uses yt-dlp for channel playlist fetch + caption download. No API keys.
Reuses transcript highlight extraction from youtube_yt.
"""
import math
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import log
# How many recent episodes to scan per channel, by depth
EPISODES_PER_CHANNEL = {
"quick": 2,
"default": 3,
"deep": 4,
}
# Minimum topic mentions in captions to count as a hit
MENTION_THRESHOLD = 5
# Max total results to return
RESULTS_CAP = {
"quick": 4,
"default": 8,
"deep": 20,
}
# Min duration in seconds to qualify as a podcast episode
MIN_DURATION = 1200 # 20 minutes
def _log(msg: str):
log.source_log("Podcasts", msg, tty_only=False)
def is_available() -> bool:
"""Podcast source is available when yt-dlp is installed."""
return shutil.which("yt-dlp") is not None
def resolve_channel(handle: str) -> Optional[str]:
"""Resolve a YouTube @handle to a channel URL.
Tries the @handle directly first (fast, ~92% success rate).
Falls back to ytsearch1 if the handle doesn't resolve.
Returns the channel URL (https://www.youtube.com/channel/...) or None.
"""
# Try @handle directly - use the channel/videos URL format
# yt-dlp can fetch from @handle URLs directly for playlist operations
direct_url = f"https://www.youtube.com/@{handle}/videos"
try:
result = subprocess.run(
["yt-dlp", "--playlist-end", "1",
"--print", "%(channel_url)s",
"--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
direct_url],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip().split("\n")[0].strip()
if channel_url and channel_url.startswith("http"):
_log(f"Resolved @{handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# Fallback: search for the podcast
_log(f"@{handle} not found, trying search fallback")
try:
result = subprocess.run(
["yt-dlp", "--flat-playlist", "--playlist-end", "1",
"--print", "%(channel_url)s",
f'ytsearch1:"{handle}" podcast full episode'],
capture_output=True, text=True, timeout=20,
)
channel_url = result.stdout.strip()
if channel_url and channel_url.startswith("http"):
_log(f"Search fallback resolved {handle} -> {channel_url}")
return channel_url
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
_log(f"Could not resolve channel: {handle}")
return None
def _fetch_recent_episodes(
channel_url: str,
limit: int,
from_date: str,
to_date: str,
) -> List[Dict[str, Any]]:
"""Fetch recent long-form episodes from a channel.
Returns list of dicts with video_id, title, channel, duration, date, views, likes.
Filters to episodes with duration >= MIN_DURATION.
"""
import json as _json
try:
result = subprocess.run(
["yt-dlp", f"--playlist-end={limit + 2}",
"--dump-json", "--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
f"{channel_url}/videos"],
capture_output=True, text=True, timeout=60,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
episodes = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
video = _json.loads(line)
except _json.JSONDecodeError:
continue
video_id = video.get("id", "")
title = video.get("title", "")
channel = video.get("channel", video.get("uploader", ""))
duration = video.get("duration") or 0
upload_date_raw = video.get("upload_date", "")
views = video.get("view_count") or 0
likes = video.get("like_count") or 0
# Convert YYYYMMDD to YYYY-MM-DD
date_str = None
if upload_date_raw and len(upload_date_raw) >= 8:
date_str = f"{upload_date_raw[:4]}-{upload_date_raw[4:6]}-{upload_date_raw[6:8]}"
# Filter: duration >= MIN_DURATION
if duration < MIN_DURATION:
continue
# Filter: within date range (soft - keep if no date available)
if date_str and (date_str < from_date or date_str > to_date):
continue
episodes.append({
"video_id": video_id,
"title": title,
"channel_name": channel,
"duration": duration,
"date": date_str,
"views": views,
"likes": likes,
"url": f"https://www.youtube.com/watch?v={video_id}",
})
return episodes[:limit]
def _fetch_captions(video_id: str, temp_dir: str) -> Optional[str]:
"""Fetch auto-captions for a video. Returns caption text or None."""
out_template = os.path.join(temp_dir, f"cap_{video_id}")
try:
subprocess.run(
["yt-dlp", "--write-auto-sub", "--sub-lang", "en",
"--skip-download", "--sub-format", "vtt",
"-o", out_template,
f"https://www.youtube.com/watch?v={video_id}"],
capture_output=True, text=True, timeout=30,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
vtt_path = f"{out_template}.en.vtt"
if not os.path.exists(vtt_path):
return None
try:
with open(vtt_path, "r", encoding="utf-8") as f:
text = f.read()
os.remove(vtt_path)
# Strip VTT formatting: timestamps, alignment, tags, duplicate lines
# VTT auto-captions repeat lines as they scroll, so deduplicate
lines = []
prev_line = ""
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("WEBVTT") or line.startswith("Kind:") or line.startswith("Language:"):
continue
if re.match(r"^\d{2}:\d{2}:", line):
continue
if re.match(r"^NOTE\b", line):
continue
if "align:" in line or "position:" in line:
continue
# Strip inline VTT tags like <c>, </c>, timestamps
cleaned = re.sub(r"<[^>]+>", "", line)
cleaned = cleaned.strip()
if cleaned and not re.match(r"^\d+$", cleaned) and cleaned != prev_line:
lines.append(cleaned)
prev_line = cleaned
return " ".join(lines)
except Exception:
return None
_NOISE_WORDS = frozenset({
"the", "a", "an", "of", "and", "or", "for", "to", "in", "on", "at",
"best", "top", "new", "latest", "review", "news", "vs", "versus",
"album", "song", "episode", "podcast", "interview", "this", "that",
"what", "how", "why", "where", "when", "who",
})
def _extract_key_terms(topic: str) -> List[str]:
"""Extract meaningful terms from topic for matching.
For "Kanye West Bully album" -> ["Kanye West", "Bully"] or similar.
For single words, just returns the word.
"""
words = [w.strip() for w in topic.split() if w.strip()]
# Remove noise words
meaningful = [w for w in words if w.lower() not in _NOISE_WORDS and len(w) > 2]
if not meaningful:
return [topic.strip()]
# If the topic has 2+ meaningful words, also include the full phrase
# and the first 2 words as a potential entity name
terms = []
if len(meaningful) >= 2:
# Full phrase first (for exact entity matches like "Taylor Swift")
terms.append(" ".join(meaningful[:2]))
terms.extend(meaningful)
return terms
def _count_mentions(text: str, topic: str) -> int:
"""Count case-insensitive topic mentions in text.
Uses the maximum mention count across key terms extracted from the topic.
"Kanye West Bully album" -> max mentions of ["Kanye West", "Kanye", "West", "Bully"].
This way, an episode mentioning "Kanye" 85 times counts as 85, not 0.
"""
text_lower = text.lower()
terms = _extract_key_terms(topic)
max_count = 0
for term in terms:
pattern = re.escape(term.lower())
count = len(re.findall(pattern, text_lower))
if count > max_count:
max_count = count
return max_count
def _extract_mention_context(text: str, topic: str, max_excerpts: int = 3) -> List[str]:
"""Extract text snippets around topic mentions for highlights."""
words = text.split()
topic_lower = topic.lower()
excerpts = []
for i, word in enumerate(words):
# Check if we're near a mention
window = " ".join(words[max(0, i - 5):i + 15]).lower()
if topic_lower in window and len(excerpts) < max_excerpts:
start = max(0, i - 10)
end = min(len(words), i + 30)
excerpt = " ".join(words[start:end])
# Avoid duplicate excerpts
if not any(excerpt[:50] in e for e in excerpts):
excerpts.append(excerpt)
return excerpts
def _scan_channel(
handle: str,
topic: str,
from_date: str,
to_date: str,
episodes_limit: int,
) -> List[Dict[str, Any]]:
"""Scan a single channel's recent episodes for topic mentions.
Returns list of hit items with mention_count and transcript data.
"""
# Step 1: Resolve channel handle to URL
channel_url = resolve_channel(handle)
if not channel_url:
return []
# Step 2: Fetch recent long-form episodes
episodes = _fetch_recent_episodes(channel_url, episodes_limit, from_date, to_date)
if not episodes:
_log(f"No recent long-form episodes from {handle}")
return []
_log(f"Scanning {len(episodes)} episodes from {handle}")
# Step 3: Fetch captions and grep for topic
hits = []
with tempfile.TemporaryDirectory() as temp_dir:
for ep in episodes:
caption_text = _fetch_captions(ep["video_id"], temp_dir)
if not caption_text:
continue
mention_count = _count_mentions(caption_text, topic)
if mention_count < MENTION_THRESHOLD:
continue
# Extract highlights around the mentions
from .youtube_yt import extract_transcript_highlights
highlights = extract_transcript_highlights(caption_text, topic, limit=5)
mention_excerpts = _extract_mention_context(caption_text, topic)
# Cap transcript for storage
words = caption_text.split()
transcript_snippet = " ".join(words[:5000]) if len(words) > 5000 else caption_text
hits.append({
"video_id": ep["video_id"],
"title": ep["title"],
"channel_name": ep["channel_name"],
"url": ep["url"],
"date": ep["date"],
"duration": ep["duration"],
"engagement": {
"views": ep["views"],
"likes": ep["likes"],
},
"mention_count": mention_count,
"transcript_snippet": transcript_snippet,
"transcript_highlights": highlights,
"mention_excerpts": mention_excerpts,
"relevance": min(1.0, mention_count / 50),
"why_relevant": f"Podcast: {ep['channel_name']} - {ep['title'][:60]} ({mention_count} mentions)",
})
_log(f" HIT: {ep['title'][:60]} ({mention_count} mentions)")
return hits
def search_podcast_youtube(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
channels: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Discover podcast content by scanning transcripts of resolved channels.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
channels: List of YouTube @handles to scan
Returns:
Dict with 'items' list. Each item has transcript and mention data.
"""
if not is_available():
_log("yt-dlp not installed")
return {"items": [], "error": "yt-dlp not installed"}
if not channels:
_log("No podcast channels provided")
return {"items": []}
episodes_limit = EPISODES_PER_CHANNEL.get(depth, EPISODES_PER_CHANNEL["default"])
results_cap = RESULTS_CAP.get(depth, RESULTS_CAP["default"])
_log(f"Scanning {len(channels)} podcast channels for '{topic}' (depth={depth}, {episodes_limit} eps/channel)")
# Scan channels in parallel
all_hits: List[Dict[str, Any]] = []
max_workers = min(4, len(channels))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_scan_channel, handle, topic, from_date, to_date, episodes_limit,
): handle
for handle in channels
}
for future in as_completed(futures):
handle = futures[future]
try:
hits = future.result()
all_hits.extend(hits)
except Exception as exc:
_log(f"Error scanning {handle}: {type(exc).__name__}: {exc}")
# Deduplicate by video_id
seen = set()
unique_hits = []
for hit in all_hits:
vid = hit["video_id"]
if vid not in seen:
seen.add(vid)
unique_hits.append(hit)
# Score: mention_count * log(views + 1)
for hit in unique_hits:
views = hit["engagement"].get("views", 0)
hit["_score"] = hit["mention_count"] * math.log(views + 1)
# Sort by score descending
unique_hits.sort(key=lambda x: x["_score"], reverse=True)
# Cap results
results = unique_hits[:results_cap]
# Clean up internal scoring field
for hit in results:
hit.pop("_score", None)
_log(f"Found {len(results)} podcast hits across {len(channels)} channels")
return {"items": results}
+20 -98
View File
@@ -12,15 +12,8 @@ import sys
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
try:
import requests as _requests
except ImportError:
_requests = None
def _first_of(*values, default=None):
"""Return first value that is not None."""
for v in values:
@@ -28,7 +21,7 @@ def _first_of(*values, default=None):
return v
return default
from . import http, log
from . import dates, http, log
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
@@ -76,14 +69,6 @@ def _log(msg: str):
log.source_log("Reddit", msg, tty_only=False)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query.
@@ -212,27 +197,16 @@ def _parse_date(value) -> Optional[str]:
Global search returns ``created_at`` as an ISO string
(e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
``created_utc`` as a Unix timestamp. Handle both.
``created_utc`` as a Unix timestamp. dates.parse_date() handles both,
plus edge cases like Z suffix and +0000 (no colon) offset.
Falsy inputs (None, "", 0) return None, matching the original behavior
where a Unix timestamp of 0 meant "no date" rather than epoch 0.
"""
if not value:
return None
# ISO-8601 string (contains 'T' or '-')
if isinstance(value, str) and ("T" in value or "-" in value):
try:
# Strip trailing offset variations (+0000, Z) for fromisoformat
clean = value.replace("Z", "+00:00")
if clean.endswith("+0000"):
clean = clean[:-5] + "+00:00"
dt = datetime.fromisoformat(clean)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
# Unix timestamp (int or float or numeric string)
try:
dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
return None
dt = dates.parse_date(str(value))
return dt.strftime("%Y-%m-%d") if dt else None
def _extract_subreddit_name(value: Any) -> str:
@@ -350,39 +324,18 @@ def _global_search(
Returns:
List of post dicts
"""
if not _requests:
_log("requests library not installed, falling back to urllib")
# Use stdlib http module as fallback
try:
from urllib.parse import urlencode
params = urlencode({"query": query, "sort": sort, "timeframe": timeframe})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("posts", data.get("data", []))
except http.HTTPError as e:
if e.status_code and e.status_code in (401, 403):
raise
_log(f"Global search error (urllib): {e}")
return []
except Exception as e:
_log(f"Global search error (urllib): {e}")
return []
try:
resp = _requests.get(
data = http.get(
f"{SCRAPECREATORS_BASE}/search",
headers=http.scrapecreators_headers(token),
params={"query": query, "sort": sort, "timeframe": timeframe},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", []))
except _requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code in (401, 403):
raise http.HTTPError(f"Auth error: {e}", e.response.status_code)
except http.HTTPError as e:
if e.status_code in (401, 403):
raise
_log(f"Global search error: {e}")
return []
except Exception as e:
@@ -409,36 +362,19 @@ def _subreddit_search(
Returns:
List of post dicts
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({
"subreddit": subreddit, "query": query,
"sort": sort, "timeframe": timeframe,
})
url = f"{SCRAPECREATORS_BASE}/subreddit/search?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("posts", data.get("data", []))
except Exception as e:
_log(f"Subreddit search error (urllib) for r/{subreddit}: {e}")
return []
try:
resp = _requests.get(
data = http.get(
f"{SCRAPECREATORS_BASE}/subreddit/search",
headers=http.scrapecreators_headers(token),
params={
"subreddit": subreddit,
"query": query,
"sort": sort,
"timeframe": timeframe,
},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("posts", data.get("data", []))
except Exception as e:
_log(f"Subreddit search error for r/{subreddit}: {e}")
@@ -458,28 +394,14 @@ def fetch_post_comments(
Returns:
List of comment dicts with score, author, body, etc.
"""
if not _requests:
try:
from urllib.parse import urlencode
params = urlencode({"url": url})
api_url = f"{SCRAPECREATORS_BASE}/post/comments?{params}"
headers = _sc_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(api_url, headers=headers, timeout=30, retries=2)
return data.get("comments", data.get("data", []))
except Exception as e:
_log(f"Comment fetch error (urllib): {e}")
return []
try:
resp = _requests.get(
data = http.get(
f"{SCRAPECREATORS_BASE}/post/comments",
headers=http.scrapecreators_headers(token),
params={"url": url},
headers=_sc_headers(token),
timeout=30,
retries=2,
)
resp.raise_for_status()
data = resp.json()
return data.get("comments", data.get("data", []))
except Exception as e:
_log(f"Comment fetch error: {e}")
-1
View File
@@ -14,7 +14,6 @@ SOURCE_LABELS = {
"x": "X",
"github": "GitHub",
"perplexity": "Perplexity",
"podcasts": "Podcasts",
}
-1
View File
@@ -19,7 +19,6 @@ SOURCE_QUALITY = {
"polymarket": 0.5,
"instagram": 0.58,
"tiktok": 0.58,
"podcasts": 0.88,
}
+12 -33
View File
@@ -9,10 +9,9 @@ API docs: https://scrapecreators.com/docs
import math
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from . import http, log
from . import dates, http, log
from .relevance import token_overlap_relevance as _compute_relevance
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
@@ -29,14 +28,6 @@ def _log(msg: str):
log.source_log("Threads", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for Threads search."""
from .query import extract_core_subject
@@ -52,29 +43,17 @@ def _extract_core_subject(topic: str) -> str:
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from Threads item to YYYY-MM-DD.
Tries common timestamp fields: taken_at (unix), created_at (ISO),
and falls back to any date-like string field.
Tries common timestamp fields in order: taken_at and create_time
(unix timestamps in Meta APIs), then created_at, published_at, and
date (ISO 8601 strings). dates.parse_date() handles both.
"""
# Unix timestamp (taken_at is common in Meta APIs)
for key in ("taken_at", "create_time"):
ts = item.get(key)
if ts:
try:
from . import dates
return dates.timestamp_to_date(int(ts))
except (ValueError, TypeError):
pass
# ISO 8601 string
for key in ("created_at", "published_at", "date"):
for key in ("taken_at", "create_time", "created_at", "published_at", "date"):
val = item.get(key)
if val and isinstance(val, str):
try:
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
if val is None:
continue
dt = dates.parse_date(str(val))
if dt:
return dt.strftime("%Y-%m-%d")
return None
@@ -183,7 +162,7 @@ def search_threads(
from urllib.parse import urlencode
params = urlencode({"keyword": core_topic})
url = f"{SCRAPECREATORS_BASE}/search?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -194,7 +173,7 @@ def search_threads(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search",
params={"keyword": core_topic},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
+7 -15
View File
@@ -109,14 +109,6 @@ def _log(msg: str):
log.source_log("TikTok", msg)
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
ts = item.get("create_time")
@@ -227,7 +219,7 @@ def _hashtag_search(
from urllib.parse import urlencode
params = urlencode({"hashtag": hashtag})
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -238,7 +230,7 @@ def _hashtag_search(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/hashtag",
params={"hashtag": hashtag},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -274,7 +266,7 @@ def _profile_videos(
from urllib.parse import urlencode
params = urlencode({"handle": handle, "sort_by": "latest"})
url = f"{profile_url}?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -285,7 +277,7 @@ def _profile_videos(
resp = _requests.get(
profile_url,
params={"handle": handle, "sort_by": "latest"},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -332,7 +324,7 @@ def search_tiktok(
from urllib.parse import urlencode
params = urlencode({"query": core_topic, "sort_by": "relevance"})
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as e:
@@ -343,7 +335,7 @@ def search_tiktok(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/keyword",
params={"query": core_topic, "sort_by": "relevance"},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -433,7 +425,7 @@ def fetch_captions(
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/video/transcript",
params={"url": url},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=15,
)
if resp.status_code == 200:
+115 -102
View File
@@ -18,117 +18,130 @@ const SearchClient = withSearch(TwitterClientBase);
const args = process.argv.slice(2);
// --check: verify that credentials can be resolved
if (args.includes('--check')) {
function writeStdout(text) {
if (text) process.stdout.write(text);
}
function writeStderr(text) {
if (text) process.stderr.write(text);
}
async function main() {
// --check: verify that credentials can be resolved
if (args.includes('--check')) {
try {
const { cookies, warnings } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
return 0;
}
writeStdout(JSON.stringify({ authenticated: false, warnings }));
return 1;
} catch (err) {
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
return 1;
}
}
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
writeStdout(cookies.source || 'authenticated');
return 0;
}
writeStderr('Not authenticated\n');
return 1;
} catch (err) {
writeStderr(`Auth check failed: ${err.message}\n`);
return 1;
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
return 1;
}
try {
// Resolve credentials (env vars, then browser cookies)
const { cookies, warnings } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
process.stdout.write(JSON.stringify({ authenticated: true, source: cookies.source }));
process.exit(0);
} else {
process.stdout.write(JSON.stringify({ authenticated: false, warnings }));
process.exit(1);
if (!cookies.authToken || !cookies.ct0) {
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
if (jsonOutput) {
writeStdout(JSON.stringify({ error: msg, items: [] }));
} else {
writeStderr(`Error: ${msg}\n`);
}
return 1;
}
} catch (err) {
process.stdout.write(JSON.stringify({ authenticated: false, error: err.message }));
process.exit(1);
}
}
// --whoami: check auth and output source
if (args.includes('--whoami')) {
try {
const { cookies } = await resolveCredentials({});
if (cookies.authToken && cookies.ct0) {
process.stdout.write(cookies.source || 'authenticated');
process.exit(0);
} else {
process.stderr.write('Not authenticated\n');
process.exit(1);
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
writeStdout(JSON.stringify({ error: result.error, items: [] }));
} else {
writeStderr(`Search failed: ${result.error}\n`);
}
return 1;
}
const tweets = result.tweets || [];
if (jsonOutput) {
writeStdout(JSON.stringify(tweets));
} else {
for (const tweet of tweets) {
const author = tweet.author?.username || 'unknown';
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
}
return 0;
} catch (err) {
process.stderr.write(`Auth check failed: ${err.message}\n`);
process.exit(1);
if (jsonOutput) {
writeStdout(JSON.stringify({ error: err.message, items: [] }));
} else {
writeStderr(`Error: ${err.message}\n`);
}
return 1;
}
}
// Parse search args
let query = null;
let count = 20;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--count' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '-n' && args[i + 1]) {
count = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === '--json') {
jsonOutput = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
if (!query) {
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
process.exit(1);
}
try {
// Resolve credentials (env vars, then browser cookies)
const { cookies, warnings } = await resolveCredentials({});
if (!cookies.authToken || !cookies.ct0) {
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: msg, items: [] }));
} else {
process.stderr.write(`Error: ${msg}\n`);
}
process.exit(1);
}
// Create search client
const client = new SearchClient({
cookies: {
authToken: cookies.authToken,
ct0: cookies.ct0,
cookieHeader: cookies.cookieHeader,
},
timeoutMs: 30000,
});
// Run search
const result = await client.search(query, count);
if (!result.success) {
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: result.error, items: [] }));
} else {
process.stderr.write(`Search failed: ${result.error}\n`);
}
process.exit(1);
}
// Output results
const tweets = result.tweets || [];
if (jsonOutput) {
process.stdout.write(JSON.stringify(tweets));
} else {
for (const tweet of tweets) {
const author = tweet.author?.username || 'unknown';
process.stdout.write(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
}
}
process.exit(0);
const code = await main();
process.exitCode = Number.isInteger(code) ? code : 1;
} catch (err) {
if (jsonOutput) {
process.stdout.write(JSON.stringify({ error: err.message, items: [] }));
} else {
process.stderr.write(`Error: ${err.message}\n`);
}
process.exit(1);
writeStderr(`Fatal error: ${err?.message || err}\n`);
process.exitCode = 1;
}
+6 -14
View File
@@ -655,14 +655,6 @@ except ImportError:
_requests = None
def _sc_headers(token: str) -> Dict[str, str]:
"""Build ScrapeCreators request headers."""
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _total_engagement(item: Dict[str, Any]) -> int:
"""Combined engagement score for ranking which videos to enrich."""
eng = item.get("engagement", {})
@@ -745,7 +737,7 @@ def _fetch_video_comments(
from urllib.parse import urlencode
params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
@@ -756,7 +748,7 @@ def _fetch_video_comments(
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/comments",
params={"id": video_id},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -906,7 +898,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
from urllib.parse import urlencode
params = urlencode({"keyword": keyword})
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
return data.get("videos", data.get("data", data.get("items", [])))
@@ -918,7 +910,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/search",
params={"keyword": keyword},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
resp.raise_for_status()
@@ -944,7 +936,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
from urllib.parse import urlencode
params = urlencode({"id": video_id})
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
headers = _sc_headers(token)
headers = http.scrapecreators_headers(token)
headers["User-Agent"] = http.USER_AGENT
data = http.get(url, headers=headers, timeout=30, retries=2)
except Exception as exc:
@@ -955,7 +947,7 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
resp = _requests.get(
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
params={"id": video_id},
headers=_sc_headers(token),
headers=http.scrapecreators_headers(token),
timeout=30,
)
if resp.status_code != 200:
+63 -4
View File
@@ -11,7 +11,7 @@ COMMON_TARGETS=(
# but local development needs the cache kept in sync with the repo.
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate
# /last30days-3 in the slash command menu alongside the plugin version.
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha"
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.1"
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
"$HOME/.agents/skills/last30days"
"$HOME/.codex/skills/last30days"
@@ -24,7 +24,7 @@ sync_target() {
echo ""
echo "--- Syncing to $target ---"
mkdir -p "$target/scripts/lib" "$target/variants/open/references"
mkdir -p "$target/scripts/lib"
cp "$skill_md" "$target/SKILL.md"
@@ -35,7 +35,13 @@ sync_target() {
"$SRC/scripts/store.py" \
"$target/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
rsync -a "$SRC/variants/open/" "$target/variants/open/"
# The OpenClaw variant lives in the private repo only. Skip cleanly when
# running this script from the public repo where variants/open does not exist.
if [ -d "$SRC/variants/open" ]; then
mkdir -p "$target/variants/open/references"
rsync -a "$SRC/variants/open/" "$target/variants/open/"
fi
if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
@@ -63,7 +69,60 @@ for t in "${COMMON_TARGETS[@]}"; do
sync_target "$t" "$SRC/SKILL.md"
done
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
# Hermes sync: deploy to Hermes skills directory if it exists
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
if [ -d "$HOME/.hermes/skills/research" ]; then
echo ""
echo "--- Syncing to Hermes ---"
mkdir -p "$HERMES_TARGET/scripts/lib"
# Use Hermes-specific SKILL.md if available, fallback to main
if [ -f "$SRC/.hermes-plugin/SKILL.md" ]; then
cp "$SRC/.hermes-plugin/SKILL.md" "$HERMES_TARGET/SKILL.md"
else
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
fi
rsync -a \
"$SRC/scripts/last30days.py" \
"$SRC/scripts/watchlist.py" \
"$SRC/scripts/briefing.py" \
"$SRC/scripts/store.py" \
"$HERMES_TARGET/scripts/"
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
if [ -d "$SRC/scripts/lib/vendor" ]; then
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
fi
if [ -d "$SRC/fixtures" ]; then
mkdir -p "$HERMES_TARGET/fixtures"
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
fi
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
echo " Copied $mod_count modules to Hermes"
if (
cd "$HERMES_TARGET/scripts" &&
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
); then
true
else
echo " Import check FAILED"
fi
fi
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
# in the source tree. The public repo does not ship variants/open (the variant
# is sanitized via strip_for_openclaw.py and published separately from
# last30days-skill-private).
if [ -d "$SRC/variants/open" ]; then
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
else
echo ""
echo "Skipping OpenClaw target (no variants/open in this repo)"
fi
echo ""
echo "Sync complete."
+4 -5
View File
@@ -1,14 +1,14 @@
---
name: last30days
version: "3.0.0"
description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
name: last30days-v3-spec
version: "3.0.1"
description: "Internal architecture spec for the v3 last30days runtime pipeline. Not user-invocable."
argument-hint: "last30days codex vs claude code"
allowed-tools: Bash, Read, Write, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
repository: https://github.com/mvanhorn/last30days-skill
author: mvanhorn
license: MIT
user-invocable: true
user-invocable: false
---
# last30days v3.0.0
@@ -86,7 +86,6 @@ fi
- `yt-dlp` enables YouTube.
- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
- Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped.
- For OpenClaw-specific watchlist, briefing, and history workflows, use `variants/open/SKILL.md`.
## Output model
+27 -1
View File
@@ -175,7 +175,7 @@ class TestVendoredBirdRuntime(unittest.TestCase):
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"]["likes"])
self.assertIsNone(items[0]["engagement"])
def test_fallback_to_second_key(self):
tweets = [
@@ -203,6 +203,32 @@ class TestVendoredBirdRuntime(unittest.TestCase):
items = parse_bird_response(tweets, "test query")
self.assertEqual(0, items[0]["engagement"]["likes"])
def test_engagement_none_when_all_fields_missing(self):
"""All-None engagement dict should become None, not propagate."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"])
def test_engagement_preserved_when_any_field_present(self):
"""Engagement dict kept when at least one metric exists."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"likeCount": 5,
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNotNone(items[0]["engagement"])
self.assertEqual(5, items[0]["engagement"]["likes"])
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -77,6 +77,13 @@ class CliV3Tests(unittest.TestCase):
with self.assertRaises(SystemExit):
cli.parse_search_flag(" , ")
def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
parser = cli.build_parser()
args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
self.assertEqual(7, args.lookback_days)
self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
self.assertEqual([], extra)
def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
stderr = io.StringIO()
with redirect_stderr(stderr):
@@ -128,6 +135,14 @@ class CliV3Tests(unittest.TestCase):
payload = json.loads(path.read_text())
self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
def test_save_output_writes_utf8_encoded_markdown(self):
report = self.make_report()
with tempfile.TemporaryDirectory() as tmp:
with mock.patch("pathlib.Path.write_text", autospec=True, return_value=1) as write_text:
cli.save_output(report, "md", tmp)
_, kwargs = write_text.call_args
self.assertEqual("utf-8", kwargs.get("encoding"))
def test_persist_report_updates_run_status_on_success_and_failure(self):
report = self.make_report()
+14
View File
@@ -0,0 +1,14 @@
from scripts.lib import env
def test_include_sources_defaults_to_empty_string(monkeypatch, tmp_path):
# Ensure the env var is not set
monkeypatch.delenv("INCLUDE_SOURCES", raising=False)
# Avoid reading any real user config file by patching the resolved module path directly
monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
cfg = env.get_config()
assert "INCLUDE_SOURCES" in cfg
assert cfg["INCLUDE_SOURCES"] == ""
+16
View File
@@ -56,6 +56,22 @@ class TestParseDate(unittest.TestCase):
def test_empty(self):
self.assertIsNone(github._parse_date(""))
def test_rejects_garbage(self):
"""The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
self.assertIsNone(github._parse_date("hello world"))
self.assertIsNone(github._parse_date("not-a-date"))
self.assertIsNone(github._parse_date("abcdefghij"))
def test_rejects_invalid_date_values(self):
"""An out-of-range date like 2026-99-99 is not a real date."""
self.assertIsNone(github._parse_date("2026-99-99"))
def test_iso_with_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
def test_iso_with_no_colon_offset(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
class TestSearchGithub(unittest.TestCase):
@patch.dict("os.environ", {}, clear=True)
+63
View File
@@ -41,3 +41,66 @@ class Test429RetryLimit(unittest.TestCase):
http.request("GET", "http://example.com", retries=3)
self.assertEqual(mock_urlopen.call_count, 3)
def _mock_response(body: str = '{"ok": true}', status: int = 200):
resp = MagicMock()
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
resp.read.return_value = body.encode("utf-8")
resp.status = status
return resp
class TestParamsEncoding(unittest.TestCase):
"""request() should urlencode the params dict into the URL."""
def _sent_url(self, mock_urlopen) -> str:
request_arg = mock_urlopen.call_args[0][0]
return request_arg.full_url
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_to_url(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "limit": 10})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertIn("limit=10", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_params_appended_with_existing_query_string(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search?api_key=secret", params={"q": "test"})
sent_url = self._sent_url(mock_urlopen)
self.assertTrue(sent_url.startswith("https://api.example.com/search?api_key=secret&"))
self.assertIn("q=test", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_none_values_dropped(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"q": "test", "filter": None})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("q=test", sent_url)
self.assertNotIn("filter", sent_url)
@patch("lib.http.urllib.request.urlopen")
def test_empty_params_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={})
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_no_params_kwarg_leaves_url_unchanged(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search")
sent_url = self._sent_url(mock_urlopen)
self.assertEqual(sent_url, "https://api.example.com/search")
@patch("lib.http.urllib.request.urlopen")
def test_int_and_bool_params_stringified(self, mock_urlopen):
mock_urlopen.return_value = _mock_response()
http.get("https://api.example.com/search", params={"count": 25, "raw": True})
sent_url = self._sent_url(mock_urlopen)
self.assertIn("count=25", sent_url)
self.assertIn("raw=True", sent_url)
+30
View File
@@ -0,0 +1,30 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _skill_version() -> str:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
match = re.search(r'^version:\s*"([^"]+)"\s*$', text, re.MULTILINE)
if not match:
raise AssertionError("SKILL.md version frontmatter not found")
return match.group(1)
class TestVersionConsistency(unittest.TestCase):
def test_root_skill_header_matches_frontmatter_version(self) -> None:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f"# last30days v{version}:", text)
def test_sync_cache_path_uses_skill_version(self) -> None:
sync_text = (ROOT / "scripts" / "sync.sh").read_text(encoding="utf-8")
version = _skill_version()
self.assertIn(f'last30days-3/{version}"', sync_text)
if __name__ == "__main__":
unittest.main()
-176
View File
@@ -1,176 +0,0 @@
# Changelog
## 0.8.0 — 2026-01-19
### Added
- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2.
- `--chrome-profile-dir` to point at Chromium profile directories or cookie DB files (Arc/Brave/etc) for cookie extraction (#16) — thanks @tekumara.
- `about` command to report account origin/location metadata (#51) — thanks @pjtf93.
- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire.
### Fixed
- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested.
- Follow/unfollow REST fallback now supports cursor pagination for followers/following (#54).
- About account live coverage now verifies data extraction paths (#51) — thanks @pjtf93.
### Tests
- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire.
## 0.7.0 — 2026-01-12
### Added
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Lists feature overrides now scope new GraphQL flags correctly (#50) — thanks @ryanh-ai.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
### Fixed
- GraphQL feature flags now include `post_ctas_fetch_enabled` to avoid 400s (#38) — thanks @philipp-spiess.
## 0.5.1 — 2026-01-01
### Changed
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
- Release docs now include explicit npm publish verification steps.
### Fixed
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
### Tests
- Add root help output regression test.
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
- `help` command: `bird help <command>`.
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
- GraphQL query ID updater: correctly pairs `operationName``queryId` (CreateTweet/CreateRetweet/etc).
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
## 0.1.1 — 2025-12-26
### Changed
- Engine default now `auto` (GraphQL primary; Sweetistics only on fallback when configured).
### Tests
- Add engine resolution tests for auto/default behavior.
### Fixed
- GraphQL read: rotate TweetDetail query IDs with fallback to avoid 404s.
## 0.1.0 — 2025-12-20
### Added
- CLI commands: `tweet`, `reply`, `read`, `replies`, `thread`, `search`, `mentions`, `whoami`, `check`.
- URL/ID shorthand for `read`, plus `--json` output where supported.
- GraphQL engine with cookie auth from Firefox/Chrome/env/flags (macOS browsers).
- Sweetistics engine (API key) with automatic fallback when configured.
- Media uploads via Sweetistics with per-item alt text (images or single video).
- Long-form Notes and Articles extraction for full text output.
- Thread + reply fetching with full conversation parsing.
- Search + mentions via GraphQL (latest timeline).
- JSON5 config files (`~/.config/bird/config.json5`, `./.birdrc.json5`) with engine defaults, profiles, allowChrome/allowFirefox, and timeoutMs.
- Request timeouts (`--timeout`, `timeoutMs`) for GraphQL and Sweetistics calls.
- Bun-compiled standalone binary via `pnpm run build`.
- Query ID refresh helper: `pnpm run graphql:update`.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-385
View File
@@ -1,385 +0,0 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth).
## Disclaimer
This project uses X/Twitters **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install
```bash
npm install -g @steipete/bird
# or
pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
```
## Quickstart
```bash
# Show the logged-in account
bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json
# Thread + replies
bird thread https://x.com/user/status/1234567890123456789
bird replies 1234567890123456789
bird replies 1234567890123456789 --max-pages 3 --json
bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions
bird search "from:steipete" -n 5
bird mentions -n 5
bird mentions --user @steipete -n 5
# User tweets (profile timeline)
bird user-tweets @steipete -n 20
bird user-tweets @steipete -n 50 --json
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird bookmarks --include-parent --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
# News and trending topics (AI-curated from Explore tabs)
bird news --ai-only -n 10
bird news --sports -n 5
# Lists
bird list-timeline 1234567890 -n 20
bird list-timeline https://x.com/i/lists/1234567890 --all --json
bird list-timeline 1234567890 --max-pages 3 --json
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
```
## News & Trending
Fetch AI-curated news and trending topics from X's Explore page tabs:
```bash
# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment)
bird news -n 10
# Fetch only AI-curated news (filters out regular trends)
bird news --ai-only -n 20
# Fetch from specific tabs
bird news --news-only --ai-only -n 10
bird news --sports -n 15
bird news --entertainment --ai-only -n 5
# Include related tweets for each news item
bird news --with-tweets --tweets-per-item 3 -n 10
# Combine multiple tab filters
bird news --sports --entertainment -n 20
# JSON output
bird news --json -n 5
bird news --json-full --ai-only -n 10 # includes raw API response
```
Tab options (can be combined):
- `--for-you` — Fetch from For You tab only
- `--news-only` — Fetch from News tab only
- `--sports` — Fetch from Sports tab only
- `--entertainment` — Fetch from Entertainment tab only
- `--trending-only` — Fetch from Trending tab only
By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs.
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
// Search for tweets
const searchResult = await client.search('from:steipete', 50);
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true });
// Fetch from specific tabs with related tweets
const sportsNews = await client.getNews(10, {
aiOnly: true,
withTweets: true,
tabs: ['sports', 'entertainment']
});
```
Account details (About profile):
```ts
const aboutResult = await client.getUserAboutAccount('steipete');
if (aboutResult.success && aboutResult.aboutProfile) {
console.log(aboutResult.aboutProfile.accountBasedIn);
}
```
Fields:
- `accountBasedIn`
- `source`
- `createdCountryAccurate`
- `locationAccurate`
- `learnMoreUrl`
## Commands
- `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs.
- `bird trending` — alias for `news` command.
- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships).
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`.
- `bird followers [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`.
- `bird about <@handle> [--json]` — get account origin and location information for a user.
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
Bookmarks flags:
- `--expand-root-only`: expand threads only when the bookmark is a root tweet.
- `--author-chain`: keep only the bookmarked author's connected self-reply chain.
- `--author-only`: include all tweets from the bookmarked author within the thread.
- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors).
- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`.
- `--include-parent`: include the direct parent tweet for non-root bookmarks.
- `--thread-meta`: add thread metadata fields to each tweet.
- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order).
Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
- `--ct0 <token>`: set the `ct0` cookie manually.
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
- `--chrome-profile <name>`: Chrome profile name for cookie extraction (e.g., `Default`, `Profile 2`).
- `--chrome-profile-dir <path>`: Chrome/Chromium profile directory or cookie DB path for cookie extraction.
- `--firefox-profile <name>`: Firefox profile for cookie extraction.
- `--cookie-timeout <ms>`: cookie extraction timeout for keychain/OS helpers (milliseconds).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
- For Chromium variants (Arc/Brave/etc), pass a profile directory or cookie DB via `--chrome-profile-dir`.
## Config (JSON5)
Config precedence: CLI flags > env vars > project config > global config.
- Global: `~/.config/bird/config.json5`
- Project: `./.birdrc.json5`
Example `~/.config/bird/config.json5`:
```json5
{
// Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
chromeProfileDir: "/path/to/Chromium/Profile",
firefoxProfile: "default-release",
cookieTimeoutMs: 30000,
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment shortcuts:
- `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
- `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the news item |
| `headline` | string | News headline or trend title |
| `category` | string? | Category (e.g., "AI · Technology", "Trending", "News") |
| `timeAgo` | string? | Relative time (e.g., "2h ago") |
| `postCount` | number? | Number of posts |
| `description` | string? | Item description |
| `url` | string? | URL to the trend or news article |
| `tweets` | array? | Related tweets (only when `--with-tweets` is used) |
| `_raw` | object? | Raw API response (only when `--json-full` is used) |
## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into Xs web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping Xs public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
```bash
bird query-ids --fresh
```
Exit codes:
- `0`: success
- `1`: runtime error (network/auth/etc)
- `2`: invalid usage/validation (e.g. bad `--user` handle)
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
Example:
```bash
bird tweet "hi" --media img.png --alt "desc"
```
## Development
```bash
cd ~/Projects/bird
pnpm install
pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only
pnpm run build:binary
pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test
pnpm run lint
```
## Notes
- GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
export {};
//# sourceMappingURL=cli.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
import { resolveCliInvocation } from './lib/cli-args.js';
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const program = createProgram(ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
}
else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
//# sourceMappingURL=cli.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,OAAO,GAAa,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAChD,MAAM,cAAc,GAAa,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAElF,MAAM,GAAG,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;AAE7C,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;AAEnC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,oBAAoB,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAEhF,IAAI,QAAQ,EAAE,CAAC;IACb,OAAO,CAAC,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,IAAI,EAAE,CAAC;IACT,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;KAAM,CAAC;IACN,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,CAAC,CAAC;AACrD,CAAC"}
-35
View File
@@ -1,35 +0,0 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export declare function parsePositiveIntFlag(raw: string | undefined, flagName: string): {
ok: true;
value: number | undefined;
} | {
ok: false;
error: string;
};
export declare function parseNonNegativeIntFlag(raw: string | undefined, flagName: string, defaultValue: number): {
ok: true;
value: number;
} | {
ok: false;
error: string;
};
export declare function parsePaginationFlags(cmdOpts: PaginationCmdOpts, opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
}): {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
} | {
ok: false;
error: string;
};
//# sourceMappingURL=pagination.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxE;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM5D;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE;IACL,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GAEC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA8B/B"}
-43
View File
@@ -1,43 +0,0 @@
export function parsePositiveIntFlag(raw, flagName) {
if (raw === undefined) {
return { ok: true, value: undefined };
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a positive integer.` };
}
return { ok: true, value };
}
export function parseNonNegativeIntFlag(raw, flagName, defaultValue) {
const value = Number.parseInt(raw ?? String(defaultValue), 10);
if (!Number.isFinite(value) || value < 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a non-negative integer.` };
}
return { ok: true, value };
}
export function parsePaginationFlags(cmdOpts, opts) {
const maxPagesImpliesPagination = opts?.maxPagesImpliesPagination ?? false;
const includeDelay = opts?.includeDelay ?? false;
const defaultDelayMs = opts?.defaultDelayMs ?? 1000;
const maxPages = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPages.ok) {
return maxPages;
}
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || (maxPagesImpliesPagination && maxPages.value !== undefined));
let pageDelayMs;
if (includeDelay) {
const delay = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', defaultDelayMs);
if (!delay.ok) {
return delay;
}
pageDelayMs = delay.value;
}
return {
ok: true,
usePagination,
maxPages: maxPages.value,
cursor: cmdOpts.cursor,
pageDelayMs,
};
}
//# sourceMappingURL=pagination.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"pagination.js","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,oBAAoB,CAClC,GAAuB,EACvB,QAAgB;IAEhB,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,gCAAgC,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,GAAuB,EACvB,QAAgB,EAChB,YAAoB;IAEpB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAC/D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,oCAAoC,EAAE,CAAC;IACvF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,OAA0B,EAC1B,IAIC;IAUD,MAAM,yBAAyB,GAAG,IAAI,EAAE,yBAAyB,IAAI,KAAK,CAAC;IAC3E,MAAM,YAAY,GAAG,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC;IACjD,MAAM,cAAc,GAAG,IAAI,EAAE,cAAc,IAAI,IAAI,CAAC;IAEpD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;IACvE,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,aAAa,GAAG,OAAO,CAC3B,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,yBAAyB,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAC7F,CAAC;IAEF,IAAI,WAA+B,CAAC;IACpC,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,KAAK,GAAG,uBAAuB,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;QAChF,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;YACd,OAAO,KAAK,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,EAAE,EAAE,IAAI;QACR,aAAa;QACb,QAAQ,EAAE,QAAQ,CAAC,KAAK;QACxB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW;KACZ,CAAC;AACJ,CAAC"}
-5
View File
@@ -1,5 +0,0 @@
import { Command } from 'commander';
import { type CliContext } from './shared.js';
export declare const KNOWN_COMMANDS: Set<string>;
export declare function createProgram(ctx: CliContext): Command;
//# sourceMappingURL=program.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../../src/cli/program.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,OAAO,EAAE,KAAK,UAAU,EAAuB,MAAM,aAAa,CAAC;AAEnE,eAAO,MAAM,cAAc,aAyBzB,CAAC;AAEH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CA+GtD"}
-113
View File
@@ -1,113 +0,0 @@
import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js';
import { registerFollowCommands } from '../commands/follow.js';
import { registerHelpCommand } from '../commands/help.js';
import { registerHomeCommand } from '../commands/home.js';
import { registerListsCommand } from '../commands/lists.js';
import { registerNewsCommand } from '../commands/news.js';
import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js';
import { collectCookieSource } from './shared.js';
export const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'bookmarks',
'unbookmark',
'follow',
'unfollow',
'following',
'followers',
'likes',
'lists',
'list-timeline',
'home',
'user-tweets',
'news',
'trending',
'help',
'whoami',
'check',
]);
export function createProgram(ctx) {
const program = new Command();
program.configureHelp({
showGlobalOptions: true,
styleTitle: (t) => ctx.colors.section(t),
styleUsage: (t) => ctx.colors.description(t),
styleCommandText: (t) => ctx.colors.command(t),
styleCommandDescription: (t) => ctx.colors.muted(t),
styleOptionTerm: (t) => ctx.colors.option(t),
styleOptionText: (t) => ctx.colors.option(t),
styleOptionDescription: (t) => ctx.colors.muted(t),
styleArgumentTerm: (t) => ctx.colors.argument(t),
styleArgumentText: (t) => ctx.colors.argument(t),
styleArgumentDescription: (t) => ctx.colors.muted(t),
styleSubcommandTerm: (t) => ctx.colors.command(t),
styleSubcommandText: (t) => ctx.colors.command(t),
styleSubcommandDescription: (t) => ctx.colors.muted(t),
styleDescriptionText: (t) => ctx.colors.muted(t),
});
const collect = (value, previous = []) => {
previous.push(value);
return previous;
};
program.addHelpText('beforeAll', () => `${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command, description) => `${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
program.addHelpText('afterAll', () => `\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird 1234567890123456789 --json', 'Read a tweet (ID or URL shorthand for `read`) and print JSON'),
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, about, lists, list-timeline, user-tweets, query-ids`)}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`);
program.addHelpText('afterAll', () => `\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`)}\n${ctx.colors.muted(` Supports: chromeProfile, chromeProfileDir, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`)}`);
program
.option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
.option('--chrome-profile-dir <path>', 'Chrome/Chromium profile directory or cookie DB path for cookie extraction', ctx.config.chromeProfileDir)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
.option('--cookie-source <source>', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
ctx.applyOutputFromCommand(actionCommand);
});
registerHelpCommand(program, ctx);
registerQueryIdsCommand(program, ctx);
registerPostCommands(program, ctx);
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerFollowCommands(program, ctx);
registerListsCommand(program, ctx);
registerHomeCommand(program, ctx);
registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerNewsCommand(program, ctx);
registerCheckCommand(program, ctx);
return program;
}
//# sourceMappingURL=program.js.map
File diff suppressed because one or more lines are too long
-77
View File
@@ -1,77 +0,0 @@
import type { Command } from 'commander';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { labelPrefix, type OutputConfig, statusPrefix } from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = {
path: string;
alt?: string;
mime: string;
buffer: Buffer;
};
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: {
timeout?: string | number;
}) => number | undefined;
resolveQuoteDepthFromOptions: (options: {
quoteDepth?: string | number;
}) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
loadMedia: (opts: {
media: string[];
alts: string[];
}) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: {
json?: boolean;
emptyMessage?: string;
showSeparator?: boolean;
}) => void;
printTweetsResult: (result: {
tweets?: TweetData[];
nextCursor?: string;
}, opts: {
json: boolean;
usePagination: boolean;
emptyMessage: string;
}) => void;
extractTweetId: (tweetIdOrUrl: string) => string;
};
export declare const collectCookieSource: (value: string, previous?: CookieSource[]) => CookieSource[];
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
cookieTimeout?: string | number;
};
export declare function createCliContext(normalizedArgs: string[], env?: NodeJS.ProcessEnv): CliContext;
export {};
//# sourceMappingURL=shared.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/cli/shared.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGzC,OAAO,EAAE,KAAK,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE1E,OAAO,EAEL,WAAW,EACX,KAAK,YAAY,EAGjB,YAAY,EACb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,MAAM,MAAM,UAAU,GAAG;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IAC7C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAErF,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,YAAY,CAAC;IAC9B,MAAM,EAAE;QACN,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QACnC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC7B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;KAC/B,CAAC;IACF,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACxD,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACvD,MAAM,EAAE,UAAU,CAAC;IACnB,sBAAsB,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,yBAAyB,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,4BAA4B,EAAE,CAAC,OAAO,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAChG,6BAA6B,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;IACnG,SAAS,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,KAAK,SAAS,EAAE,CAAC;IACtE,WAAW,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACtH,iBAAiB,EAAE,CACjB,MAAM,EAAE;QACN,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;QACrB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,EACD,IAAI,EAAE;QACJ,IAAI,EAAE,OAAO,CAAC;QACd,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,MAAM,CAAC;KACtB,KACE,IAAI,CAAC;IACV,cAAc,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;CAClD,CAAC;AAYF,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,EAAE,WAAU,YAAY,EAAO,KAAG,YAAY,EAG9F,CAAC;AA4FF,KAAK,kBAAkB,GAAG;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA2P3G"}
-327
View File
@@ -1,327 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import { hyperlink, labelPrefix, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, statusPrefix, } from '../lib/output.js';
const COOKIE_SOURCES = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value) {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
export const collectCookieSource = (value, previous = []) => {
previous.push(parseCookieSource(value));
return previous;
};
function resolveCookieSourceOrder(input) {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
function resolveTimeoutMs(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveQuoteDepth(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path) {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path, warn) {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
return parsed ?? {};
}
catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn) {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
export function createCliContext(normalizedArgs, env = process.env) {
const isTty = process.stdout.isTTY;
let output = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap = (styler) => (text) => isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind) => {
const prefix = statusPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'ok') {
return kleur.green(prefix);
}
if (kind === 'warn') {
return kleur.yellow(prefix);
}
if (kind === 'err') {
return kleur.red(prefix);
}
if (kind === 'info') {
return kleur.cyan(prefix);
}
return kleur.gray(prefix);
};
const l = (kind) => {
const prefix = labelPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'url') {
return kleur.cyan(prefix);
}
if (kind === 'date') {
return kleur.magenta(prefix);
}
if (kind === 'source') {
return kleur.gray(prefix);
}
if (kind === 'engine') {
return kleur.blue(prefix);
}
if (kind === 'credentials') {
return kleur.yellow(prefix);
}
if (kind === 'user') {
return kleur.cyan(prefix);
}
if (kind === 'userId') {
return kleur.magenta(prefix);
}
if (kind === 'email') {
return kleur.green(prefix);
}
return kleur.gray(prefix);
};
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command) {
const opts = command.optsWithGlobals();
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options) {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function resolveCookieTimeoutFromOptions(options) {
return resolveTimeoutMs(options.cookieTimeout, config.cookieTimeoutMs, env.BIRD_COOKIE_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options) {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveCredentialsFromOptions(opts) {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
const chromeProfile = opts.chromeProfileDir || opts.chromeProfile || config.chromeProfileDir || config.chromeProfile;
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource,
chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
cookieTimeoutMs: resolveCookieTimeoutFromOptions(opts),
});
}
function loadMedia(opts) {
if (opts.media.length === 0) {
return [];
}
const specs = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
function printTweets(tweets, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
const useEmoji = output.emoji && !output.plain;
const articleLabel = useEmoji ? '📰' : 'Article:';
const mediaLabel = (type) => {
if (useEmoji) {
return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️';
}
return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:';
};
const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' };
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
// Display tweet text, with article indicator if present
if (tweet.article) {
// Full body mode: text starts with article title (from extractArticleText)
// Preview mode: text is short tweet intro that doesn't start with title
const hasFullBody = tweet.text.startsWith(tweet.article.title);
if (hasFullBody) {
console.log(`${articleLabel} ${tweet.text}`);
}
else {
console.log(`${articleLabel} ${tweet.article.title}`);
if (tweet.article.previewText) {
console.log(` ${tweet.article.previewText}`);
}
}
}
else {
console.log(tweet.text);
}
// Display media attachments
if (tweet.media && tweet.media.length > 0) {
for (const m of tweet.media) {
console.log(`${mediaLabel(m.type)} ${m.url}`);
}
}
// Display quoted tweet
if (tweet.quotedTweet) {
console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`);
const qtText = tweet.quotedTweet.article
? `${articleLabel} ${tweet.quotedTweet.article.title}`
: tweet.quotedTweet.text;
// Indent and truncate quoted tweet text
const maxLen = 280;
const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText;
for (const line of truncated.split('\n').slice(0, 4)) {
console.log(`${quotePrefix.mid}${line}`);
}
// Display quoted tweet media
if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) {
for (const m of tweet.quotedTweet.media) {
console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`);
}
}
console.log(`${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`);
}
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
function printTweetsResult(result, opts) {
const tweets = result.tweets ?? [];
if (opts.json && opts.usePagination) {
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
return;
}
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions,
loadMedia,
printTweets,
printTweetsResult,
extractTweetId,
};
}
//# sourceMappingURL=shared.js.map
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerBookmarksCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=bookmarks.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"bookmarks.d.ts","sourceRoot":"","sources":["../../src/commands/bookmarks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAsOhF"}
-189
View File
@@ -1,189 +0,0 @@
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { addThreadMetadata, filterAuthorChain, filterAuthorOnly, filterFullChain } from '../lib/thread-filters.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program, ctx) {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--expand-root-only', 'Only expand threads when bookmarked tweet is root')
.option('--author-chain', 'Only include author self-reply chains connected to the bookmark')
.option('--author-only', 'Include all tweets from bookmarked tweet author in thread')
.option('--full-chain-only', 'Save entire reply chain connected to the bookmarked tweet')
.option('--include-ancestor-branches', 'Include sibling branches for ancestors when using --full-chain-only')
.option('--include-parent', 'Include direct parent tweet for non-root bookmarks')
.option('--thread-meta', 'Add metadata fields (isThread, threadPosition, etc.)')
.option('--sort-chronological', 'Sort output globally oldest -> newest')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts);
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const maxPages = pagination.maxPages;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const usePagination = pagination.usePagination;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor };
const result = folderId
? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
: await client.getBookmarkFolderTimeline(folderId, count, timelineOptions)
: usePagination
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
if (cmdOpts.authorChain && (cmdOpts.authorOnly || cmdOpts.fullChainOnly)) {
console.error(`${ctx.p('warn')}--author-chain already limits to the connected self-reply chain; ` +
'other chain filters are redundant.');
}
if (cmdOpts.includeAncestorBranches && !cmdOpts.fullChainOnly) {
console.error(`${ctx.p('warn')}--include-ancestor-branches only applies with --full-chain-only.`);
}
const bookmarks = result.tweets;
if (!bookmarks || bookmarks.length === 0) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
return;
}
const expandedResults = [];
const threadCache = new Map();
const includeMeta = Boolean(cmdOpts.threadMeta);
const includeParent = Boolean(cmdOpts.includeParent);
const expandRootOnly = Boolean(cmdOpts.expandRootOnly);
const filterAuthorChainFlag = Boolean(cmdOpts.authorChain);
const filterAuthorOnlyFlag = Boolean(cmdOpts.authorOnly);
const filterFullChainFlag = Boolean(cmdOpts.fullChainOnly);
const includeAncestorBranches = Boolean(cmdOpts.includeAncestorBranches) && filterFullChainFlag;
const useChronologicalSort = Boolean(cmdOpts.sortChronological);
const shouldAttemptExpand = expandRootOnly || filterAuthorChainFlag || filterAuthorOnlyFlag || filterFullChainFlag;
const shouldFetchThread = shouldAttemptExpand || includeMeta;
const fetchThread = async (tweet) => {
const cachedKey = tweet.conversationId ?? tweet.id;
const cached = threadCache.get(cachedKey);
if (cached) {
return cached;
}
const threadResult = await client.getThread(tweet.id, { includeRaw });
if (!threadResult.success) {
console.error(`${ctx.p('warn')}Failed to expand thread for ${tweet.id}: ${threadResult.error ?? 'Unknown error'}`);
return null;
}
if (!threadResult.tweets) {
console.error(`${ctx.p('warn')}No thread tweets returned for ${tweet.id}.`);
return null;
}
const rootKey = threadResult.tweets[0]?.conversationId ?? cachedKey;
threadCache.set(rootKey, threadResult.tweets);
return threadResult.tweets;
};
const delayBetweenExpansionsMs = 1000;
for (let index = 0; index < bookmarks.length; index += 1) {
const bookmark = bookmarks[index];
const isRoot = !bookmark.inReplyToStatusId;
let threadTweets = null;
if (shouldFetchThread) {
if (!expandRootOnly || isRoot || includeMeta) {
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, delayBetweenExpansionsMs));
}
threadTweets = await fetchThread(bookmark);
}
}
let outputTweets = [bookmark];
if (shouldAttemptExpand) {
if (expandRootOnly && !isRoot) {
outputTweets = [bookmark];
}
else if (threadTweets) {
if (filterAuthorChainFlag) {
outputTweets = filterAuthorChain(threadTweets, bookmark);
}
else {
outputTweets = filterFullChainFlag
? filterFullChain(threadTweets, bookmark, { includeAncestorBranches })
: threadTweets;
if (filterAuthorOnlyFlag) {
outputTweets = filterAuthorOnly(outputTweets, bookmark);
}
}
}
}
if (includeParent && bookmark.inReplyToStatusId) {
const alreadyIncluded = outputTweets.some((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (!alreadyIncluded) {
const parentFromThread = threadTweets?.find((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (parentFromThread) {
expandedResults.push(parentFromThread);
}
else {
const parentResult = await client.getTweet(bookmark.inReplyToStatusId, { includeRaw });
if (parentResult.success && parentResult.tweet) {
expandedResults.push(parentResult.tweet);
}
}
}
}
expandedResults.push(...outputTweets);
}
let finalResults = expandedResults;
if (includeMeta) {
finalResults = expandedResults.map((tweet) => {
const cacheKey = tweet.conversationId ?? tweet.id;
let conversationTweets = threadCache.get(cacheKey);
if (!conversationTweets) {
conversationTweets = [tweet];
}
return addThreadMetadata(tweet, conversationTweets);
});
}
const uniqueTweets = Array.from(new Map(finalResults.map((tweet) => [tweet.id, tweet])).values());
if (useChronologicalSort) {
uniqueTweets.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
}
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult({ tweets: uniqueTweets, nextCursor: result.nextCursor }, { json: isJson, usePagination, emptyMessage });
});
}
//# sourceMappingURL=bookmarks.js.map
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerCheckCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=check.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4C5E"}
-43
View File
@@ -1,43 +0,0 @@
export function registerCheckCommand(program, ctx) {
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}auth_token: not found`);
}
if (cookies.ct0) {
console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${ctx.l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${ctx.p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${ctx.p('ok')}Ready to tweet!`);
}
else {
console.log(`\n${ctx.p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1);
}
});
}
//# sourceMappingURL=check.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,+BAA+B,CAAC;SAC5C,MAAM,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAChD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;QAE5B,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACtB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QAChF,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;QACtD,CAAC;QAED,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YAChB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC/C,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;YAC3C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;gBAC/B,OAAO,CAAC,GAAG,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;QAED,IAAI,OAAO,CAAC,SAAS,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YACrC,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACjD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+BAA+B,CAAC,CAAC;YAC9D,OAAO,CAAC,GAAG,CAAC,+CAA+C,CAAC,CAAC;YAC7D,OAAO,CAAC,GAAG,CAAC,oDAAoD,CAAC,CAAC;YAClE,OAAO,CAAC,GAAG,CAAC,wCAAwC,CAAC,CAAC;YACtD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerFollowCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=follow.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmCnD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8E9E"}
-91
View File
@@ -1,91 +0,0 @@
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
const ONLY_DIGITS_REGEX = /^\d+$/;
async function resolveUserId(client, usernameOrId, ctx) {
const raw = usernameOrId.trim();
const isNumeric = ONLY_DIGITS_REGEX.test(raw);
// Otherwise, treat as username and look up
const handle = normalizeHandle(raw);
if (handle) {
const lookup = await client.getUserIdByUsername(handle);
if (lookup.success && lookup.userId) {
return { userId: lookup.userId, username: lookup.username };
}
if (!isNumeric) {
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
}
}
if (isNumeric) {
return { userId: raw };
}
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
return null;
}
export function registerFollowCommands(program, ctx) {
program
.command('follow')
.description('Follow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to follow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.follow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Now following ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to follow ${displayName}: ${result.error}`);
process.exit(1);
}
});
program
.command('unfollow')
.description('Unfollow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to unfollow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.unfollow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Unfollowed ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to unfollow ${displayName}: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=follow.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"follow.js","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,iBAAiB,GAAG,OAAO,CAAC;AAElC,KAAK,UAAU,aAAa,CAC1B,MAAqB,EACrB,YAAoB,EACpB,GAAe;IAEf,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAE9C,2CAA2C;IAC3C,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC;QACxD,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YACpC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC9D,CAAC;QACD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,MAAM,KAAK,MAAM,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACnG,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACzB,CAAC;IAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,qBAAqB,YAAY,EAAE,CAAC,CAAC;IAClE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,OAAgB,EAAE,GAAe;IACtE,OAAO;SACJ,OAAO,CAAC,QAAQ,CAAC;SACjB,WAAW,CAAC,eAAe,CAAC;SAC5B,QAAQ,CAAC,kBAAkB,EAAE,mDAAmD,CAAC;SACjF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC3C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,SAAS,EAAE,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACjF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,UAAU,CAAC;SACnB,WAAW,CAAC,iBAAiB,CAAC;SAC9B,QAAQ,CAAC,kBAAkB,EAAE,qDAAqD,CAAC;SACnF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC7C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;QACvD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,sBAAsB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACnF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHelpCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=help.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"help.d.ts","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAmB3E"}
-19
View File
@@ -1,19 +0,0 @@
export function registerHelpCommand(program, ctx) {
program
.command('help [command]')
.description('Show help for a command')
.action((commandName) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
//# sourceMappingURL=help.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"help.js","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,gBAAgB,CAAC;SACzB,WAAW,CAAC,yBAAyB,CAAC;SACtC,MAAM,CAAC,CAAC,WAAoB,EAAE,EAAE;QAC/B,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO;QACT,CAAC;QAED,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,WAAW,CAAC,CAAC;QACnE,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,EAAE,CAAC,CAAC;YAChE,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QAED,GAAG,CAAC,UAAU,EAAE,CAAC;IACnB,CAAC,CAAC,CAAC;AACP,CAAC"}
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHomeCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=home.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"home.d.ts","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8C3E"}
-43
View File
@@ -1,43 +0,0 @@
import { TwitterClient } from '../lib/twitter-client.js';
export function registerHomeCommand(program, ctx) {
program
.command('home')
.description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = cmdOpts.following
? await client.getHomeLatestTimeline(count, { includeRaw })
: await client.getHomeTimeline(count, { includeRaw });
if (result.success) {
const feedType = cmdOpts.following ? 'Following' : 'For You';
const emptyMessage = `No tweets found in ${feedType} timeline.`;
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
}
else {
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=home.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"home.js","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,MAAM,CAAC;SACf,WAAW,CAAC,yCAAyC,CAAC;SACtD,MAAM,CAAC,sBAAsB,EAAE,2BAA2B,EAAE,IAAI,CAAC;SACjE,MAAM,CAAC,aAAa,EAAE,2DAA2D,CAAC;SAClF,MAAM,CAAC,QAAQ,EAAE,gBAAgB,CAAC;SAClC,MAAM,CAAC,aAAa,EAAE,yDAAyD,CAAC;SAChF,MAAM,CAAC,KAAK,EAAE,OAAoF,EAAE,EAAE;QACrG,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC;QAEzD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;YAC1C,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+CAA+C,CAAC,CAAC;YAC9E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QACzD,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;QAE7C,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS;YAC9B,CAAC,CAAC,MAAM,MAAM,CAAC,qBAAqB,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC;YAC3D,CAAC,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;QAExD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;YAC7D,MAAM,YAAY,GAAG,sBAAsB,QAAQ,YAAY,CAAC;YAChE,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;YACzD,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;QACjE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,kCAAkC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YAC/E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerListsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=lists.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"lists.d.ts","sourceRoot":"","sources":["../../src/commands/lists.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA4BnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAyH5E"}
-125
View File
@@ -1,125 +0,0 @@
// ABOUTME: CLI command for fetching Twitter Lists.
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractListId } from '../lib/extract-list-id.js';
import { hyperlink } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
function printLists(lists, ctx) {
if (lists.length === 0) {
console.log('No lists found.');
return;
}
for (const list of lists) {
const visibility = list.isPrivate ? '[private]' : '[public]';
console.log(`${list.name} ${ctx.colors.muted(visibility)}`);
if (list.description) {
console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`);
}
console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`);
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
const listUrl = `https://x.com/i/lists/${list.id}`;
console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`);
console.log('──────────────────────────────────────────────────');
}
}
export function registerListsCommand(program, ctx) {
program
.command('lists')
.description('Get your Twitter lists')
.option('--member-of', 'Show lists you are a member of (instead of owned lists)')
.option('-n, --count <number>', 'Number of lists to fetch', '100')
.option('--json', 'Output as JSON')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
if (result.success && result.lists) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.lists, null, 2));
}
else {
const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.';
if (result.lists.length === 0) {
console.log(emptyMessage);
}
else {
printLists(result.lists, ctx);
}
}
}
else {
console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`);
process.exit(1);
}
});
program
.command('list-timeline <list-id-or-url>')
.description('Get tweets from a list timeline')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all tweets from list (paged). WARNING: your account might get banned using this flag')
.option('--max-pages <number>', 'Fetch N pages (implies --all)')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (listIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const usePagination = pagination.usePagination;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination,
emptyMessage: 'No tweets found in this list.',
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=lists.js.map
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerNewsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=news.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"news.d.ts","sourceRoot":"","sources":["../../src/commands/news.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAuG3E"}
-131
View File
@@ -1,131 +0,0 @@
import { TwitterClient } from '../lib/twitter-client.js';
function formatPostCount(count) {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return String(count);
}
function printNewsItems(items, ctx, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(items, null, 2));
return;
}
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No news items found.');
return;
}
for (const item of items) {
const categoryLabel = item.category ? `[${item.category}]` : '';
console.log(`\n${ctx.colors.accent(categoryLabel)} ${ctx.colors.command(item.headline)}`);
if (item.description) {
console.log(` ${ctx.colors.muted(item.description)}`);
}
const meta = [];
if (item.timeAgo) {
meta.push(item.timeAgo);
}
if (item.postCount) {
meta.push(`${formatPostCount(item.postCount)} posts`);
}
if (meta.length > 0) {
console.log(` ${ctx.colors.muted(meta.join(' | '))}`);
}
if (item.url) {
console.log(` ${ctx.l('url')}${item.url}`);
}
// Print related tweets if available
if (item.tweets && item.tweets.length > 0) {
console.log(` ${ctx.colors.section('Related tweets:')}`);
const tweetLimit = opts.tweetLimit ?? item.tweets.length;
for (const tweet of item.tweets.slice(0, tweetLimit)) {
console.log(` @${tweet.author.username}: ${tweet.text.slice(0, 100)}${tweet.text.length > 100 ? '...' : ''}`);
}
}
console.log(ctx.colors.muted('─'.repeat(50)));
}
}
export function registerNewsCommand(program, ctx) {
program
.command('news')
.alias('trending')
.description('Fetch AI-curated news and trending topics from Explore tabs')
.option('-n, --count <number>', 'Number of items to fetch', '10')
.option('--ai-only', 'Show only AI-curated news items')
.option('--with-tweets', 'Also fetch related tweets for each news item')
.option('--tweets-per-item <number>', 'Number of tweets to fetch per news item (default: 5)', '5')
.option('--for-you', 'Fetch only from For You tab')
.option('--news-only', 'Fetch only from News tab')
.option('--sports', 'Fetch only from Sports tab')
.option('--entertainment', 'Fetch only from Entertainment tab')
.option('--trending-only', 'Fetch only from Trending tab')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`);
process.exit(1);
}
if (Number.isNaN(tweetsPerItem) || tweetsPerItem < 1) {
console.error(`${ctx.p('err')}--tweets-per-item must be a positive number`);
process.exit(1);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
// Determine which tabs to fetch from
const tabs = [];
if (cmdOpts.forYou) {
tabs.push('forYou');
}
if (cmdOpts.newsOnly) {
tabs.push('news');
}
if (cmdOpts.sports) {
tabs.push('sports');
}
if (cmdOpts.entertainment) {
tabs.push('entertainment');
}
if (cmdOpts.trendingOnly) {
tabs.push('trending');
}
// If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false;
const result = await client.getNews(count, {
includeRaw,
withTweets,
tweetsPerItem,
aiOnly,
tabs: tabsToFetch,
});
if (result.success) {
printNewsItems(result.items, ctx, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No news items found.',
tweetLimit: withTweets ? tweetsPerItem : undefined,
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch news: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=news.js.map
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerPostCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=post.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"post.d.ts","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAyB9D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4F5E"}
-101
View File
@@ -1,101 +0,0 @@
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(client, media, ctx) {
if (media.length === 0) {
return undefined;
}
const uploaded = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program, ctx) {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl, text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=post.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"post.js","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,KAAK,UAAU,iBAAiB,CAC9B,MAAqB,EACrB,KAAkB,EAClB,GAAe;IAEf,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAChG,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,GAAG,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACrF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,kBAAkB,CAAC;SAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAElD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,4BAA4B,CAAC;SACzC,QAAQ,CAAC,mBAAmB,EAAE,6BAA6B,CAAC;SAC5D,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,IAAY,EAAE,EAAE;QACnD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,MAAM,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;QAEjD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,sBAAsB,OAAO,EAAE,CAAC,CAAC;QAE/D,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;QAE3D,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
-4
View File
@@ -1,4 +0,0 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerQueryIdsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=query-ids.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"query-ids.d.ts","sourceRoot":"","sources":["../../src/commands/query-ids.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAqBnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAgF/E"}

Some files were not shown because too many files have changed in this diff Show More