Metadata-Version: 2.5
Name: leancpms-cli
Version: 0.1.0
Summary: The cpms command alone: a client of a LeanCPMS instance's JSON API
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Requires-Python: >=3.14
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.14.1
Requires-Dist: pyyaml>=6.0.3
Description-Content-Type: text/markdown

# `cpms`, the LeanCPMS command line

`cpms` manages an organization in a LeanCPMS instance from your own
machine: live state, sessions, the monthly statement, and the declared
state (organization, price list, sites) as YAML documents you can keep in
a git repository. It talks to the instance's JSON API over HTTPS with an
API key, and the same commands work for people and for AI agents.

This directory builds `leancpms-cli`, the command without the server. It
depends on httpx, pydantic and PyYAML only.

## Install

With [uv](https://docs.astral.sh/uv/), from PyPI once it is published there:

```bash
uv tool install leancpms-cli
cpms --help
```

Until then, from a GitHub release (a tag `cli-v<version>`) or straight
from the repository:

```bash
gh release download cli-v0.1.0 --repo forsslund/leancpms --pattern '*.whl'
uv tool install ./leancpms_cli-0.1.0-py3-none-any.whl
# or
uv tool install "git+https://github.com/forsslund/leancpms#subdirectory=cli"
```

- The repository is private for now, so both need read access to it on
  GitHub; over SSH the URL is
  `git+ssh://git@github.com/forsslund/leancpms#subdirectory=cli`.
- It needs Python 3.14. uv downloads it if your machine lacks it.
- To get a newer version, run `uv tool upgrade leancpms-cli`. To pin a
  version, add `@<tag or commit>` after the repository URL.
- `pipx install "git+…#subdirectory=cli"` works too.
- Do not install it into an environment that already has the server
  (`leancpms`). The server already includes `cpms`, and both packages
  carry the same files, so uninstalling one breaks the other.

## Configure

Log in with the same e-mail address you use for the instance's portal:

```bash
cpms login --url https://cpms.example.com
```

It prints a code and opens the portal's page for it in your browser (or
prints the address only: `--no-browser`, or no display). Log in there if
you are not, check that the page shows the same code, choose your
organization and whether the key may act (`write`) or only look (`read`),
and approve. Your role as a member decides which you may choose: members
are `read` unless the operator, or a colleague with a `write` key, made
them `write` (`cpms members set-role`). `cpms login` waits for that and then saves a
**personal key**, yours and that organization's, as the profile
`[default]` (`--profile NAME` for another) in `~/.config/cpms/config.toml`,
readable only by you. Other profiles in the file stay as they were.

```bash
cpms whoami      # which key is in use, whose, for which organization
cpms logout      # revokes the key on the server and removes the profile
```

The key stops working when you are removed as a member, and so does
every key you issued with it. Approve a code only if you just ran
`cpms login` yourself: whoever started it gets the key.

### Machine keys, for CI and agents

A pipeline or an agent of its own gets a machine key instead, which
records who issued it: `cpms keys issue --name ci --scope read` with your
personal `write` key. It is shown only once. An organization key sees only
its own organization. A key you issue lives only as long as your
membership: when you leave, it is revoked with your own. An integration
that must outlive you gets its key from the operator. Members you add do
stay when you leave; `cpms members list` shows who added whom.

Put the URL and the key in `~/.config/cpms/config.toml` (or
`$XDG_CONFIG_HOME/cpms/`, or the path in `CPMS_CONFIG`), readable only by
you (`chmod 600`):

```toml
[default]
url = "https://cpms.example.com"
api_key = "lcp_…"

[test]
url = "http://localhost:8000"
api_key = "lcp_…"
```

Alternatively, set `CPMS_URL` and `CPMS_API_KEY` in the environment, which
suits CI and agents. `--profile test` (or `CPMS_PROFILE=test`) picks a
profile. Without a profile, the environment wins, then `[default]`.
A networked command that has neither exits 2 and says what to set.

## Use

```bash
cpms                                      # live state: sites, fuse groups, outlets, current per phase
cpms sessions --since 2026-10-01
cpms report statement --month 2026-10     # receipts incl./excl. VAT, sessions without one
cpms manifest export kvartersel > all.yaml
cpms manifest export kvartersel --site odalvagen > sites/odalvagen.yaml
cpms manifest validate sites/odalvagen.yaml   # offline: the schema
cpms manifest plan sites/odalvagen.yaml       # the server: the diff, references, live warnings
cpms manifest apply sites/odalvagen.yaml -m "Fused up to 50 A"
cpms manifest history kvartersel --site odalvagen
```

`cpms --help` and `cpms manifest --help` list the rest.

## A repository of manifests

One file per document works well:

```
organization.yaml
pricelist.yaml
sites/odalvagen.yaml
sites/brotorp.yaml
```

Edit a file, run `validate` and `plan`, commit, then run `apply` with the
commit's reason as `-m`. The server holds the truth and the files are a
way to say things: if someone changed a price in the portal, `plan` shows
that applying your file would undo it, and `export` brings the file up to
date. `apply` refuses a
document that changed on the server since your plan; plan again. Removing
anything needs `--prune`. `validate` needs no key, so CI can run it on
every pull request; `plan` needs one.

## In your editor: the JSON Schema

The documents have a JSON Schema, so an editor can complete field names
and flag mistakes as you type. That works in editors that use
yaml-language-server: VS Code with Red Hat's YAML extension, Neovim,
Helix, Zed. Add this line at the top of each file:

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/forsslund/leancpms/main/schema/manifest.cpms-v1.schema.json
```

That URL works once the repository is public; replace `main` with a tag
to pin a version. Until then, use a local copy, written by the command
itself and kept in your manifest repository:

```bash
cpms manifest schema > cpms-v1.schema.json
```

```yaml
# yaml-language-server: $schema=../cpms-v1.schema.json
```

The path is relative to the YAML file. `export` does not write the
line, so add it again after an export.

The schema checks types, required and unknown fields, allowed values,
key spelling, and that amounts are quoted (`"4.70"`, not `4.70`). It does
not check anything that spans several fields: a budget above the fuse,
duplicate keys or ocpp ids, a plan naming a missing tariff, or an unknown
time zone. `validate` and `plan` check those.

## For agents

- `--json` works on every command, before or after the subcommand.
- Exit codes: 0 ok, 1 the action failed (the server said no, the server
  could not be reached, a plan has errors), 2 usage or configuration.
- `cpms manifest apply` never prompts without a TTY. It prints the plan
  and exits 2 unless you pass `--yes`. Show the plan to a person first,
  then apply with `--yes -m "why"`.
- `session … stop|close` acts at once, with no confirmation step.
- Pass the key in the environment or the config file, never on the
  command line or in a transcript.

## Releasing (maintainers)

Raise `version` in both `cli/pyproject.toml` and the root `pyproject.toml`
(a test keeps them equal), then tag and push:

```bash
git tag cli-v0.1.1 && git push origin cli-v0.1.1
```

`.github/workflows/release-cli.yml` builds the wheel and sdist, smoke-tests
the wheel, and attaches both to a GitHub release. It also publishes to PyPI
when the repository variable `PYPI_PUBLISH` is `true`. That needs, once:
a PyPI account with two-factor authentication; a *pending trusted
publisher* on PyPI for the project `leancpms-cli` (owner `forsslund`,
repository `leancpms`, workflow `release-cli.yml`, environment `pypi`);
and an environment `pypi` in the GitHub repository's settings. A pending
publisher does not reserve the name: if someone else registers
`leancpms-cli` first, it becomes invalid. The first release creates the
project and so reserves the name. Trusted publishing uses GitHub's OIDC
token, so no PyPI token is stored anywhere. A file published to PyPI cannot be replaced,
only yanked: a wrong release is fixed with a new version.
