Metadata-Version: 2.5
Name: gravi-cli
Version: 0.16.0
Summary: CLI tool for Gravitate infrastructure management
Project-URL: Homepage, https://github.com/gravitate/mom
Project-URL: Documentation, https://github.com/gravitate/mom/tree/main/cli
Project-URL: Repository, https://github.com/gravitate/mom
Author-email: Gravitate Engineering <engineering@gravitate.com>
License: Proprietary
License-File: LICENSE
Keywords: cli,devops,gravitate,infrastructure
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Requires-Dist: click>=8.1.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: requests>=2.31.0
Requires-Dist: websockets>=10.0
Provides-Extra: test
Requires-Dist: pytest-cov>=4.1.0; extra == 'test'
Requires-Dist: pytest-mock>=3.11.0; extra == 'test'
Requires-Dist: pytest>=7.4.0; extra == 'test'
Requires-Dist: responses>=0.24.0; extra == 'test'
Description-Content-Type: text/markdown

# Gravi CLI

Command-line tool for Gravitate infrastructure management. Provides CLI access to mom infrastructure, allowing developers to authenticate, manage tokens, and access instance configurations and credentials.

> **For agents:** there's a sister command, **`gravi-axi`** — an [AXI](https://axi.md)
> surface over the same burner tooling with TOON output, pre-computed aggregates,
> structured errors, and one-call `up`/`wait`. Ships from this same package. See
> [`AXI.md`](AXI.md).

## Features

- **OAuth-style device authorization** - Secure browser-based authentication
- **Automatic token refresh** - Tokens auto-renew when <7 days remaining
- **Token management** - List, revoke, and manage CLI tokens
- **Instance access** - Get configurations and credentials for Gravitate instances
- **Python library API** - Use programmatically in scripts and applications
- **CI/CD support** - Environment variable-based authentication

## Installation

### From Source (Development)

Using `uv` (recommended):

```bash
cd /home/jvogel/src/work/tools/mom/cli
uv pip install -e ".[test]"
```

Or with traditional pip:

```bash
cd /home/jvogel/src/work/tools/mom/cli
pip install -e ".[test]"
```

### From PyPI (Future)

```bash
pip install gravi-cli
# or
uv pip install gravi-cli
```

## Quick Start

**Note:** If you installed with `uv pip install -e .`, you can run the CLI using `uvx --from . gravi` or just `gravi` if it's in your PATH.

### 1. Login

```bash
gravi login
# or with uvx:
uvx --from . gravi login
```

This will:
1. Open your browser to mom authorization page
2. Display a user code (e.g., `ABCD-1234`)
3. Wait for you to authorize in the browser
4. Save credentials to `~/.config/gravi/config.json`

### 2. Check Status

```bash
gravi status
```

Shows:
- Current user email
- Mom URL
- Device name
- Token ID
- Token expiry

### 3. Get Instance Configuration

```bash
# Get config as JSON
gravi config dev

# Get config as environment variables
gravi config prod --format=env
```

### 4. Logout

```bash
gravi logout
```

Revokes token on server and clears local credentials.

## CLI Commands

### Authentication

```bash
gravi login                          # Authenticate via browser
gravi logout                         # Clear credentials and revoke token
gravi status                         # Show login status and token expiry
gravi whoami                         # Show current user info
```

### Token Management

```bash
gravi tokens list                    # List all CLI tokens
gravi tokens revoke <token_id>       # Revoke a specific token
gravi tokens revoke --all            # Revoke all tokens
```

### Instance Access

```bash
gravi config <instance_key>          # Get instance configuration
gravi config <instance_key> --format=env    # Get as environment variables
```

### Seed Datasets

A dataset version is **the whole payload**, not a patch — publishing one that
omits a tab publishes a dataset without that tab, and the burner it seeds still
boots `ready`. So `push` sends everything and refuses a payload missing
`tanks`, `supply` or `drivers`; `--merge` layers the tabs you edited onto the
latest version instead.

```bash
gravi dataset list [--q costco] [--tag e2e]
gravi dataset show <name> [--version 3]      # counts, services, version history
gravi dataset diff <name> [--against 8]      # row-level, per tab

gravi dataset clone <name> <new-name>        # never edit a shared dataset in place
gravi dataset pull <name> [-o dir]           # one JSON file per tab, sorted keys
$EDITOR <name>/drivers.json
gravi dataset push <name> --notes "why"      # append a new version
gravi dataset push <name> --merge            # only the tabs present on disk
gravi dataset push <name> --dry-run          # report the shape, send nothing

gravi dataset sheet export <name> --open     # edit in Sheets instead
gravi dataset sheet import <name> <sheet-id>
```

`pull` writes stable, sorted JSON with one file per tab, so a dataset can live
in a repo and a rename across it reviews as an ordinary diff. Non-row keys
(`title`, `service_config`, `seeding_config`, `env_overrides`) land in
`_meta.json`, and `push` reassembles them — the round trip is lossless.

### Demand Model Debugger

Everything mom's Demand Model Debugger page can do, minus its two LLM tabs.
The loop the page is built around is three commands — find the parent run,
find the child that failed, pull its solver payload:

```bash
gravi demand triage --range 7d               # failed runs, every customer
gravi demand runs <instance> --failed        # one customer's parent runs
gravi demand children <instance> <history_id> --incomplete
gravi demand pull <instance> <run_id> --summary      # status/orders/cost chips
gravi demand pull <instance> <run_id> --request-only -o req.json
```

When you already know the store and the day — the usual way the question
arrives — go straight there instead:

```bash
gravi demand store marathon "ANB 881" --date 2026-09-10
#   → the child run_ids covering that store that day, newest first
```

That's **one** API call: mom's `/recent_runs` already returns each parent's
full store → child `run_id` table, so nothing has to walk `run_detail` per
parent (77KB each, ~17 parents on a busy day). A store number matches
exactly when anything does and as a substring otherwise, so `"881"` finds
`ANB 881` — the `store` column shows what else it caught.

`store` and `runs` both take `--date YYYY-MM-DD`, or `--since`/`--until` for
a range. `--days` widens automatically to reach whatever day you asked for.

Day boundaries are UTC, matching `time_ran`. A US customer's late-evening run
carries the next UTC date, so a single `--date` can miss it — widen the window
with `--since`/`--until` when a result looks a run or two short.

Then edit `req.json` and re-run it against the customer's own solver:

```bash
$EDITOR req.json
gravi demand rerun <instance> <run_id> -f req.json
```

...or make the edit inline. `--set` addresses a field by **name** inside the
request's two nested lists, because nobody knows a tank's ordinal:

```bash
gravi demand rerun <instance> <run_id> \
  --set 'stores[GOM0019].tanks[2].currentInventory=4000' \
  --set 'stores[*].tanks[*].erDrainCost=0' \
  --set 'multiStoreDropCost=250'
```

**A bracket selector matches a name before an index.** It first looks for an
element whose `tank` / `name` / `store` field equals it, and only falls back
to positional addressing when nothing goes by that name. Real tank ids are
plain digit strings — tank `"1"` sits at index `0` — so a positional reading
of `tanks[1]` would quietly edit the wrong tank. `[*]` fans out across the
whole list, and `[0]` / `[-1]` still index a list whose elements aren't named.

**Field names work in either case convention.** GCS stores the request
snake_case while mom's schema and the UI talk camelCase, so `--set
multiProductDropCost=500` and `--set multi_product_drop_cost=500` both hit
the key that's really there rather than adding a second one beside it.

Values parse as JSON when they can (`0`, `null`, `[1,2]`), otherwise as a
bare string. `pull --request-only` writes the unwrapped `ModelRunRequest`
these paths address — GCS stores it inside a `DemandModelStartReq` envelope,
which `pull` and `rerun -f` both peel for you.

**`rerun` is ephemeral.** Mom forwards the edited request straight to the
customer's solver and hands the answer back: nothing is written to the
customer DB or to GCS, and the original child run stays the source of truth.
By default it prints a before/after comparison against that original run,
and `--dry-run` shows the edits without spending solver time.

The rest of the page:

```bash
gravi demand store <instance> <store> [--date D | --since D --until D]
gravi demand dash [--range 24h] [--env prod] [--attention] [--refresh]
gravi demand adoption [--stage active]
gravi demand show <instance> <history_id> [--logs]
gravi demand orders <instance> <run_id> [--audits]   # did they reach SND?
gravi demand annotate <instance> <history_id> --state resolved --note "why"
gravi demand annotations <instance>
gravi demand solver show <instance>
gravi demand solver set <instance> prod              # prompts; --yes to skip
```

`dash`, `adoption` and `triage` read a Redis blob an arq cron refreshes every
6h. On a cold cache mom enqueues a refresh and answers "warming" — re-run in
~30s rather than reading that as "no runs".

Annotations are shared: marking a run resolved here is the same act as
marking it resolved in the UI, and everyone sees it.

**What writes.** Everything above reads, with three exceptions: `annotate` /
`unannotate` write to mom (team-visible), `--refresh` enqueues a mom-side cache
job, and `solver set` writes to the **customer's own** `demand_model_config`
through their API — changing which solver their production runs use. `rerun`
writes nothing anywhere; it forwards one request to the solver and returns the
answer. `solver set` confirms in both directions (`--yes` to skip); the agent
surface requires an explicit `--confirm` flag and refuses with exit 2 without it.

For driving this from an agent, use `gravi-axi demand` — same surface, TOON
output, structured errors. See `AXI.md`.

## Python Library API

Use gravi_cli programmatically in Python scripts:

```python
from gravi_cli.api import get_instance_config, get_instance_token

# Get database credentials
config = get_instance_config("prod")
db_url = config["config"]["database_url"]

# Get ServiceNow access token
token_response = get_instance_token("dev")
sn_token = token_response["access_token"]

# Get mom access token directly
from gravi_cli.api import get_mom_token
mom_token = get_mom_token()  # Auto-refreshes if needed
```

### Example: Database Connection

```python
from gravi_cli.api import get_instance_config
import psycopg2

# Get prod database credentials
config = get_instance_config("prod")
conn = psycopg2.connect(config["config"]["database_url"])

# Use database
cursor = conn.cursor()
cursor.execute("SELECT * FROM users LIMIT 10")
```

### Example: ServiceNow API

```python
from gravi_cli.api import get_instance_config, get_instance_token
import requests

# Get ServiceNow config and token
config = get_instance_config("dev")
token_response = get_instance_token("dev")

# Make ServiceNow API call
response = requests.get(
    f"{config['api_url']}/api/now/table/incident",
    headers={"Authorization": f"Bearer {token_response['access_token']}"}
)
incidents = response.json()
```

## CI/CD Usage

For automated scripts and CI/CD pipelines:

```bash
# Set refresh token as environment variable
export GRAVI_REFRESH_TOKEN="your_refresh_token_here"

# Commands will automatically use the env var
gravi config prod
```

**Getting a CI/CD token:**
1. Run `gravi login` on your local machine
2. Run `gravi tokens list` to see your token ID
3. Copy the refresh token from `~/.config/gravi/config.json`
4. Set as `GRAVI_REFRESH_TOKEN` in your CI/CD system

**Security Note:** Treat `GRAVI_REFRESH_TOKEN` like a password. Use secret management systems (GitHub Secrets, AWS Secrets Manager, etc.) to store it securely.

## Configuration

### Config File Location

`~/.config/gravi/config.json`

### Config File Format

```json
{
  "version": 1,
  "user_email": "john.doe@gravitate.com",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token_expires_at": "2025-11-09T10:30:00Z",
  "token_id": "507f1f77bcf86cd799439011",
  "device_name": "John's MacBook"
}
```

### Environment Variables

- `GRAVI_MOM_URL` - Override mom API URL (default: `https://mom.gravitate.energy`)
- `GRAVI_REFRESH_TOKEN` - CI/CD refresh token (bypasses config file)

### File Permissions

Config file is automatically set to `0600` (owner read/write only) for security.

## Token Auto-Renewal

Refresh tokens are automatically renewed when <7 days remaining:
- CLI checks expiry on each use
- If <7 days: requests new 14-day token
- Config file updated automatically
- Seamless for users - no re-login needed

## Development

### Setup

```bash
cd /home/jvogel/src/work/tools/mom/cli
pip install -e ".[test]"
```

### Run Tests

```bash
# All tests
pytest

# With coverage
pytest --cov=gravi_cli --cov-report=term-missing

# Specific test file
pytest tests/test_config.py

# Verbose
pytest -v
```

### Project Structure

```
cli/
├── gravi_cli/
│   ├── __init__.py          # Package metadata
│   ├── api.py               # Public Python API
│   ├── auth.py              # Token refresh and auth logic
│   ├── cli.py               # CLI commands (Click)
│   ├── client.py            # Mom API client
│   ├── config.py            # Config file management
│   └── exceptions.py        # Custom exceptions
├── tests/
│   ├── conftest.py          # Pytest fixtures
│   ├── test_config.py       # Config tests
│   └── test_client.py       # API client tests
├── pyproject.toml           # Package configuration
└── README.md                # This file
```

## Troubleshooting

### "Not logged in" Error

```bash
gravi login
```

### "Token expired" Error

Tokens auto-renew, but if expired:

```bash
gravi login
```

### Rate Limiting

If you hit rate limits, wait and retry. Rate limits reset every minute.

### Mom URL Override (Development)

```bash
# Temporary override
GRAVI_MOM_URL=https://mom.dev gravi login

# Or with flag
gravi login --mom-url=https://mom.dev
```

## Security

- **Refresh tokens** stored in config file with `0600` permissions
- **Access tokens** never persisted (in-memory only)
- **No tokens in CLI arguments** (prevents shell history exposure)
- **HTTPS only** for all API calls
- **Audit logging** - All token operations logged in mom

## Support

For issues or questions:
- GitHub Issues: https://github.com/gravitate/mom/issues
- Internal Slack: #engineering

## License

MIT
