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
118 changes: 118 additions & 0 deletions .github/check-license-files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
# SPDX-License-Identifier: GPL-3.0-or-later
"""Enforce Steelbore Standard §4.3 license-file naming and §5.6 license carriage.

For every shipped skill this checks that:

* the license file exists, is named per §4.3, and is a regular file
(never a symlink -- a parent-relative link dangles once the directory
is packaged alone, which is what bundling and the flake's per-skill
``cp -r`` do);
* its bytes are identical to the canonical text in ``LICENSES/``, which
is what keeps the copies one maintained text rather than the two
independent copies §4.3 forbids;
* the ``.zip`` and ``.skill`` bundles actually ship it -- a bundle is a
distribution in its own right and the repo-root LICENSE never reaches
the consumer.

Which license a skill is under comes from ``REUSE.toml``, so this gate has
no second source of truth to drift against. Third-party vendored trees
(``android-skills/``, ``orca-skills/``) are exempt: §4.2 preserves upstream
layout verbatim, including upstream's own license filenames.
"""

from __future__ import annotations

import sys
import tomllib
import zipfile
from fnmatch import fnmatch
from pathlib import Path

VENDORED = ("android-skills", "orca-skills")
# §4.3: these names are non-compliant inside a skill directory.
FORBIDDEN = ("LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "COPYING")


def spdx_for(path: str, annotations: list[dict]) -> str:
"""Last matching REUSE.toml annotation wins, mirroring reuse's own precedence."""
found = "?"
for ann in annotations:
paths = ann["path"]
for pat in paths if isinstance(paths, list) else [paths]:
if fnmatch(path, pat) or fnmatch(path, pat.rstrip("*") + "**"):
found = ann["SPDX-License-Identifier"]
return found


def expected_files(spdx: str) -> dict[str, str]:
"""Map an SPDX expression to {filename: spdx-id} per §4.3."""
ids = [p.strip() for p in spdx.split(" OR ")]
if len(ids) == 1:
return {"LICENSE": ids[0]}
# More than one license: one LICENSE.<TAG> per text, never concatenated.
return {f"LICENSE.{i.split('-')[0]}": i for i in ids}


def main(argv: list[str]) -> int:
repo = Path(argv[1] if len(argv) > 1 else ".").resolve()
annotations = tomllib.loads((repo / "REUSE.toml").read_text())["annotations"]
problems: list[str] = []

skills = [(p.parent, p.parent.name) for p in sorted(repo.glob("*/SKILL.md"))
if (repo / f"{p.parent.name}.zip").exists()]
Comment on lines +63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check new skills even before their bundles exist

When a new top-level skill is added without <name>.zip, this filter omits it entirely, so the gate reports success instead of detecting the missing license and both missing bundles. This matters because flake.nix auto-detects every top-level directory containing SKILL.md and can therefore distribute that unlicensed skill source despite CI passing; enumerate all non-vendored skill directories and let the later bundle-existence checks report omissions.

AGENTS.md reference: AGENTS.md:L413-L420

Useful? React with 👍 / 👎.

skills += [(p.parent, p.parent.name) for p in sorted(repo.glob("grok-skills/*/SKILL.md"))]

for skill_dir, name in skills:
rel = skill_dir.relative_to(repo).as_posix()
if rel.split("/")[0] in VENDORED:
continue
spdx = spdx_for(f"{rel}/SKILL.md", annotations)
wanted = expected_files(spdx)

for fname, spdx_id in wanted.items():
target = skill_dir / fname
canonical = repo / "LICENSES" / f"{spdx_id}.txt"
if not canonical.exists():
problems.append(f"{rel}: no canonical text at LICENSES/{spdx_id}.txt")
continue
if not target.exists():
problems.append(f"{rel}: missing {fname} (§5.6 license carriage; skill is {spdx})")
continue
if target.is_symlink():
problems.append(f"{rel}/{fname}: is a symlink; §5.6 requires a regular file")
continue
if target.read_bytes() != canonical.read_bytes():
problems.append(
f"{rel}/{fname}: not byte-identical to LICENSES/{spdx_id}.txt (§5.6)")

for bad in FORBIDDEN:
if (skill_dir / bad).exists():
problems.append(f"{rel}/{bad}: non-compliant license filename (§4.3)")
for stray in sorted(skill_dir.glob("LICENSE-*")):
problems.append(
f"{rel}/{stray.name}: §4.3 uses LICENSE.<TAG>, not LICENSE-<TAG>")

# §5.6: the bundle is the distribution -- it must carry the text.
flat = rel.startswith("grok-skills/")
for ext in (".zip", ".skill"):
bundle = (skill_dir.parent / f"{name}{ext}") if flat else (repo / f"{name}{ext}")
if not bundle.exists():
problems.append(f"{rel}: no {name}{ext} bundle")
continue
with zipfile.ZipFile(bundle) as zf:
names = set(zf.namelist())
for fname in wanted:
entry = fname if flat else f"{name}/{fname}"
if entry not in names:
problems.append(f"{name}{ext}: does not ship {entry} (§5.6)")
Comment on lines +108 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the license bytes stored in each bundle

If a bundle contains the expected license entry but its contents are stale or corrupted, this check passes because it only tests the entry name; for example, replacing spacecraft-lua-guidelines/LICENSE inside its zip with arbitrary bytes still yields zero license-file problems. Since neither this workflow nor construct skill ship otherwise compares bundle contents, a license-file update can reach consumers with the previous text unless zf.read(entry) is compared with the canonical or working-tree bytes.

AGENTS.md reference: AGENTS.md:L423-L426

Useful? React with 👍 / 👎.


for p in problems:
print(p)
print(f"\n{len(problems)} license-file problem(s). Checked {len(skills)} skills.")
return 1 if problems else 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ jobs:
find . -name SKILL.md -not -path './.git/*' -print0 \
| xargs -0 python3 .githooks/check-description-length.py

# Standard §5.6 license carriage: a bundle is a distribution in its own
# right, so the repo-root LICENSE never reaches a consumer who installs
# the .zip/.skill. Every skill therefore carries its own license text,
# byte-identical to LICENSES/ (§4.3 forbids two independently maintained
# copies; enforced equality is what keeps these one text), named per §4.3,
# a regular file, and actually present inside both bundles. Which license
# applies is read from REUSE.toml, so there is no second source of truth.
- name: License files (§4.3 naming, §5.6 carriage)
run: python3 .github/check-license-files.py .

# The gates above verify each skill in isolation. This one verifies that a
# skill still agrees with the catalogue around it: that every skill id it
# names resolves to a real directory, and that it does not pin a Standard
Expand Down
49 changes: 33 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ changes flow skill → published standard, so this `SKILL.md` may lead it.
```
<skill-name>/
├── SKILL.md # frontmatter (name, description, license, maintainer, website) + body
├── LICENSE | LICENSE.md # optional in-dir copy; GPL-3.0-or-later (microsoft-rust-guidelines is dual `GPL-3.0-or-later OR MIT`, shipping a LICENSE-GPL + LICENSE-MIT pairMicrosoft upstream is MIT). About half the skills omit it and fall back on the repo-root LICENSE.
├── LICENSE # REQUIRED (Standard §5.6 license carriage). Verbatim license text, byte-identical to the matching `LICENSES/` file, a regular file, no extension (§4.3). Almost always GPL-3.0-or-later; `gnu-coding-standards` carries the GFDL-1.3-or-later text instead. Multi-licensed skills carry `LICENSE.<TAG>` in its place`microsoft-rust-guidelines` ships `LICENSE.GPL` + `LICENSE.MIT`.
├── CREDITS.md # required when the skill builds on third-party work (Standard §15.3); currently microsoft-rust-guidelines, gnu-coding-standards, spacecraft-cli-preference, spacecraft-rust-guidelines, spacecraft-ada-guidelines
├── references/ # optional; loaded on demand by the agent
└── assets/ # optional; only spacecraft-agentic-cli has one today
Expand All @@ -70,8 +70,8 @@ must stay in sync.
## Bundling (.zip and .skill)

Each skill ships as two bundles at the repo root: `<name>.zip` and
`<name>.skill`. They contain only `SKILL.md`, `LICENSE`, `CREDITS.md`, and
`references/` (plus `assets/` where present) — never tooling, generator
`<name>.skill`. They contain only `SKILL.md`, the license file(s), `CREDITS.md`,
and `references/` (plus `assets/` where present) — never tooling, generator
scripts, or raw upstream sources. Auxiliary inputs that don't belong in the
shipped skill live in `Excluded/` (e.g., `Rust-Guidelines.{md,txt}`,
`skill.ps1`).
Expand All @@ -84,10 +84,12 @@ zip -qr <name>.zip <name>/SKILL.md <name>/LICENSE <name>/CREDITS.md <name>/re
zip -qrD <name>.skill <name>/SKILL.md <name>/LICENSE <name>/CREDITS.md <name>/references
```

Include each argument only when that file/dir exists in the skill. LICENSE may
be entirely absent from the directory (about half are — repo-root `LICENSE`
covers them); when present, the filename varies (`LICENSE` vs `LICENSE.md`).
`CREDITS.md` appears only where §15.3 triggers fire (currently
**`LICENSE` is never optional** — Standard §5.6 makes the bundle the unit of
distribution, so every bundle ships the license text. The only variation is the
name: `microsoft-rust-guidelines` is dual-licensed and passes
`microsoft-rust-guidelines/LICENSE.GPL microsoft-rust-guidelines/LICENSE.MIT`
in place of a single `LICENSE`. Include the other arguments only when they
exist. `CREDITS.md` appears only where §15.3 triggers fire (currently
`microsoft-rust-guidelines`, `gnu-coding-standards`, `spacecraft-cli-preference`,
`spacecraft-rust-guidelines`, `spacecraft-ada-guidelines`). `references/` and `assets/` are optional.
Run `ls <name>/` first whenever you're unsure.
Expand All @@ -110,12 +112,13 @@ is mechanical — apply it after **any** edit inside a `<skill-name>/` directory
zip -qrD <name>.skill <name>/SKILL.md <name>/LICENSE <name>/CREDITS.md <name>/references
```
Add `<name>/assets` to both lines if the skill has an `assets/` dir
(today only `spacecraft-agentic-cli` does). Omit any argument the skill
doesn't have — `spacecraft-steelbore-standard` is SKILL.md-only; many skills omit
the in-directory LICENSE entirely; `CREDITS.md` exists only where §15.3
applies (`microsoft-rust-guidelines`, `gnu-coding-standards`,
`spacecraft-cli-preference`, `spacecraft-rust-guidelines`,
`spacecraft-ada-guidelines`). Run `ls <name>/`
(today only `spacecraft-agentic-cli` does). `SKILL.md` and the license file
are always present; omit any other argument the skill doesn't have.
`microsoft-rust-guidelines` is dual-licensed and passes
`<name>/LICENSE.GPL <name>/LICENSE.MIT` instead of `<name>/LICENSE`.
`CREDITS.md` exists only where §15.3 applies (`microsoft-rust-guidelines`,
`gnu-coding-standards`, `spacecraft-cli-preference`,
`spacecraft-rust-guidelines`, `spacecraft-ada-guidelines`). Run `ls <name>/`
first when in doubt.
2. **Stage** the skill directory **and** both bundles in the same commit —
never separately. Always stage by explicit name:
Expand Down Expand Up @@ -298,9 +301,13 @@ subdirectory listing.

Frontmatter is also minimal for Grok — just `name` and `description`. No
`license`, `maintainer`, `website` fields (Grok's loader does not consume
them). License compliance still tracks the repo-root `LICENSE` per
Standard §4 — the canonical GPL-3.0-or-later text as a regular file, with
`LICENSES/GPL-3.0-or-later.txt` a symlink back to it (§4.3, v1.38 direction).
them). A Grok skill still carries its own `LICENSE`, because Standard §5.6
license carriage is about the *bundle*, not the frontmatter — and because the
flat layout puts it at the zip root rather than under `<name>/`, the recipe
above passes a bare `LICENSE`. The repo-root `LICENSE` remains the canonical
GPL-3.0-or-later text as a regular file, with `LICENSES/GPL-3.0-or-later.txt`
a symlink back to it (§4.3, v1.38 direction), and every skill copy is
byte-identical to it.

## Local agent fan-out (Home Manager hosts)

Expand Down Expand Up @@ -403,6 +410,16 @@ The assistant performs no `rsync`, no symlink setup, and no
so it's hidden from the `/` menu on purpose (Claude Code docs: "background
knowledge users shouldn't invoke directly"). Do **not** remove the field to
"fix" a perceived load failure — its absence from the menu is by design.
- **License files are named `LICENSE`, with no extension** (Standard §4.3).
`LICENSE.md` and `LICENSE.txt` are non-compliant, and a skill offered under
more than one license carries `LICENSE.<TAG>` per license — never a dash
(`LICENSE-MIT`) and never a combined file. Every skill has one, it is a
regular file, and it is byte-identical to the matching text in `LICENSES/`
(§5.6). `.github/check-license-files.py` is the gate and reads which license
applies from `REUSE.toml`, so there is no second list to maintain; run
`python3 .github/check-license-files.py .` before pushing. Third-party
vendored trees (`android-skills/`, `orca-skills/`) are exempt — §4.2 keeps
upstream's own layout and filenames verbatim.
- **Rebuild BOTH bundles after any skill-dir edit**, in the same commit:
`<name>.zip` (`zip -qr`, keeps dir entries) and `<name>.skill` (`zip -qrD`,
drops them). A bundle that lags its `SKILL.md`/`references/` ships broken
Expand Down
13 changes: 8 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ commit** as the source change.
```
<skill-name>/
├── SKILL.md # frontmatter (name, description, …) + body
├── LICENSE | LICENSE.md # optional in-dir copy; ~half the skills omit it and
│ # fall back on the repo-root LICENSE
├── LICENSE # REQUIRED (§5.6): verbatim text, byte-identical to the
│ # matching LICENSES/ file, regular file, no extension
│ # (§4.3). Multi-licensed skills carry LICENSE.<TAG>.
├── CREDITS.md # required when the skill builds on third-party work
│ # (Standard §15.3)
├── references/ # optional; loaded on demand by the agent
Expand All @@ -41,7 +42,7 @@ not codenames (Standard §2.2 reserves codenames for projects, not skill IDs).

## Bundling (`.zip` and `.skill`)

Bundles contain only `SKILL.md`, `LICENSE`, `CREDITS.md`, and `references/` (plus
Bundles contain only `SKILL.md`, the license file(s), `CREDITS.md`, and `references/` (plus
`assets/` where present) — never tooling, generator scripts, or raw upstream
sources (those live in `Excluded/`, which is gitignored).

Expand All @@ -51,8 +52,10 @@ zip -qr <name>.zip <name>/SKILL.md <name>/LICENSE <name>/CREDITS.md <name>/re
zip -qrD <name>.skill <name>/SKILL.md <name>/LICENSE <name>/CREDITS.md <name>/references
```

Include each argument only when that file/dir exists in the skill — run
`ls <name>/` first when unsure. Add `<name>/assets` to both lines if the skill has
`SKILL.md` and the license file are always present (§5.6);
`microsoft-rust-guidelines` passes `<name>/LICENSE.GPL <name>/LICENSE.MIT`
instead of `<name>/LICENSE`. Include each other argument only when it exists —
run `ls <name>/` first when unsure. Add `<name>/assets` to both lines if the skill has
one. The `.skill` bundle uses `-D` to drop directory entries; the `.zip` keeps
them. The two are built from the **same args in the same commit**, so they never
diverge. Verify with `unzip -l <name>.zip` before committing.
Expand Down
1 change: 1 addition & 0 deletions COPYING
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ Every skill follows the same shape:
```
<skill-name>/
├── SKILL.md # Frontmatter + the agent-facing instructions
├── LICENSE.md # Skill license (Standard §4.1.1: skills are GPL-3.0-or-later; third-party-derived skills keep their upstream license)
├── LICENSE # Skill license, verbatim and byte-identical to the matching `LICENSES/` text
│ # (Standard §4.3 naming, §5.6 carriage; no extension). Multi-licensed skills
│ # carry `LICENSE.<TAG>` instead — `microsoft-rust-guidelines` ships
│ # `LICENSE.GPL` + `LICENSE.MIT`. Third-party-derived skills keep their
│ # upstream license text (`gnu-coding-standards` is GFDL-1.3-or-later).
├── CREDITS.md # Required when the skill builds on third-party work (Standard §15.3)
└── references/ # Optional; consulted only when depth is needed
├── <topic>.md
Expand Down
12 changes: 11 additions & 1 deletion construct-cli/src/commands/ship.rs
Original file line number Diff line number Diff line change
Expand Up @@ -751,10 +751,20 @@ fn gh_launch_error(ctx: &Context, err: &std::io::Error) -> AppError {
}
}

/// Non-`references/` files a bundle carries, in the order they are passed to `zip`.
///
/// `LICENSE` is the Standard §4.3 canonical name; `LICENSE.<TAG>` is its
/// multi-license form, used where one artifact is offered under more than one
/// license (`microsoft-rust-guidelines` is `GPL-3.0-or-later OR MIT`). §5.6
/// requires every bundle to ship its license text, so a name missing from this
/// list produces a rebuild hint that silently drops the license. `LICENSE.md`
/// is deliberately absent — §4.3 makes it non-compliant.
const BUNDLE_FILES: &[&str] = &["LICENSE", "LICENSE.GPL", "LICENSE.MIT", "CREDITS.md"];

/// The exact bundle-rebuild command for a drifted skill (a runnable hint).
fn rebuild_cmd(repo: &Path, skill: &str) -> String {
let mut parts = vec![format!("{skill}/SKILL.md")];
for candidate in ["LICENSE", "LICENSE.md", "CREDITS.md"] {
for candidate in BUNDLE_FILES {
if repo.join(skill).join(candidate).exists() {
parts.push(format!("{skill}/{candidate}"));
}
Expand Down
Binary file modified gnu-coding-standards.skill
Binary file not shown.
Binary file modified gnu-coding-standards.zip
Binary file not shown.
Loading
Loading