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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `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
Expand Down
33 changes: 29 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
49 changes: 45 additions & 4 deletions tests/test_pointers_survive_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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(
Expand All @@ -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)
3 changes: 2 additions & 1 deletion tests/test_umbrella_metadata_agrees_with_the_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion umbrella/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `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
Expand Down
25 changes: 24 additions & 1 deletion umbrella/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading