From a250375abd8b2de4cf32cd4755e9bd3c057fc5c5 Mon Sep 17 00:00:00 2001 From: Vitaly Neyman Date: Sun, 15 Feb 2026 23:35:19 +0200 Subject: [PATCH 1/3] Add nanobot and picoclaw variant detection and scanning and fix some bugs --- README.md | 9 +- nano_scanner.py | 442 ++++++++++++++++++++++++++++++++++++++ openclaw_usage.py | 196 +++++++++-------- output_structures.py | 109 ++++++++++ platform_compat/common.py | 9 +- pyproject.toml | 10 +- scanner_utils.py | 180 ++++++++++++++++ structures.py | 23 +- uv.lock | 262 +++------------------- 9 files changed, 910 insertions(+), 330 deletions(-) create mode 100644 nano_scanner.py create mode 100644 output_structures.py create mode 100644 scanner_utils.py diff --git a/README.md b/README.md index 466a55b..1164460 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # OpenClaw Scanner -Scan and analyze OpenClaw/Moltbot installations for usage patterns, active skills, and configuration status. +Scan and analyze OpenClaw/Moltbot installations — and compatible variants **nanobot** and **picoclaw** — for usage patterns, active skills, and configuration status. ## Overview -OpenClaw Scanner detects OpenClaw (formerly Moltbot) CLI installations and collects information about: +OpenClaw Scanner auto-detects which variant is installed and collects information about: - Active skills and their configurations - Session logs and tools/apps usage - Cron jobs, plugins, channels, nodes, and models +- Provider configurations and API key status - Security audit results Supports macOS and Linux. @@ -89,8 +90,8 @@ Returns JSON with the following fields: ## Requirements -- OpenClaw, Moltbot, or Clawdbot CLI installed -- `.openclaw` folder in user home directory +- OpenClaw, Moltbot, Clawdbot or smaller variants: nanobot picobot CLI installed +- Config directory such as `.openclaw` directory detected in the user home directory ## License diff --git a/nano_scanner.py b/nano_scanner.py new file mode 100644 index 0000000..9399ca3 --- /dev/null +++ b/nano_scanner.py @@ -0,0 +1,442 @@ +"""Shared scanner for OpenClaw variant tools (nanobot, picoclaw). + +Both variants share ~90% identical structure, differing mainly in: + - Key casing: nanobot=camelCase, picoclaw=snake_case + - A few directory paths (sessions, cron, skill tiers) + +This scanner produces output compatible with the existing openclaw scanner report +schema, so the backend receives the same JSON shape regardless of which variant +was detected. +""" + +import re +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from platform_compat.common import get_system_info +from structures import ClawdbotInstallInfo +from scanner_utils import camel_to_snake, has_api_key, mask_api_key, parse_skill_md, read_json_config +from output_structures import OutputSkillEntry, OutputSummary, _EMPTY_MISSING + + +# ============================================================================= +# Variant Definitions (data-driven, not code-branched) +# ============================================================================= + +NANO_VARIANT_DEFS: Dict[str, Dict[str, Any]] = { + "nanobot": { + "home": "~/.nanobot", + "config_file": "config.json", + "key_casing": "camel", # needs camel_to_snake() normalization + "workspace_subdir": "workspace", + "sessions_path": "sessions", # relative to home (NOT workspace) + "cron_path": "cron/jobs.json", # relative to home + "skill_dirs": ["workspace/skills"], # relative to home + "log_file": "logs/nanobot.log", + "binary": "nanobot", + "process_patterns": ["nanobot gateway", "nanobot agent", "python.*nanobot"], + "port": 18790, + "pip_package": "nanobot-ai", + }, + "picoclaw": { + "home": "~/.picoclaw", + "config_file": "config.json", + "key_casing": "snake", # already snake_case, no normalization needed + "workspace_subdir": "workspace", + "sessions_path": "workspace/sessions", # relative to home + "cron_path": "workspace/cron/jobs.json", # relative to home + "skill_dirs": ["workspace/skills", "skills"], # 2 tiers: workspace + global + "log_file": None, # JSON structured log, path TBD + "binary": "picoclaw", + "process_patterns": ["picoclaw gateway", "picoclaw agent"], + "port": 18790, + "pip_package": None, # Go binary, not pip + }, +} + +# ============================================================================= +# Config Reading & Normalization +# ============================================================================= + +def _read_variant_config(name: str, home: Path) -> Optional[Dict[str, Any]]: + """Read and normalize variant config to snake_case. + + After this function, nanobot and picoclaw configs have identical key names. + """ + vdef = NANO_VARIANT_DEFS[name] + config_path = home / vdef["config_file"] + cfg = read_json_config(config_path) + if cfg is None: + return None + + # Normalize camelCase -> snake_case for nanobot + if vdef["key_casing"] == "camel": + cfg = camel_to_snake(cfg) + + return cfg + + +# ============================================================================= +# Data Extraction (from normalized config — identical for both variants) +# ============================================================================= + +def _extract_providers(cfg: Dict[str, Any]) -> Dict[str, Any]: + """Extract provider info from config. Returns dict of provider_name -> info.""" + providers = cfg.get("providers", {}) + result = {} + for name, pcfg in providers.items(): + if not isinstance(pcfg, dict): + continue + result[name] = { + "has_api_key": has_api_key(pcfg), + "api_key_masked": mask_api_key(pcfg.get("api_key", "")) if has_api_key(pcfg) else None, + "has_api_base": bool(pcfg.get("api_base")), + } + return result + + +def _extract_channels(cfg: Dict[str, Any]) -> Dict[str, Any]: + """Extract channel info from config. Returns dict of channel_name -> info.""" + channels = cfg.get("channels", {}) + result = {} + for name, ccfg in channels.items(): + if not isinstance(ccfg, dict): + continue + result[name] = { + "enabled": ccfg.get("enabled", False), + } + return result + + +def _extract_agent_defaults(cfg: Dict[str, Any]) -> Dict[str, Any]: + """Extract agent defaults (model, workspace, etc.).""" + agents = cfg.get("agents", {}) + defaults = agents.get("defaults", {}) + return { + "model": defaults.get("model"), + "max_tokens": defaults.get("max_tokens"), + "temperature": defaults.get("temperature"), + "max_tool_iterations": defaults.get("max_tool_iterations"), + "workspace": defaults.get("workspace"), + "restrict_to_workspace": defaults.get("restrict_to_workspace"), + } + + +def _extract_mcp_servers(cfg: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract MCP server configurations from tools section.""" + tools = cfg.get("tools", {}) + mcp_servers = tools.get("mcp_servers", {}) + result = [] + for name, scfg in mcp_servers.items(): + if not isinstance(scfg, dict): + continue + result.append({ + "name": name, + "command": scfg.get("command"), + "has_url": bool(scfg.get("url")), + "has_args": bool(scfg.get("args")), + }) + return result + + +def _extract_gateway(cfg: Dict[str, Any]) -> Dict[str, Any]: + """Extract gateway configuration.""" + gw = cfg.get("gateway", {}) + return { + "host": gw.get("host"), + "port": gw.get("port"), + } + + +def _normalize_skill(parsed: Dict[str, Any], skill_rel_dir: str) -> OutputSkillEntry: + """Normalize raw parse_skill_md output to the backend-expected shape.""" + # Extract variant-specific metadata (nanobot or picoclaw nested dict) + raw_meta = parsed.get("metadata", {}) + meta: Dict[str, Any] = {} + if isinstance(raw_meta, dict): + # Try both variant keys; at most one will exist + nested = raw_meta.get("nanobot", raw_meta.get("picoclaw")) + if isinstance(nested, dict): + meta = nested + + # Map relative skill dir to a source label ("workspace", "builtin", etc.) + is_workspace = "workspace" in skill_rel_dir + source = "workspace" if is_workspace else "builtin" + + return { + "name": parsed.get("name", ""), + "description": parsed.get("description", ""), + "emoji": meta.get("emoji") if isinstance(meta, dict) else None, + "eligible": True, # present on disk → eligible + "disabled": False, # no disable mechanism for file-scanned skills + "blockedByAllowlist": False, + "source": source, + "bundled": source == "builtin", + "primaryEnv": meta.get("primaryEnv") if isinstance(meta, dict) else None, + "homepage": parsed.get("homepage"), + "missing": _EMPTY_MISSING, + } + + +def _scan_skills(home: Path, vdef: Dict[str, Any]) -> List[OutputSkillEntry]: + """Scan SKILL.md files from variant's skill directories. + + Returns skills normalized to OutputSkillEntry shape (backend contract). + """ + skills: List[OutputSkillEntry] = [] + for skill_rel_dir in vdef["skill_dirs"]: + skill_base = home / skill_rel_dir + if not skill_base.is_dir(): + continue + for entry in sorted(skill_base.iterdir()): + if entry.is_dir(): + parsed = parse_skill_md(entry) + if parsed: + skills.append(_normalize_skill(parsed, skill_rel_dir)) + return skills + + +def _read_cron_jobs(home: Path, vdef: Dict[str, Any]) -> List[Dict[str, Any]]: + """Read cron jobs from jobs.json.""" + cron_path = home / vdef["cron_path"] + if not cron_path.exists(): + return [] + + cfg = read_json_config(cron_path) + if cfg is None: + return [] + + # Both variants store jobs under a "jobs" key + jobs = cfg.get("jobs", []) + if not isinstance(jobs, list): + return [] + + # Normalize job entries (nanobot uses camelCase too) + if vdef["key_casing"] == "camel": + jobs = [camel_to_snake(j) for j in jobs] + + return jobs + + +def _detect_binary(vdef: Dict[str, Any]) -> Optional[str]: + """Find binary in PATH. Returns path string or None.""" + return shutil.which(vdef["binary"]) + + +# ============================================================================= +# Main Scan Function +# ============================================================================= + +def scan_nano(install_info: ClawdbotInstallInfo, full: bool = False, limit: int = 50) -> Optional[Dict[str, Any]]: # pylint: disable=unused-argument + """Scan a variant and produce a report compatible with openclaw scanner output. + + This is the main entry point for variant scanning. It: + 1. Reads + normalizes config + 2. Extracts providers, channels, skills, cron, MCP servers + 3. Returns a dict compatible with (matching) the openclaw scanner's output shape + + Args: + install_info: Detected install info (bot_variant, bot_config_dir, etc.) + full: If True, include detailed sub-sections alongside summary. + limit: Max tool_calls to include in session analysis (TBD: future use). + + Returns: + Report dict compatible with openclaw_usage.py, or None if variant unknown. + """ + name = install_info.bot_variant + home = install_info.bot_config_dir + + vdef = NANO_VARIANT_DEFS.get(name) + if not vdef: + return None + + # Read and normalize config + cfg = _read_variant_config(name, home) + + # Binary detection — prefer already-resolved path from install_info + binary_path = install_info.bot_cli_cmd[0] if install_info.bot_cli_cmd else _detect_binary(vdef) + version = _get_version(binary_path) + + # Extract data from config (if config exists) + providers = _extract_providers(cfg) if cfg else {} + channels = _extract_channels(cfg) if cfg else {} + agent_defaults = _extract_agent_defaults(cfg) if cfg else {} + mcp_servers = _extract_mcp_servers(cfg) if cfg else [] + gateway = _extract_gateway(cfg) if cfg else {} + + # Scan filesystem + skills = _scan_skills(home, vdef) + cron_jobs = _read_cron_jobs(home, vdef) + + # Build report in the same shape as openclaw_usage.py main() produces + # This ensures backend compatibility + system_info = get_system_info() + + # Build summary matching existing schema + channels_list = [ + {"name": ch_name, "enabled": ch_info.get("enabled", False)} + for ch_name, ch_info in channels.items() + ] + + # MCP servers are the plugin equivalent in nano/picoclaw + active_plugins_list = [ + {"name": s["name"], "source": "mcp", "enabled": True} + for s in mcp_servers + ] + + # Security posture — restrict_to_workspace lives in agents.defaults + tools_cfg = cfg.get("tools", {}) if cfg else {} + agents_defaults = cfg.get("agents", {}).get("defaults", {}) if cfg else {} + security_audit: Dict[str, Any] = { + "restrict_to_workspace": agents_defaults.get("restrict_to_workspace", False), + "exec_timeout": tools_cfg.get("exec", {}).get("timeout", 60), + "channels_with_allowlist": [ + ch_name for ch_name, ch_info in channels.items() + if ch_info.get("allow_from") + ], + "passed": True, + } + + # Models status — which providers have API keys configured + models_status: Dict[str, Any] = { + "models_status": { + pname: {"has_auth": pinfo.get("has_api_key", False)} + for pname, pinfo in providers.items() + }, + "has_auth": any( + pinfo.get("has_api_key", False) for pinfo in providers.values() + ), + } + + # Empty but structurally valid web activity + web_activity: Dict[str, Any] = { + "browser_urls": [], + "fetched_urls": [], + "search_queries": [], + "browser_urls_count": 0, + "fetched_urls_count": 0, + "search_queries_count": 0, + } + + summary: OutputSummary = { + "active_skills": skills, + "active_skills_count": len(skills), + "apps_detected": [], + "apps_detected_count": 0, + "apps_call_count": {}, + "apps_commands": {}, + "tools_used": [], + "tools_used_count": 0, + "tools_call_count": {}, + "total_tool_calls": 0, + "sessions_scanned": 0, + "cron_jobs": cron_jobs, + "cron_jobs_count": len(cron_jobs), + # CISO-relevant + "security_audit": security_audit, + "active_plugins": active_plugins_list, + "active_plugins_count": len(active_plugins_list), + "channels": channels_list, + "channels_count": len(channels_list), + "nodes": [], + "nodes_count": 0, + "models_status": models_status, + "autonomous_mode": agent_defaults.get("max_tool_iterations", 0) > 0, + "max_concurrent": 1, + "permissions": [], + "permissions_count": 0, + "web_activity": web_activity, + # Variant-specific additions + "variant_providers": providers, + "variant_mcp_servers": mcp_servers, + "variant_agent_defaults": agent_defaults, + "variant_gateway": gateway, + } + + result: Dict[str, Any] = { + "scan_timestamp": datetime.now().isoformat(), + "bot_cli_cmd": binary_path or name, + "bot_variant": name, + "bot_version": version, + "bot_config_dir": str(home), + "system_info": system_info, + "summary": summary, + } + + if full: + result["active_skills"] = { + "active_skills": skills, + "count": len(skills), + "total": len(skills), + } + result["session_analysis"] = { # no data — no session log parsing yet + "tool_calls": [], + "tools_summary": {}, + "apps_summary": {}, + "apps_commands": {}, + "web_activity": web_activity, + "total_tool_calls": 0, + "unique_tools": 0, + "unique_apps": 0, + "sessions_scanned": 0, + } + result["cron_jobs"] = { + "cron_jobs": cron_jobs, + "count": len(cron_jobs), + } + result["security_audit"] = security_audit # partial — from config, no CLI audit + result["active_plugins"] = { # MCP servers mapped as plugins + "active_plugins": active_plugins_list, + "count": len(active_plugins_list), + "total": len(active_plugins_list), + } + result["channels"] = { + "channels": channels_list, + "count": len(channels_list), + } + result["nodes"] = { # no data — no remote nodes concept + "nodes": [], + "count": 0, + } + result["models_status"] = models_status # from config providers + result["agent_config"] = { + "autonomous_mode": agent_defaults.get("max_tool_iterations", 0) > 0, + "max_concurrent": 1, # no data — single-process, always 1 + "agent_defaults": agent_defaults, + } + result["exec_approvals"] = { # no data — no approvals system + "approvals_path": None, + "approvals_exist": False, + "permissions": [], + "permissions_count": 0, + } + + return result + + +def _get_version(binary_path: Optional[str]) -> Optional[str]: + """Try to get variant version. Best-effort, returns None on failure. + + Extracts a semver-like version (e.g. ``1.2.3``, ``v0.1.0``) from + wherever it appears in the ``--version`` output, ignoring any emoji, + binary-name, or other prefix/suffix decoration. + """ + if not binary_path: + return None + + try: + result = subprocess.run( + [binary_path, "--version"], + capture_output=True, text=True, timeout=5, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + line = result.stdout.strip().splitlines()[0] + match = re.search(r"v?\d+\.\d+(?:\.\d+)?", line) + return match.group(0) if match else line.strip() or None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + return None diff --git a/openclaw_usage.py b/openclaw_usage.py index 2bf2257..2e5887e 100755 --- a/openclaw_usage.py +++ b/openclaw_usage.py @@ -8,6 +8,7 @@ import argparse import json import os +import re import ssl import subprocess import sys @@ -21,7 +22,9 @@ from platform_compat import compat as _compat from platform_compat.common import build_install_info_from_cli, detect_clawd_install, find_bot_cli_only, get_system_info -from structures import CLAWDBOT_VARIANT_NAMES, CliCommand +from structures import CLAWDBOT_VARIANT_NAMES, CliCommand, ClawdbotInstallInfo +from output_structures import OutputSkillEntry, OutputSummary +from nano_scanner import scan_nano API_ENDPOINT = "https://oneclaw.prompt.security/api/reports" @@ -94,20 +97,23 @@ def get_bot_version(bot_cli_cmd: Optional[CliCommand] = None) -> Optional[str]: bot_cli_cmd: Bot CLI base command as list of string parts. If None, auto-detects. Returns: - Version string (e.g., "2026.1.24") or None on failure + Version string (e.g., "2026.1.24", "v0.1.0") or None on failure """ try: stdout = _run_bot_cli(bot_cli_cmd, "--version", timeout=10) - # Output is typically just the version number, possibly with prefix - version = stdout.strip().split()[-1] # Take last word (handles "v2026.1.24" or "2026.1.24") - return version + # Extract semver-like version from first line of output. + # Handles decorated output like "🦞 picoclaw v0.1.1\n Build: ...\n Go: go1.25.7" + # and "🐈 nanobot v0.1.0" and plain "2026.1.24". + first_line = stdout.strip().splitlines()[0] if stdout.strip() else "" + match = re.search(r"v?\d+\.\d+(?:\.\d+)?", first_line) + return match.group(0) if match else first_line.strip() or None except CliExecError: return None class SkillsResult(TypedDict, total=False): """Return type for get_active_skills().""" - active_skills: List[Dict[str, Any]] # Skill structure from CLI + active_skills: List[OutputSkillEntry] # Shape from openclaw skills list --json count: int total: int # Only on success error: str # Only on error @@ -633,80 +639,10 @@ def send_report(report_data: Dict[str, Any], api_key: str, verify_ssl: bool = Tr } -def main(): - """CLI entry point for openclaw-scanner.""" - parser = argparse.ArgumentParser( - description="Scan OpenClaw usage: active skills, tools, and apps. Outputs JSON." - ) - parser.add_argument( - "--scanner-report-api-key", - type=str, - default=os.environ.get("SCANNER_REPORT_API_KEY"), - help="API key for sending report to the server (or set SCANNER_REPORT_API_KEY env var)" - ) - parser.add_argument( - "--cli", - type=str, - default=None, - help="Bot CLI command/path. If not provided, auto-detects (openclaw, clawdbot)" - ) - parser.add_argument( - "--compact", - action="store_true", - help="Compact JSON output (no indentation)" - ) - parser.add_argument( - "--full", - action="store_true", - help="Include full response with all details (default: only user and summary)" - ) - parser.add_argument( - "--limit", - type=int, - default=50, - help="Limit number of recent tool calls to include in full output (default: 50)" - ) - parser.add_argument( - "--no-ssl-verify", - action="store_true", - help="Disable SSL certificate verification (use only if behind a corporate proxy)" - ) - - args = parser.parse_args() - - # Detect install: either via bot CLI command specified on cmdline, or using auto-detect - if args.cli: - install_info = build_install_info_from_cli(args.cli.split()) - else: - install_info = detect_clawd_install() - - if install_info is None: - # Detect failed - figure out why for better error message - home = Path.home() - checked_variants = CLAWDBOT_VARIANT_NAMES - checked_config_paths = [str(home / f".{v}") for v in checked_variants] - - # Check if CLI exists but config dir doesn't (installed but never used) - cli_only = find_bot_cli_only() - if cli_only: - variant_name, cli_cmd = cli_only - version = get_bot_version(cli_cmd) - result = { - "error": "Bot installed but never used (no config found)", - "bot_variant": variant_name, - "bot_version": version, - "bot_cli_cmd": " ".join(cli_cmd), - "bot_config_dir": str(home / f".{variant_name}"), - } - else: - result = { - "error": "No bot installed", - "checked_variants": checked_variants, - "checked_config_paths": checked_config_paths, - } - print(json.dumps(result, indent=None if args.compact else 2)) - sys.exit(1) - +def scan_openclaw(install_info: ClawdbotInstallInfo, full: bool = False, limit: int = 50) -> Dict[str, Any]: + """Scan an OpenClaw/Clawdbot variant using CLI commands. + Returns result dict with scan_timestamp, bot_variant, system_info, summary, etc. + """ bot_cli_cmd = install_info.bot_cli_cmd bot_cli_str = " ".join(install_info.bot_cli_cmd) bot_config_dir = install_info.bot_config_dir @@ -727,14 +663,14 @@ def main(): exec_approvals = get_exec_approvals(bot_cli_cmd) # permissions # Limit tool calls in output - logs_result["tool_calls"] = logs_result["tool_calls"][:args.limit] + logs_result["tool_calls"] = logs_result["tool_calls"][:limit] # Build summary active_skills_list = skills_result.get("active_skills", []) apps_summary = logs_result.get("apps_summary", {}) tool_names = list(logs_result.get("tools_summary", {}).keys()) - summary = { + summary: OutputSummary = { "active_skills": active_skills_list, "active_skills_count": len(active_skills_list), "apps_detected": list(apps_summary.keys()), @@ -768,8 +704,8 @@ def main(): } # Build output based on --full flag - if args.full: - result = { + if full: + result: Dict[str, Any] = { "scan_timestamp": datetime.now().isoformat(), "bot_cli_cmd": bot_cli_str, "bot_variant": bot_variant, @@ -800,6 +736,98 @@ def main(): "summary": summary } + return result + + +def main(): + """CLI entry point for openclaw-scanner.""" + parser = argparse.ArgumentParser( + description="Scan OpenClaw usage: active skills, tools, and apps. Outputs JSON." + ) + parser.add_argument( + "--scanner-report-api-key", + type=str, + default=os.environ.get("SCANNER_REPORT_API_KEY"), + help="API key for sending report to the server (or set SCANNER_REPORT_API_KEY env var)" + ) + parser.add_argument( + "--cli", + type=str, + default=None, + help="Bot CLI command/path. If not provided, auto-detects (openclaw, clawdbot)" + ) + parser.add_argument( + "--compact", + action="store_true", + help="Compact JSON output (no indentation)" + ) + parser.add_argument( + "--full", + action="store_true", + help="Include full response with all details (default: only user and summary)" + ) + parser.add_argument( + "--limit", + type=int, + default=50, + help="Limit number of recent tool calls to include in full output (default: 50)" + ) + parser.add_argument( + "--no-ssl-verify", + action="store_true", + help="Disable SSL certificate verification (use only if behind a corporate proxy)" + ) + + args = parser.parse_args() + + # Detect install: either via bot CLI command specified on cmdline, or using auto-detect + if args.cli: + install_info = build_install_info_from_cli(args.cli.split()) + else: + install_info = detect_clawd_install() + + if install_info is None: + # Detect failed - figure out why for better error message + home = Path.home() + checked_variants = CLAWDBOT_VARIANT_NAMES + checked_config_paths = [str(home / f".{v}") for v in checked_variants] + + # Check if CLI exists but config dir doesn't (installed but never used) + cli_only = find_bot_cli_only() + if cli_only: + variant_name, cli_cmd = cli_only + version = get_bot_version(cli_cmd) + result = { + "error": "Bot installed but never used (no config found)", + "bot_variant": variant_name, + "bot_version": version, + "bot_cli_cmd": " ".join(cli_cmd), + "bot_config_dir": str(home / f".{variant_name}"), + } + else: + result = { + "error": "No bot installed", + "checked_variants": checked_variants, + "checked_config_paths": checked_config_paths, + } + print(json.dumps(result, indent=None if args.compact else 2)) + sys.exit(1) + + # Scan using strategy-appropriate function + if install_info.scanning_strategy == "nano": + result = scan_nano(install_info, full=args.full, limit=args.limit) + else: + result = scan_openclaw(install_info, full=args.full, limit=args.limit) + + if result is None: + result = { + "error": f"{install_info.bot_variant} detected but scan failed", + "bot_variant": install_info.bot_variant, + "bot_config_dir": str(install_info.bot_config_dir), + } + print(json.dumps(result, indent=None if args.compact else 2)) + sys.exit(1) + # Send report to API if scanner-report-api-key is provided if args.scanner_report_api_key: api_result = send_report(result, args.scanner_report_api_key, verify_ssl=not args.no_ssl_verify) diff --git a/output_structures.py b/output_structures.py new file mode 100644 index 0000000..a5be9d6 --- /dev/null +++ b/output_structures.py @@ -0,0 +1,109 @@ +"""Output data structures — typed contracts for scanner output. + +These TypedDicts define the exact JSON shape that the consumer expects. +Both scan_openclaw() and scan_nano() must produce data conforming to these +types. +""" + +from typing import Any, Dict, List, NotRequired, Optional, TypedDict + + +# ============================================================================= +# Skill entry — shape of each item in summary["active_skills"] +# ============================================================================= + +class OutputMissingRequirements(TypedDict): + """Requirements that are NOT satisfied for a skill.""" + + bins: List[str] + anyBins: List[str] + env: List[str] + config: List[str] + os: List[str] + + +class OutputSkillEntry(TypedDict): + """One skill in summary.active_skills[]. + + Matches the per-skill object returned by ``openclaw skills list --json``. + Every scanner must produce this exact shape; the backend depends on it. + """ + + name: str + description: str + emoji: Optional[str] + eligible: bool + disabled: bool + blockedByAllowlist: bool + source: str # "openclaw-bundled", "workspace", "managed", "builtin", … + bundled: bool + primaryEnv: Optional[str] + homepage: Optional[str] + missing: OutputMissingRequirements + + +# ============================================================================= +# Summary — the ``summary`` section of the scan report +# ============================================================================= + +_EMPTY_MISSING: OutputMissingRequirements = { + "bins": [], + "anyBins": [], + "env": [], + "config": [], + "os": [], +} +"""Convenience constant for skills with no missing requirements.""" + + +class OutputSummary(TypedDict): + """Shape of result["summary"] — the section the backend consumes. + + Fields present in every scan (both openclaw and nano strategies): + """ + + # -- Skills --------------------------------------------------------------- + active_skills: List[OutputSkillEntry] + active_skills_count: int + + # -- Apps/tools from session-log analysis --------------------------------- + apps_detected: List[str] + apps_detected_count: int + apps_call_count: Dict[str, Any] + apps_commands: Dict[str, Any] + tools_used: List[str] + tools_used_count: int + tools_call_count: Dict[str, Any] + total_tool_calls: int + sessions_scanned: int + + # -- Cron ----------------------------------------------------------------- + cron_jobs: List[Dict[str, Any]] + cron_jobs_count: int + + # -- CISO-relevant -------------------------------------------------------- + security_audit: Dict[str, Any] + active_plugins: List[Dict[str, Any]] + active_plugins_count: int + channels: List[Dict[str, Any]] + channels_count: int + nodes: List[Dict[str, Any]] + nodes_count: int + models_status: Dict[str, Any] + + # -- Agent config --------------------------------------------------------- + autonomous_mode: Optional[bool] + max_concurrent: Optional[int] + + # -- Permissions ---------------------------------------------------------- + permissions: List[Dict[str, Any]] + permissions_count: int + + # -- Web activity --------------------------------------------------------- + web_activity: Dict[str, Any] + + # -- Variant-specific (nano/picoclaw only) -------------------------------- + variant_providers: NotRequired[Dict[str, Any]] + variant_mcp_servers: NotRequired[List[Dict[str, Any]]] + variant_agent_defaults: NotRequired[Dict[str, Any]] + variant_gateway: NotRequired[Dict[str, Any]] diff --git a/platform_compat/common.py b/platform_compat/common.py index 1952803..afcbac7 100644 --- a/platform_compat/common.py +++ b/platform_compat/common.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import List, Optional -from structures import CliCommand, ClawdbotInstallInfo, ProcessInfo, SystemInfo, ToolPaths, CLAWDBOT_VARIANT_NAMES +from structures import CliCommand, ClawdbotInstallInfo, ProcessInfo, SystemInfo, ToolPaths, CLAWDBOT_VARIANT_NAMES, SCANNING_STRATEGY_MAP # Folders that aren't apps - skip these @@ -264,6 +264,7 @@ def _find_clawdbot_cli_binary(cli_name: str, # 6. Common fallback locations fallbacks = [ + home / ".local" / "bin" / cli_name, # pip / pipx / uv installs home / ".npm-global" / "bin" / cli_name, Path("/usr/local/bin") / cli_name, ] @@ -319,7 +320,8 @@ def detect_clawd_install(extra_paths_fn=None) -> Optional[ClawdbotInstallInfo]: # Both CLI and config dir must exist for valid detection if bot_cli_cmd and bot_config_dir.exists(): - return ClawdbotInstallInfo(bot_variant=name, bot_cli_cmd=bot_cli_cmd, bot_config_dir=bot_config_dir) + scanning_strategy = SCANNING_STRATEGY_MAP.get(name, "openclaw") + return ClawdbotInstallInfo(bot_variant=name, bot_cli_cmd=bot_cli_cmd, bot_config_dir=bot_config_dir, scanning_strategy=scanning_strategy) return None @@ -342,7 +344,8 @@ def build_install_info_from_cli(bot_cli_cmd: CliCommand) -> Optional[ClawdbotIns if not bot_config_dir.exists(): return None - return ClawdbotInstallInfo(bot_variant=name, bot_cli_cmd=bot_cli_cmd, bot_config_dir=bot_config_dir) + scanning_strategy = SCANNING_STRATEGY_MAP.get(name, "openclaw") + return ClawdbotInstallInfo(bot_variant=name, bot_cli_cmd=bot_cli_cmd, bot_config_dir=bot_config_dir, scanning_strategy=scanning_strategy) # Legacy alias for backward compatibility with platform_compat/{linux,darwin}.py diff --git a/pyproject.toml b/pyproject.toml index ec3b7d1..5f69cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,16 +6,15 @@ authors = [ { name = "Prompt Security", email = "support@prompt.security" } ] license = { text = "MIT" } -requires-python = ">=3.9" -dependencies = ["certifi>=2023.0.0"] +requires-python = ">=3.11" +dependencies = ["certifi>=2023.0.0", "json5>=0.9.0", "pyyaml>=6.0"] [project.urls] Homepage = "https://prompt.security" Repository = "https://github.com/prompt-security/openclaw-scanner" [project.optional-dependencies] -yaml = ["pyyaml>=6.0"] -dev = ["pylint>=3.0", "mypy>=1.0"] +dev = ["pylint>=3.0", "mypy>=1.0", "types-PyYAML>=6.0"] [project.scripts] openclaw-scanner = "openclaw_usage:main" @@ -28,6 +27,9 @@ build-backend = "hatchling.build" include = [ "openclaw_usage.py", "structures.py", + "output_structures.py", + "nano_scanner.py", + "scanner_utils.py", "platform_compat/*.py", ] diff --git a/scanner_utils.py b/scanner_utils.py new file mode 100644 index 0000000..b38cd2f --- /dev/null +++ b/scanner_utils.py @@ -0,0 +1,180 @@ +"""Shared utilities for scanning OpenClaw variants (nanobot, picoclaw). + +Provides helpers reusable across all variant scanners: +- camel_to_snake: normalize camelCase config keys to snake_case +- parse_skill_md: parse SKILL.md files with YAML frontmatter +- mask_api_key: redact API keys for safe reporting +""" + +import json +import re +from pathlib import Path +from typing import Any, Dict, Optional + +import json5 +import yaml + + +# ============================================================================= +# Key Casing Normalization +# ============================================================================= + +def _camel_to_snake_key(key: str) -> str: + """Convert a single camelCase key to snake_case. + + Examples: + maxTokens -> max_tokens + apiKey -> api_key + mcpServers -> mcp_servers + botToken -> bot_token + """ + # Insert underscore before uppercase letters, then lowercase + s1 = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', key) + return re.sub(r'([a-z\d])([A-Z])', r'\1_\2', s1).lower() + + +def camel_to_snake(obj: Any) -> Any: + """Recursively convert all dict keys from camelCase to snake_case. + + Leaves values untouched. Handles nested dicts and lists. + Used to normalize nanobot config (camelCase) to match picoclaw (snake_case). + + Verified mapping (from picoclaw's own migrate command): + maxTokens -> max_tokens + apiKey -> api_key + apiBase -> api_base + mcpServers -> mcp_servers + botToken -> bot_token + allowFrom -> allow_from + restrictToWorkspace -> restrict_to_workspace + maxToolIterations -> max_tool_iterations + memoryWindow -> memory_window + """ + if isinstance(obj, dict): + return {_camel_to_snake_key(k): camel_to_snake(v) for k, v in obj.items()} + if isinstance(obj, list): + return [camel_to_snake(item) for item in obj] + return obj + + +# ============================================================================= +# SKILL.md Parsing +# ============================================================================= + +def parse_skill_md(skill_dir: Path) -> Optional[Dict[str, Any]]: + """Parse a SKILL.md file with YAML frontmatter. + + Expected format: + --- + name: skill-name + description: What the skill does. + homepage: https://example.com + metadata: {"nanobot": {"emoji": "🐙", "requires": {"bins": ["gh"]}}} + --- + # Skill Title + Markdown instructions... + + Args: + skill_dir: Directory containing SKILL.md + + Returns: + Dict with parsed frontmatter fields, or None if no valid SKILL.md found. + Always includes 'path' key with the directory path. + """ + skill_file = skill_dir / "SKILL.md" + if not skill_file.exists(): + return None + + try: + content = skill_file.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + # Extract YAML frontmatter between --- markers + if not content.startswith("---"): + return {"name": skill_dir.name, "path": str(skill_dir), "raw": True} + + parts = content.split("---", 2) + if len(parts) < 3: + return {"name": skill_dir.name, "path": str(skill_dir), "raw": True} + + frontmatter_text = parts[1] + + try: + parsed = yaml.safe_load(frontmatter_text) + except yaml.YAMLError: + return {"name": skill_dir.name, "path": str(skill_dir), "raw": True} + + if not isinstance(parsed, dict): + return {"name": skill_dir.name, "path": str(skill_dir), "raw": True} + + result: Dict[str, Any] = {"path": str(skill_dir)} + result.update(parsed) + + # Ensure name is always present + if "name" not in result: + result["name"] = skill_dir.name + + return result + + +# ============================================================================= +# API Key Masking +# ============================================================================= + +def mask_api_key(key: str) -> str: + """Mask an API key for safe reporting: show first 4 + last 2 chars. + + Examples: + sk-ant-api03-xxxx...yyyy -> sk-a****yy + short -> **** + """ + if not key or len(key) <= 8: + return "****" + return key[:4] + "****" + key[-2:] + + +def has_api_key(provider_config: Dict[str, Any]) -> bool: + """Check if a provider config has a non-empty API key. + + Works with both camelCase (apiKey) and snake_case (api_key) configs. + """ + key = provider_config.get("api_key") or provider_config.get("apiKey") or "" + return bool(key.strip()) + + +# ============================================================================= +# Config File Reading +# ============================================================================= + +def read_json_config(path: Path) -> Optional[Dict[str, Any]]: + """Read and parse a JSON config file. + + Handles: + - Standard JSON (nanobot, picoclaw) + - JSON5 / JSONC with comments and trailing commas (OpenClaw) + + Uses stdlib json.loads() first (fast, strict), then falls back to + json5.loads() for files that contain comments or trailing commas. + + Returns None if file doesn't exist or can't be parsed. + """ + if not path.exists(): + return None + + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + # Try strict JSON first (handles nanobot + picoclaw) + try: + return json.loads(content) + except json.JSONDecodeError: + pass + + # Fall back to json5 for files with comments / trailing commas (OpenClaw) + try: + return json5.loads(content) + except ValueError: + return None diff --git a/structures.py b/structures.py index a296f42..82f78a0 100644 --- a/structures.py +++ b/structures.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional, TypedDict +from typing import Any, Dict, List, Literal, Optional, TypedDict # CLI command as list of string parts (ex. ["/usr/bin/openclaw"] or ["npx", "openclaw"]) CliCommand = List[str] @@ -15,16 +15,29 @@ # Variant Detection # ============================================================================= -# Supported product names (newest → oldest) -CLAWDBOT_VARIANT_NAMES = ["openclaw", "moltbot", "clawdbot"] +# Supported product names (ordered newest → oldest, then nano variants) +CLAWDBOT_VARIANT_NAMES = ["openclaw", "moltbot", "clawdbot", "nanobot", "picoclaw"] + +# Scanning strategy: determines which scanning approach to use, based on detected variant +ScanningStrategy = Literal["openclaw", "nano"] + +# Mapping from detected bot variant to scanning strategy to use. +SCANNING_STRATEGY_MAP: Dict[str, ScanningStrategy] = { + "openclaw": "openclaw", + "moltbot": "openclaw", + "clawdbot": "openclaw", + "nanobot": "nano", + "picoclaw": "nano", +} @dataclass class ClawdbotInstallInfo: - """Information about a detected OpenClaw/Clawdbot installation.""" - bot_variant: str # "openclaw" or "clawdbot" + """Information about a detected bot installation (OpenClaw family then nano/pico variants).""" + bot_variant: str # detected variant: one of CLAWDBOT_VARIANT_NAMES bot_cli_cmd: CliCommand # Command to run bot CLI: ["/usr/bin/openclaw"] bot_config_dir: Path # Bot config directory: ~/.openclaw/ + scanning_strategy: str # ScanningStrategy to use with the detected bot_variant scan # ============================================================================= diff --git a/uv.lock b/uv.lock index 464d510..945b5b0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,40 +1,15 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", - "python_full_version < '3.10'", -] - -[[package]] -name = "astroid" -version = "3.3.11" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, + "python_full_version < '3.12'", ] [[package]] name = "astroid" version = "4.0.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" }, @@ -67,45 +42,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - [[package]] name = "isort" -version = "6.1.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] [[package]] -name = "isort" -version = "7.0.0" +name = "json5" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, ] [[package]] @@ -114,16 +66,6 @@ version = "0.7.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, - { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, - { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, - { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, - { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, @@ -179,16 +121,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/2668bb01f568bc89ace53736df950845f8adfcacdf6da087d5cef12110cb/librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6", size = 56680, upload-time = "2026-01-14T12:56:02.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d4/dbb3edf2d0ec4ba08dcaf1865833d32737ad208962d4463c022cea6e9d3c/librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b", size = 58612, upload-time = "2026-01-14T12:56:03.616Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/64b029de4ac9901fcd47832c650a0fd050555a452bd455ce8deddddfbb9f/librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c", size = 163654, upload-time = "2026-01-14T12:56:04.975Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/95e2abb1b48eb8f8c7fc2ae945321a6b82777947eb544cc785c3f37165b2/librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5", size = 172477, upload-time = "2026-01-14T12:56:06.103Z" }, - { url = "https://files.pythonhosted.org/packages/7e/27/9bdf12e05b0eb089dd008d9c8aabc05748aad9d40458ade5e627c9538158/librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71", size = 186220, upload-time = "2026-01-14T12:56:09.958Z" }, - { url = "https://files.pythonhosted.org/packages/53/6a/c3774f4cc95e68ed444a39f2c8bd383fd18673db7d6b98cfa709f6634b93/librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e", size = 183841, upload-time = "2026-01-14T12:56:11.109Z" }, - { url = "https://files.pythonhosted.org/packages/58/6b/48702c61cf83e9c04ad5cec8cad7e5e22a2cde23a13db8ef341598897ddd/librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63", size = 179751, upload-time = "2026-01-14T12:56:12.278Z" }, - { url = "https://files.pythonhosted.org/packages/35/87/5f607fc73a131d4753f4db948833063c6aad18e18a4e6fbf64316c37ae65/librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94", size = 199319, upload-time = "2026-01-14T12:56:13.425Z" }, - { url = "https://files.pythonhosted.org/packages/6e/cc/b7c5ac28ae0f0645a9681248bae4ede665bba15d6f761c291853c5c5b78e/librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb", size = 43434, upload-time = "2026-01-14T12:56:14.781Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5d/dce0c92f786495adf2c1e6784d9c50a52fb7feb1cfb17af97a08281a6e82/librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be", size = 49801, upload-time = "2026-01-14T12:56:15.827Z" }, ] [[package]] @@ -208,17 +140,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, @@ -243,12 +168,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" }, - { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" }, - { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] @@ -267,26 +186,27 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "certifi" }, + { name = "json5" }, + { name = "pyyaml" }, ] [package.optional-dependencies] dev = [ { name = "mypy" }, - { name = "pylint", version = "3.3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pylint", version = "4.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -yaml = [ - { name = "pyyaml" }, + { name = "pylint" }, + { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ { name = "certifi", specifier = ">=2023.0.0" }, + { name = "json5", specifier = ">=0.9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.0" }, - { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, ] -provides-extras = ["yaml", "dev"] +provides-extras = ["dev"] [[package]] name = "pathspec" @@ -297,73 +217,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - [[package]] name = "platformdirs" version = "4.5.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] -[[package]] -name = "pylint" -version = "3.3.9" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "astroid", version = "3.3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "dill", marker = "python_full_version < '3.10'" }, - { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mccabe", marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, - { name = "tomlkit", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" }, -] - [[package]] name = "pylint" version = "4.0.4" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] dependencies = [ - { name = "astroid", version = "4.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "dill", marker = "python_full_version >= '3.10'" }, - { name = "isort", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mccabe", marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, - { name = "tomlkit", marker = "python_full_version >= '3.10'" }, + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } wheels = [ @@ -376,15 +250,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -432,69 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] @@ -507,19 +309,19 @@ wheels = [ ] [[package]] -name = "typing-extensions" -version = "4.15.0" +name = "types-pyyaml" +version = "6.0.12.20250915" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] [[package]] -name = "zipp" -version = "3.23.0" +name = "typing-extensions" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] From 32943638e273a19ff8ff1eae8f7969fbe701a920 Mon Sep 17 00:00:00 2001 From: Vitaly Neyman Date: Sun, 15 Feb 2026 23:46:42 +0200 Subject: [PATCH 2/3] Update README.md - fix bot variants naming Co-authored-by: baz-reviewer[bot] <174234987+baz-reviewer[bot]@users.noreply.github.com> --- README.md | 4 ++-- nano_scanner.py | 39 ++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1164460..4ee05dc 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ Returns JSON with the following fields: ## Requirements -- OpenClaw, Moltbot, Clawdbot or smaller variants: nanobot picobot CLI installed -- Config directory such as `.openclaw` directory detected in the user home directory +- OpenClaw, Moltbot, Clawdbot or smaller variants: nanobot picoclaw CLI installed +- Config directory such as `.openclaw` detected in the user home directory ## License diff --git a/nano_scanner.py b/nano_scanner.py index 9399ca3..47086c9 100644 --- a/nano_scanner.py +++ b/nano_scanner.py @@ -107,6 +107,7 @@ def _extract_channels(cfg: Dict[str, Any]) -> Dict[str, Any]: continue result[name] = { "enabled": ccfg.get("enabled", False), + "allow_from": ccfg.get("allow_from", []), } return result @@ -253,19 +254,19 @@ def scan_nano(install_info: ClawdbotInstallInfo, full: bool = False, limit: int if not vdef: return None - # Read and normalize config - cfg = _read_variant_config(name, home) + # Read and normalize config (always a dict so helpers need no guards) + cfg = _read_variant_config(name, home) or {} # Binary detection — prefer already-resolved path from install_info binary_path = install_info.bot_cli_cmd[0] if install_info.bot_cli_cmd else _detect_binary(vdef) version = _get_version(binary_path) - # Extract data from config (if config exists) - providers = _extract_providers(cfg) if cfg else {} - channels = _extract_channels(cfg) if cfg else {} - agent_defaults = _extract_agent_defaults(cfg) if cfg else {} - mcp_servers = _extract_mcp_servers(cfg) if cfg else [] - gateway = _extract_gateway(cfg) if cfg else {} + # Extract data from config + providers = _extract_providers(cfg) + channels = _extract_channels(cfg) + agent_defaults = _extract_agent_defaults(cfg) + mcp_servers = _extract_mcp_servers(cfg) + gateway = _extract_gateway(cfg) # Scan filesystem skills = _scan_skills(home, vdef) @@ -288,8 +289,8 @@ def scan_nano(install_info: ClawdbotInstallInfo, full: bool = False, limit: int ] # Security posture — restrict_to_workspace lives in agents.defaults - tools_cfg = cfg.get("tools", {}) if cfg else {} - agents_defaults = cfg.get("agents", {}).get("defaults", {}) if cfg else {} + tools_cfg = cfg.get("tools", {}) + agents_defaults = cfg.get("agents", {}).get("defaults", {}) security_audit: Dict[str, Any] = { "restrict_to_workspace": agents_defaults.get("restrict_to_workspace", False), "exec_timeout": tools_cfg.get("exec", {}).get("timeout", 60), @@ -300,11 +301,19 @@ def scan_nano(install_info: ClawdbotInstallInfo, full: bool = False, limit: int "passed": True, } - # Models status — which providers have API keys configured + # Models status — match OpenClaw CLI schema so backend/UI gets expected keys models_status: Dict[str, Any] = { - "models_status": { - pname: {"has_auth": pinfo.get("has_api_key", False)} - for pname, pinfo in providers.items() + "model": None, + "imageModel": None, + "auth": { + "providers": { + pname: { + "has_auth": pinfo.get("has_api_key", False), + "api_key_masked": pinfo.get("api_key_masked"), + "has_api_base": pinfo.get("has_api_base", False), + } + for pname, pinfo in providers.items() + }, }, "has_auth": any( pinfo.get("has_api_key", False) for pinfo in providers.values() @@ -358,7 +367,7 @@ def scan_nano(install_info: ClawdbotInstallInfo, full: bool = False, limit: int result: Dict[str, Any] = { "scan_timestamp": datetime.now().isoformat(), - "bot_cli_cmd": binary_path or name, + "bot_cli_cmd": " ".join(install_info.bot_cli_cmd) if install_info.bot_cli_cmd else (binary_path or name), "bot_variant": name, "bot_version": version, "bot_config_dir": str(home), From c51151e9f0f04452446c5d3ae1dfc4e96bfba192 Mon Sep 17 00:00:00 2001 From: Vitaly Neyman Date: Mon, 16 Feb 2026 16:25:11 +0200 Subject: [PATCH 3/3] Extract shared aggregate_tool_calls to eliminate duplicate code --- nano_scanner.py | 93 ++---------------------------------- openclaw_usage.py | 94 ++----------------------------------- scanner_utils.py | 117 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 181 deletions(-) diff --git a/nano_scanner.py b/nano_scanner.py index b92ba9d..e9f8bd4 100644 --- a/nano_scanner.py +++ b/nano_scanner.py @@ -20,8 +20,8 @@ from platform_compat import compat from platform_compat.common import get_system_info from structures import ClawdbotInstallInfo -from scanner_utils import camel_to_snake, has_api_key, mask_api_key, parse_skill_md, read_json_config -from scrubber import scrub_arguments, scrub_url +from scanner_utils import aggregate_tool_calls, camel_to_snake, has_api_key, mask_api_key, parse_skill_md, read_json_config +from scrubber import scrub_arguments from output_structures import OutputSkillEntry, OutputSummary, _EMPTY_MISSING @@ -335,94 +335,7 @@ def _scan_session_logs(home: Path, vdef: Dict[str, Any]) -> Dict[str, Any]: except OSError: continue - # Sort by timestamp (most recent first) - tool_calls.sort(key=lambda x: x.get("timestamp", ""), reverse=True) - - # Build tools usage summary - tools_summary: Dict[str, int] = {} - for tc in tool_calls: - tname = tc.get("tool_name", "unknown") - tools_summary[tname] = tools_summary.get(tname, 0) + 1 - tools_summary = dict(sorted(tools_summary.items(), key=lambda x: x[1], reverse=True)) - - # Build apps usage summary and collect full commands per app - apps_summary: Dict[str, int] = {} - apps_commands: Dict[str, List[Dict[str, str]]] = {} - for tc in tool_calls: - command = tc.get("arguments", {}).get("command", "") if isinstance(tc.get("arguments"), dict) else "" - timestamp = tc.get("timestamp", "") - for app in tc.get("apps_detected", []): - apps_summary[app] = apps_summary.get(app, 0) + 1 - if command: - if app not in apps_commands: - apps_commands[app] = [] - apps_commands[app].append({ - "command": command, - "timestamp": timestamp, - "session": tc.get("session", ""), - }) - apps_summary = dict(sorted(apps_summary.items(), key=lambda x: x[1], reverse=True)) - apps_commands = {app: apps_commands[app] for app in apps_summary if app in apps_commands} - - # Extract web activity from tool calls - browser_urls: List[Dict[str, str]] = [] - fetched_urls: List[Dict[str, str]] = [] - search_queries: List[Dict[str, str]] = [] - - for tc in tool_calls: - tool_name = tc.get("tool_name", "") - tc_args = tc.get("arguments", {}) - if not isinstance(tc_args, dict): - continue - tc_ts = tc.get("timestamp", "") - tc_session = tc.get("session", "") - - if tool_name == "browser": - url = tc_args.get("targetUrl", "") or tc_args.get("url", "") - if url: - browser_urls.append({ - "url": scrub_url(url), - "action": tc_args.get("action", "open"), - "timestamp": tc_ts, - "session": tc_session, - }) - elif tool_name == "web_fetch": - url = tc_args.get("url", "") - if url: - fetched_urls.append({ - "url": scrub_url(url), - "timestamp": tc_ts, - "session": tc_session, - }) - elif tool_name == "web_search": - query = tc_args.get("query", "") - if query: - search_queries.append({ - "query": query, - "timestamp": tc_ts, - "session": tc_session, - }) - - web_activity: Dict[str, Any] = { - "browser_urls": browser_urls, - "fetched_urls": fetched_urls, - "search_queries": search_queries, - "browser_urls_count": len(browser_urls), - "fetched_urls_count": len(fetched_urls), - "search_queries_count": len(search_queries), - } - - return { - "tool_calls": tool_calls, - "tools_summary": tools_summary, - "apps_summary": apps_summary, - "apps_commands": apps_commands, - "web_activity": web_activity, - "total_tool_calls": len(tool_calls), - "unique_tools": len(tools_summary), - "unique_apps": len(apps_summary), - "sessions_scanned": len(json_files) + len(jsonl_files), - } + return aggregate_tool_calls(tool_calls, len(json_files) + len(jsonl_files)) def _read_cron_jobs(home: Path, vdef: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/openclaw_usage.py b/openclaw_usage.py index d54109a..a55c88a 100755 --- a/openclaw_usage.py +++ b/openclaw_usage.py @@ -22,7 +22,8 @@ from platform_compat import compat from platform_compat.common import build_install_info_from_cli, detect_clawd_install, find_bot_cli_only, get_system_info -from scrubber import scrub_arguments, scrub_url +from scanner_utils import aggregate_tool_calls +from scrubber import scrub_arguments from structures import CLAWDBOT_VARIANT_NAMES, CliCommand, ClawdbotInstallInfo from output_structures import OutputSkillEntry, OutputSummary from nano_scanner import scan_nano @@ -476,95 +477,8 @@ def scan_session_logs(bot_config_dir: Path) -> Dict[str, Any]: except Exception: continue - # Sort by timestamp (most recent first) - tool_calls.sort(key=lambda x: x.get("timestamp", ""), reverse=True) - - # Build tools usage summary - tools_summary: Dict[str, int] = {} - for tc in tool_calls: - name = tc.get("tool_name", "unknown") - tools_summary[name] = tools_summary.get(name, 0) + 1 - - # Sort by count (descending) - tools_summary = dict(sorted(tools_summary.items(), key=lambda x: x[1], reverse=True)) - - # Build apps usage summary and collect full commands per app - apps_summary: Dict[str, int] = {} - apps_commands: Dict[str, List[Dict[str, str]]] = {} - for tc in tool_calls: - command = tc.get("arguments", {}).get("command", "") - timestamp = tc.get("timestamp", "") - for app in tc.get("apps_detected", []): - apps_summary[app] = apps_summary.get(app, 0) + 1 - if command: - if app not in apps_commands: - apps_commands[app] = [] - apps_commands[app].append({ - "command": command, - "timestamp": timestamp, - "session": tc.get("session", ""), - }) - - apps_summary = dict(sorted(apps_summary.items(), key=lambda x: x[1], reverse=True)) - apps_commands = {app: apps_commands[app] for app in apps_summary if app in apps_commands} - - # Extract web activity from tool calls (browser, web_fetch, web_search) - browser_urls: List[Dict[str, str]] = [] - fetched_urls: List[Dict[str, str]] = [] - search_queries: List[Dict[str, str]] = [] - - for tc in tool_calls: - tool_name = tc.get("tool_name", "") - tc_args = tc.get("arguments", {}) - tc_timestamp = tc.get("timestamp", "") - tc_session = tc.get("session", "") - - if tool_name == "browser": - url = tc_args.get("targetUrl", "") or tc_args.get("url", "") - if url: - browser_urls.append({ - "url": scrub_url(url), - "action": tc_args.get("action", "open"), - "timestamp": tc_timestamp, - "session": tc_session, - }) - elif tool_name == "web_fetch": - url = tc_args.get("url", "") - if url: - fetched_urls.append({ - "url": scrub_url(url), - "timestamp": tc_timestamp, - "session": tc_session, - }) - elif tool_name == "web_search": - query = tc_args.get("query", "") - if query: - search_queries.append({ - "query": query, - "timestamp": tc_timestamp, - "session": tc_session, - }) - - web_activity: Dict[str, Any] = { - "browser_urls": browser_urls, - "fetched_urls": fetched_urls, - "search_queries": search_queries, - "browser_urls_count": len(browser_urls), - "fetched_urls_count": len(fetched_urls), - "search_queries_count": len(search_queries), - } - - return { - "tool_calls": tool_calls, - "tools_summary": tools_summary, - "apps_summary": apps_summary, - "apps_commands": apps_commands, - "web_activity": web_activity, - "total_tool_calls": len(tool_calls), - "unique_tools": len(tools_summary), - "unique_apps": len(apps_summary), - "sessions_scanned": len([f for f in session_files if f.name != "sessions.json"]) - } + sessions_count = len([f for f in session_files if f.name != "sessions.json"]) + return aggregate_tool_calls(tool_calls, sessions_count) def send_report(report_data: Dict[str, Any], api_key: str, verify_ssl: bool = True) -> Dict[str, Any]: diff --git a/scanner_utils.py b/scanner_utils.py index b38cd2f..4688ec6 100644 --- a/scanner_utils.py +++ b/scanner_utils.py @@ -4,16 +4,19 @@ - camel_to_snake: normalize camelCase config keys to snake_case - parse_skill_md: parse SKILL.md files with YAML frontmatter - mask_api_key: redact API keys for safe reporting +- aggregate_tool_calls: build summaries from raw tool_call lists """ import json import re from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import json5 import yaml +from scrubber import scrub_url + # ============================================================================= # Key Casing Normalization @@ -178,3 +181,115 @@ def read_json_config(path: Path) -> Optional[Dict[str, Any]]: return json5.loads(content) except ValueError: return None + + +# ============================================================================= +# Tool Call Aggregation (shared by nano_scanner + openclaw_usage) +# ============================================================================= + +def aggregate_tool_calls(tool_calls: List[Dict[str, Any]], sessions_scanned: int) -> Dict[str, Any]: + """Build summaries, web activity, and return dict from raw tool_call list. + + Both nano_scanner._scan_session_logs() and openclaw_usage.scan_session_logs() + collect a list of tool_call dicts, then need identical post-processing. + This function is that shared tail. + + Args: + tool_calls: List of dicts with tool_name, arguments, apps_detected, + timestamp, session, tool_id. + sessions_scanned: Number of session files that were parsed. + + Returns: + Dict with tool_calls, tools_summary, apps_summary, apps_commands, + web_activity, total_tool_calls, unique_tools, unique_apps, + sessions_scanned. + """ + # Sort by timestamp (most recent first) + tool_calls.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + + # Build tools usage summary + tools_summary: Dict[str, int] = {} + for tc in tool_calls: + tname = tc.get("tool_name", "unknown") + tools_summary[tname] = tools_summary.get(tname, 0) + 1 + tools_summary = dict(sorted(tools_summary.items(), key=lambda x: x[1], reverse=True)) + + # Build apps usage summary and collect full commands per app + apps_summary: Dict[str, int] = {} + apps_commands: Dict[str, List[Dict[str, str]]] = {} + for tc in tool_calls: + tc_args = tc.get("arguments", {}) + command = tc_args.get("command", "") if isinstance(tc_args, dict) else "" + timestamp = tc.get("timestamp", "") + for app in tc.get("apps_detected", []): + apps_summary[app] = apps_summary.get(app, 0) + 1 + if command: + if app not in apps_commands: + apps_commands[app] = [] + apps_commands[app].append({ + "command": command, + "timestamp": timestamp, + "session": tc.get("session", ""), + }) + apps_summary = dict(sorted(apps_summary.items(), key=lambda x: x[1], reverse=True)) + apps_commands = {app: apps_commands[app] for app in apps_summary if app in apps_commands} + + # Extract web activity from tool calls + browser_urls: List[Dict[str, str]] = [] + fetched_urls: List[Dict[str, str]] = [] + search_queries: List[Dict[str, str]] = [] + + for tc in tool_calls: + tool_name = tc.get("tool_name", "") + tc_args = tc.get("arguments", {}) + if not isinstance(tc_args, dict): + continue + tc_ts = tc.get("timestamp", "") + tc_session = tc.get("session", "") + + if tool_name == "browser": + url = tc_args.get("targetUrl", "") or tc_args.get("url", "") + if url: + browser_urls.append({ + "url": scrub_url(url), + "action": tc_args.get("action", "open"), + "timestamp": tc_ts, + "session": tc_session, + }) + elif tool_name == "web_fetch": + url = tc_args.get("url", "") + if url: + fetched_urls.append({ + "url": scrub_url(url), + "timestamp": tc_ts, + "session": tc_session, + }) + elif tool_name == "web_search": + query = tc_args.get("query", "") + if query: + search_queries.append({ + "query": query, + "timestamp": tc_ts, + "session": tc_session, + }) + + web_activity: Dict[str, Any] = { + "browser_urls": browser_urls, + "fetched_urls": fetched_urls, + "search_queries": search_queries, + "browser_urls_count": len(browser_urls), + "fetched_urls_count": len(fetched_urls), + "search_queries_count": len(search_queries), + } + + return { + "tool_calls": tool_calls, + "tools_summary": tools_summary, + "apps_summary": apps_summary, + "apps_commands": apps_commands, + "web_activity": web_activity, + "total_tool_calls": len(tool_calls), + "unique_tools": len(tools_summary), + "unique_apps": len(apps_summary), + "sessions_scanned": sessions_scanned, + }