From 3e5dfdc168ec432015defd0b5366abc88fac9e98 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:05:37 -0400 Subject: [PATCH] Publish Aether Agent to PyPI as the aether-agent launcher Aether Agent has shipped on npm only, so a machine that manages its tools with pip or pipx had to reach for a global npm install to get it. This adds packages/pypi-cli, published to PyPI as `aether-agent`: the same agent, the same commands, installed the way that machine already installs things. It is a launcher, not a second agent, and the tests hold it to that: - Every argument outside the `self` namespace is forwarded to the real `aether` CLI unchanged, and its exit code becomes the process's exit code. `doctor`, `auth`, `sessions`, `config` and the rest reach the agent; only `self install`, `self doctor`, `self path`, and `self uninstall` belong to the launcher, so a launcher command can never shadow an agent command. - The version of this package is the version of `aether-agents` it installs. packages/sync-version.mjs copies package.json's version into both the pyproject and the module, `--check` fails CI when they drift, and the publish workflow proves the release tag agrees before building. - Installation goes into a private prefix under the user's own data directory with --ignore-scripts, so it needs no administrator rights and runs no package lifecycle scripts. An `aether` already on PATH wins, and nothing is installed behind the user's back. - AETHER_AGENT_NPM_VERSION is validated before it can reach an npm argument list, so an override cannot smuggle a flag or shell syntax into the install command. - No runtime dependencies: it shells out to node and npm, which the agent requires anyway. The Node floor is asserted against package.json's engines field rather than hardcoded twice. publish-pypi.yml mirrors release.yml: same immutable release tag, same ancestor-of-main check, same evidence upload, and PyPI Trusted Publishing (OIDC) rather than a stored token. workflow_dispatch defaults to a dry run so the path can be rehearsed before the first real publish. The pypi-production environment must have its Trusted Publisher registered on pypi.org first. The pypi-launcher CI job runs the unit tests, strict mypy, the version-sync check, and a wheel install smoke test on Python 3.10 -- the floor requires-python declares. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 53 +++ .github/workflows/publish-pypi.yml | 150 +++++++++ .gitignore | 6 + README.md | 6 + packages/pypi-cli/LICENSE | 201 ++++++++++++ packages/pypi-cli/NOTICE.md | 18 ++ packages/pypi-cli/README.md | 79 +++++ packages/pypi-cli/pyproject.toml | 68 ++++ .../pypi-cli/src/aether_agent/__init__.py | 19 ++ packages/pypi-cli/src/aether_agent/cli.py | 303 ++++++++++++++++++ packages/pypi-cli/tests/test_cli.py | 243 ++++++++++++++ packages/pypi-cli/tests/test_packaging.py | 63 ++++ packages/sync-version.mjs | 66 ++++ 13 files changed, 1275 insertions(+) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 packages/pypi-cli/LICENSE create mode 100644 packages/pypi-cli/NOTICE.md create mode 100644 packages/pypi-cli/README.md create mode 100644 packages/pypi-cli/pyproject.toml create mode 100644 packages/pypi-cli/src/aether_agent/__init__.py create mode 100644 packages/pypi-cli/src/aether_agent/cli.py create mode 100644 packages/pypi-cli/tests/test_cli.py create mode 100644 packages/pypi-cli/tests/test_packaging.py create mode 100644 packages/sync-version.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be5480b..5b70f259 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,3 +91,56 @@ jobs: path: aether-agents.cdx.json if-no-files-found: error retention-days: 90 + + pypi-launcher: + name: PyPI launcher + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + # The floor requires-python declares, so the published wheel is proved on the + # oldest interpreter it claims to support. + python-version: '3.10' + + - name: Launcher version tracks package.json + run: node packages/sync-version.mjs --check + + - name: Refuse runtime dependencies + working-directory: packages/pypi-cli + run: grep -qx 'dependencies = \[\]' pyproject.toml + + - name: Unit tests + working-directory: packages/pypi-cli + env: + PYTHONPATH: src + run: python -m unittest discover -s tests -v + + - name: Type check + working-directory: packages/pypi-cli + run: | + python -m pip install --disable-pip-version-check mypy==2.3.1 + python -m mypy src + + - name: Build and smoke-test the wheel + working-directory: packages/pypi-cli + run: | + set -Eeuo pipefail + python -m pip install --disable-pip-version-check build==1.4.0 twine==6.2.0 + python -m build + python -m twine check --strict dist/* + python -m venv "$RUNNER_TEMP/launcher" + "$RUNNER_TEMP/launcher/bin/pip" install --quiet dist/*.whl + "$RUNNER_TEMP/launcher/bin/aether-agent" self --version + "$RUNNER_TEMP/launcher/bin/aether-agent" self --help > /dev/null diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..3b232556 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,150 @@ +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. +# +# 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 +# Publisher must be registered with owner AetherAI3, repo aether-agent, workflow +# publish-pypi.yml, and environment name "pypi-production" (Manage project -> Publishing on +# pypi.org). +# +# workflow_dispatch is there to rehearse and to repair: it defaults to a dry run, and it +# publishes only when dry_run is explicitly false. + +on: + release: + types: + - published + workflow_dispatch: + inputs: + ref: + description: Release tag to build (for example v0.3.0) + required: true + type: string + dry_run: + description: Build and verify without publishing + required: false + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: pypi-production-${{ github.event.release.tag_name || inputs.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: pypi-production + permissions: + contents: read + id-token: write # PyPI Trusted Publishing (OIDC) + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.ref }} + steps: + - name: Checkout immutable release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify release commit belongs to main + shell: bash + run: | + git fetch --no-tags origin main + git merge-base --is-ancestor HEAD origin/main + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + # The floor declared by requires-python, so the published wheel is proved on the + # oldest interpreter it claims to support. + python-version: '3.10' + + - name: Verify the launcher, the npm package, and the tag agree + shell: bash + run: | + set -Eeuo pipefail + node packages/sync-version.mjs --check + version="$(node -p "require('./package.json').version")" + test "$version" = "${RELEASE_TAG#v}" + + - name: Refuse runtime dependencies + working-directory: packages/pypi-cli + run: grep -qx 'dependencies = \[\]' pyproject.toml + + - name: Unit tests + working-directory: packages/pypi-cli + env: + PYTHONPATH: src + run: python -m unittest discover -s tests -v + + - name: Type check + working-directory: packages/pypi-cli + run: | + python -m pip install --disable-pip-version-check mypy==2.3.1 + 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" + test -f "dist/aether_agent-${version}.tar.gz" + test -f "dist/aether_agent-${version}-py3-none-any.whl" + sha256sum dist/* + + - name: Smoke-test the exact wheel + shell: bash + working-directory: packages/pypi-cli + run: | + 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" + test "$("$RUNNER_TEMP/launcher/bin/aether-agent" self --version)" \ + = "${{ steps.build.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 + "$RUNNER_TEMP/launcher/bin/aether-agent" self --help > /dev/null + + - name: Upload release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pypi-release-${{ env.RELEASE_TAG }} + path: packages/pypi-cli/dist/* + if-no-files-found: error + retention-days: 90 + + - name: Publish to PyPI + if: ${{ github.event_name == 'release' || !inputs.dry_run }} + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: packages/pypi-cli/dist + + - name: Dry run summary + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + shell: bash + run: | + set -Eeuo pipefail + echo "Dry run only. Built aether-agent ${{ steps.build.outputs.version }}." + echo "Re-run with dry_run=false to publish." diff --git a/.gitignore b/.gitignore index a269038a..e7f7cbeb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ _tmp/ # packed npm tarballs (npm pack output) — the registry is the distribution # channel, not the repo *.tgz + +# Python launcher (packages/pypi-cli) +__pycache__/ +*.py[cod] +.mypy_cache/ +.ruff_cache/ diff --git a/README.md b/README.md index 1696a567..f63ccf94 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![CI](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/AetherAI3/aether-agent/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/aether-agents?label=npm)](https://www.npmjs.com/package/aether-agents) +[![PyPI](https://img.shields.io/pypi/v/aether-agent?label=PyPI&color=3775a9)](https://pypi.org/project/aether-agent/) [![Node 24+](https://img.shields.io/badge/node-24%2B-14b8a6)](https://nodejs.org/) [![License](https://img.shields.io/badge/license-Apache--2.0-06b6d4)](LICENSE) @@ -29,6 +30,10 @@ aether auth login aether agent --test-cmd "npm test" "fix the failing test" ``` +Prefer Python tooling? `pipx install aether-agent` installs the same CLI and forwards +every command to it, so `aether-agent code "..."` and `aether code "..."` do the same +work. See [`packages/pypi-cli`](packages/pypi-cli/README.md). + The third command gives Aether one task and one verification command. The local host runs `npm test`; its real exit code determines whether the result is verified. @@ -165,6 +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. | | `main` source build | **0.3.0** | Current repository source and its 0.3 workflow. | The [release record](docs/releases/2026-08-22.md), diff --git a/packages/pypi-cli/LICENSE b/packages/pypi-cli/LICENSE new file mode 100644 index 00000000..e9fc0b43 --- /dev/null +++ b/packages/pypi-cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Aether + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/pypi-cli/NOTICE.md b/packages/pypi-cli/NOTICE.md new file mode 100644 index 00000000..72892d70 --- /dev/null +++ b/packages/pypi-cli/NOTICE.md @@ -0,0 +1,18 @@ +# NOTICE + +Aether Agent +Copyright 2026 Aether AI LLC +Created by Brandon Barrante (https://github.com/AetherAI3), founder of Aether AI. + +This product is licensed under the Apache License, Version 2.0 (the "License"); +you may not use this software except in compliance with the License. A copy of +the License is in [`LICENSE`](LICENSE). + +Aether Agent is a **client**. It talks to the Aether API +(`https://api.aethersystems.net`) and requires an Aether account to run models. +The models, orchestrators, billing, and audit log are operated by Aether AI and +are not part of this open-source repository. + +"Aether", "Aether Agent", "Aether AI", "Neo", and "Kronus" are names used by +Aether AI LLC. The Apache-2.0 license covers the code in this repository; it +does not grant rights to the Aether name, logos, or hosted service. diff --git a/packages/pypi-cli/README.md b/packages/pypi-cli/README.md new file mode 100644 index 00000000..a8726eab --- /dev/null +++ b/packages/pypi-cli/README.md @@ -0,0 +1,79 @@ +# aether-agent + +The pip/pipx front door to **[Aether Agent](https://github.com/AetherAI3/aether-agent) by Aether +AI** — an open-source terminal coding agent that edits your repository, runs your chosen checks, +and reports verified results, through hosted models or local Ollama. + +```bash +pipx install aether-agent +aether-agent auth login +aether-agent code --test-cmd "npm test" "fix the failing test" +``` + +Aether Agent itself is a Node program, published to npm as +[`aether-agents`](https://www.npmjs.com/package/aether-agents). This package installs and runs it, +so a Python-first machine can get the agent with the installer it already uses. It is the same +agent and the same commands — not a reimplementation, and not a second interface to keep in sync. + +Requires **Node 24+** on PATH (the agent's own requirement) and Python 3.10+. + +## What it actually does + +- **Forwards everything.** Every argument that is not in the `self` namespace goes to the real + `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. +- **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. + +## The `self` namespace + +Launcher-owned commands are namespaced so they can never shadow an agent command — `aether doctor` +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 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. + +## 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. | + +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 +[`COMMANDS.md`](https://github.com/AetherAI3/aether-agent/blob/main/COMMANDS.md#environment-variables). + +## Which install should I use? + +Use whichever matches how you manage tools. They install the same agent: + +```bash +pipx install aether-agent # this package +npm install -g aether-agents@latest --ignore-scripts # npm directly +``` + +`pipx` keeps the launcher in its own environment; the agent still lands in the launcher's prefix +rather than in your Python environment. + +## Where the authority stays + +Repository tools, permission decisions, session records, and verification run on your machine +under the agent, exactly as they do for the npm install — this launcher only starts it. Hosted runs +send the task and context you provide to the Aether API; the local Ollama route needs no Aether +account. See the [security policy](https://github.com/AetherAI3/aether-agent/blob/main/SECURITY.md). + +Apache-2.0 · [Aether AI](https://github.com/AetherAI3) · [`NOTICE.md`](NOTICE.md) diff --git a/packages/pypi-cli/pyproject.toml b/packages/pypi-cli/pyproject.toml new file mode 100644 index 00000000..ae03da7d --- /dev/null +++ b/packages/pypi-cli/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["hatchling==1.28.0"] +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. +[project] +name = "aether-agent" +version = "0.3.0" +description = "Install and run Aether Agent, the open-source terminal coding agent, from pip or pipx." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE.md"] +authors = [{ name = "Brandon Barrante (Aether AI)", email = "aetherai@aethersystems.net" }] +keywords = [ + "coding-agent", + "ai-coding-assistant", + "developer-tools", + "terminal-agent", + "local-first", + "local-llm", + "ollama", + "mcp", + "code-review", + "software-engineering", + "cli", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", +] +# No runtime dependencies, deliberately: this shells out to node and npm, which the agent +# requires anyway. +dependencies = [] + +[project.urls] +Homepage = "https://aethersystems.net" +Repository = "https://github.com/AetherAI3/aether-agent" +Issues = "https://github.com/AetherAI3/aether-agent/issues" +Documentation = "https://github.com/AetherAI3/aether-agent/blob/main/COMMANDS.md" + +[project.scripts] +aether-agent = "aether_agent.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/aether_agent"] + +[tool.hatch.build.targets.sdist] +include = ["src", "tests", "README.md", "LICENSE", "NOTICE.md", "pyproject.toml"] + +[tool.mypy] +python_version = "3.10" +strict = true +packages = ["aether_agent"] +mypy_path = "src" diff --git a/packages/pypi-cli/src/aether_agent/__init__.py b/packages/pypi-cli/src/aether_agent/__init__.py new file mode 100644 index 00000000..80a6907a --- /dev/null +++ b/packages/pypi-cli/src/aether_agent/__init__.py @@ -0,0 +1,19 @@ +"""pip/pipx installer and launcher for the Aether Agent CLI. + +Aether Agent itself is a Node program published to npm as ``aether-agents``. This package +is the Python front door to it: ``pipx install aether-agent`` gets you the same ``aether`` +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. +""" + +from __future__ import annotations + +__version__ = "0.3.0" + +#: The npm package this launcher installs and runs. +NPM_PACKAGE = "aether-agents" + +__all__ = ["NPM_PACKAGE", "__version__"] diff --git a/packages/pypi-cli/src/aether_agent/cli.py b/packages/pypi-cli/src/aether_agent/cli.py new file mode 100644 index 00000000..dc2c41d4 --- /dev/null +++ b/packages/pypi-cli/src/aether_agent/cli.py @@ -0,0 +1,303 @@ +"""The ``aether-agent`` launcher. + +Aether Agent is a Node program. This launcher exists so a Python-first machine can install +and run it the same way it installs anything else, without a hand-rolled global npm +install and without a second copy of the agent's own interface to drift from it. + +Two rules keep it honest: + +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. + +Zero runtime dependencies: it shells out to ``node`` and ``npm``, which the agent requires +anyway. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from . import NPM_PACKAGE, __version__ + +# package.json declares "engines": { "node": ">=24" }, and the test script uses +# --test-isolation=none, which older Node rejects outright. +MIN_NODE_MAJOR = 24 + +REPO = "https://github.com/AetherAI3/aether-agent" +VERSION_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$") + +SELF_COMMANDS = ("install", "doctor", "path", "uninstall") + +NEXT_STEPS = f""" +Next steps: + aether-agent auth login Sign in for hosted models + aether-agent setup --local Or prepare a local Ollama route + aether-agent code "fix the failing test" Run the agent on this repository + +Every command is forwarded to the `aether` CLI unchanged. Its own reference is +`aether-agent --help`; the launcher's is `aether-agent self --help`. + +Docs: {REPO}#readme +""" + + +def _print_error(message: str) -> None: + print(message, file=sys.stderr) + + +def _requested_version() -> str: + """The npm version to install: this package's version unless overridden.""" + override = os.environ.get("AETHER_AGENT_NPM_VERSION", "").strip() + if not override: + return __version__ + if not VERSION_PATTERN.match(override): + _print_error(f"Invalid AETHER_AGENT_NPM_VERSION: {override}") + raise SystemExit(2) + return override + + +def install_root() -> Path: + """Where the launcher keeps its private npm prefix. + + A prefix under the user's own data directory means installation never needs + administrator rights and never fights a system-wide npm install. + """ + override = os.environ.get("AETHER_AGENT_HOME", "").strip() + if override: + return Path(override).expanduser().resolve() + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local") + else: + base = os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share") + return Path(base).expanduser().resolve() / "aether-agent" + + +def _managed_binary() -> Path: + name = "aether.cmd" if sys.platform == "win32" else "aether" + return install_root() / "node_modules" / ".bin" / name + + +def _require(tool: str) -> str: + found = shutil.which(tool) + if found is None: + _print_error( + f"{tool} is required to run Aether Agent. Install Node {MIN_NODE_MAJOR}+ " + "(https://nodejs.org/), then re-run." + ) + raise SystemExit(1) + return found + + +def _node_major() -> int | None: + if shutil.which("node") is None: + return None + probe = subprocess.run( + ["node", "--version"], capture_output=True, text=True, check=False + ) + if probe.returncode != 0: + return None + match = re.match(r"v(\d+)", probe.stdout.strip()) + return int(match.group(1)) if match else None + + +def _require_supported_node() -> None: + _require("node") + major = _node_major() + if major is None: + _print_error( + "Could not read `node --version`. Check the Node installation on PATH." + ) + raise SystemExit(1) + if major < MIN_NODE_MAJOR: + _print_error( + f"Aether Agent requires Node {MIN_NODE_MAJOR} or newer; found major version {major}. " + "Upgrade Node (https://nodejs.org/), then re-run." + ) + raise SystemExit(1) + + +def resolve_binary() -> tuple[Path, str] | None: + """The `aether` this launcher would run, and where it came from.""" + on_path = shutil.which("aether") + if on_path: + return Path(on_path), "PATH" + managed = _managed_binary() + if managed.exists(): + return managed, "launcher install" + return None + + +def install(version: str | None = None) -> Path: + """Install or update the npm CLI into the private prefix and return its binary.""" + _require_supported_node() + _require("npm") + target = install_root() + target.mkdir(parents=True, exist_ok=True) + wanted = version or _requested_version() + print(f"Installing {NPM_PACKAGE}@{wanted} into {target} ...", flush=True) + # --ignore-scripts matches the documented npm install line: the agent needs no + # lifecycle scripts, and refusing them keeps installation from executing package code. + command = [ + "npm", + "install", + "--prefix", + str(target), + "--ignore-scripts", + "--no-audit", + "--no-fund", + f"{NPM_PACKAGE}@{wanted}", + ] + result = subprocess.run(command, check=False) + if result.returncode != 0: + _print_error("npm install failed. See the output above.") + raise SystemExit(result.returncode or 1) + binary = _managed_binary() + if not binary.exists(): + _print_error(f"npm reported success but {binary} is missing.") + raise SystemExit(1) + return binary + + +def _installed_version(binary: Path) -> str | None: + probe = subprocess.run( + [str(binary), "--version"], capture_output=True, text=True, check=False + ) + if probe.returncode != 0: + return None + return probe.stdout.strip().splitlines()[0] if probe.stdout.strip() else None + + +def _cmd_install(arguments: argparse.Namespace) -> int: + binary = install(arguments.npm_version) + print(f"\nInstalled: {binary}") + print(NEXT_STEPS) + return 0 + + +def _cmd_doctor(_: argparse.Namespace) -> int: + print(f"launcher aether-agent {__version__} (PyPI)") + print(f"python {sys.version.split()[0]}") + node = _node_major() + if node is None: + print(f"node not found (need {MIN_NODE_MAJOR}+)") + else: + supported = ( + "ok" if node >= MIN_NODE_MAJOR else f"too old, need {MIN_NODE_MAJOR}+" + ) + print(f"node v{node} ({supported})") + print(f"npm {'found' if shutil.which('npm') else 'not found'}") + print(f"install root {install_root()}") + + found = resolve_binary() + if found is None: + print("aether CLI not installed (run `aether-agent self install`)") + return 1 + binary, source = found + version = _installed_version(binary) or "unknown" + print(f"aether CLI {version} from {source}") + print(f" {binary}") + if source == "PATH": + print( + "\nAn `aether` on PATH takes precedence, so this launcher runs the CLI you " + "already installed rather than a second copy." + ) + return 0 + + +def _cmd_path(_: argparse.Namespace) -> int: + found = resolve_binary() + if found is None: + _print_error("No aether CLI found. Run `aether-agent self install`.") + return 1 + print(found[0]) + return 0 + + +def _cmd_uninstall(_: argparse.Namespace) -> int: + """Remove only what this launcher installed. An `aether` on PATH is never touched.""" + target = install_root() + if not target.exists(): + print(f"Nothing to remove: {target} does not exist.") + return 0 + shutil.rmtree(target) + print(f"Removed {target}") + return 0 + + +def _self_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="aether-agent self", + description="Manage the Aether Agent CLI this launcher runs.", + epilog=f"Every other argument is forwarded to the `aether` CLI. Docs: {REPO}#readme", + ) + parser.add_argument("--version", action="version", version=__version__) + subparsers = parser.add_subparsers(dest="command") + + installer = subparsers.add_parser("install", help="install or update the CLI") + installer.add_argument( + "--npm-version", + default=None, + help=f"npm version to install (default {__version__})", + ) + installer.set_defaults(handler=_cmd_install) + + subparsers.add_parser( + "doctor", help="report the launcher's view of this machine" + ).set_defaults(handler=_cmd_doctor) + subparsers.add_parser( + "path", help="print the aether binary that would run" + ).set_defaults(handler=_cmd_path) + subparsers.add_parser( + "uninstall", help="remove the launcher's private install" + ).set_defaults(handler=_cmd_uninstall) + return parser + + +def _run_self(argv: list[str]) -> int: + parser = _self_parser() + arguments = parser.parse_args(argv) + handler = getattr(arguments, "handler", None) + if handler is None: + parser.print_help() + return 2 + result: int = handler(arguments) + return result + + +def forward(argv: list[str]) -> int: + """Run the real CLI with these arguments, installing it first if it is missing.""" + found = resolve_binary() + if found is None: + _require_supported_node() + print( + f"Aether Agent is not installed yet. Fetching {NPM_PACKAGE}@{_requested_version()}." + ) + binary = install() + else: + binary = found[0] + result = subprocess.run([str(binary), *argv], check=False) + return result.returncode + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments and arguments[0] == "self": + return _run_self(arguments[1:]) + if not arguments: + # No arguments starts the agent's own REPL, exactly as `aether` alone does. + return forward([]) + return forward(arguments) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/pypi-cli/tests/test_cli.py b/packages/pypi-cli/tests/test_cli.py new file mode 100644 index 00000000..478c1cfc --- /dev/null +++ b/packages/pypi-cli/tests/test_cli.py @@ -0,0 +1,243 @@ +"""The launcher's contract: forward everything, install one known version, own nothing else.""" + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path +from unittest import mock + +from aether_agent import NPM_PACKAGE, __version__, cli + +AETHER = Path("/bin/aether") +MANAGED = Path("/managed/aether") + + +def _completed( + returncode: int = 0, stdout: str = "" +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr="" + ) + + +class TestInstallRoot(unittest.TestCase): + def test_honours_an_explicit_home(self) -> None: + with mock.patch.dict( + "os.environ", {"AETHER_AGENT_HOME": "/tmp/somewhere"}, clear=False + ): + self.assertEqual(cli.install_root(), Path("/tmp/somewhere").resolve()) + + def test_falls_back_to_a_per_user_directory_needing_no_admin_rights(self) -> None: + with mock.patch.dict("os.environ", {"AETHER_AGENT_HOME": ""}, clear=False): + root = cli.install_root() + self.assertEqual(root.name, "aether-agent") + self.assertTrue(root.is_absolute()) + + +class TestRequestedVersion(unittest.TestCase): + def test_defaults_to_this_package_version(self) -> None: + with mock.patch.dict( + "os.environ", {"AETHER_AGENT_NPM_VERSION": ""}, clear=False + ): + self.assertEqual(cli._requested_version(), __version__) + + def test_accepts_an_explicit_override(self) -> None: + with mock.patch.dict( + "os.environ", {"AETHER_AGENT_NPM_VERSION": "0.2.1"}, clear=False + ): + self.assertEqual(cli._requested_version(), "0.2.1") + + def test_refuses_a_version_that_could_carry_shell_or_flag_syntax(self) -> None: + for bad in ( + "--registry=http://evil", + "0.1.0; rm -rf /", + "$(id)", + "&& npm login", + ): + with ( + self.subTest(bad=bad), + mock.patch.dict( + "os.environ", {"AETHER_AGENT_NPM_VERSION": bad}, clear=False + ), + self.assertRaises(SystemExit) as caught, + ): + cli._requested_version() + self.assertEqual(caught.exception.code, 2) + + +class TestResolution(unittest.TestCase): + def test_prefers_an_aether_already_on_path(self) -> None: + with mock.patch.object( + cli.shutil, "which", return_value="/usr/local/bin/aether" + ): + found = cli.resolve_binary() + assert found is not None + self.assertEqual(found[1], "PATH") + + def test_falls_back_to_the_launcher_install(self) -> None: + with ( + mock.patch.object(cli.shutil, "which", return_value=None), + mock.patch.object(cli.Path, "exists", return_value=True), + ): + found = cli.resolve_binary() + assert found is not None + self.assertEqual(found[1], "launcher install") + + def test_reports_nothing_when_neither_exists(self) -> None: + with ( + mock.patch.object(cli.shutil, "which", return_value=None), + mock.patch.object(cli.Path, "exists", return_value=False), + ): + self.assertIsNone(cli.resolve_binary()) + + +class TestForwarding(unittest.TestCase): + def test_passes_every_argument_through_untouched(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=(AETHER, "PATH")), + mock.patch.object(cli.subprocess, "run", return_value=_completed()) as run, + ): + cli.main(["code", "--test-cmd", "npm test", "fix the failing test"]) + self.assertEqual( + run.call_args.args[0], + [str(AETHER), "code", "--test-cmd", "npm test", "fix the failing test"], + ) + + def test_returns_the_agent_exit_code(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=(AETHER, "PATH")), + mock.patch.object( + cli.subprocess, "run", return_value=_completed(returncode=7) + ), + ): + self.assertEqual(cli.main(["audit"]), 7) + + def test_no_arguments_starts_the_agents_own_repl(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=(AETHER, "PATH")), + mock.patch.object(cli.subprocess, "run", return_value=_completed()) as run, + ): + cli.main([]) + self.assertEqual(run.call_args.args[0], [str(AETHER)]) + + def test_installs_on_first_use_then_forwards(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=None), + mock.patch.object(cli, "_require_supported_node"), + mock.patch.object(cli, "install", return_value=MANAGED) as install, + mock.patch.object(cli.subprocess, "run", return_value=_completed()) as run, + ): + cli.main(["models"]) + install.assert_called_once_with() + self.assertEqual(run.call_args.args[0], [str(MANAGED), "models"]) + + def test_agent_commands_are_never_shadowed_by_the_launcher(self) -> None: + # `aether doctor` is a real agent command. Only the `self` namespace is the + # launcher's, so doctor, auth, sessions and the rest must reach the agent. + for command in ("doctor", "auth", "sessions", "config", "install", "help"): + with ( + self.subTest(command=command), + mock.patch.object(cli, "resolve_binary", return_value=(AETHER, "PATH")), + mock.patch.object( + cli.subprocess, "run", return_value=_completed() + ) as run, + ): + cli.main([command]) + self.assertEqual(run.call_args.args[0], [str(AETHER), command]) + + +class TestSelfNamespace(unittest.TestCase): + def test_install_pins_the_version_this_launcher_declares(self) -> None: + with ( + mock.patch.object(cli, "_require_supported_node"), + mock.patch.object(cli, "_require", return_value="npm"), + mock.patch.object(cli.Path, "mkdir"), + mock.patch.object(cli.Path, "exists", return_value=True), + mock.patch.object(cli.subprocess, "run", return_value=_completed()) as run, + mock.patch.dict( + "os.environ", {"AETHER_AGENT_NPM_VERSION": ""}, clear=False + ), + ): + cli.main(["self", "install"]) + 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__}") + + def test_install_accepts_an_explicit_npm_version(self) -> None: + with ( + mock.patch.object(cli, "_require_supported_node"), + mock.patch.object(cli, "_require", return_value="npm"), + mock.patch.object(cli.Path, "mkdir"), + mock.patch.object(cli.Path, "exists", return_value=True), + mock.patch.object(cli.subprocess, "run", return_value=_completed()) as run, + ): + cli.main(["self", "install", "--npm-version", "0.2.0"]) + self.assertEqual(run.call_args.args[0][-1], f"{NPM_PACKAGE}@0.2.0") + + def test_install_fails_loudly_when_npm_fails(self) -> None: + with ( + mock.patch.object(cli, "_require_supported_node"), + mock.patch.object(cli, "_require", return_value="npm"), + mock.patch.object(cli.Path, "mkdir"), + mock.patch.object( + cli.subprocess, "run", return_value=_completed(returncode=1) + ), + self.assertRaises(SystemExit) as caught, + ): + cli.main(["self", "install"]) + self.assertEqual(caught.exception.code, 1) + + def test_doctor_reports_a_missing_cli_as_a_failure(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=None), + mock.patch.object(cli, "_node_major", return_value=24), + mock.patch.object(cli.shutil, "which", return_value="npm"), + ): + self.assertEqual(cli.main(["self", "doctor"]), 1) + + def test_doctor_passes_once_the_cli_is_present(self) -> None: + with ( + mock.patch.object(cli, "resolve_binary", return_value=(AETHER, "PATH")), + mock.patch.object(cli, "_node_major", return_value=24), + mock.patch.object(cli, "_installed_version", return_value="0.3.0"), + mock.patch.object(cli.shutil, "which", return_value="npm"), + ): + self.assertEqual(cli.main(["self", "doctor"]), 0) + + def test_uninstall_removes_only_the_launchers_own_directory(self) -> None: + with ( + mock.patch.object( + cli, "install_root", return_value=Path("/managed/aether-agent") + ), + mock.patch.object(cli.Path, "exists", return_value=True), + mock.patch.object(cli.shutil, "rmtree") as rmtree, + ): + self.assertEqual(cli.main(["self", "uninstall"]), 0) + rmtree.assert_called_once_with(Path("/managed/aether-agent")) + + def test_bare_self_prints_help_rather_than_guessing(self) -> None: + self.assertEqual(cli.main(["self"]), 2) + + +class TestNodeRequirement(unittest.TestCase): + def test_refuses_a_node_older_than_the_agent_supports(self) -> None: + with ( + mock.patch.object(cli.shutil, "which", return_value="/usr/bin/node"), + mock.patch.object(cli, "_node_major", return_value=20), + self.assertRaises(SystemExit) as caught, + ): + cli._require_supported_node() + self.assertEqual(caught.exception.code, 1) + + def test_accepts_the_supported_node(self) -> None: + with ( + mock.patch.object(cli.shutil, "which", return_value="/usr/bin/node"), + mock.patch.object(cli, "_node_major", return_value=cli.MIN_NODE_MAJOR), + ): + cli._require_supported_node() + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/pypi-cli/tests/test_packaging.py b/packages/pypi-cli/tests/test_packaging.py new file mode 100644 index 00000000..17a63fbd --- /dev/null +++ b/packages/pypi-cli/tests/test_packaging.py @@ -0,0 +1,63 @@ +"""One release, two ecosystems: the launcher must never claim a version the agent is not. + +package.json is the source of truth; `node packages/sync-version.mjs` copies it here. +""" + +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + +import aether_agent +from aether_agent import cli + +PACKAGE = Path(__file__).resolve().parents[1] +REPO_ROOT = PACKAGE.parents[1] + + +def _pyproject_field(name: str) -> str: + text = (PACKAGE / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(rf'^{name} = "([^"]+)"', text, re.MULTILINE) + assert match is not None, f"{name} missing from pyproject.toml" + return match.group(1) + + +class TestPackaging(unittest.TestCase): + def setUp(self) -> None: + self.npm = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) + + def test_the_launcher_version_tracks_the_npm_package(self) -> None: + self.assertEqual( + _pyproject_field("version"), + self.npm["version"], + "run `node packages/sync-version.mjs` and commit the result", + ) + + def test_the_module_version_matches_the_distribution_version(self) -> None: + self.assertEqual(aether_agent.__version__, _pyproject_field("version")) + + def test_the_launcher_installs_the_package_the_repository_publishes(self) -> None: + self.assertEqual(aether_agent.NPM_PACKAGE, self.npm["name"]) + + def test_the_node_floor_matches_the_npm_engines_field(self) -> None: + engines = self.npm["engines"]["node"] + self.assertEqual(engines, f">={cli.MIN_NODE_MAJOR}") + + def test_the_binary_name_is_one_the_npm_package_provides(self) -> None: + self.assertIn("aether", self.npm["bin"]) + + def test_the_package_declares_no_runtime_dependencies(self) -> None: + self.assertIn( + "dependencies = []", + (PACKAGE / "pyproject.toml").read_text(encoding="utf-8"), + ) + + def test_the_launcher_ships_the_licence_and_notice_it_claims(self) -> None: + self.assertTrue((PACKAGE / "LICENSE").is_file()) + self.assertTrue((PACKAGE / "NOTICE.md").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/sync-version.mjs b/packages/sync-version.mjs new file mode 100644 index 00000000..66550f14 --- /dev/null +++ b/packages/sync-version.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Copies the npm package's version into the PyPI launcher, so a release never ships a +// launcher that installs a different version of the agent than it claims to be. +// +// package.json is the single source of truth: the release flow already owns it, and this +// script only follows it. +// +// Usage: node packages/sync-version.mjs [--check] + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packagesDir = dirname(fileURLToPath(import.meta.url)); +const root = dirname(packagesDir); +const checkOnly = process.argv.includes("--check"); + +const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(version)) { + console.error(`package.json has an unusable version: ${version}`); + process.exit(2); +} + +const targets = [ + { + path: join(packagesDir, "pypi-cli", "pyproject.toml"), + pattern: /^version = "([^"]+)"$/m, + label: "packages/pypi-cli/pyproject.toml", + }, + { + path: join(packagesDir, "pypi-cli", "src", "aether_agent", "__init__.py"), + pattern: /^__version__ = "([^"]+)"$/m, + label: "packages/pypi-cli/src/aether_agent/__init__.py", + }, +]; + +let drifted = false; +for (const { path, pattern, label } of targets) { + const raw = readFileSync(path, "utf8"); + const match = raw.match(pattern); + if (!match) throw new Error(`Could not find a version to sync in ${label}`); + const from = match[1]; + if (from === version) { + console.log(`${label}: ${version} (already in sync)`); + continue; + } + drifted = true; + if (checkOnly) { + console.error(`${label}: ${from} != package.json ${version}`); + continue; + } + writeFileSync(path, raw.replace(pattern, (full) => full.replace(from, version))); + console.log(`${label}: ${from} -> ${version}`); +} + +if (checkOnly && drifted) { + console.error("\nRun `node packages/sync-version.mjs` and commit the result."); + process.exit(1); +} + +if (!checkOnly && drifted) { + console.log( + "\nNext: review the diff, commit, then publish -- npm: publish the GitHub release as " + + "usual; PyPI: dispatch publish-pypi.yml.", + ); +}