Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ go install github.com/texops/tx/cmd/tx@latest
## Usage

```
tx login # Authenticate with TexOps
tx init # Initialize a project in the current directory
tx build # Build all documents
tx build <name> # Build a specific document
tx build --live # Watch for changes and rebuild automatically
tx status # Show project status
tx token create [--name "CI"] # Create an API token
tx token list # List API tokens
tx token delete [name] # Delete an API token
tx login [--no-browser] [--timeout <d>] # Authenticate with TexOps
tx init [--texlive <v>] [--compiler <c>] # Initialize a project in the current directory
tx build # Build all documents
tx build <name> # Build a specific document
tx build --log=file # Save the LaTeX log to .texops/logs/ instead of streaming it
tx build --live # Watch for changes and rebuild (until Ctrl+C)
tx status # Show authentication status
tx token create [name] # Create an API token
tx token list # List API tokens
tx token delete [name] [--yes] # Delete an API token
tx <command> --json # Print one JSON document to stdout
tx <command> --help # Describe a command, with examples
```

### Getting started
Expand All @@ -45,12 +48,31 @@ tx token delete [name] # Delete an API token
Project settings are stored in `.texops.yaml`:

- `project_key` — unique identifier (safe to commit)
- `texlive` — TexLive version (e.g. `texlive:2025`)
- `texlive` — TexLive version (e.g. `"2025"`)
- `compiler` — LaTeX compiler: `pdflatex` (default), `xelatex`, `lualatex`, `latex`, `platex`, `uplatex`
- `documents` — list of documents to build

The API URL defaults to `https://api.texops.dev` and can be overridden with `TX_API_URL` or `api_url` in `.texops.yaml`.

### Scripts and coding agents

`tx` never prompts without a terminal: it uses a default or fails with a non-zero exit code. With `--json`, stdout carries exactly one JSON document, also on failure. Authenticate with `TX_API_TOKEN` (create one with `tx token create`). Under a coding agent (detected from `AI_AGENT`, `CLAUDECODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT` or `GEMINI_CLI`; turn off with `TX_AGENT=none`), `tx build` saves the LaTeX log to `.texops/logs/<doc>.log` instead of streaming it. See [Using tx from coding agents](https://texops.dev/docs/agents) and the [CLI reference](https://texops.dev/docs/cli).

### Output and exit codes

Results (the build summary, `tx status` fields, token values) go to stdout; progress, the build log and errors go to stderr.

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Service or unexpected failure (network, server error, build timeout) |
| `2` | Usage error (unknown or missing command, unknown flag or document, unsupported `--texlive` or `--compiler`, `--json` with `--live`, missing input or confirmation without a terminal) |
| `3` | Not authenticated, or the session or token expired or was rejected |
| `4` | `.texops.yaml` missing or invalid, or unsupported TeX Live version |
| `5` | A document failed to compile |

When documents fail for different reasons, the most severe code wins (`1`, then `3`, then `4`); `5` means every failure was a compile failure.

## License

[MIT](LICENSE)
35 changes: 1 addition & 34 deletions cmd/tx/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
package main

import (
"errors"
"fmt"
"os"
"runtime/debug"
"strings"

flags "github.com/jessevdk/go-flags"

"github.com/texops/tx/internal/cli"
)

Expand All @@ -29,34 +25,5 @@ func resolveVersion() string {
}

func main() {
var opts cli.Options
parser := flags.NewParser(&opts, flags.Default)
parser.Name = "tx"

if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "version") {
fmt.Println("tx " + resolveVersion())
os.Exit(0)
}

ui := cli.NewUI(os.Stdout)
opts.Login.UI = ui
opts.Init.UI = ui
opts.Build.UI = ui
opts.Status.UI = ui
opts.Token.Create.UI = ui
opts.Token.List.UI = ui
opts.Token.Delete.UI = ui

if _, err := parser.Parse(); err != nil {
if flagsErr, ok := errors.AsType[*flags.Error](err); ok {
switch {
case errors.Is(flagsErr.Type, flags.ErrHelp):
os.Exit(0)
case errors.Is(flagsErr.Type, flags.ErrCommandRequired):
parser.WriteHelp(os.Stdout)
os.Exit(0)
}
}
os.Exit(1)
}
os.Exit(cli.Run(resolveVersion(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
67 changes: 67 additions & 0 deletions internal/cli/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package cli

import (
"fmt"
"os"
"runtime"
"strings"
"sync/atomic"
)

// DetectAgent returns the name of the coding agent tx runs under, or "" when none is detected.
func DetectAgent(getenv func(string) string) string {
if getenv("TX_AGENT") == "none" {
return ""
}
if name, _, _ := strings.Cut(getenv("AI_AGENT"), "_"); name != "" {
return name
}
if getenv("CLAUDECODE") == "1" {
return "claude-code"
}
if getenv("CODEX_THREAD_ID") != "" {
return "codex"
}
if getenv("CURSOR_AGENT") != "" {
return "cursor"
}
if getenv("GEMINI_CLI") != "" {
return "gemini-cli"
}
return ""
}

// UserAgent formats the User-Agent header value sent with every request.
func UserAgent(version, agent string) string {
ua := fmt.Sprintf("tx/%s (%s; %s)", version, runtime.GOOS, runtime.GOARCH)
if agent = sanitizeUAToken(agent); agent != "" {
ua += " agent/" + agent
}
return ua
}

func sanitizeUAToken(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', strings.ContainsRune(".-+", r):
return r
default:
return -1
}
}, s)
}

var appVersion atomic.Value

// SetVersion sets the tx version reported in the User-Agent header.
func SetVersion(v string) {
appVersion.Store(v)
}

func currentUserAgent() string {
v, _ := appVersion.Load().(string)
if v == "" {
v = "dev"
}
return UserAgent(v, DetectAgent(os.Getenv))
}
Loading
Loading