Metadata-Version: 2.4
Name: xctl
Version: 0.1.0
Summary: Agent-first CLI for X/Twitter: post, thread, stats, search, delete - stdlib only
Project-URL: Homepage, https://github.com/andresleecom/xctl
Project-URL: Repository, https://github.com/andresleecom/xctl
Author-email: Andres Lee <hola@andreslee.com>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# xctl

Agent-first CLI for X/Twitter.
Post tweets and threads, pull stats, search, and delete - with JSON-only I/O, deterministic exit codes, and zero runtime dependencies (Python stdlib only).

## Install

Requires Python 3.10 or newer.
Runtime dependencies: none (stdlib only).

With [uv](https://github.com/astral-sh/uv):

```bash
uv tool install xctl
```

With pip:

```bash
pip install xctl
```

## Credentials

Create an app in the [X Developer Portal](https://developer.x.com/) with **Read and Write** permissions.
You need four OAuth 1.0a user-context values: API key, API secret, access token, and access token secret.

Set them as environment variables:

```bash
export XCTL_API_KEY="..."
export XCTL_API_SECRET="..."
export XCTL_ACCESS_TOKEN="..."
export XCTL_ACCESS_SECRET="..."
```

Or write them to `~/.config/xctl/credentials.json`:

```json
{
  "api_key": "...",
  "api_secret": "...",
  "access_token": "...",
  "access_secret": "..."
}
```

Environment variables win field-by-field over the file.
Missing fields raise an auth error that names the unset variables and the file path - never secret values.

Verify credentials are present:

```bash
xctl auth check
```

```json
{"ok":true,"data":{"credentials":"present","source":"unknown","live":false}}
```

Verify them live against the API (`GET /2/users/me`):

```bash
xctl auth check --live
```

```json
{"ok":true,"data":{"handle":"yourhandle","id":"1234567890","live":true}}
```

## Quickstart

Global flags belong before the subcommand: `--pretty`, `--version`, `--max-retries N` (default `1`).
Success envelopes go to stdout; errors go to stderr.
Default JSON is compact; use `--pretty` for indented output.

### Version

```bash
xctl --version
```

```json
{"ok":true,"data":{"name":"xctl","version":"0.1.0"}}
```

Pretty:

```bash
xctl --pretty --version
```

```json
{
  "ok": true,
  "data": {
    "name": "xctl",
    "version": "0.1.0"
  }
}
```

### Post

Text as a positional argument:

```bash
xctl post "Hello from xctl"
```

```json
{"ok":true,"data":{"id":"1234567890123456789","text":"Hello from xctl"}}
```

Text from stdin (omit `TEXT`, or use `--file -`):

```bash
echo "Hello from stdin" | xctl post
```

```json
{"ok":true,"data":{"id":"1234567890123456789","text":"Hello from stdin"}}
```

With images (comma-separated paths, max 4, images only, each ≤5MB):

```bash
xctl post "Photo post" --media ./a.png,./b.jpg
```

```json
{"ok":true,"data":{"id":"1234567890123456789","text":"Photo post","media_ids":["9876543210","9876543211"]}}
```

Reply to a tweet:

```bash
xctl post "Agree" --reply-to 1234567890123456789
```

```json
{"ok":true,"data":{"id":"1234567890123456790","text":"Agree"}}
```

Optional: `--file PATH` reads text from a file; `--quote ID` attaches a quote tweet.
Provide text via `TEXT` or `--file`/stdin, not both.

### Thread dry-run

Validates stanzas locally (280-char naive limit) and prints a plan - no network, no checkpoint:

```bash
xctl thread examples/demo-thread.md --dry-run
```

```json
{"ok":true,"data":{"dry_run":true,"count":3,"posts":[{"index":0,"chars":163,"text":"Shipping a small CLI is mostly packaging discipline: a clear entry point, a single source of truth for the version, and a dry-run path that never hits the network."},{"index":1,"chars":155,"text":"Agents need predictable stdout. JSON envelopes with stable keys beat ad-hoc text when a script has to post a thread, check stats, or clean up a failed run."},{"index":2,"chars":141,"text":"When the dry-run plan looks right, the same file becomes the live thread. Checkpoint after each post so a resume picks up where you left off."}]}}
```

### Thread live

```bash
xctl thread thread.md
```

```json
{"ok":true,"data":{"count":3,"posts":[{"index":0,"id":"111"},{"index":1,"id":"222"},{"index":2,"id":"333"}],"first_id":"111","last_id":"333"}}
```

Optional: `--delay SECONDS` sleeps between posts (default `0`).
On full success the checkpoint file is deleted.

### Thread resume after partial failure

If a mid-thread API call fails, exit code is `5`.
stderr carries a `partial_failure` envelope with the checkpoint id and posted IDs:

```bash
xctl thread thread.md
```

```json
{"ok":false,"error":{"code":"partial_failure","message":"Thread failed at post 2 of 3","retryable":false,"hint":"thread --resume a1b2c3d4e5f6"},"data":{"checkpoint":"a1b2c3d4e5f6","posted":[{"index":0,"id":"111"}],"failed_index":1,"remaining":2,"hint_resume":"thread --resume a1b2c3d4e5f6"}}
```

Resume without re-posting completed stanzas:

```bash
xctl thread --resume a1b2c3d4e5f6
```

```json
{"ok":true,"data":{"count":3,"posts":[{"index":0,"id":"111"},{"index":1,"id":"222"},{"index":2,"id":"333"}],"first_id":"111","last_id":"333"}}
```

`--resume` cannot be combined with a file path or with `--dry-run`.

### Stats

Authenticated account metrics:

```bash
xctl stats
```

```json
{"ok":true,"data":{"user":{"id":"1234567890","handle":"yourhandle","name":"Your Name","created_at":"2010-01-01T00:00:00.000Z","metrics":{"followers_count":100,"following_count":50,"tweet_count":200,"listed_count":1}}}}
```

Include recent posts (`--last` is 1-100):

```bash
xctl stats --last 5
```

```json
{"ok":true,"data":{"user":{"id":"1234567890","handle":"yourhandle","name":"Your Name","metrics":{"followers_count":100,"following_count":50,"tweet_count":200,"listed_count":1}},"posts":[{"id":"111","text":"recent post","created_at":"2026-01-01T12:00:00.000Z","metrics":{"retweet_count":0,"reply_count":0,"like_count":1,"quote_count":0}}]}}
```

Optional: `--user USERNAME` looks up another account (with or without leading `@`).

### Search

Recent search (default limit 10; API max results clamped 10-100):

```bash
xctl search "from:yourhandle" --limit 10
```

```json
{"ok":true,"data":{"count":1,"tweets":[{"id":"111","text":"Hello","created_at":"2026-01-01T12:00:00.000Z","metrics":{"retweet_count":0,"reply_count":0,"like_count":1,"quote_count":0}}],"next_token":null}}
```

Paginate with `--next-token` from a previous response.

### Delete

By numeric id:

```bash
xctl delete 1234567890123456789
```

```json
{"ok":true,"data":{"count":1,"results":[{"id":"1234567890123456789","deleted":true}]}}
```

By status URL (x.com or twitter.com):

```bash
xctl delete "https://x.com/user/status/1234567890123456789"
```

```json
{"ok":true,"data":{"count":1,"results":[{"id":"1234567890123456789","deleted":true}]}}
```

Dry-run parses and dedupes ids only - no network:

```bash
xctl delete 1234567890123456789 "https://x.com/user/status/1234567890123456789" --dry-run
```

```json
{"ok":true,"data":{"dry_run":true,"count":1,"ids":["1234567890123456789"]}}
```

Multiple ids are accepted.
If some deletes succeed and others fail, exit code is `5` with per-id results under top-level `data`.

## Thread file format

Split posts on lines that contain exactly `---` (whitespace around the dashes is ignored for the match; each stanza is then stripped).
One post per stanza.
Empty stanzas are a usage error.

Example (see `examples/demo-thread.md`):

```markdown
Shipping a small CLI is mostly packaging discipline: a clear entry point, a single source of truth for the version, and a dry-run path that never hits the network.

---

Agents need predictable stdout. JSON envelopes with stable keys beat ad-hoc text when a script has to post a thread, check stats, or clean up a failed run.

---

When the dry-run plan looks right, the same file becomes the live thread. Checkpoint after each post so a resume picks up where you left off.
```

Local validation uses a naive character limit of **280** (`len` of the stanza text).
X uses weighted counting (URLs, emoji, and CJK weigh differently), so a stanza that passes dry-run may still be rejected by the API.
v1 stanzas are **text-only**; attach media with `xctl post --media`, not inside thread files.

## For agents

### Output contract

Success always looks like this on **stdout**:

```json
{"ok":true,"data":{}}
```

Errors always look like this on **stderr** (optional fields omitted when null):

```json
{"ok":false,"error":{"code":"usage","message":"...","retryable":false,"hint":"..."}}
```

With rate-limit timing:

```json
{"ok":false,"error":{"code":"rate_limited","message":"...","retryable":true,"hint":"Wait for retry_after seconds before retrying","retry_after":90}}
```

Partial work (thread or multi-delete) adds top-level `data` beside `error`:

```json
{"ok":false,"error":{"code":"partial_failure","message":"...","retryable":false,"hint":"..."},"data":{}}
```

All tweet and user IDs in success and error payloads are JSON **strings**, never bare numbers.
Do not print secrets; credentials never appear in envelopes, hints, or logs.

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | API error, network error, or unexpected internal error |
| 2 | Usage / local validation |
| 3 | Auth missing or unauthorized |
| 4 | Rate limited (HTTP 429) |
| 5 | Partial failure (some work succeeded) |

### Error codes

| `error.code` | Exit | `retryable` | Notes |
|--------------|------|-------------|--------|
| `usage` | 2 | false | Bad flags, empty text, overlong stanza, missing file |
| `auth` | 3 | false | Missing credentials or unauthorized API response |
| `rate_limited` | 4 | true | May include `retry_after` (seconds); **never auto-retried** |
| `api_error` | 1 | sometimes | 5xx often `retryable: true`; 4xx usually false |
| `network_error` | 1 | true | Timeout, DNS, connection failures |
| `partial_failure` | 5 | false | Inspect top-level `data`; resume threads with checkpoint |
| `internal_error` | 1 | false | Unexpected exception wrapped into a stable envelope |

`retryable: true` means an agent may retry after waiting.
When present, `retry_after` is seconds to wait (from `x-rate-limit-reset` / headers when available).
**HTTP 429 is never auto-retried by xctl**; agents own the sleep-and-retry loop.

### `--max-retries`

```bash
xctl --max-retries 2 post "hello"
```

Default is `1` (one original attempt plus one extra for retryable 5xx / network failures).
Flat 1s sleep between attempts.
Does not apply to 429.

### Checkpoints

On live `thread` runs, checkpoints live under the per-user state directory:

- Windows: `%LOCALAPPDATA%\xctl\state\checkpoints\<id>.json`
- Unix: `$XDG_STATE_HOME/xctl/checkpoints/<id>.json` or `~/.local/state/xctl/checkpoints/<id>.json`

Ids are 12 hex characters.
After each successful post the checkpoint is rewritten with `posted` entries `{index, id}`.
On full success the checkpoint is removed.
On partial failure, resume with `xctl thread --resume <id>`.

## Limitations (v1)

- Images only, simple multipart upload, each file ≤5MB (jpg/jpeg/png/gif/webp).
- No video and no chunked upload.
- Thread length checks use naive `len` (not X weighted counting).
- Thread stanzas are text-only (no per-stanza media).
- No scheduling or queue.

## License

MIT.
