Skip to content
Open
7,542 changes: 0 additions & 7,542 deletions sponsio/cli.py

This file was deleted.

100 changes: 100 additions & 0 deletions sponsio/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Sponsio CLI.

Historically a single ``sponsio/cli.py`` module; now a package split
into :mod:`sponsio.cli.commands` (one module per top-level command) and
:mod:`sponsio.cli.groups` (one module per command group). The root
``cli`` group lives in :mod:`sponsio.cli.app`; cross-command helpers in
:mod:`sponsio.cli._shared`.

Importing a command/group module registers it on the shared ``cli``
group as a side effect. Every command, group, and the internal helpers
that other modules and tests import from ``sponsio.cli`` are re-exported
here, so ``from sponsio.cli import X`` keeps working regardless of which
submodule ``X`` now lives in.
"""

from __future__ import annotations

from sponsio.cli._shared import _resolve_entry
from sponsio.cli.app import cli

# Top-level commands (importing each registers it on `cli`).
from sponsio.cli.commands.check import check
from sponsio.cli.commands.demo import demo
from sponsio.cli.commands.doctor import doctor
from sponsio.cli.commands.eval import eval_cmd
from sponsio.cli.commands.explain import explain
from sponsio.cli.commands.export import export_cmd
from sponsio.cli.commands.export_sessions import export_sessions_cmd
from sponsio.cli.commands.init import init
from sponsio.cli.commands.mode import _patch_mode_in_yaml, cmd_mode
from sponsio.cli.commands.onboard import onboard
from sponsio.cli.commands.packs import packs
from sponsio.cli.commands.patterns import patterns
from sponsio.cli.commands.prompt import cmd_prompt
from sponsio.cli.commands.replay import replay
from sponsio.cli.commands.report import report
from sponsio.cli.commands.scan import (
_drop_contract_indices,
_filter_invalid_contracts,
scan,
)
from sponsio.cli.commands.serve import serve
from sponsio.cli.commands.validate import validate

# Command groups.
from sponsio.cli.groups.cursor import cursor
from sponsio.cli.groups.daemon import daemon
from sponsio.cli.groups.host import _refresh_per_host_bundles, host
from sponsio.cli.groups.plugin import _stamp_bundled_source, plugin
from sponsio.cli.groups.skill import (
_SKILL_TOOL_DIRS,
_packaged_skill_source,
_verify_skill_install_target,
skill,
)


def main():
cli()


__all__ = [
"cli",
"main",
# commands
"demo",
"patterns",
"packs",
"validate",
"check",
"explain",
"replay",
"report",
"serve",
"scan",
"export_cmd",
"export_sessions_cmd",
"eval_cmd",
"init",
"doctor",
"onboard",
"cmd_mode",
"cmd_prompt",
# groups
"skill",
"plugin",
"host",
"daemon",
"cursor",
# helpers imported elsewhere
"_resolve_entry",
"_patch_mode_in_yaml",
"_filter_invalid_contracts",
"_drop_contract_indices",
"_stamp_bundled_source",
"_refresh_per_host_bundles",
"_SKILL_TOOL_DIRS",
"_packaged_skill_source",
"_verify_skill_install_target",
]
14 changes: 14 additions & 0 deletions sponsio/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Enable ``python -m sponsio.cli``.

The CLI is also exposed as the ``sponsio`` console script (see
``[project.scripts]`` in pyproject), but some callers — and the test
suite — invoke it via ``python -m sponsio.cli``. A package needs an
explicit ``__main__`` for that form to work.
"""

from __future__ import annotations

from sponsio.cli import main

if __name__ == "__main__":
main()
183 changes: 183 additions & 0 deletions sponsio/cli/_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Helpers shared across more than one CLI command/group module.

Kept deliberately small: only things genuinely used by multiple
command modules live here, so the per-command modules stay focused and
there is one obvious home for cross-cutting helpers.
"""

from __future__ import annotations

import re
import time
from pathlib import Path

import click


def _contract_guarantee(entry):
"""Read the guarantee block out of a YAML/dict contract entry.

Reads the canonical ``G`` (short) / ``guarantee`` (long) keys. No
legacy alias support. the rename is hard.
"""
if not isinstance(entry, dict):
return None
return entry.get("G") or entry.get("guarantee")


def _looks_like_sponsio_config(path: Path) -> bool:
"""Return True if ``path`` is probably a :file:`sponsio.yaml` (not
an arbitrary string the user wanted to parse as a contract).

Kept intentionally narrow so ``sponsio validate interesting.yaml`` only
auto-routes when the file *looks* like a Sponsio config, not every YAML
on disk.
"""
try:
head = path.read_text(encoding="utf-8", errors="replace")[:32768]
except OSError:
return False
# Project configs list agents; ``init`` output uses version+extractor.
if re.search(r"(?m)^\s*agents:\s*", head):
return True
return bool(
re.search(r"(?m)^\s*version:\s*\d", head)
and re.search(r"(?m)^\s*extractor:\s*", head)
)


def _resolve_entry(entry):
"""Resolve a constraint entry (string or ConstraintEntry) to (nl_text, parsed_result).

For structured entries (pattern + args), compiles directly.
For NL strings, runs through parse_nl_unified.
"""
from sponsio.config import ConstraintEntry, _compile_structured
from sponsio.generation.dsl_to_contract import (
ContractSyntaxError,
UnifiedParseResult,
parse_nl_unified,
)

if isinstance(entry, ConstraintEntry):
if entry.is_structured:
try:
compiled = _compile_structured(entry)
nl = f"{entry.pattern}({', '.join(str(a) for a in entry.args)})"
return nl, UnifiedParseResult(original_nl=nl, hard=compiled)
except Exception:
return str(entry.pattern), None
elif entry.is_ltl:
from sponsio.config import _compile_ltl

try:
compiled = _compile_ltl(entry)
return entry.ltl or "", UnifiedParseResult(
original_nl=entry.ltl or "", hard=compiled
)
except Exception:
return entry.ltl or "ltl", None
else:
nl = entry.nl
else:
nl = str(entry)
try:
return nl, parse_nl_unified(nl)
except ContractSyntaxError:
# Unparseable. `sponsio check` signals this by returning
# a None result, same shape as a structured-compile error.
return nl, None


def _parse_since(since: str) -> float:
"""Parse a relative duration like ``"24h"`` / ``"7d"`` / ``"30m"``
into a Unix-timestamp cutoff (seconds).

Returns ``0.0`` (= no cutoff) for the empty / sentinel values the
user might pass when they want everything. Bare integers are
interpreted as hours (``--since 6`` == ``--since 6h``) since
``hour`` is the unit operators reach for first.
"""
import re as _re

s = (since or "").strip().lower()
if not s or s in ("0", "all"):
return 0.0
m = _re.fullmatch(r"(\d+(?:\.\d+)?)\s*([smhd]?)", s)
if not m:
raise click.BadParameter(
f"invalid --since value {since!r}; expected '24h' / '7d' / '30m' / '90s'",
)
n = float(m.group(1))
unit = m.group(2) or "h"
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
return time.time() - n * multipliers[unit]


def _parse_existing_contracts(yaml_path: Path, agent_id: str) -> list[dict]:
"""Extract the on-disk yaml's contracts so ``--emit-context``
consumers can dedupe their semantic-pass proposals.

Pulls only the fields a deduper actually needs (pattern, args,
source) and only from the named agent's block. Conservative:
on any parse error, returns an empty list. a malformed yaml
will still surface elsewhere (doctor, validate), no need to
block the diagnostic JSON over it.

Each returned dict has the shape::

{"pattern": "arg_blacklist",
"args": ["delete_snapshot", "path", ["...", "..."]],
"source": "scan" | "library:tier1.shell" | "agent-extracted" | ...}

Pack-included rules (resolved via ``include:``) are NOT walked
here. the host agent only needs to dedupe against rules
actually written into THIS yaml (the inline ``contracts:``
block). Pack rules round-trip through ``include:`` and the
template's "don't inline what the pack already covers" rule
keeps them out of the agent's proposals.
"""
try:
import yaml as _yaml
except ImportError:
return []

try:
text = yaml_path.read_text(encoding="utf-8")
data = _yaml.safe_load(text)
except Exception:
return []

if not isinstance(data, dict):
return []
agents = data.get("agents")
if not isinstance(agents, dict):
return []
agent_block = agents.get(agent_id)
if not isinstance(agent_block, dict):
return []
contracts = agent_block.get("contracts")
if not isinstance(contracts, list):
return []

out: list[dict] = []
for c in contracts:
if not isinstance(c, dict):
continue
# Contracts can be written ``- G: {...}`` or ``- A: {...}, G: {...}``.
# We pull from whichever has the pattern.
g = _contract_guarantee(c)
body = g if isinstance(g, dict) else c
if not isinstance(body, dict):
continue
pattern = body.get("pattern")
if not isinstance(pattern, str):
continue
out.append(
{
"pattern": pattern,
"args": body.get("args") or [],
"source": body.get("source") or c.get("source") or "",
}
)
return out
19 changes: 19 additions & 0 deletions sponsio/cli/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""The root Sponsio CLI group.

Lives in its own module so every command and group module can register
itself on the shared group via ``from sponsio.cli.app import cli`` /
``@cli.command()`` without importing the whole :mod:`sponsio.cli`
package (which would be circular).
"""

from __future__ import annotations

import click

from sponsio import __version__


@click.group()
@click.version_option(version=__version__, prog_name="sponsio")
def cli():
"""Sponsio. the contract layer for LLM agent systems."""
Empty file.
Loading