# Commands

```
tf <command> [options]
tweet-factory <command>   # same program, via the package bin
```

`tf --help`, `tf -h`, and `tf help` print the short list and exit 0. `tf --version`, `tf -v`, and `tf version` print `tf <version>` and exit 0. An unknown command prints the help and exits 2.

Every command accepts `--dir <path>` (see [Configuration](configuration.md)). This page is checked against the command table in `src/cli.ts` when the docs build: a new command that is missing here fails the build.

## tf auth

OAuth 2.0 PKCE. Writes `.tokens.json`.

```bash
tf auth [--port N] [--bind HOST] [--redirect-uri URI] [--no-browser] [--timeout SECS]
```

| Flag | Default |
|---|---|
| `--port` | `OAUTH_PORT` or `8675` |
| `--bind` | `OAUTH_BIND_HOST` or `127.0.0.1` |
| `--redirect-uri` | `OAUTH_REDIRECT_URI` or `http://127.0.0.1:<port>/callback` |
| `--no-browser` | off; when set, the URL is printed and no browser is launched |
| `--timeout` | `300` seconds |

Scopes: `tweet.read`, `users.read`, `like.read`, `bookmark.read`, `offline.access`. Details and the human click are in [X app setup](x-app.md).

> **Human required.** Someone clicks **Authorize app** in a browser.
>
> **Verify.** `.tokens.json` exists and the log includes `Has refresh token: true`.

## tf fetch

Pull likes, bookmarks, and your posts into `tweets.db`.

```bash
tf fetch [like|bookmark|own] [--incremental]
```

With no source words, it fetches all three. User id comes from `/2/users/me`. Pages are 100 tweets, with a one-second pause between pages. A 401 refreshes the access token and retries. A 403 skips that source. A 429 sleeps until the rate-limit reset.

`--incremental` stops a source early once a page contains no ids that are already stored. The first pull should be a full fetch (no flag). `tf refresh` and `tf cron` always fetch incrementally.

> **Verify.** The log ends with a database summary: counts for `like`, `bookmark`, `own`, and distinct authors. `test -s tweets.db`.

## tf onboard

OpenTUI onboarding. Needs a terminal.

```bash
tf onboard [--selftest]
```

Screens, in order: welcome, four interview questions, account tagging, a heuristic roast, forge, optional Notion, generate.

Interview questions:

1. What do you want to be known for, in one breath?
2. Your hottest take that's gotten you in trouble?
3. What are you building right now?
4. Describe your voice in 3 words.

Account tags, cycled with space: (none), `inspiration`, `competitor`, `north-star`, `friend`. Arrow keys move. Enter continues.

The roast uses the same per-head scorer as `tf generate`: editorial strengths times the published action weights. It does not apply account calibration. `tf analyze` prints whether your own history is thick enough to calibrate.

On the forge screen, `f` writes `persona/PERSONA.md`. `n` opens Notion search. `g` generates drafts with your LLM. `q` quits.

`--selftest` renders one frame and exits. It still starts the TUI renderer, so it needs an environment where `tf onboard` can open.

> **Verify.** After forge, `persona/PERSONA.md` exists and names the handle `tf analyze` prints.

## tf generate

Draft tweets with your LLM. Writes `drafts/YYYY-MM-DD.md` and `onboarding.json`.

```bash
tf generate [--n N] [--timely] [--post-to-slack] [--provider NAME] [--model NAME] [--json] [--no-style-check]
```

| Flag | Effect |
|---|---|
| `--n` | How many ideas to ask for. Default `4`. Each idea is 2–3 variants. |
| `--timely` | One Tavily news search is added to the prompt when `TAVILY_API_KEY` is set. The query is `AI agents coding startups` when your posts hit the analyzer’s energy-marker list, otherwise `technology`. For a query you choose, run `tf search` yourself. |
| `--post-to-slack` | After writing the file, post it with `tf notify`. |
| `--provider` | Overrides `TF_LLM_PROVIDER` for this run. |
| `--model` | Overrides `TF_LLM_MODEL` for this run. |
| `--json` | Print the bundle on stdout instead of writing the markdown file. `onboarding.json` is still written. |
| `--no-style-check` | Skip the [style check](#style-check). The prompt's style rules are dropped too. |

The model is asked for one JSON object: persona summary, content pillars, reply targets, cadence, strategy, `ideas` (each with 2–3 variants), and a flat `drafts` array. The model’s `score` field is ignored. After Zod validates the JSON, tweet-factory scores every variant locally: copy-link share, reply (20 on an original a mutual would see, 5 otherwise), DM share, quote, follow, repost, like, and the negative heads report, mute, not-interested, and block. The markdown file shows that breakdown, the weighted total, and a 0–100 score. A note at the top says to post one variant per idea, because a second post from you in one viewer’s pool is multiplied by 0.625 and DPP separates near-duplicates.

When `tweets.db` has at least 8 of your own posts with impressions, reply, quote, repost, and like rates (per impression, per format) scale those four strengths. Bookmarks are printed as a diagnostic and do not scale a head. Fewer than 8 impressed posts leaves the scales at 1 and says the history is thin. Posts older than 48 hours are preferred when 8 of them exist.

Context sent as fenced reference data (not as instructions): `flush/pending.md` when it has new items, a few recent posts, tagged Notion pages (about 6,000 characters), the calibration block, and the optional Tavily blurb. The system prompt is your persona plus measured stats plus the ranking prompt built from `src/rank/weights.ts`.

Default backend and keys: [LLM providers](llm.md).

Every variant goes through the [style check](#style-check) before it is scored, so the score in the file is the score of the text in the file. A variant the check touched shows a `Style check:` block with each fix and any flags left, and the score before fixes. In `--json` and `onboarding.json`, each variant carries `style: { fixes, flags, original?, originalScore? }`. stderr ends with a one-line count.

> **Verify.** `drafts/<today>.md` contains a `| Head |` table and `Post one variant`, and `onboarding.json` parses as JSON with a `drafts` array. `--json` prints that object and exits 0.

## tf brain-dump

Turn raw dictation into an outline with notes and 2–3 scored angles, before full drafting. Same LLM, persona, and scorer as `tf generate`.

```bash
tf brain-dump "rough thoughts, pasted or dictated"
tf brain-dump --file memo.txt
pbpaste | tf brain-dump -
tf brain-dump --file memo.txt --draft 2
tf brain-dump [<text> | --file PATH | -] [--angles 2|3] [--draft N] [--json] [--provider NAME] [--model NAME] [--no-style-check]
```

| Flag | Effect |
|---|---|
| `<text>` | Dictation as a quoted argument. |
| `--file`, `-f` | Read the dictation from a file, such as a voice-memo transcript. |
| `-` | Read stdin. Piped input is read without the dash. |
| `--angles` | How many distinct angles to ask for, clamped to 2–3. Default `3`. |
| `--draft N` | After printing, feed angle `N` into the `tf generate` flow. That writes `drafts/YYYY-MM-DD.md` and `onboarding.json` like `tf generate`. |
| `--json` | Print the outline and scored angles as JSON. |
| `--provider`, `--model` | Override `TF_LLM_PROVIDER` and `TF_LLM_MODEL` for this run. |
| `--no-style-check` | Skip the [style check](#style-check) on the angles and on `--draft` drafts. |

Output is a one-line summary, a numbered outline where each point can carry notes (your own words worth keeping, gaps), and the angles. Each angle is one tweet in your voice with its aim and a 0–100 score from the same per-head scorer as `tf generate`; the model's own scores are ignored. Angles go through the [style check](#style-check) first, and any fixes or flags print under the angle. Nothing is written to disk unless you pass `--draft`. No input exits 2.

The dictation is sent to your LLM as fenced reference data, not instructions, and is cut at 20,000 characters. Persona loading and backends are the same as `tf generate` ([LLM providers](llm.md)).

> **Verify.** `tf brain-dump --help` prints usage. With a configured LLM, `tf brain-dump "a rough thought"` prints `## Outline` and `### Angle 1`.

## tf lint

Run the style check on one draft without an LLM. Fixed text goes to stdout and the report to stderr, so it pipes.

```bash
tf lint "Here's the thing — agents need memory"
pbpaste | tf lint -
tf lint --file draft.txt --json --score
tf lint [<text> | --file PATH | -] [--json] [--no-fix] [--strict] [--score]
```

| Flag | Effect |
|---|---|
| `<text>`, `--file`, `-` | The draft: a quoted argument, a file, or stdin. |
| `--no-fix` | Leave the text alone and report every rewrite as a flag. |
| `--strict` | Exit 1 when a flag remains. |
| `--score` | Add the 0–100 score before and after fixes (uncalibrated). |
| `--json` | Print `{ text, original, changed, fixes, flags }` (plus `score`, `originalScore` with `--score`). |

```text
$ tf lint "Here's the thing — agents need memory. Fast. Cheap. Reliable."
Agents need memory. Fast. Cheap. Reliable.

fixed (heres-the-thing): "Here's the thing — agents need" -> "Agents need"
flag (triad): stacked rhetorical triad reads as AI; keep the one that matters: "Fast. Cheap. Reliable."
```

No input exits 2.

### Style check

Inspired by Theo's `noEmDashCopy` lint rule. It runs on every draft `tf generate` and `tf brain-dump` produce (and on the generate screen in `tf onboard`), before the draft is scored or shown. The same rules are listed in the generation prompt so the model avoids them in the first place. The tweetfactory.sh score page runs the same check and offers the rewrite.

Rewritten, with deterministic rules:

| Pattern | Rewrite |
|---|---|
| `X — and/but/so/which …` | comma |
| `X — it's / we / the / 3 …` (a new clause) | period, capitalized unless the draft is all lowercase |
| `X — short tail.` (4 words or fewer) | colon |
| `X — aside — Y` | parentheses |
| dash before a line break / at the end / as a bullet | colon / `...` / `- ` |
| anything else with an em dash, a spaced en dash, or ` -- ` | comma |
| `pre–seed` (en dash between letters) | hyphen |
| `delve into` (any tense) | `dig into` |
| leading `Here's the thing` / `In today's fast-paced world,` | dropped |

Kept as is: number ranges such as `2023–2026` and `2–3`, inline code, fenced code, and URLs.

Flagged, not rewritten: `delve`, `tapestry`, `testament to`, `game-changer`, `unleash` / `unlock the potential`, `it's not X, it's Y` and `X isn't Y, it's Z`, `not just X, but Y`, leading `In a world where`, `let's dive in`, `ever-evolving landscape`, three or more one- or two-word sentences in a row or `no X, no Y, no Z`, more than 2 emoji, more than 1 hashtag.

The list is one data file, `src/style/tells.ts`. Each entry is a regex, an optional replacement, a message, and the phrase the prompt names. Add a replacement only when it is safe in every sentence the pattern can match.

> **Verify.** `tf lint "we shipped — and nobody cared"` prints `we shipped, and nobody cared` and exits 0. `tf lint --strict "a game-changer"` exits 1.

## tf notion

Tag Notion pages as grounding for generation. Requires `NOTION_API_KEY`, and the pages must be shared with the integration.

```bash
tf notion search [query] [--json]
tf notion tag <pageId> --tag <label> [--note <text>]
tf notion untag <pageId>
tf notion list [--json] [--full]
tf notion sync [--force]
```

`search` lists pages the integration can see. `tag` stores the page (markdown body included) in `tweets.db` under your freeform label. `list` prints tags and titles; `--full` with `--json` includes cached content. `sync` refreshes tagged pages whose `last_edited` changed. `--force` refreshes all of them. `tf cron` calls `sync` when `NOTION_API_KEY` is set.

> **Verify.** `tf notion list` shows the page you tagged. A later `tf generate` can quote the tag only as background; it is instructed not to paste the page verbatim.

## tf enrich

Pull article text for URLs on stored tweets. Requires `TAVILY_API_KEY`.

```bash
tf enrich [--all] [--force]
```

Default source is bookmarks only. `--all` also scans likes and your own posts. X article URLs (`x.com/i/article/…` or `twitter.com/i/article/…`) are read through the X API. Other URLs go through Tavily Extract. `t.co` links are skipped. Results are cached in `article_content`; `--force` refetches.

> **Verify.** The log reports extract or x-article counts, and the command exits 0.

## tf flush

Show interactions that are not yet marked processed.

```bash
tf flush [--commit] [--status] [--count]
```

| Flag | Effect |
|---|---|
| (none) | Write `flush/pending.md`. The first run baselines: every current row is marked processed and the pending total is 0. |
| `--commit` | Mark the current pending ids processed, and move `pending.md` to `flush/archive/`. |
| `--status` | Print pending counts to stderr. |
| `--count` | Print a single integer to stdout. `tf cron` uses this. |

Sections in the file: `NEW LIKES`, `NEW BOOKMARKS`, `YOUR NEW TWEETS`.

> **Verify.** After the first `tf flush` on a fresh database, the file contains `Total pending: 0`. After a later incremental fetch of new posts, a second `tf flush` lists them.

## tf search

Tavily web search. Requires `TAVILY_API_KEY`.

```bash
tf search "query words" [--news] [--days N] [--max N] [--json]
```

Without `--news` the topic is general. With `--news`, the topic is news and `--days` (default 7) limits the window. `--max` defaults to 5. Human output is a short markdown list; `--json` is the raw response.

> **Verify.** The command prints a `## Tavily:` heading, or JSON with `--json`.

## tf notify

Post a file to Slack in chunks of about 3,500 characters. Requires `SLACK_WEBHOOK_URL`. If the webhook is unset, it logs a skip and returns.

```bash
tf notify <file> [header]
```

The default header inside the library is `🧵 Fresh tweet drafts ready`. The argument replaces it.

> **Verify.** The log says `posted to Slack`, and the channel shows the file. With no webhook, the log says `SLACK_WEBHOOK_URL not set`.

## tf refresh

Incremental fetch, then rebuild `flush/pending.md`. Does not call an LLM, Tavily, Notion, or Slack.

```bash
tf refresh
```

`update.sh` in the repo is a thin wrapper around this. This is the command to schedule when you have no Tavily key. It used to be `tf update`. `tf update` now replaces a release binary.

> **Verify.** The log shows `Fetch mode: incremental` and a `pending written` line (or a baseline line on the very first flush).

## tf cron

Server-loop entrypoint used by `cron_run.sh`.

```bash
tf cron [--enrich-all]
```

Order: incremental fetch, enrich, Notion sync if `NOTION_API_KEY` is set, flush rebuild, then a Slack ping when the pending count is greater than 0 and `SLACK_WEBHOOK_URL` is set. The Slack text tells you to run `tf generate`. It does not generate drafts.

`--enrich-all` passes `--all` through to enrich (likes and your own posts, not only bookmarks).

Enrich throws if `TAVILY_API_KEY` is missing, and that happens after the fetch. Without Tavily, schedule `tf refresh` instead.

> **Verify.** The stderr log contains `===== cron` and `pending=<n>`. With new items and a webhook, it also contains `slack pinged`.

## tf analyze

Voice and taste stats from `tweets.db`. No network.

```bash
tf analyze [--json] [--roast]
```

Default output: handle, counts of your tweets / likes / bookmarks, percent of posts that start lowercase, median word count, up to three energy phrases from a fixed list, and the ten authors you have liked most.

`--json` prints the same stats as JSON, including six recent original posts. `--roast` scores those recent posts with the per-head scorer and a one-line quip, and prints whether account calibration has enough history. The human-readable summary includes that calibration line too.

> **Verify.** `tf analyze --json` prints JSON with `handle`, `n_own`, `n_likes`, and `n_bookmarks`.

## tf login

Link this checkout to a tweetfactory.sh account. Does not upload tweets, the persona, or drafts.

```bash
tf login [--origin URL]
```

Prints a device code and a verification URL. Approve it at [tweetfactory.sh/account](https://tweetfactory.sh/account/) while signed in with X. On success, writes `.cloud.json` mode `0600`.

`--origin` overrides `TF_CLOUD_ORIGIN` for this run. The default origin is `https://tweetfactory.sh`.

> **Human required.** Someone signs in on tweetfactory.sh and approves the code.
>
> **Verify.** `.cloud.json` exists, mode `600`. The log says nothing is uploaded until `tf sync push`. See [Cloud sync](cloud-sync.md).

## tf sync

Optional cloud copy of `tweets.db`, `persona/PERSONA.md`, `drafts/*.md`, and Notion tags. Requires `tf login` first, except `status` with no link, which only prints that the checkout is not linked.

```bash
tf sync push
tf sync pull [--replace]
tf sync status [--json]
tf sync delete --yes
```

| Subcommand | Effect |
|---|---|
| `push` | Upload a full snapshot. Replaces the previous remote snapshot on commit. |
| `pull` | Download into the local database and files. Keeps a local file newer than the remote copy. |
| `pull --replace` | Clear local sync tables first, then drop local persona or draft files the remote snapshot lacks. |
| `status` | Bytes, rows, quota, and counts. `--json` prints the status object. |
| `delete --yes` | Delete the remote copy. Local files stay. Without `--yes` the command refuses. |

`tf sync --help` prints the subcommands and exits 0.

> **Verify.** `tf sync status` before login says the checkout is not linked. After a push, the same command prints a byte quota. `tf sync delete` without `--yes` exits with an error and leaves the remote copy.

## tf update

Replace this release binary with the latest one published at tweetfactory.sh.

```bash
tf update
tf update --check
```

The command reads the current tag from [https://tweetfactory.sh/download/latest/VERSION](https://tweetfactory.sh/download/latest/VERSION) and compares it with the version compiled into this binary (`tf version`). When the published tag is newer, it downloads `tf-darwin-arm64`, `tf-darwin-x64`, `tf-linux-x64`, or `tf-linux-arm64` for this machine from `https://tweetfactory.sh/download/vX.Y.Z/`, checks that file against `SHA256SUMS`, and replaces the running executable. The new bytes are written beside the current binary, marked executable, then renamed into place. Those URLs redirect to a public Blob store.

When this binary is already that tag, the command prints that it is up to date and does not download. A binary newer than the published tag is left in place. `--check` prints the comparison and does not download or replace anything.

A dev build (`bun run src/index.ts`, or a binary compiled without a release tag) refuses and points at [install.sh](https://tweetfactory.sh/install.sh). If the install directory is not writable, the command exits with a permission error and leaves the current binary in place.

The database refresh is `tf refresh`, not `tf update`.

> **Verify.** `tf update --check` on a release binary prints either `up to date` or `is available` and exits 0. `bun run src/index.ts update` exits non-zero and mentions `install.sh`.

## tf version

Print the version and exit 0.

```bash
tf version
tf --version
tf -v
```

A release binary prints the tag it was compiled from, such as `tf v0.3.0`. Running from a checkout prints the `package.json` version with `-dev` appended, such as `tf 0.3.1-dev`.

> **Verify.** `tf version` prints a single line starting with `tf ` and exits 0. `tf --help` lists `version`.
