mvanhorn/last30days-skill · error · SystemExit

[CompetitorsPlan] Top-level must be a dict of {entity: {targ

Error message

[CompetitorsPlan] Top-level must be a dict of {entity: {targeting}}, got {type(parsed).__name__}\n

What it means

A structural guard in parse_competitors_plan(): valid JSON that parses to a list, string, number, or null is rejected because the plan must be a dict mapping entity names to targeting dicts. The message names the actual top-level type received. Inner entries that are not dicts are only warned-and-skipped; only the top level is fatal.

Source

Thrown at skills/last30days/scripts/last30days.py:814

    plan_str = raw
    if os.path.isfile(plan_str):
        try:
            with open(plan_str, encoding="utf-8") as f:
                plan_str = f.read()
        except (OSError, UnicodeDecodeError) as exc:
            sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
            raise SystemExit(2)
    try:
        parsed = json.loads(plan_str)
    except json.JSONDecodeError as exc:
        sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n")
        raise SystemExit(2)
    if not isinstance(parsed, dict):
        sys.stderr.write(
            f"[CompetitorsPlan] Top-level must be a dict of "
            f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n"
        )
        raise SystemExit(2)
    known_fields = {
        "x_handle", "x_related", "subreddits",
        "github_user", "github_repos", "trustpilot_domain", "context",
    }
    normalized: dict[str, dict] = {}
    for entity, entry in parsed.items():
        if not isinstance(entry, dict):
            sys.stderr.write(
                f"[CompetitorsPlan] Entry for {entity!r} must be a dict, "
                f"got {type(entry).__name__}; skipping.\n"
            )
            continue
        unknown = set(entry.keys()) - known_fields
        if unknown:
            sys.stderr.write(
                f"[CompetitorsPlan] Unknown fields in {entity!r}: "
                f"{sorted(unknown)}; ignoring.\n"
            )

View on GitHub (pinned to c7460f6114)

Solutions

  1. Reshape the plan to a flat dict: {"Acme": {"x_handle": "acme"}, "Beta": {"subreddits": ["beta"]}}.
  2. Allowed targeting keys are enumerated just below the guard: x_handle, x_related, subreddits, github_user, github_repos, trustpilot_domain, context — use only these.
  3. If you just want entity names without targeting, use --competitors-list 'Acme,Beta' instead.

Example fix

# before
[{"entity": "Acme", "x_handle": "acme"}]

# after
{"Acme": {"x_handle": "acme"}}
Defensive patterns

Strategy: type-guard

Validate before calling

parsed = json.loads(plan_str)
if not isinstance(parsed, dict) or not all(isinstance(v, dict) for v in parsed.values()):
    raise ValueError('plan must be {entity: {targeting...}}, got a different shape')

Type guard

def is_competitors_plan(value: Any) -> TypeGuard[dict[str, dict]]:
    return isinstance(value, dict) and all(
        isinstance(k, str) and isinstance(v, dict) for k, v in value.items()
    )

Prevention

When it happens

Trigger: --competitors-plan '["Acme", "Beta"]' or a file containing a bare array of entities; a top-level {'entities': [...]} wrapper instead of the flat {entity: {targeting}} shape.

Common situations: Plan schemas from other tools (list-of-objects) fed in unchanged; LLM-generated plans wrapping entities under a key; confusion with --competitors-list which does take a comma string.

Related errors


AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15). Data as JSON: /api/errors/37e26c06aa8c47bd. Report an issue: GitHub.