T016: CLI scaffold, config loading, exit-code map
This commit is contained in:
parent
c607bc04b7
commit
035d8771dc
3 changed files with 167 additions and 0 deletions
1
cli/src/m2_market/__init__.py
Normal file
1
cli/src/m2_market/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
__version__ = "0.1.0"
|
||||||
93
cli/src/m2_market/cli.py
Normal file
93
cli/src/m2_market/cli.py
Normal file
|
|
@ -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())
|
||||||
73
cli/src/m2_market/config.py
Normal file
73
cli/src/m2_market/config.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Reference in a new issue