docs: CLAUDE.md, CONTRIBUTING.md, CHANGELOG.md, docs/

* CLAUDE.md: project layout, key conventions, where to look first.
  Captures the non-obvious things a future session needs.
* CONTRIBUTING.md: build/test workflow, how to add a subcommand
  (concrete walkthrough), code style.
* CHANGELOG.md: history. 0.1.0 entry covers initial feature set;
  Unreleased captures stability + optimization batch.
* docs/architecture.md: module graph, layering rules, the HTTP funnel,
  pager + SIGINT, repo resolution, test strategy.
* docs/jq.md: --jq syntax cheatsheet (dot paths, brackets, negative
  indices, pipes, what's not supported).
* docs/troubleshooting.md: keychain re-prompts, debug logging, pager
  opt-out, alias precedence, hook bypass, common 401s.
* README.md: links into docs/ and updates binary size to 4 MB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 12:48:46 -07:00
parent a6fbf45ba9
commit d87a30bb29
No known key found for this signature in database
8 changed files with 591 additions and 1 deletions

63
CHANGELOG.md Normal file
View file

@ -0,0 +1,63 @@
# Changelog
All notable changes will be recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `tokio::signal::ctrl_c()` race in `cli::run` so the pager guard drops
cleanly on SIGINT.
- 9 wiremock-backed HTTP client integration tests covering retry
behavior, header forwarding, pagination, and panic-free error paths.
- `Client::for_base_url` test constructor pointing at an arbitrary URL.
### Changed
- Trimmed dependencies (no more `indicatif`, `futures-util`,
`is-terminal`, `textwrap`, `tempfile`). Dropped reqwest features we
don't use (`stream`, `brotli`). Release profile now uses `lto = "fat"`
and `panic = "abort"`.
- HTTP retry loop builds the request once and clones via
`reqwest::Request::try_clone` per attempt (was rebuilding the full
RequestBuilder each time).
- Binary size: 5.94 MB → 4.15 MB stripped (-30%).
### Fixed
- Removed the unsafe `std::env::set_var("FJ_NO_PAGER")` from dispatch.
The `--no-pager` flag is now threaded into `pager::maybe_start`.
- Replaced the panicking `.expect("token contains invalid header chars")`
in `auth_headers` with a typed error that tells the user how to
recover.
## 0.1.0 — 2026-05-13
Initial release. Multi-host Forgejo CLI with feature parity to `gh`
across the surface Forgejo exposes. Commands:
- `auth`: login, status, logout, list, switch, token, refresh, setup-git
- `repo`: list, view, clone, create, fork, sync, edit, rename, archive,
unarchive, delete, branches, topics, mirror, mirror-sync
- `issue`: list, view, create, edit, close, reopen, comment, develop
- `pr`: list, view, create, edit, diff, commits, files, checks, ready,
review, status, checkout, merge, close, reopen
- `release`: list, view, create, edit, delete, upload, download
- `label`, `run`, `secret`, `variable`, `search`, `browse`, `status`,
`org`, `ssh-key`, `gpg-key`, `alias`, `config`, `protect`, `hook`,
`extension`, `gist`, `api`, `completion`, `man`
Other highlights:
- Repo auto-detection from `upstream` / `origin` git remote.
- `--web` flag on all list/view subcommands.
- `$EDITOR` integration for body inputs.
- `fj api` with `-H`, `-X`, `-f`, `-F`, `--paginate`, `--include`,
`--silent`, `--jq` (dot-paths, `[N]`/`[-N]`, pipes).
- `--debug` / `FJ_DEBUG` request logging.
- Tokens in the OS keychain.
- Pager via `dup2` redirect to `$FJ_PAGER` / `$PAGER` / `less -FRX`.
- Pre-push hook running fmt, clippy `-D warnings`, tests, and release
build before any push. Live API smoke gated on `FJ_E2E=1`.

83
CLAUDE.md Normal file
View file

@ -0,0 +1,83 @@
# fj — project notes for Claude
`fj` is a Forgejo CLI, in the spirit of GitHub's `gh`. Multi-host, tokens
in the OS keychain, ~10k LOC Rust with full feature parity to `gh` for
the surface Forgejo exposes.
## Layout
```
src/
main.rs entry: alias expansion, fj-<name> plugin dispatch
cli/ clap subcommand tree
mod.rs top-level Cli + dispatch + Ctrl+C race + pager
context.rs RepoFlag + resolve_repo (`-R` or git-remote detect)
editor.rs $EDITOR + body resolution helpers
web.rs cross-platform `--web` opener
<subcommand>.rs one file per command group (auth, repo, pr, …)
api/ typed wrappers around /api/v1
mod.rs split_repo + shared serde helpers
<resource>.rs one file per resource (issue, pull, release, …)
client/ HTTP client
mod.rs Client, retry-on-5xx, debug logging, headers
pagination.rs Page<T> + Link header parser
error.rs ApiError
integration_tests wiremock-based tests
config/ hosts.toml shape + load/save
auth/ OS keychain wrapper
git/ git invocation + remote URL parser
output/ tables, colors, relative_time, state_pill, pager
hooks/pre-push local CI gate
scripts/ install-hooks.sh, e2e-smoke.sh
docs/ architecture, jq syntax, troubleshooting
```
## Build / test / hook
- `cargo build --release` produces a ~4 MB binary at `target/release/fj`.
- `cargo test --all` runs 60 tests (51 unit + 9 wiremock integration).
- `cargo clippy --all-targets --all-features -- -D warnings` is the lint
bar. CI (the pre-push hook) treats warnings as errors.
- `./scripts/install-hooks.sh` symlinks `hooks/pre-push` into `.git/hooks`.
- `FJ_E2E=1` enables the live API smoke against `stephen/fj-cli-test`.
- `FJ_SKIP_PREPUSH=1` bypasses the hook for genuine emergencies only.
## Key conventions
- Every repo-scoped subcommand accepts `-R/--repo` AND auto-detects from
the git remote (`upstream` then `origin`). The resolver is
`cli::context::resolve_repo`.
- Bodies (`--body`) support: an inline string, `-` for stdin, or omit to
open `$EDITOR`. See `cli::editor`.
- Lists with `--limit > 50` transparently follow `Link: rel=next` via
`Client::get_all`. Per-page cap is the Forgejo default (50).
- The simple jq projector lives in `cli::api::extract_path`. Supports
`.field`, `.0`, `.[0]`, `[-1]`, and `|` pipes. See `docs/jq.md`.
- All commands respect `--host` / `FJ_HOST`, `--debug` / `FJ_DEBUG`,
`--no-pager`, `FJ_PAGER` / `PAGER`.
## Things to remember
- Tokens are stored in the OS keychain (service `fj`, key = hostname).
Never serialized to disk. Re-prompts after rebuilds are expected on
macOS because the binary hash changes; click "Always Allow."
- `cargo test` builds a separate test binary with a different hash, so
it gets its own keychain prompt. Tests that need keychain access are
the wiremock-backed ones in `src/client/integration_tests.rs`, which
bypass the keychain via `Client::for_base_url`.
- The repo URL in `Cargo.toml` is `https://rasterhub.com/rasterstate/fj`.
- Commit author is `Stephen Way <stephen@rasterstate.com>`. The user has
a CLAUDE.md preference: no em-dashes, terse responses, no preamble.
- MSRV is 1.82 (we use `Option::is_none_or`).
## Where to look first
- Adding a new subcommand: copy an existing `cli/<x>.rs` (e.g. `label`).
Wire into `cli/mod.rs` (`Command` enum, `dispatch`) and `main.rs`
(`KNOWN_SUBCOMMANDS`). Typed API wrapper in `api/<x>.rs`.
- Adding a new Forgejo endpoint: `api/<resource>.rs` is the right home.
- Touching HTTP behavior: `client/mod.rs::request_with_headers` is the
one spot all requests funnel through. Don't add a parallel code path.
- Test a new API surface: add to `src/client/integration_tests.rs`
rather than `tests/` (this is a binary crate, no `[lib]` target, so
integration tests live inline).

77
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,77 @@
# Contributing to fj
## Setup
```sh
git clone ssh://git@rasterhub.com:2222/rasterstate/fj.git
cd fj
./scripts/install-hooks.sh # symlinks hooks/pre-push into .git/hooks
cargo build --release # 4 MB binary at target/release/fj
```
The hook gates every push on fmt, clippy (warnings as errors), the full
test suite, and a release build. Set `FJ_E2E=1` to additionally run the
live API smoke (requires `fj auth login` already done).
## Workflow
1. Branch off `main`.
2. Make changes. Keep commits focused.
3. `cargo fmt --all` before committing.
4. The pre-push hook runs the full bar; if it fails, fix the issue
rather than passing `FJ_SKIP_PREPUSH=1` unless you genuinely need
to bypass.
## Testing
- 60 tests live in the source tree (51 unit + 9 wiremock integration).
- Unit tests stay close to the code they cover, in inline `#[cfg(test)]`
modules.
- HTTP integration tests live in `src/client/integration_tests.rs` and
use a wiremock server. Construct test clients via
`Client::for_base_url(base, token)`.
- Live API tests live in `scripts/e2e-smoke.sh` and exercise read-only
operations against the configured Forgejo. Toggle with `FJ_E2E=1`.
## Adding a subcommand
Concrete example: adding `fj sticker list`.
1. `src/api/sticker.rs` — typed API wrappers and request/response
structs. Use the `serde_util::deserialize_null_to_default` helper
for fields that Forgejo returns as `null` when empty.
2. `src/cli/sticker.rs` — clap `Args` / `Subcommand` definitions and
the `run` dispatch.
3. `src/api/mod.rs``pub mod sticker;`
4. `src/cli/mod.rs``pub mod sticker;`, add to `Command`, add the
dispatch arm in `dispatch()`.
5. `src/main.rs` — add `"sticker"` to `KNOWN_SUBCOMMANDS` so plugin
discovery doesn't shadow it.
6. Add inline `#[cfg(test)] mod tests` for any non-trivial pure code.
7. If you touched HTTP behavior, add a wiremock test in
`src/client/integration_tests.rs`.
## Code style
- See `CLAUDE.md` for in-tree conventions.
- One small line of comment max per non-obvious thing. Don't narrate
what code does, only why.
- No `.unwrap()` / `.expect()` in non-test code paths. Use `?` and
`anyhow::Error::context` to attach actionable messages.
- Prefer `Result` over panics. The one exception is `unreachable!()`
for genuinely impossible match arms, but flag it in review.
## Releasing
```sh
cargo build --release
./target/release/fj completion zsh > ~/.zfunc/_fj
./target/release/fj man -o ./man
```
No cargo-dist or Homebrew tap yet. Binary lives at `target/release/fj`;
symlink into a user-level bin dir.
## License
MIT. By contributing you agree to the same license.

View file

@ -112,11 +112,21 @@ fj api /repos/migrate --input '{"clone_addr":"...","repo_name":"x","repo_owner":
## Building
```sh
cargo build --release # 5-6 MB stripped binary at target/release/fj
cargo build --release # ~4 MB stripped binary at target/release/fj
./target/release/fj completion zsh > ~/.zfunc/_fj
./target/release/fj man -o ~/man/man1
```
## Documentation
- [`docs/architecture.md`](docs/architecture.md) — module graph, HTTP
funnel, pager + SIGINT, repo resolution
- [`docs/jq.md`](docs/jq.md) — `fj api --jq` projection syntax
- [`docs/troubleshooting.md`](docs/troubleshooting.md) — keychain
prompts, 401s, hook bypass, pager opt-out, alias precedence
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — build / test / release workflow
- [`CHANGELOG.md`](CHANGELOG.md) — release notes
## License
MIT

11
docs/README.md Normal file
View file

@ -0,0 +1,11 @@
# `fj` documentation
- [`architecture.md`](architecture.md) — module graph, layering rules,
the HTTP funnel, pager + SIGINT, repo resolution, test strategy.
- [`jq.md`](jq.md) — the `fj api --jq` syntax. Dot paths, brackets,
negative indices, pipes.
- [`troubleshooting.md`](troubleshooting.md) — keychain prompts, hangs,
401 errors, `--debug`, pager opt-out, alias precedence, hook bypass.
For build/test workflow see [`../CONTRIBUTING.md`](../CONTRIBUTING.md).
For project conventions see [`../CLAUDE.md`](../CLAUDE.md).

138
docs/architecture.md Normal file
View file

@ -0,0 +1,138 @@
# Architecture
`fj` is a thin Rust CLI over Forgejo's `/api/v1`. The whole binary is
~10k LOC and shapes itself like `gh`: clap-parsed subcommands, one
typed wrapper per API resource, one HTTP client funnel for everything.
## Module graph
```
┌─────────────┐
│ main.rs │ alias expansion + fj-<x> plugin dispatch
└──────┬──────┘
┌──────▼──────┐
│ cli::run │ Ctrl+C race + pager guard
└──────┬──────┘
┌─────────────┼────────────────────┐
│ │ │
┌────▼─────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ cli::auth│ │ cli::repo │ ... │ cli::api │ raw escape hatch
└────┬─────┘ └─────┬─────┘ └──────┬──────┘
│ │ │
│ ┌─────▼─────┐ │
│ │ context:: │ -R or git-remote autodetect
│ │resolve_repo│
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
└──────►│ api::* │◄─────────────┘
│ typed │
│ wrappers │
└─────┬─────┘
┌─────▼─────┐
│ client │ one funnel: auth headers, retry, pagination,
│ │ debug logging, custom headers
└─────┬─────┘
┌─────▼─────┐
│ reqwest │ rustls-tls, JSON, multipart
└───────────┘
```
## Layering rules
- `cli::*` handles flags, argument validation, output formatting. It
never makes HTTP calls directly: it calls `api::*`.
- `api::*` is one file per Forgejo resource. Each function takes a
`&Client` and returns typed `Result<T>`.
- `client::*` is the only place that touches `reqwest`. All HTTP goes
through `Client::request_with_headers`, which handles auth headers,
retries, debug logging, and custom header merging. There is no
parallel HTTP path (other than the multipart asset upload in
`api::release::upload_asset`, which goes through `client.http()` but
still uses `client.token()` for auth).
- `config::*` is the on-disk shape of `hosts.toml`. Tokens are NOT
stored here. They live in the OS keychain via `auth::*`.
- `git::*` shells out to the `git` binary. The only place we call git.
## Repo resolution
Every command that operates on a specific repo calls
`cli::context::resolve_repo(explicit_repo, explicit_host)`. The
algorithm:
1. If `-R/--repo` was given, parse it as `owner/name`.
2. Otherwise call `git remote -v` and pick the first remote that parses
as a Forgejo URL. Preference order is `upstream`, then `origin`,
then any other.
3. The host comes from `--host`, then the detected remote's host, then
the configured default host in `hosts.toml`.
This is the magic that lets `fj pr list` work without any flags inside
a clone.
## HTTP retry
In `client::request_with_headers`:
- The request is built once and stored as `reqwest::Request`.
- Idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) get 3 attempts.
- POST and PATCH get 1 attempt — they're never retried.
- Backoff is `200ms * 2^attempt` between retries.
- On 5xx or transport error, we retry. On any other status, we return.
## Auto-pagination
`Client::get_all(path, query, total_limit)` follows `Link: rel=next`
until either `total_limit` items have accumulated or there are no more
pages. List commands switch to this path automatically when the user
asks for `--limit > 50` (Forgejo's per-page cap).
## Pager
When `cli::run` dispatches a command whose output can run long (list,
view, diff, search, status, api), it calls
`output::pager::maybe_start(force_disabled)`. That function:
1. Bails if stdout isn't a TTY or `--no-pager` / `FJ_NO_PAGER` is set.
2. Spawns `$FJ_PAGER` / `$PAGER` / `less -FRX`.
3. `dup`s our stdout fd (saving it for restore) then `dup2`s the pager
child's stdin fd onto stdout (fd 1).
4. Returns a `PagerGuard` whose `Drop` impl flushes stdout, restores
the saved fd, closes the saved fd, and waits on the child.
This means the rest of `fj` keeps using `println!`/`print!` unmodified
even though it's now writing to the pager.
## SIGINT
`cli::run` `tokio::select!`s the command future against
`tokio::signal::ctrl_c()`. On SIGINT the command future is dropped,
which propagates to the `_pager` binding's `Drop` and tears down the
pager cleanly.
## Aliases and extensions
`main.rs` runs two passes before clap:
1. **Alias expansion** via `cli::alias::expand_argv`. Reads
`aliases.toml`. If `argv[1]` matches a defined alias, replaces it
with the tokenized expansion.
2. **Plugin dispatch** via `main::detect_plugin`. If `argv[1]` isn't a
known subcommand AND `fj-<name>` is on PATH, exec it.
Only if neither pass triggers do we hand off to clap.
## Test strategy
- **Unit tests** stay inline next to the code they cover. Pure
functions only (no I/O).
- **Integration tests** for HTTP behavior live in
`src/client/integration_tests.rs` and use a wiremock server.
Construct test clients via `Client::for_base_url`.
- **Live E2E** lives in `scripts/e2e-smoke.sh`, gated on `FJ_E2E=1`
in the pre-push hook. It hits the real Forgejo at `rasterhub.com`
against `stephen/fj-cli-test`.

76
docs/jq.md Normal file
View file

@ -0,0 +1,76 @@
# `fj api --jq` syntax
`fj api` ships a small jq-flavored JSON projector. It's not a full jq
implementation; it covers the 95% of cases gh users use `--jq` for.
If you need anything more complex, pipe the raw JSON through real `jq`:
```sh
fj api /repos/foo/bar | jq '.<full expression>'
```
## Stages
Expressions are split on `|` into stages. Each stage runs against the
output of the previous stage.
```
fj api /repos/foo/bar | head
# vs
fj api /repos/foo/bar -q '.owner | .login'
```
## Path segments
Within a stage, dot-separated segments traverse the JSON tree. Each
segment is one of:
| Segment | Meaning |
| ------------ | --------------------------------------------- |
| `.field` | Object field lookup |
| `.0` | Array index (positional) |
| `.[0]` | Same as `.0`, bracket form |
| `.[-1]` | Last element of an array |
| `.[-2]` | Second-to-last element |
Bracket form and dot form are interchangeable: `.data.0.name` and
`.data[0].name` mean the same thing.
## Examples
Given:
```json
{
"data": [
{"id": 1, "owner": {"login": "alice"}},
{"id": 2, "owner": {"login": "bob"}}
],
"total": 2
}
```
| Query | Output |
| ------------------------------ | ------------------ |
| `.total` | `2` |
| `.data.0.id` | `1` |
| `.data[0].owner.login` | `"alice"` |
| `.data[-1].owner.login` | `"bob"` |
| `.data | .[0]` | first element |
| `.data[0] | .owner | .login` | `"alice"` |
## Errors
- A missing field returns a hard error with the field name.
- An out-of-range index returns a hard error with the index.
- Negative indices that overshoot (e.g. `[-5]` on a 3-element array)
also error.
## Not supported
The following are intentionally NOT implemented; reach for real jq:
- Filters like `.[] | select(.state == "open")`
- Functions: `map`, `length`, `keys`, etc.
- String interpolation
- Multiple outputs from a single expression

132
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,132 @@
# Troubleshooting
## macOS keeps prompting for keychain access
After every `cargo build --release` the binary's hash changes, so the
macOS keychain treats it as a new application and re-prompts. Click
"Always Allow" once and it'll stick until the next rebuild.
For automated test environments, see
`src/client/integration_tests.rs` — those tests use
`Client::for_base_url` to bypass the keychain entirely.
## `fj` hangs on the first command after a rebuild
Same root cause as above. macOS is prompting for keychain access in a
modal dialog you can't see (the binary doesn't have a TTY). Bring the
Keychain Access prompt to the foreground and approve it, or run
`fj auth status` from a regular terminal first to surface the prompt.
## `error: HTTP 401 ... authentication failed`
The stored token is invalid or revoked. Re-authenticate:
```sh
fj auth refresh # re-verify the existing token
fj auth refresh --token NEW # replace the stored token
fj auth login --host <host> # full re-login (overwrites)
```
## I want to see the raw HTTP requests fj is making
```sh
fj --debug pr list -R foo/bar
# or
FJ_DEBUG=1 fj pr list -R foo/bar
```
You'll get `→ METHOD URL?query` and `← STATUS url` for every request,
plus a 200-character preview of the request body if one was sent.
## A command is hanging in my CI pipeline
CI runners often don't have an `$EDITOR`. `fj issue create` and friends
will hang waiting for `$EDITOR` to return if you omit `--body`.
- Pass `--body "text"` explicitly.
- Or pass `--body -` to read from stdin.
- Set `$VISUAL` or `$EDITOR` to a non-interactive program.
## The pager doesn't exit / output gets paged when I don't want it
```sh
fj --no-pager repo list # opt out per-command
FJ_NO_PAGER=1 fj repo list # opt out in your shell
```
The default pager is `less -FRX`, which exits when output fits one
screen. Override with `$FJ_PAGER` or `$PAGER`:
```sh
FJ_PAGER='less -R' fj repo list # don't auto-quit
FJ_PAGER=cat fj repo list # disable paging
```
## `error: unexpected argument '-R' found`
Some `fj repo` subcommands take a positional `repo` arg AND accept `-R`
(for symmetry with the rest of the CLI). If you see this on an older
build, update to a build after commit `eb716ee`. All commands now accept
both forms.
## `-R` works in some shells, not in others (zsh globbing)
If `fj api /repos/foo` returns a "no matches found" error before fj
even runs, you've hit shell globbing on `/`. Quote the path:
```sh
fj api '/repos/foo/bar'
```
## Aliases aren't expanding
Aliases are looked up before clap parses, but only the FIRST positional
is matched. So:
```sh
fj alias set co "pr checkout"
fj co 42 # works: co → pr checkout
fj --host other.host co 42 # works: --host is parsed by clap after expansion
fj help co # does NOT expand co; "help" is a builtin
```
If your alias name collides with a real subcommand, the real one wins.
Aliases never shadow built-in commands.
## Extension `fj-foo` isn't being found
`fj` looks for `fj-<name>` on PATH only when `<name>` isn't a known
subcommand. The list of known subcommands lives in
`src/main.rs::KNOWN_SUBCOMMANDS`. If your plugin name collides with a
built-in, rename it.
## `fj pr ready` returns "pull does not allow editing draft state"
Forgejo only lets you flip `draft: false` via PATCH if the API user is
the PR author or has write permissions. If you're not the author and
not a maintainer, this fails. There's no workaround on the client side.
## The hook hangs on `cargo test`
Likely a keychain re-prompt for the test binary's new hash. Either
approve the prompt or rebuild outside the hook context first, then push:
```sh
cargo test --all # accept any keychain prompts
git push # hook reruns; tests fast-cache hit
```
If genuinely stuck:
```sh
FJ_SKIP_PREPUSH=1 git push # bypass once, only when necessary
```
## My commit was rejected because clippy complains about new code
The hook runs `cargo clippy --all-targets --all-features -- -D warnings`.
Every warning is an error. Either fix it or, very rarely and with a
comment explaining why, add a targeted `#[allow(clippy::lint_name)]`.
Don't add `#[allow(dead_code)]` to silence "never used" warnings on
real code — that's a signal you have unused code paths to remove.