diff --git a/cli/src/m2_market/__init__.py b/cli/src/m2_market/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/cli/src/m2_market/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/cli/src/m2_market/cli.py b/cli/src/m2_market/cli.py new file mode 100644 index 0000000..a783498 --- /dev/null +++ b/cli/src/m2_market/cli.py @@ -0,0 +1,93 @@ +"""m2-market CLI entrypoint (contracts/cli.md).""" + +from __future__ import annotations + +import sys + +import click + +from m2_market import __version__ +from m2_market.config import ConfigError, load_config + +# Exit-code map (contracts/cli.md). +EXIT_OK = 0 # ok / no-op +EXIT_USAGE = 1 # usage (also: missing/invalid config) +EXIT_NOT_FOUND = 2 # not found +EXIT_INSUFFICIENT_FUNDS = 3 # insufficient funds +EXIT_APPLY_FAILED = 4 # apply failed (refunded) +EXIT_VALIDATION = 5 # validation / firewall rejection +EXIT_RAILS_NOT_LANDED = 6 # rails-not-landed (m2core_sync stub invoked) + + +@click.group(name="m2-market") +@click.option("--json", "json_output", is_flag=True, default=False, help="Emit machine-readable JSON output.") +@click.version_option(version=__version__, prog_name="m2-market") +@click.pass_context +def main(ctx: click.Context, json_output: bool) -> None: + """M2 Marketplace CLI (search | show | install | wallet | publish | reindex).""" + ctx.ensure_object(dict) + ctx.obj["json"] = json_output + try: + ctx.obj["config"] = load_config() + except ConfigError as exc: + click.echo(f"m2-market: config error: {exc}", err=True) + ctx.exit(EXIT_USAGE) + + +def _not_implemented(ctx: click.Context, command: str) -> None: + click.echo(f"m2-market {command}: not implemented", err=True) + ctx.exit(EXIT_USAGE) + + +@main.command() +@click.argument("query") +@click.option("--type", "listing_type", default=None, help="Filter by listing type (e.g. solution).") +@click.option("--limit", type=int, default=None, help="Maximum number of results.") +@click.pass_context +def search(ctx: click.Context, query: str, listing_type: str | None, limit: int | None) -> None: + """Semantic catalog query, tenant-filtered.""" + _not_implemented(ctx, "search") + + +@main.command() +@click.argument("listing_id") +@click.pass_context +def show(ctx: click.Context, listing_id: str) -> None: + """Full listing detail: evidence, price, permissions diff, seller, version.""" + _not_implemented(ctx, "show") + + +@main.command() +@click.argument("listing_id") +@click.option("--yes", is_flag=True, default=False, help="Skip interactive confirmation.") +@click.pass_context +def install(ctx: click.Context, listing_id: str, yes: bool) -> None: + """Resolve, pay, fetch, apply, and record a listing install.""" + _not_implemented(ctx, "install") + + +@main.command() +@click.option("--operator", "operator", default=None, help="Operator id to query (defaults to config).") +@click.pass_context +def wallet(ctx: click.Context, operator: str | None) -> None: + """Balance + recent transactions from the ledger.""" + _not_implemented(ctx, "wallet") + + +@main.command() +@click.argument("bundle_dir") +@click.pass_context +def publish(ctx: click.Context, bundle_dir: str) -> None: + """Validate a bundle and open a listing PR on the registry.""" + _not_implemented(ctx, "publish") + + +@main.command() +@click.pass_context +def reindex(ctx: click.Context) -> None: + """Invoke the catalog indexer full rebuild (admin).""" + _not_implemented(ctx, "reindex") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cli/src/m2_market/config.py b/cli/src/m2_market/config.py new file mode 100644 index 0000000..7b1784d --- /dev/null +++ b/cli/src/m2_market/config.py @@ -0,0 +1,73 @@ +"""Config loading for the m2-market CLI (docs/config.md, contracts/cli.md).""" + +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_CONFIG_PATH = Path.home() / ".m2-market" / "config.toml" + +REQUIRED_KEYS = ( + "operator_id", + "tenant", + "ledger_url", + "ledger_api_key", + "memory_api_url", + "memory_api_key", + "forgejo_url", + "forgejo_token", +) + + +class ConfigError(Exception): + """Raised when config.toml is missing, unreadable, or invalid.""" + + +@dataclass +class Config: + operator_id: str + tenant: str + ledger_url: str + ledger_api_key: str + memory_api_url: str + memory_api_key: str + forgejo_url: str + forgejo_token: str + apply_adapter: str = "local" + + +def config_path() -> Path: + """Resolve the config.toml path, honoring the M2_MARKET_CONFIG override.""" + override = os.environ.get("M2_MARKET_CONFIG") + return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH + + +def load_config(path: Path | None = None) -> Config: + """Load and validate ~/.m2-market/config.toml, raising ConfigError on failure.""" + path = path or config_path() + + if not path.is_file(): + raise ConfigError( + f"config not found: {path} (set M2_MARKET_CONFIG or see docs/config.md)" + ) + + try: + with path.open("rb") as f: + data = tomllib.load(f) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"invalid TOML in {path}: {exc}") from exc + except OSError as exc: + raise ConfigError(f"cannot read {path}: {exc}") from exc + + missing = [key for key in REQUIRED_KEYS if not data.get(key)] + if missing: + raise ConfigError( + f"{path} missing required key(s): {', '.join(missing)} (see docs/config.md)" + ) + + kwargs = {key: data[key] for key in REQUIRED_KEYS} + kwargs["apply_adapter"] = data.get("apply_adapter", "local") + + return Config(**kwargs)