Metadata-Version: 2.5
Name: gravity-cli
Version: 0.1.0
Summary: gravity cli — init, run, and push python agents to the Gravity marketplace
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Requires-Python: >=3.12
Requires-Dist: gravity-schema==0.1.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: keyring>=25.0.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: rich>=13.9.0
Requires-Dist: typer>=0.15.0
Requires-Dist: uv>=0.8.0
Description-Content-Type: text/markdown

<div align="center">

# Gravity CLI

**Build, test and publish AI agents on Gravity.**

Write ordinary LangGraph, declare what your agent may touch in one manifest,<br>
and run it against real tools with those permissions enforced.

[![PyPI](https://img.shields.io/pypi/v/gravity-cli?color=ff6b35&label=pypi)](https://pypi.org/project/gravity-cli/)
[![Python](https://img.shields.io/badge/python-3.12%2B-ffb627)](https://www.python.org/downloads/)
[![License](https://img.shields.io/badge/license-Apache%202.0-e63946)](https://www.apache.org/licenses/LICENSE-2.0)

</div>

```bash
pipx install gravity-cli
gravity init my-agent
```

There is no SDK. Your agent reads three environment variables (`GRAVITY_GATEWAY_URL`, `GRAVITY_LLM_URL`, `GRAVITY_RUN_TOKEN`), and `gravity` is the only Gravity-specific tool you use.

Here is `gravity run` on an agent that tried a tool it never declared. The gateway refused the call, and the run carried on:

```
  inbox-digest@1.0.0 · run grt_…4e7b · 2 tools declared

●     read                                                          0.9s
  ✓   gmail.read                3 found                             0.8s
●     digest                                                        2.3s
  ◆   openai/gpt-6-luna                                             2.1s
  ✗   slack.send                refused — not in permissions.tools  0.0s

╭────────────────────────── result ──────────────────────────╮
│ unread  3                                                  │
│ headline  Two invoices and a meeting request need replies. │
╰────────────────────────────────────────────────────────────╯
  ✔ done in 3.3s
```

New to building agents? Start with the [builder guide](BUILDER_GUIDE.md). The exact rules are in the [agent standard](AGENT_STANDARD.md), and [templates](TEMPLATES.md) helps you pick a starting point.

## Contents

- [How it works](#how-it-works)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Environment variables](#environment-variables)
- [Running a local gateway](#running-a-local-gateway)
- [Authentication](#authentication)
- [Commands](#commands)
- [An agent project](#an-agent-project)
- [Troubleshooting](#troubleshooting)
- [Limitations](#limitations)
- [Development](#development)
- [Repository layout](#repository-layout)
- [License](#license)

## How it works

```
gravity init      scaffold a working agent from a template
gravity validate  check the manifest, entrypoint, lockfile and adapters, locally
gravity run       run it through the gateway, with only the declared tools allowed
gravity build     bundle it into a deterministic build/agent.zip
gravity push      upload exactly that bundle to the control plane
```

Three services are involved. The CLI talks to two of them; your agent talks only to the gateway.

| Service | What it does | Who talks to it |
|---|---|---|
| **Gateway** | Serves tools over MCP (`/mcp`) and models over an OpenAI-compatible API (`/v1`). Mints run tokens and refuses any tool a run was not granted. | `gravity run` mints a token; your agent makes every tool and model call through it |
| **Control plane** | Accounts, the tool catalog, pushed agents, builds and hosted runs. | `login`, `push`, `tools`, `agents`, `runs`, `logs` |
| **Your agent** | Your LangGraph code, started by `gravity run` in its own virtual environment. | Sees only a run token scoped to its declared tools |

On `gravity run`, the CLI trades your dev token for a run token scoped to `permissions.tools` and starts the agent with only that token. A call to an undeclared tool is refused by the gateway, exactly as it will be when the agent runs hosted. Calls listed under `approvals.required_for` pause until you approve them in the terminal.

## Installation

Requires Python 3.12 or newer, and [uv](https://docs.astral.sh/uv/) for your agents' own environments.

```bash
pipx install gravity-cli       # recommended: gravity gets its own environment
uv tool install gravity-cli    # the same, with uv
pip install gravity-cli        # into the current environment
```

`gravity` then works from any folder. Upgrade with `pipx upgrade gravity-cli` or `uv tool upgrade gravity-cli`.

## Quick start

1. Set up `~/.gravity/.env` as described in [Environment variables](#environment-variables). At minimum, `gravity run` needs `GRAVITY_DEV_TOKEN`.
2. Make sure a gateway is reachable: either a [local one](#running-a-local-gateway) or the hosted dev gateway.
3. Then:

```bash
gravity init my-agent              # creates ./my-agent from the minimal template
cd my-agent
uv venv && uv pip install -r requirements.txt

gravity validate                   # offline checks, compiles requirements.lock
gravity run --input query="hello"  # live timeline of steps, model and tool calls
gravity build                      # writes build/agent.zip and build/build.json
gravity login                      # once, before your first push
gravity push                       # uploads the build; refuses if it is stale
```

## Environment variables

### Where to set them

Put them in `~/.gravity/.env` (on Windows, `%USERPROFILE%\.gravity\.env`). Every `gravity` command loads this file, from any directory, so you set a value once and never export it again. [`.env.example`](.env.example) in this repository is a commented template you can copy there:

```bash
mkdir -p ~/.gravity
cp .env.example ~/.gravity/.env      # then fill in the values
```

When the same variable is set in more than one place, the first match wins:

1. a variable exported in your shell
2. `~/.gravity/.env`
3. `~/.gravity/config.json` (only the API URL, written by `gravity --api-url URL <command>`)
4. the built-in default

Never commit a `.env` file. This repository's `.gitignore`, and the one every template ships with, keep `.env` and `.gravity/` out of git.

### Which ones you need

| You want to... | Commands | Required | Usually also set |
|---|---|---|---|
| Scaffold, check and package an agent | `init`, `validate`, `build` | nothing | — |
| Run an agent locally | `run`, `schedule`, `adapt` | `GRAVITY_DEV_TOKEN` | `GRAVITY_GATEWAY_URL`, unless the gateway is local on port 8000 |
| Publish and inspect agents | `push`, `tools`, `agents`, `runs`, `logs`, `whoami` | `gravity login`, or `GRAVITY_API_TOKEN` | `GRAVITY_API_URL`, unless the control plane is at `http://localhost:3010` |

### Reference

| Variable | What it is | Required | Default |
|---|---|---|---|
| `GRAVITY_DEV_TOKEN` | Your gateway dev token. `gravity run` trades it for a run token scoped to the manifest's tools. A local gateway prints one at startup, or uses its `LOCAL_DEV_TOKEN`. | For `run`, `schedule`, `adapt` | none |
| `GRAVITY_GATEWAY_URL` | The gateway's MCP endpoint, ending in `/mcp`. The CLI derives the gateway's base URL from it by removing `/mcp`. | When the gateway is not local | `http://127.0.0.1:8000/mcp` |
| `GRAVITY_LLM_URL` | The OpenAI-compatible model endpoint. Override it per run with `--llm-url`. | No | the gateway's base URL + `/v1` |
| `GRAVITY_API_URL` | The control plane's base URL. | When the control plane is not local | `http://localhost:3010` |
| `GRAVITY_API_TOKEN` | The control plane's dev token. Commands then run as the control plane's dev user without `gravity login`, and it wins over a stored session. For development only. | Instead of `gravity login` | none |
| `GRAVITY_CONFIG_DIR` | Moves `~/.gravity` elsewhere: the `.env` file, `config.json` and the fallback session file. Useful for tests and CI. | No | `~/.gravity` |
| `GRAVITY_UNATTENDED` | When `1`, gated tool calls are rejected without prompting, as if nobody were at the terminal. `gravity schedule` sets it for you. | No | unset |

### Set for your agent, never by you

`gravity run` starts your agent with exactly these three, and the hosted runner does the same:

| Variable | Value |
|---|---|
| `GRAVITY_GATEWAY_URL` | the gateway's `/mcp` endpoint, for tools |
| `GRAVITY_LLM_URL` | the gateway's `/v1` endpoint, for models |
| `GRAVITY_RUN_TOKEN` | a token for this run only, scoped to the declared tools. Use it as the bearer token for tools and as the API key for models. |

Your own `GRAVITY_DEV_TOKEN` and `GRAVITY_API_TOKEN` are removed from the agent's environment, so agent code can never act as you. Read the three variables inside `graph()`, not at import time; see the [agent standard](AGENT_STANDARD.md).

### Example setups

Everything on your machine (local gateway and control plane):

```bash
# ~/.gravity/.env
GRAVITY_DEV_TOKEN=<printed by gravity-gateway serve, or its LOCAL_DEV_TOKEN>
GRAVITY_API_URL=http://localhost:3010
GRAVITY_API_TOKEN=<the control plane's PLATFORM_DEV_TOKEN>
```

Hosted dev gateway:

```bash
# ~/.gravity/.env
GRAVITY_GATEWAY_URL=https://<hosted gateway host>/mcp
GRAVITY_DEV_TOKEN=<the dev token you were issued>
GRAVITY_API_URL=https://<control plane host>
```

`GRAVITY_LLM_URL` is left out in both: it follows the gateway.

## Running a local gateway

The gateway is part of the Gravity platform repository, not this one. It is open to the Gravity team; outside builders use the hosted dev gateway instead. Start it with `gravity-gateway serve`; it listens on port 8000 and prints a dev token.

It reads its own `.env` file in the gateway's directory. None of its variables is needed for a first run: with nothing set it serves only the built-in `example.echo` tool, keeps tokens in memory, and prints a new dev token at every start.

| Variable | What it enables | When you need it |
|---|---|---|
| `COMPOSIO_API_KEY` | Real tools (Gmail, Slack, Linear and others) through Composio. Unset: only `example.echo`. | To call any real tool |
| `OPENROUTER_API_KEY` | Model calls on `/v1`, forwarded to OpenRouter. Unset: `/v1` returns 503. | For any agent that calls a model |
| `OPENROUTER_BASE_URL` | Where model calls go. Default `https://openrouter.ai/api/v1`. | Rarely |
| `LOCAL_DEV_TOKEN` | A fixed dev token (24+ random characters), so restarts don't change it. Put the same value in `~/.gravity/.env` as `GRAVITY_DEV_TOKEN`. | Recommended |
| `GATEWAY_PORT` | The listening port. Default `8000`. If you change it, set `GRAVITY_GATEWAY_URL=http://127.0.0.1:<port>/mcp`. | Only if 8000 is taken |
| `DATABASE_URL`, `PLATFORM_DB_SCHEMA` | Keeps run tokens in the platform's Postgres database instead of memory. | Platform development only |
| `PLATFORM_SERVICE_KEY`, `AWS_REGION` | Lets the platform's hosted runs mint tokens and report back. | Platform development only |
| `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` | The older token store, used when `DATABASE_URL` is unset. | Legacy setups only |

Real tools act on a connected account. On a local gateway every run uses one shared test identity; connect an account to it with `gravity-gateway connect --user-id builder:local --toolkit <app>`. See the gateway's own README for details.

## Authentication

There are two separate credentials, for two separate services:

| Credential | Used for | How you get it |
|---|---|---|
| Control-plane session | `push`, `tools`, `agents`, `runs`, `logs`, `whoami` | `gravity login`, or `GRAVITY_API_TOKEN` for development |
| Gateway dev token (`GRAVITY_DEV_TOKEN`) | `run`, `schedule`, `adapt` | Printed by a local gateway, or issued for the hosted one |

- **`gravity login`** opens the control plane's sign-in page and waits for you to approve a device code. The session is stored in the OS keychain, or in `~/.gravity/session.json` (readable only by you) on machines without one. `gravity logout` removes it.
- **`GRAVITY_API_TOKEN`** set to the control plane's dev token skips login entirely. `gravity whoami` then prints the dev user's id followed by `(dev token)`.

## Commands

| Command | What it does |
|---|---|
| `gravity init <name> [--template T] [--here]` | Scaffold a working agent. Templates: `minimal`, `structured`, `deepagent`, `chat`. `--here` writes only a manifest into the current directory, for an existing project. |
| `gravity validate [path]` | Check the manifest, the entrypoint, the declared adapters and the lockfile, and warn about tool names or network imports the manifest does not declare. Compiles `requirements.lock` for the platform image (ARM64 Linux). |
| `gravity run [path] --input k=v` | Run the agent locally through the gateway. `--input k=@file.txt` reads a value from a file. `--reply "..."` continues the last conversation of a chat agent. `--llm-url URL` overrides `GRAVITY_LLM_URL`. |
| `gravity schedule [path] [--cron EXPR] [--now]` | Run the agent on its `triggers.schedule` until Ctrl+C. Nobody is at the terminal, so gated calls are not approved. |
| `gravity build [path]` | Validate, then write `build/agent.zip` and `build/build.json`. The same source always produces the same bytes. Nothing leaves your machine. |
| `gravity push [path]` | Upload what `build` made. Refuses if there is no build or the source changed since. Published versions are frozen: bump `version` to publish again. |
| `gravity adapt <slot> <name> --tools a.b,c.d` | Have a coding agent write a new adapter for a slot. The file is kept only if the slot's conformance tests pass; otherwise nothing changes. |
| `gravity tools [search]` | List the tool catalog: the names you can put in `permissions.tools`. |
| `gravity agents` | Your published agents and their review status. |
| `gravity runs <agent>` / `gravity logs <run-id>` | Hosted run history and logs. |
| `gravity login` / `logout` / `whoami` | Sign in to and out of the control plane, and show who you are. |

Every command accepts `--help`. The global option `--api-url URL`, given before the command, saves a new control-plane URL to `~/.gravity/config.json`.

## An agent project

```
my-agent/
├── manifest.yaml       what the agent is and may do
├── requirements.txt    your dependencies
├── requirements.lock   compiled by `gravity validate`; ships with the agent
└── src/
    └── agent.py        exports `graph`, an async factory returning a compiled LangGraph graph
```

### The manifest

`manifest.yaml` declares everything about the agent that Gravity enforces: what the consumer fills in, which tools it may call, which calls need approval, and how it is triggered. Only `name`, `runtime.entrypoint` and, for a useful agent, `permissions.tools` are needed; everything else has a default.

The smallest useful manifest:

```yaml
contract: v1
name: my-agent
version: 1.0.0
runtime:
  entrypoint: src/agent.py:graph
inputs:
  - name: query
    type: text
    label: "What should the agent do?"
    required: true
permissions:
  tools: [example.echo]
```

Every section, annotated. This one is valid as written:

```yaml
contract: v1                      # manifest contract version; v1 is the only one
name: inbox-digest                # lowercase letters, digits, single hyphens; appears in URLs
version: 1.0.0                    # semver; a published version is frozen, so bump it to publish again
description: "Summarises unread email and sends a digest"
kind: interactive                 # transform | interactive | monitor

runtime:
  framework: langgraph            # the only framework in v1
  python: "3.12"                  # the platform image runs Python 3.12
  entrypoint: src/agent.py:graph  # file:object, relative to the project folder
  timeout_seconds: 300            # 10 to 28800

dependencies:
  lockfile: requirements.lock     # written by `gravity validate`

inputs:                           # each becomes a field in the consumer's form
  - name: lookback
    type: static-dropdown
    label: "How far back?"
    options: ["1 day", "7 days"]
    default: "1 day"
  - name: send_to
    type: email
    label: "Send the digest to"
    required: true

permissions:
  tools:                          # the only tools this agent's runs may call
    - gmail.read
    - gmail.send
  models:
    tier: standard                # standard | premium

approvals:
  required_for:                   # these calls pause until a person approves them
    - gmail.send

resources:
  memory_mb: 512                  # 128 to 4096; a request the platform may lower

triggers:
  manual: true                    # the consumer can start a run
  schedule:
    cron: "0 8 * * 1-5"           # weekdays at 08:00, local time
    consumer_can_change: true

state:
  max_kb: 64                      # memory kept between runs (1 to 256 KB); omit for none

slots:                            # a part the consumer chooses; each adapter gets only its own tools
  deliver:
    label: "Where should the digest go?"
    default: email
    adapters:
      email: [gmail.draft]        # code in src/adapters/deliver/email.py
      slack: [slack.send]         # code in src/adapters/deliver/slack.py

outputs:                          # extra formats the platform renders and delivers itself
  report:
    renderer: markdown            # markdown | docx | pptx | rows
    label: "Digest as a document"
    destinations: [download]
```

### Field reference

| Field | Required | Default | Rules |
|---|---|---|---|
| `contract` | no | `v1` | Only `v1` exists. |
| `name` | **yes** | | Lowercase letters, digits and single hyphens, up to 64 characters. Appears in URLs. |
| `version` | no | `1.0.0` | Strict semver, e.g. `1.2.0`. A published version can never change; bump it to publish again. |
| `description` | no | empty | Up to 500 characters. |
| `kind` | no | `interactive` | `transform` (input in, result out), `interactive` (calls tools mid-run, may converse), `monitor` (runs on a schedule or webhook and remembers the last run; needs a trigger). |
| `runtime.entrypoint` | **yes** | | `path/to/file.py:object`, inside the project. The object is your `graph` factory. |
| `runtime.framework` | no | `langgraph` | Only `langgraph`. |
| `runtime.python` | no | `3.13` | `3.10` to `3.13` are accepted, but the platform image runs **3.12**: set `"3.12"` before you push. |
| `runtime.timeout_seconds` | no | `300` | 10 to 28800. |
| `dependencies.lockfile` | no | `requirements.lock` | Compiled by `gravity validate`; ships with the agent. |
| `inputs[]` | no | none | Up to 50. Each has `name` (lowercase identifier, becomes the key in `state["inputs"]`), `type`, `label` (shown in the form), and optionally `required`, `default`, `options`, `accept`. |
| `inputs[].type` | **yes** | | `text`, `textarea`, `number`, `boolean`, `static-dropdown` (needs `options`), `email`, `url`, `date`, `file` (no `default`; `accept` lists extensions such as `[pdf, csv]`). |
| `permissions.tools` | no | none | Up to 50 catalog names, e.g. `gmail.read`. Run `gravity tools` for the list. The run token allows exactly these. |
| `permissions.models.tier` | no | `standard` | `standard` or `premium`. |
| `approvals.required_for` | no | none | Tools that pause until a person approves. Each must also be declared under `permissions.tools` or a slot adapter. |
| `resources.memory_mb` | no | `512` | 128 to 4096. A request; the platform may lower it. |
| `triggers.manual` | no | `true` | Whether the consumer can start a run. Something must start the agent: `manual`, a `schedule` or a `webhook`. |
| `triggers.schedule` | no | none | `cron` (5 fields, local time, e.g. `"0 9 * * 1"`) and `consumer_can_change` (default `true`). Try it with `gravity schedule`. |
| `triggers.webhook` | no | none | A list of events; `github.push` is the only one today. |
| `state.max_kb` | no | no memory | Include `state:` to keep memory between runs, in `state["memory"]`. 1 to 256 KB. |
| `slots.<name>` | no | none | A swappable part: `label`, `default` adapter, and `adapters` mapping each adapter name to the tools it may call. Adapter code lives in `src/adapters/<slot>/<adapter>.py`. The pick arrives in `state["inputs"]` under the slot's name, so slot names cannot repeat an input's name. |
| `outputs.<name>` | no | none | Up to 8 extra formats the platform renders from the result: `renderer` (`markdown`, `docx`, `pptx`, `rows`), `label`, and `destinations` (`download`, or a catalog tool such as `gmail.send` that the platform calls itself). |

Unknown fields are rejected rather than ignored, so a typo is reported instead of silently dropped. `gravity validate` lists every problem at once, with a suggested fix for each.

### Your code

Inputs arrive in `state["inputs"]`, and the final `state["result"]` is what the consumer gets. Add a `messages` key to your state to make a chat agent. The exact rules are in the [agent standard](AGENT_STANDARD.md), and the machine-readable schema is [`manifest.schema.json`](packages/gravity-schema/schema/manifest.schema.json).

`graph` is an async factory, not a graph built at import time: the runner calls it fresh for every run, after that run's environment variables are set.

Files the CLI writes in a project:

| Path | Written by | Contents |
|---|---|---|
| `requirements.lock` | `validate` | exact pins for the platform image; commit it |
| `build/agent.zip`, `build/build.json` | `build` | the bundle `push` uploads, and its hashes |
| `.gravity/payload.json` | `run` | the inputs passed to the agent |
| `.gravity/thread.json` | `run` | the last conversation of a chat agent, for `--reply` |
| `.gravity/memory.json` | `run` | memory kept between runs, when the manifest declares `state` |

The template `.gitignore` excludes `build/` and `.gravity/`.

## Troubleshooting

| Message | Cause and fix |
|---|---|
| `missing: GRAVITY_DEV_TOKEN` | `run`, `schedule` and `adapt` need a gateway dev token. Add `GRAVITY_DEV_TOKEN` to `~/.gravity/.env`. |
| `can't reach the gateway at …` | No gateway at that address. Start a local one with `gravity-gateway serve`, or set `GRAVITY_GATEWAY_URL` to the hosted gateway's `/mcp` URL. |
| `the gateway refused this run — …` | The gateway rejected the token or a declared tool. A token from a restarted local gateway is no longer valid: set `LOCAL_DEV_TOKEN` there so it stays fixed. |
| `not logged in — run gravity login first, or set GRAVITY_API_TOKEN.` | A control-plane command without credentials. Run `gravity login`, or set `GRAVITY_API_TOKEN` for development. |
| `missing required input(s): …` | Pass each required input with `--input name=value`, or give it a `default` in the manifest. |
| `no build found` / `build is stale` | Run `gravity build` again after any change to `src/`, the manifest or the requirements. |
| `` `uv pip compile` failed for aarch64-manylinux2014 `` | A dependency has no wheel for the platform image (ARM64 Linux). Pick a version that publishes one, or a different package. |
| A tool row shows `refused — not in permissions.tools` | The agent called a tool its manifest does not declare. Add it under `permissions.tools`, then run again. |

## Limitations

- **No offline mode.** `gravity run` always talks to a real gateway. There is no mock-tool mode, so every local run needs a gateway, and real tools need a connected account.
- **Static drift check.** `gravity validate` finds undeclared tools by scanning source for imports and tool-shaped strings. Expect false positives; they are warnings, never failures.
- **Models are not restricted yet.** The gateway forwards whatever model name the agent asks for, and `permissions.models.tier` is not enforced.
- **Hosted history.** `gravity runs` and `gravity logs` show what the control plane returns, which is empty until hosted runs exist.

## Development

```bash
git clone https://github.com/AIGravity/gravity-cli.git
cd gravity-cli
uv sync                                   # installs both packages and dev tools
uv run gravity --help                     # or activate .venv to use gravity directly
uv run pytest                             # CLI tests
cd packages/gravity-schema && uv run pytest && cd ../..   # manifest contract tests
uv run ruff check .
```

After changing the manifest model in `packages/gravity-schema`, regenerate the committed JSON Schema; a test fails until you do:

```bash
uv run python -m gravity_schema            # --check only reports whether it is stale
```

After changing `catalog.yaml`, regenerate the tool lists in the platform repository:

```bash
uv run python -m gravity_schema.gen_catalog --root <platform repo>/code-first
```

### Continuous integration

Every pull request and every push to `main` runs [`ci.yml`](.github/workflows/ci.yml):

- `ruff check`, and a check that `uv.lock` matches `pyproject.toml`
- both test suites on Ubuntu and Windows, Python 3.12 and 3.13 (these include a check that the committed JSON Schema is current)
- a build of both packages, installed into a clean environment and used for real (`gravity init` and `gravity validate`), which catches files missing from the package before PyPI does

### Releasing

`gravity-cli` and `gravity-schema` are released together, always at the same version.

1. Set the new version in three places: `version` in `pyproject.toml`, `version` in `packages/gravity-schema/pyproject.toml`, and the `gravity-schema==` pin in `pyproject.toml`'s dependencies. Run `uv lock`, then merge to `main`.
2. Tag the merge commit and push the tag:
   - `git tag v0.1.0rc1 && git push origin v0.1.0rc1` publishes a release candidate to TestPyPI.
   - `git tag v0.1.0 && git push origin v0.1.0` publishes to PyPI.
3. Watch the Release run in the repository's Actions tab. A PyPI release also creates a GitHub Release with generated notes.

[`release.yml`](.github/workflows/release.yml) runs the full CI first, refuses a tag that doesn't match all three versions, and publishes with PyPI trusted publishing, so no PyPI token is stored anywhere. Each package is published in its own job and environment, because a trusted publisher can create only one new project per login.

One-time setup, done once by a maintainer:

| Where | Project | Workflow | Environment |
|---|---|---|---|
| pypi.org | `gravity-cli` | `release.yml` | `pypi` |
| pypi.org | `gravity-schema` | `release.yml` | `pypi-schema` |
| test.pypi.org | `gravity-cli` | `release.yml` | `testpypi` |
| test.pypi.org | `gravity-schema` | `release.yml` | `testpypi-schema` |

All four use the repository `AIGravity/gravity-cli`. On GitHub, under Settings → Environments, create the four environments named in the last column, each limited to tags matching `v*`.

To install a release candidate, which has its dependencies on the real PyPI:

```bash
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ gravity-cli==0.1.0rc2
```

## Repository layout

```
src/gravity_cli/            the CLI
  commands/                 one module per command
  templates/                what `gravity init` copies
  _runner_script.py         runs an agent inside its own venv for `gravity run`
packages/gravity-schema/    the manifest contract: models, validation, tool catalog, JSON Schema
  gravity_schema/catalog.yaml           the curated tool catalog
  gravity_schema/catalog_imported.yaml  generated by the gateway's sync-tools; experimental tools
starter/                    the builder guide's example agent (inbox digest)
tests/                      CLI tests
```

`gravity-schema` is its own package because the CLI, the gateway, the hosted runner and the control plane's intake all validate the same manifest against the same rules.

## License

Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
