Skip to content

Commit efada90

Browse files
committed
feat: initial v0.1 — thin X/Twitter scraping CLI
x-cli is a small, sharp Go command-line tool that scrapes and lightly automates X (formerly Twitter) from an imported browser session. One static binary, built-in throttling, keychain-stored cookies, no server, no database, no MCP. Scope (v0.1): - auth import | status | logout cookie-only auth, no credential login - doctor endpoints, session, egress ASN, TLS - config get | path - profile get <user> end-to-end (UserByScreenName) - tweets | search | followers | following | thread | media | monitor scaffolded as stubs so the command tree matches SKILL.md from day one - grow follow-engagers | follow-by-keyword dry-run by default, hard mutation budget, refuses cloud ASN Transport (api/): - GraphQL + REST helpers keyed off endpoints.yaml (data, not code — patch X's rotating query IDs without a rebuild) - Per-endpoint token bucket for reads - Global mutation state: min_gap + jitter + daily cap (date-keyed so the counter resets correctly across year boundaries) - Slot reservation under lock before sleep, so two concurrent callers cannot both fire within one min_gap window - Autopause on x-rate-limit-reset and on consecutive error clusters - Adaptive backoff for 5xx/net errors, clamped to time.Duration - Deterministic BFS cursor walker (sorted keys) so paginated reads return the same cursor on every run - Set-Cookie rotation with an allowlist and deletion-directive guard so a single bad response cannot nuke a live session - Typed errors (AuthError, RateLimitError, NotFoundError, APIError with body truncation, NetworkError, BudgetExhaustedError) - Client protects session with an RWMutex Storage (internal/store/): - OS keychain primary via go-keyring - AES-256-GCM file fallback with machine-id-derived key and an AAD tag bound to the format version. Atomic write via temp + rename + fsync + explicit chmod so a crash mid-write cannot corrupt a previously valid session. README is honest about the fallback being obfuscation, not encryption. Skill (skills/x-cli/): - SKILL.md with the command tree (the CLI is the skill; no MCP) - references/{auth,throttle,endpoints}.md Docs (docs/): - comparison-xactions.md: detail-level diff against nirholas/XActions with file:line citations, focused on auth posture CI (.github/workflows/ci.yml): - ubuntu + macos matrix, go vet, go build, go test -race with coverage, go mod tidy drift check - cross-compile matrix: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 Tests: - api/ 76.9% coverage, internal/store/ 69.8% coverage - Concurrent mutation race regression test - Deterministic cursor walker regression test - Set-Cookie rotation + deletion-directive hardening tests - Race-clean under go test -race Credits: - Endpoint map cross-referenced from d60/twikit (MIT) and the-convocation/twitter-scraper (MIT) - Reference layout inspired by lroolle/atlas-cli and cli/cli - nirholas/XActions used as a reference only (BSL-1.1); no code copied
0 parents  commit efada90

39 files changed

Lines changed: 5063 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
test:
18+
name: test (${{ matrix.os }})
19+
runs-on: ${{ matrix.os }}
20+
strategy:
21+
fail-fast: false
22+
matrix:
23+
os: [ubuntu-latest, macos-latest]
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- uses: actions/setup-go@v5
28+
with:
29+
go-version: '1.23'
30+
cache: true
31+
32+
- name: Module tidy check
33+
run: |
34+
go mod tidy
35+
git diff --exit-code go.mod go.sum
36+
37+
- name: Vet
38+
run: go vet ./...
39+
40+
- name: Build
41+
run: go build -v ./...
42+
43+
- name: Test (race, coverage)
44+
run: go test -race -count=1 -coverprofile=coverage.out -covermode=atomic ./...
45+
46+
- name: Coverage summary
47+
run: go tool cover -func=coverage.out
48+
49+
- name: Upload coverage artifact
50+
if: matrix.os == 'ubuntu-latest'
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: coverage
54+
path: coverage.out
55+
retention-days: 7
56+
57+
cross-compile:
58+
name: cross (${{ matrix.goos }}/${{ matrix.goarch }})
59+
runs-on: ubuntu-latest
60+
strategy:
61+
fail-fast: false
62+
matrix:
63+
include:
64+
- { goos: linux, goarch: amd64 }
65+
- { goos: linux, goarch: arm64 }
66+
- { goos: darwin, goarch: amd64 }
67+
- { goos: darwin, goarch: arm64 }
68+
- { goos: windows, goarch: amd64 }
69+
steps:
70+
- uses: actions/checkout@v4
71+
72+
- uses: actions/setup-go@v5
73+
with:
74+
go-version: '1.23'
75+
cache: true
76+
77+
- name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
78+
env:
79+
GOOS: ${{ matrix.goos }}
80+
GOARCH: ${{ matrix.goarch }}
81+
CGO_ENABLED: '0'
82+
run: |
83+
mkdir -p dist
84+
ext=""
85+
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
86+
out="dist/x-${GOOS}-${GOARCH}${ext}"
87+
go build -trimpath -ldflags "-s -w" -o "$out" ./cmd/x
88+
ls -la "$out"

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
bin/
2+
dist/
3+
*.test
4+
*.out
5+
.env
6+
.env.*
7+
!.env.example
8+
~/.config/x-cli/
9+
session.enc
10+
.DS_Store
11+
.idea/
12+
.vscode/
13+
coverage.out
14+
reference/

LICENSE

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Apache License
2+
Version 2.0, January 2004
3+
http://www.apache.org/licenses/
4+
5+
Copyright 2026 x-cli authors
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.

Makefile

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
BINARY := x
2+
PKG := github.com/lroolle/x-cli
3+
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
4+
LDFLAGS := -s -w -X $(PKG)/internal/version.Version=$(VERSION)
5+
GOFLAGS := -trimpath
6+
7+
.PHONY: all build install tidy test test-race cover lint vet clean run ci
8+
9+
all: build
10+
11+
build:
12+
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/$(BINARY) ./cmd/x
13+
14+
install:
15+
CGO_ENABLED=0 go install $(GOFLAGS) -ldflags '$(LDFLAGS)' ./cmd/x
16+
17+
tidy:
18+
go mod tidy
19+
20+
test:
21+
go test -count=1 ./...
22+
23+
test-race:
24+
go test -race -count=1 ./...
25+
26+
cover:
27+
go test -race -count=1 -coverprofile=coverage.out -covermode=atomic ./...
28+
go tool cover -func=coverage.out | tail -20
29+
30+
lint: vet
31+
32+
vet:
33+
go vet ./...
34+
35+
clean:
36+
rm -rf bin/ dist/ coverage.out
37+
38+
run: build
39+
./bin/$(BINARY)
40+
41+
ci: vet test-race build

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# x-cli
2+
3+
A small, sharp command-line tool for scraping and lightly automating X
4+
(formerly Twitter) from your own logged-in session. One static Go binary,
5+
built-in throttling, keychain-stored cookies, no server, no database, no MCP.
6+
7+
> **Heads-up.** x-cli talks to X's internal web endpoints, not a supported
8+
> public API. Your real account cookie is the identity. This is not an official
9+
> client, there is no SLA, and mutations (follow / unfollow / like) can get
10+
> your account rate-limited, action-blocked, or suspended. Reading public data
11+
> is low risk. Mutating at scale is not. Read `skills/x-cli/references/auth.md`
12+
> before you run anything with `grow`.
13+
14+
## What it does
15+
16+
- `x profile get <user>` — scrape profile
17+
- `x followers <user>` / `x following <user>` — paginated scraping
18+
- `x tweets list <user>` / `x tweets get <id>` — user timeline and single tweet
19+
- `x search posts <query>` / `x search users <query>` — scrape search results
20+
- `x thread unroll <id>` — reassemble a thread from a root tweet
21+
- `x media download <id|url>` — download images and videos from a tweet
22+
- `x monitor account <user>` — poll a profile/timeline and stream deltas
23+
- `x grow follow-engagers <tweet-id>` — follow likers/retweeters of a tweet (mutation, dry-run by default)
24+
- `x grow follow-by-keyword <query>` — follow authors matching a query (mutation, dry-run by default)
25+
26+
## What it is not
27+
28+
- Not a wrapper over X's official v2 API. No API keys, no OAuth.
29+
- No MCP server. The CLI *is* the skill — see `skills/x-cli/SKILL.md`.
30+
- No Chrome extension, no dashboard, no database, no payments.
31+
- No credential/password login. Cookie import only.
32+
33+
## Install
34+
35+
```
36+
make build
37+
./bin/x auth import
38+
./bin/x doctor
39+
./bin/x profile get jack
40+
```
41+
42+
## Auth
43+
44+
Log into x.com in your real browser, DevTools → Application → Cookies, copy
45+
`auth_token` and `ct0`, then:
46+
47+
```
48+
x auth import
49+
# paste: auth_token=...; ct0=...; twid=u%3D...
50+
```
51+
52+
**Where the cookie lives.** x-cli tries the OS keychain first (`go-keyring`:
53+
Keychain on macOS, libsecret on Linux, Credential Manager on Windows). If the
54+
keychain is unavailable — headless boxes, containers, CI, Linux without a
55+
Secret Service daemon — x-cli falls back to an AES-256-GCM file at
56+
`$XDG_CONFIG_HOME/x-cli/session.enc` with mode `0600`.
57+
58+
**Be honest about the fallback.** The file's encryption key is derived from a
59+
machine-stable seed (`/etc/machine-id` or the hostname), not from a passphrase
60+
you control. Its job is to keep the cookie from being casually visible in
61+
plaintext and to fail-closed on a file copied between machines. It is **not**
62+
a defense against an attacker with read access to your home directory — they
63+
can reproduce the key and decrypt it. Treat the keychain path as the real
64+
at-rest protection; treat the file fallback as obfuscation.
65+
66+
`x auth logout` removes both the keychain entry and the file fallback.
67+
68+
## Throttle
69+
70+
Built in. Per-endpoint token buckets; mutation commands have a hard daily
71+
budget, minimum action gap, and jitter. Configured in `endpoints.yaml`
72+
alongside the query IDs. Mutations require `--apply`; default is dry-run.
73+
74+
## Layout
75+
76+
```
77+
cmd/ cobra commands
78+
api/ HTTP transport, endpoints, throttle, auth
79+
internal/ cmdutil, keychain store, TLS fingerprint, version
80+
skills/ agentic skill (CLI as skill)
81+
endpoints.yaml query IDs + features + per-endpoint budgets
82+
```
83+
84+
## Credits
85+
86+
Endpoint map cross-referenced from [`twikit`](https://github.com/d60/twikit)
87+
and [`twitter-scraper`](https://github.com/the-convocation/twitter-scraper),
88+
both MIT. Reference layout inspired by
89+
[`atlas-cli`](https://github.com/lroolle/atlas-cli) and
90+
[`gh`](https://github.com/cli/cli). `XActions` is a reference only; no code
91+
was copied (BSL-1.1).

api/auth.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"strings"
8+
)
9+
10+
// Session holds the cookies and derived tokens for one authenticated user.
11+
// All fields are opaque to callers except through the methods on *Client.
12+
type Session struct {
13+
Cookies map[string]string
14+
User *User
15+
}
16+
17+
type User struct {
18+
ID string `json:"id_str"`
19+
Username string `json:"screen_name"`
20+
Name string `json:"name"`
21+
}
22+
23+
// ParseCookieString accepts a browser-exported header like
24+
//
25+
// auth_token=abc; ct0=def; twid=u%3D123
26+
//
27+
// and returns a cookie map. Empty names and empty values are dropped so
28+
// that pasting stale `document.cookie` output does not produce `name=`
29+
// entries that X's gateway may reject. Values are NOT URL-decoded — `twid`
30+
// and friends are echoed raw on the wire.
31+
func ParseCookieString(s string) map[string]string {
32+
out := map[string]string{}
33+
for _, pair := range strings.Split(s, ";") {
34+
pair = strings.TrimSpace(pair)
35+
if pair == "" {
36+
continue
37+
}
38+
idx := strings.IndexByte(pair, '=')
39+
if idx <= 0 {
40+
continue
41+
}
42+
name := strings.TrimSpace(pair[:idx])
43+
val := strings.TrimSpace(pair[idx+1:])
44+
if name == "" || val == "" {
45+
continue
46+
}
47+
out[name] = val
48+
}
49+
return out
50+
}
51+
52+
// RequireAuthCookies returns an AuthError if the required cookies are absent.
53+
func RequireAuthCookies(cookies map[string]string) error {
54+
if cookies["auth_token"] == "" {
55+
return &AuthError{Msg: "missing auth_token cookie"}
56+
}
57+
if cookies["ct0"] == "" {
58+
return &AuthError{Msg: "missing ct0 (CSRF) cookie"}
59+
}
60+
return nil
61+
}
62+
63+
// VerifyCredentials hits /1.1/account/verify_credentials.json and returns the
64+
// user if the session is alive, or an AuthError otherwise.
65+
func (c *Client) VerifyCredentials(ctx context.Context) (*User, error) {
66+
url := c.endpoints.Bases.REST + "/1.1/account/verify_credentials.json"
67+
resp, err := c.request(ctx, "GET", url, nil, requestOpts{authenticated: true, endpointName: "verifyCredentials"})
68+
if err != nil {
69+
return nil, err
70+
}
71+
defer resp.Body.Close()
72+
73+
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
74+
return nil, &AuthError{Msg: "session invalid or expired", Status: resp.StatusCode}
75+
}
76+
if resp.StatusCode >= 400 {
77+
return nil, &APIError{Endpoint: "verifyCredentials", Status: resp.StatusCode}
78+
}
79+
80+
var u User
81+
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
82+
return nil, err
83+
}
84+
if u.ID == "" {
85+
return nil, &AuthError{Msg: "verify_credentials returned empty user"}
86+
}
87+
c.sessionMu.Lock()
88+
c.session.User = &u
89+
c.sessionMu.Unlock()
90+
return &u, nil
91+
}

0 commit comments

Comments
 (0)