diff --git a/CHANGELOG.md b/CHANGELOG.md index 249fde5..430923d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ declares; a release with no section fails the gate. ## Unreleased +- The index pages of `sayfirst-cli` and `sayfirst` say what the product is in + their first line, carry keywords and the classifiers that describe it (the + console, the audience, Linux and macOS, security), and link to the issues, + to the control plane's repository and to its page on the index. The pointer + guard holds each of those links to the one address decided for it. Both + READMEs open with the same summary and the same links. + ## 0.3.3 - A second distribution is built from this repository: `sayfirst`, the product diff --git a/README.md b/README.md index 1e1ff84..a2bf4e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ -# `sayfirst` — ask before you act +# SayFirst CLI (`sayfirst-cli`) — ask before you act + +**`sayfirst-cli` is the open-source command line of the [SayFirst control plane +(`sayfirst-control-plane`)](https://github.com/fredaime/sayfirst-control-plane): +runtime governance for AI agents and the programs they drive.** A governed +program asks a local daemon before an effect and does only what was allowed; +`sayfirst` puts that question in front of a program that was never written for +it, holds what waits for a person until one approves it, and exports evidence +anyone can verify offline. + +[![PyPI - sayfirst-cli](https://img.shields.io/pypi/v/sayfirst-cli.svg?label=PyPI%20sayfirst-cli)](https://pypi.org/project/sayfirst-cli/) +[![PyPI - sayfirst-control-plane](https://img.shields.io/pypi/v/sayfirst-control-plane.svg?label=PyPI%20sayfirst-control-plane)](https://pypi.org/project/sayfirst-control-plane/) + +**Links:** [`sayfirst-cli` on GitHub](https://github.com/fredaime/sayfirst-cli) · +[`sayfirst-cli` on PyPI](https://pypi.org/project/sayfirst-cli/) · +[control plane on GitHub](https://github.com/fredaime/sayfirst-control-plane) · +[control plane on PyPI](https://pypi.org/project/sayfirst-control-plane/) This repository is **the command**. `sayfirst` is the open-source product command-line interface of the `sayfirst` control plane: it asks the daemon diff --git a/pyproject.toml b/pyproject.toml index 6037870..fbc4b00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,17 +2,37 @@ [project] name = "sayfirst-cli" version = "0.3.3" -description = "The product command-line interface of the sayfirst control plane" +description = "SayFirst CLI (sayfirst-cli): ask the SayFirst control plane before an effect — govern AI agents and programs, approve what waits for a person, verify the evidence offline" readme = "README.md" requires-python = ">=3.12,<3.15" license = "Apache-2.0" authors = [{ name = "fredaime", email = "frederic.aime@gmail.com" }] +keywords = [ + "sayfirst", + "sayfirst-cli", + "sayfirst-control-plane", + "ai-agents", + "agent-security", + "ai-security", + "llm-security", + "runtime-governance", + "policy-enforcement", + "human-in-the-loop", + "audit-trail", +] classifiers = [ "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Topic :: Security", + "Topic :: System :: Systems Administration", ] # Article 13 and article 14: the client depends on the contract distribution and # on nothing else this project publishes. The closure this line produces is what @@ -26,13 +46,18 @@ dependencies = ["sayfirst-contract==0.3.3", "sayfirst-boundary==0.3.3"] # the act that published it (2026-09-17) under the names the operator chose for # that act. The guard # `tests/test_pointers_survive_publication.py` holds them, so a rename lands -# here and there together or not at all. No licence classifier: the licence is -# declared as an expression above, and an index refuses a distribution that -# carries both. +# here and there together or not at all. The last two send a reader to the +# sibling the client asks — the control plane's repository and its page on the +# index — and the same guard holds that they point there and nowhere else. No +# licence classifier: the licence is declared as an expression above, and an +# index refuses a distribution that carries both. [project.urls] Repository = "https://github.com/fredaime/sayfirst-cli" Documentation = "https://github.com/fredaime/sayfirst-cli/blob/main/README.md" Changelog = "https://github.com/fredaime/sayfirst-cli/blob/main/CHANGELOG.md" +Issues = "https://github.com/fredaime/sayfirst-cli/issues" +"Control plane" = "https://github.com/fredaime/sayfirst-control-plane" +"Control plane on PyPI" = "https://pypi.org/project/sayfirst-control-plane/" [project.scripts] sayfirst = "sayfirst_cli.main:run" diff --git a/tests/test_pointers_survive_publication.py b/tests/test_pointers_survive_publication.py index 9f7fe73..e9f86c7 100644 --- a/tests/test_pointers_survive_publication.py +++ b/tests/test_pointers_survive_publication.py @@ -118,12 +118,42 @@ def test_the_rule_leaves_a_local_sibling_path_alone() -> None: PUBLIC_CLIENT = "https://github.com/fredaime/sayfirst-cli" +#: The three URLs every page of this repository's distributions carries, each +#: into this repository. +OWN_URLS = frozenset({"Repository", "Documentation", "Changelog"}) + +#: Every other URL a page here may carry, and the one address each may hold: +#: this repository's issues, and the control plane the client asks — its +#: repository and its page on the index. A closed table rather than a pattern, +#: so a misspelt project name on the index, which would send a reader to a +#: page anybody could register, is a failure here rather than a guess. +CROSS_LINKS = { + "Issues": f"{PUBLIC_CLIENT}/issues", + "Control plane": PUBLIC_CONTROL_PLANE, + "Control plane on PyPI": "https://pypi.org/project/sayfirst-control-plane/", +} + + def project_urls(root: Path) -> dict[str, str]: """The `[project.urls]` table of the distribution at `root`, or an empty one.""" project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"] return dict(project.get("urls") or {}) +def misdirected(urls: dict[str, str]) -> list[str]: + """Every entry of this table that sends a reader somewhere this module has not decided.""" + found = [f"missing: {name}" for name in sorted(OWN_URLS - set(urls))] + for name, url in sorted(urls.items()): + if NEVER_PUBLISHED in url: + found.append(f"never published: {name} = {url}") + elif name in OWN_URLS: + if not url.startswith(PUBLIC_CLIENT): + found.append(f"outside this repository: {name} = {url}") + elif CROSS_LINKS.get(name) != url: + found.append(f"undecided: {name} = {url}") + return found + + def test_every_published_url_of_this_distribution_is_a_public_repository() -> None: """A package page is read by people who hold nothing of this project. @@ -133,10 +163,20 @@ def test_every_published_url_of_this_distribution_is_a_public_repository() -> No module already refuses in `README.md` and `NOTICE`, made to a wider audience. """ urls = project_urls(REPOSITORY) - assert set(urls) == {"Repository", "Documentation", "Changelog"}, urls - for name, url in urls.items(): - assert url.startswith(PUBLIC_CLIENT), (name, url) - assert NEVER_PUBLISHED not in url, (name, url) + assert misdirected(urls) == [], urls + assert set(CROSS_LINKS) <= set(urls), urls + + +def test_the_url_rule_fires_on_a_misspelt_page_of_the_index() -> None: + """WATCHED FIRING, on the cross-link that is easiest to get wrong: a project + name on the index that nobody here publishes.""" + urls = { + **project_urls(REPOSITORY), + "Control plane on PyPI": "https://pypi.org/project/sayfirst-controlplane/", + } + assert misdirected(urls) == [ + "undecided: Control plane on PyPI = https://pypi.org/project/sayfirst-controlplane/" + ] def test_the_url_rule_fires_on_a_pointer_to_the_repository_that_is_never_published( @@ -156,3 +196,4 @@ def test_the_url_rule_fires_on_a_pointer_to_the_repository_that_is_never_publish assert urls, "the reader saw no table: the probe proves nothing" assert not all(url.startswith(PUBLIC_CLIENT) for url in urls.values()) assert any(NEVER_PUBLISHED in url for url in urls.values()) + assert f"never published: Repository = {urls['Repository']}" in misdirected(urls) diff --git a/tests/test_umbrella_metadata_agrees_with_the_client.py b/tests/test_umbrella_metadata_agrees_with_the_client.py index 85bf114..0a416cc 100644 --- a/tests/test_umbrella_metadata_agrees_with_the_client.py +++ b/tests/test_umbrella_metadata_agrees_with_the_client.py @@ -65,7 +65,8 @@ def test_every_url_of_the_umbrella_is_this_public_repository() -> None: client = project_urls(REPOSITORY) assert set(urls) == set(client), urls assert urls["Repository"] == client["Repository"] == PUBLIC_CLIENT - assert urls["Changelog"] == client["Changelog"] + for key in set(urls) - {"Documentation"}: + assert urls[key] == client[key], key page = urls["Documentation"].removeprefix(f"{PUBLIC_CLIENT}/blob/main/") assert page == "umbrella/README.md", urls["Documentation"] assert (REPOSITORY / page).is_file(), page diff --git a/umbrella/README.md b/umbrella/README.md index a74fde6..ad7a7c7 100644 --- a/umbrella/README.md +++ b/umbrella/README.md @@ -1,5 +1,18 @@ -# `sayfirst` — ask before you act +# SayFirst (`sayfirst`) — ask before you act + +**SayFirst is open-source runtime governance for AI agents: a local control +plane a program asks before an effect, a person who approves what waits, and +evidence anyone can verify offline.** + +[![PyPI - sayfirst](https://img.shields.io/pypi/v/sayfirst.svg?label=PyPI%20sayfirst)](https://pypi.org/project/sayfirst/) +[![PyPI - sayfirst-cli](https://img.shields.io/pypi/v/sayfirst-cli.svg?label=PyPI%20sayfirst-cli)](https://pypi.org/project/sayfirst-cli/) +[![PyPI - sayfirst-control-plane](https://img.shields.io/pypi/v/sayfirst-control-plane.svg?label=PyPI%20sayfirst-control-plane)](https://pypi.org/project/sayfirst-control-plane/) + +**Links:** [`sayfirst-cli` on GitHub](https://github.com/fredaime/sayfirst-cli) · +[`sayfirst-cli` on PyPI](https://pypi.org/project/sayfirst-cli/) · +[control plane on GitHub](https://github.com/fredaime/sayfirst-control-plane) · +[control plane on PyPI](https://pypi.org/project/sayfirst-control-plane/) This distribution is the product, installed whole. It installs no code of its own: it depends on the three distributions that carry the three commands, each diff --git a/umbrella/pyproject.toml b/umbrella/pyproject.toml index cf70e41..fd65b56 100644 --- a/umbrella/pyproject.toml +++ b/umbrella/pyproject.toml @@ -2,17 +2,37 @@ [project] name = "sayfirst" version = "0.3.3" -description = "The sayfirst control plane, its daemon and its command, installed whole" +description = "SayFirst, installed whole: the control plane, its daemon and its command — runtime governance for AI agents, asked before an effect" readme = "README.md" requires-python = ">=3.12,<3.15" license = "Apache-2.0" authors = [{ name = "fredaime", email = "frederic.aime@gmail.com" }] +keywords = [ + "sayfirst", + "sayfirst-cli", + "sayfirst-control-plane", + "ai-agents", + "agent-security", + "ai-security", + "llm-security", + "runtime-governance", + "policy-enforcement", + "human-in-the-loop", + "audit-trail", +] classifiers = [ "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Topic :: Security", + "Topic :: System :: Systems Administration", ] # This distribution is the product installed whole, and it is not the client. # It depends on the client, on the daemon and on the daemon's operator surface, @@ -31,6 +51,9 @@ dependencies = [ Repository = "https://github.com/fredaime/sayfirst-cli" Documentation = "https://github.com/fredaime/sayfirst-cli/blob/main/umbrella/README.md" Changelog = "https://github.com/fredaime/sayfirst-cli/blob/main/CHANGELOG.md" +Issues = "https://github.com/fredaime/sayfirst-cli/issues" +"Control plane" = "https://github.com/fredaime/sayfirst-control-plane" +"Control plane on PyPI" = "https://pypi.org/project/sayfirst-control-plane/" # Each entry mirrors the declaration of the distribution that owns the command, # word for word. An installer that exposes only the commands of the package it