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
37 changes: 24 additions & 13 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ name: Publish PyPI launcher

# Ships packages/pypi-cli to PyPI as `aether-agent`: the pip/pipx front door that installs
# and runs the npm CLI. Separate from release.yml (which publishes the npm package itself)
# -- own ecosystem, own artifact, own job -- but cut from the same immutable release tag, so
# `pipx install aether-agent` and `npm install -g aether-agents` are the same release.
# -- own ecosystem, own artifact, own job. A release publishes it from that release's own
# immutable tag; the first publish is dispatched from main, because no tag cut before the
# launcher existed contains packages/pypi-cli at all.
#
# Authentication is PyPI Trusted Publishing (OIDC): no token, nothing to rotate or leak. The
# `pypi-production` environment scopes which GitHub identity PyPI will accept; its Trusted
Expand All @@ -21,8 +22,9 @@ on:
workflow_dispatch:
inputs:
ref:
description: Release tag to build (for example v0.3.0)
required: true
description: Ref to build -- a release tag, or main for the first publish
required: false
default: main
type: string
dry_run:
description: Build and verify without publishing
Expand All @@ -48,7 +50,7 @@ jobs:
env:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.ref }}
steps:
- name: Checkout immutable release tag
- name: Checkout the release tag, or main for a first publish
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.RELEASE_TAG }}
Expand All @@ -73,13 +75,24 @@ jobs:
# oldest interpreter it claims to support.
python-version: '3.10'

- name: Verify the launcher, the npm package, and the tag agree
# The launcher's version must always agree with package.json, and with the tag when
# the ref is one. The first publish is cut from main because no existing tag contains
# packages/pypi-cli at all, so the tag check applies only to a ref that is a tag.
- name: Verify the launcher and the npm package agree
id: version
shell: bash
run: |
set -Eeuo pipefail
node packages/sync-version.mjs --check
version="$(node -p "require('./package.json').version")"
test "$version" = "${RELEASE_TAG#v}"
if [ "$RELEASE_TAG" = "main" ]; then
test "$GITHUB_EVENT_NAME" = "workflow_dispatch"
echo "building main at $(git rev-parse HEAD) as version ${version}"
else
test "$version" = "${RELEASE_TAG#v}"
echo "ref ${RELEASE_TAG} agrees with package.json ${version}"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"

- name: Refuse runtime dependencies
working-directory: packages/pypi-cli
Expand All @@ -98,16 +111,14 @@ jobs:
python -m mypy src

- name: Build sdist and wheel
id: build
shell: bash
working-directory: packages/pypi-cli
run: |
set -Eeuo pipefail
python -m pip install --upgrade pip build==1.4.0 twine==6.2.0
python -m build
python -m twine check --strict dist/*
version="${RELEASE_TAG#v}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
version="${{ steps.version.outputs.version }}"
test -f "dist/aether_agent-${version}.tar.gz"
test -f "dist/aether_agent-${version}-py3-none-any.whl"
sha256sum dist/*
Expand All @@ -119,9 +130,9 @@ jobs:
set -Eeuo pipefail
python -m venv "$RUNNER_TEMP/launcher"
"$RUNNER_TEMP/launcher/bin/pip" install --quiet \
"dist/aether_agent-${{ steps.build.outputs.version }}-py3-none-any.whl"
"dist/aether_agent-${{ steps.version.outputs.version }}-py3-none-any.whl"
test "$("$RUNNER_TEMP/launcher/bin/aether-agent" self --version)" \
= "${{ steps.build.outputs.version }}"
= "${{ steps.version.outputs.version }}"
# doctor exits 1 until the npm CLI is present, which is the correct answer on a
# clean runner; what is being proved here is that the console script runs at all.
"$RUNNER_TEMP/launcher/bin/aether-agent" self doctor || true
Expand All @@ -146,5 +157,5 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
echo "Dry run only. Built aether-agent ${{ steps.build.outputs.version }}."
echo "Dry run only. Built aether-agent ${{ steps.version.outputs.version }}."
echo "Re-run with dry_run=false to publish."
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ badge or `npm view aether-agents version` for the npm `latest` dist-tag, and
| Install | Version | What it represents |
|---|---:|---|
| npm `latest` | [![npm latest](https://img.shields.io/npm/v/aether-agents?label=&color=14b8a6)](https://www.npmjs.com/package/aether-agents) | Published package; the badge resolves the live dist-tag. |
| PyPI `aether-agent` | [![PyPI latest](https://img.shields.io/pypi/v/aether-agent?label=&color=3775a9)](https://pypi.org/project/aether-agent/) | Launcher that installs and runs the npm CLI; its version is the agent version it installs. |
| PyPI `aether-agent` | [![PyPI latest](https://img.shields.io/pypi/v/aether-agent?label=&color=3775a9)](https://pypi.org/project/aether-agent/) | Launcher that installs and runs the npm CLI; it fetches the npm `latest` dist-tag unless you pin one. |
| `main` source build | **0.3.0** | Current repository source and its 0.3 workflow. |

The [release record](docs/releases/2026-08-22.md),
Expand Down
20 changes: 13 additions & 7 deletions packages/pypi-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ Requires **Node 24+** on PATH (the agent's own requirement) and Python 3.10+.
`aether` CLI unchanged, and its exit code becomes this process's exit code. `aether-agent code`,
`aether-agent doctor`, `aether-agent sessions`, and the slash commands inside the REPL all behave
exactly as documented in [`COMMANDS.md`](https://github.com/AetherAI3/aether-agent/blob/main/COMMANDS.md).
- **Installs one known version.** The version of this package *is* the version of the agent it
installs, so `pipx install aether-agent==0.3.0` gets you agent `0.3.0`. Installation goes into a
private prefix under your own data directory, with `--ignore-scripts`, so it needs no
administrator rights and runs no package lifecycle scripts.
- **Installs the same agent the npm route does.** By default it fetches the npm `latest`
dist-tag, so `pipx install aether-agent` and `npm install -g aether-agents@latest` land on the
same agent. Pin a specific one with `self install --npm-version 0.3.1` or
`AETHER_AGENT_NPM_VERSION`. Installation goes into a private prefix under your own data
directory, with `--ignore-scripts`, so it needs no administrator rights and runs no package
lifecycle scripts.
- **Defers to an agent you already have.** If `aether` is already on PATH, that is the one it runs.
It never installs a second copy behind your back.
- **Adds no dependencies.** It shells out to `node` and `npm`, which the agent requires anyway.
Expand All @@ -38,20 +40,24 @@ is the agent's, `aether-agent self doctor` is the launcher's.

```bash
aether-agent self install # install or update the agent CLI
aether-agent self install --npm-version 0.2.1
aether-agent self install --npm-version 0.3.1
aether-agent self doctor # node, npm, install root, and which aether would run
aether-agent self path # print that binary's path
aether-agent self uninstall # remove only what this launcher installed
```

`self install` is optional: the first forwarded command installs the agent if it is missing.
`self install` is optional: the first forwarded command installs the agent if it is missing. Run
it again later to update to the current `latest`.

This package's own version tracks the repository's `main`, so the launcher and the agent release
together; it is not the version installed. `aether-agent self doctor` prints both.

## Environment

| Variable | Effect |
| --- | --- |
| `AETHER_AGENT_HOME` | Where the launcher keeps its private npm prefix. Defaults to `$XDG_DATA_HOME/aether-agent` (`%LOCALAPPDATA%\aether-agent` on Windows). |
| `AETHER_AGENT_NPM_VERSION` | Install a different version of `aether-agents` than this package declares. Validated before use. |
| `AETHER_AGENT_NPM_VERSION` | Pin the version or dist-tag of `aether-agents` to install, instead of `latest`. Validated before use. |

The agent's own variables — `AETHER_API_KEY`, `OLLAMA_HOST`, and the rest — are read by the agent,
not by this launcher, and are documented in
Expand Down
7 changes: 4 additions & 3 deletions packages/pypi-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ build-backend = "hatchling.build"

# The pip/pipx front door to the Aether Agent CLI. Aether Agent itself is the Node package
# `aether-agents` on npm; this package installs and launches it. The version below tracks
# that package exactly and is the version this launcher installs by default, so a release
# never ships a launcher pointing at a different agent. packages/sync-version.mjs copies
# package.json's version here, and tests/test_packaging.py fails the build if they drift.
# main's package.json so the two halves of the repository release together -- it is not the
# version installed, which is the npm `latest` dist-tag unless pinned.
# packages/sync-version.mjs copies package.json's version here, and tests/test_packaging.py
# fails the build if they drift.
[project]
name = "aether-agent"
version = "0.3.0"
Expand Down
6 changes: 4 additions & 2 deletions packages/pypi-cli/src/aether_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
CLI without hand-rolling an npm global install, and every command you type is forwarded to
that CLI unchanged.

The version here tracks the npm package exactly, and is the version this launcher installs
by default.
The version here tracks main's npm package version, so the two halves of the repository
release together. It is not the version installed: the launcher fetches the npm `latest`
dist-tag unless you pin one, so `pipx install aether-agent` and
`npm install -g aether-agents@latest` land on the same agent.
"""

from __future__ import annotations
Expand Down
22 changes: 16 additions & 6 deletions packages/pypi-cli/src/aether_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
1. Every argument that is not in the ``self`` namespace is forwarded to the real ``aether``
CLI unchanged, and its exit code is this process's exit code. This launcher never
reimplements, filters, or renames an agent command.
2. It installs one known version -- the version of this package -- into a private prefix
that needs no administrator rights, unless an ``aether`` is already on PATH, in which
case that one is used and nothing is installed behind your back.
2. It installs the same agent the documented npm route installs -- the ``latest``
dist-tag, unless you pin one -- into a private prefix that needs no administrator
rights, unless an ``aether`` is already on PATH, in which case that one is used and
nothing is installed behind your back.

Zero runtime dependencies: it shells out to ``node`` and ``npm``, which the agent requires
anyway.
Expand Down Expand Up @@ -55,11 +56,19 @@ def _print_error(message: str) -> None:
print(message, file=sys.stderr)


#: What `self install` fetches when nothing is pinned. `latest` rather than this
#: package's own version on purpose: the agent ships maintenance releases from branches
#: that never reach main, so a launcher pinned to main's version would quietly install an
#: older agent than `npm install -g aether-agents@latest` gives. The two install routes
#: must land on the same agent; pin explicitly when you want a fixed one.
DEFAULT_NPM_TAG = "latest"


def _requested_version() -> str:
"""The npm version to install: this package's version unless overridden."""
"""The npm version or dist-tag to install: `latest` unless pinned."""
override = os.environ.get("AETHER_AGENT_NPM_VERSION", "").strip()
if not override:
return __version__
return DEFAULT_NPM_TAG
if not VERSION_PATTERN.match(override):
_print_error(f"Invalid AETHER_AGENT_NPM_VERSION: {override}")
raise SystemExit(2)
Expand Down Expand Up @@ -197,6 +206,7 @@ def _cmd_doctor(_: argparse.Namespace) -> int:
print(f"node v{node} ({supported})")
print(f"npm {'found' if shutil.which('npm') else 'not found'}")
print(f"install root {install_root()}")
print(f"installs {NPM_PACKAGE}@{_requested_version()}")

found = resolve_binary()
if found is None:
Expand Down Expand Up @@ -247,7 +257,7 @@ def _self_parser() -> argparse.ArgumentParser:
installer.add_argument(
"--npm-version",
default=None,
help=f"npm version to install (default {__version__})",
help=f"npm version or dist-tag to install (default {DEFAULT_NPM_TAG})",
)
installer.set_defaults(handler=_cmd_install)

Expand Down
13 changes: 8 additions & 5 deletions packages/pypi-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from unittest import mock

from aether_agent import NPM_PACKAGE, __version__, cli
from aether_agent import NPM_PACKAGE, cli

AETHER = Path("/bin/aether")
MANAGED = Path("/managed/aether")
Expand Down Expand Up @@ -36,11 +36,14 @@ def test_falls_back_to_a_per_user_directory_needing_no_admin_rights(self) -> Non


class TestRequestedVersion(unittest.TestCase):
def test_defaults_to_this_package_version(self) -> None:
def test_defaults_to_the_npm_latest_dist_tag(self) -> None:
# Not this package's version: the agent ships maintenance releases from branches
# that never reach main, so pinning to main's version would install an older agent
# than the documented `npm install -g aether-agents@latest` route.
with mock.patch.dict(
"os.environ", {"AETHER_AGENT_NPM_VERSION": ""}, clear=False
):
self.assertEqual(cli._requested_version(), __version__)
self.assertEqual(cli._requested_version(), "latest")

def test_accepts_an_explicit_override(self) -> None:
with mock.patch.dict(
Expand Down Expand Up @@ -148,7 +151,7 @@ def test_agent_commands_are_never_shadowed_by_the_launcher(self) -> None:


class TestSelfNamespace(unittest.TestCase):
def test_install_pins_the_version_this_launcher_declares(self) -> None:
def test_install_targets_the_same_agent_as_the_npm_route(self) -> None:
with (
mock.patch.object(cli, "_require_supported_node"),
mock.patch.object(cli, "_require", return_value="npm"),
Expand All @@ -163,7 +166,7 @@ def test_install_pins_the_version_this_launcher_declares(self) -> None:
command = run.call_args.args[0]
self.assertEqual(command[:2], ["npm", "install"])
self.assertIn("--ignore-scripts", command)
self.assertEqual(command[-1], f"{NPM_PACKAGE}@{__version__}")
self.assertEqual(command[-1], f"{NPM_PACKAGE}@{cli.DEFAULT_NPM_TAG}")

def test_install_accepts_an_explicit_npm_version(self) -> None:
with (
Expand Down