@miraiva_test/miravia-order-report 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ @echo off
2
+ REM ──────────────────────────────────────────────────────────────────────
3
+ REM run.bat — Windows CMD wrapper for running any Python script
4
+ REM
5
+ REM Purpose: Bypass PowerShell Constrained Language Mode that blocks
6
+ REM .NET method calls (System.Convert, System.Text.Encoding) in
7
+ REM enterprise-managed Windows environments.
8
+ REM
9
+ REM Usage: scripts\run.bat <script.py> [arguments...]
10
+ REM Example: scripts\run.bat scripts/export_orders.py --agent-type cursor --ping
11
+ REM scripts\run.bat scripts/estimate_time.py --agent-type cursor --last-days 30
12
+ REM ──────────────────────────────────────────────────────────────────────
13
+
14
+ cd /d "%~dp0.."
15
+ python %*
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Skill Hub encrypted payload decryption.
5
+
6
+ Aligned with server-side SkillSignatureUtil:
7
+ - Cipher: RSA/ECB/PKCS1Padding (signMethod=SHA256withRSA / SHA1withRSA default)
8
+ or RSA/ECB/OAEPWithSHA-512AndMGF1Padding (signMethod=SHA512withRSA)
9
+ - Encoding: Base64URL (no padding)
10
+ - Chunking: each ciphertext block = keySize / 8 (RSA-2048 -> 256 bytes)
11
+
12
+ Dependency: cryptography>=41
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ from typing import Dict
20
+
21
+ from cryptography.hazmat.primitives import hashes, serialization
22
+ from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
23
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
24
+
25
+
26
+ class SkillAuthDecryptError(RuntimeError):
27
+ pass
28
+
29
+
30
+ def _b64url_decode(s: str) -> bytes:
31
+ if not s:
32
+ raise SkillAuthDecryptError("cipher is empty")
33
+ s = s.strip()
34
+ pad = "=" * (-len(s) % 4)
35
+ try:
36
+ return base64.urlsafe_b64decode(s + pad)
37
+ except Exception as e: # noqa: BLE001
38
+ raise SkillAuthDecryptError(f"cipher Base64URL decode failed: {e}") from e
39
+
40
+
41
+ def _build_padding(sign_method: str):
42
+ sm = (sign_method or "").upper()
43
+ if sm == "SHA512WITHRSA":
44
+ return asym_padding.OAEP(
45
+ mgf=asym_padding.MGF1(algorithm=hashes.SHA512()),
46
+ algorithm=hashes.SHA512(),
47
+ label=None,
48
+ )
49
+ # SHA256withRSA / SHA1withRSA defaults to PKCS1v15, aligned with server-side default
50
+ return asym_padding.PKCS1v15()
51
+
52
+
53
+ def decrypt_payload(
54
+ cipher_b64url: str,
55
+ private_key_pem: bytes,
56
+ sign_method: str = "SHA256withRSA",
57
+ ) -> Dict:
58
+ """RSA chunked decryption of encryptedPayload, returns parsed JSON dict."""
59
+ raw = _b64url_decode(cipher_b64url)
60
+
61
+ try:
62
+ sk = serialization.load_pem_private_key(private_key_pem, password=None)
63
+ except Exception as e: # noqa: BLE001
64
+ raise SkillAuthDecryptError(f"Failed to load private key: {e}") from e
65
+ if not isinstance(sk, RSAPrivateKey):
66
+ raise SkillAuthDecryptError("Private key is not RSA type")
67
+
68
+ block_size = sk.key_size // 8 # RSA-2048 → 256
69
+ if len(raw) == 0 or len(raw) % block_size != 0:
70
+ raise SkillAuthDecryptError(
71
+ f"cipher length {len(raw)} is not a multiple of block_size={block_size}"
72
+ )
73
+
74
+ pad = _build_padding(sign_method)
75
+ parts = []
76
+ try:
77
+ for i in range(0, len(raw), block_size):
78
+ parts.append(sk.decrypt(raw[i : i + block_size], pad))
79
+ except Exception as e: # noqa: BLE001
80
+ raise SkillAuthDecryptError(f"RSA chunked decryption failed: {e}") from e
81
+
82
+ plaintext = b"".join(parts)
83
+ try:
84
+ return json.loads(plaintext.decode("utf-8"))
85
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
86
+ raise SkillAuthDecryptError(f"Plaintext is not valid JSON: {e}") from e
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Skill keypair management (consumer side only)
5
+
6
+ The private key is generated by the backend during Skill registration;
7
+ the seller persists it to .keypair.json during initialization.
8
+ This module only reads and normalizes the format (raw Base64 -> PEM).
9
+
10
+ File location: <skill_dir>/.keypair.json
11
+ File format (example):
12
+ {
13
+ "skill_code": "<32-char skillCode>",
14
+ "private_key": "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----",
15
+ "sign_method": "SHA256withRSA"
16
+ }
17
+
18
+ private_key can also be raw Base64 (PKCS#8 single-line); this module auto-wraps it into PEM.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import textwrap
26
+ from typing import Dict
27
+
28
+ import config
29
+
30
+
31
+ class SkillKeypairError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _to_pem(private_key_str: str) -> bytes:
36
+ """Normalize a private key string into PEM bytes.
37
+
38
+ Supports two input formats:
39
+ - Already PEM text (with -----BEGIN/-----END headers);
40
+ - Raw Base64 PKCS#8 single-line (no headers), auto-wrapped into PEM.
41
+ """
42
+ s = private_key_str.strip()
43
+ if not s:
44
+ raise SkillKeypairError("private_key is empty")
45
+ if "BEGIN" in s and "END" in s:
46
+ # Already PEM; normalize line endings
47
+ return s.replace("\r\n", "\n").encode("utf-8")
48
+ # Raw Base64 -> wrap as PEM; fold at 64 chars
49
+ body = "\n".join(textwrap.wrap(s, 64))
50
+ pem = f"-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----\n"
51
+ return pem.encode("utf-8")
52
+
53
+
54
+ def load_keypair(path: str = None) -> Dict:
55
+ """Read .keypair.json and return a dict.
56
+
57
+ Returned fields:
58
+ - skill_code: str
59
+ - private_key_pem: bytes (PEM format, can be fed directly to cryptography.serialization.load_pem_private_key)
60
+ - sign_method: str (default SHA256withRSA)
61
+ """
62
+ path = path or config.KEYPAIR_FILE
63
+ if not os.path.exists(path):
64
+ raise SkillKeypairError(
65
+ f".keypair.json not found: {path}\n"
66
+ f"Please persist the Skill-registered private key and skillCode to this file.\n"
67
+ f"Template: {{\"skill_code\":\"...\",\"private_key\":\"-----BEGIN PRIVATE KEY-----...\",\"sign_method\":\"SHA256withRSA\"}}"
68
+ )
69
+ try:
70
+ with open(path, "r", encoding="utf-8") as f:
71
+ raw = json.load(f)
72
+ except json.JSONDecodeError as e:
73
+ raise SkillKeypairError(f".keypair.json parse failed: {e}") from e
74
+
75
+ skill_code = (raw.get("skill_code") or "").strip()
76
+ private_key = raw.get("private_key") or ""
77
+ if not skill_code:
78
+ raise SkillKeypairError(".keypair.json missing skill_code")
79
+ if not private_key:
80
+ raise SkillKeypairError(".keypair.json missing private_key")
81
+
82
+ return {
83
+ "skill_code": skill_code,
84
+ "private_key_pem": _to_pem(private_key),
85
+ "sign_method": (raw.get("sign_method") or "SHA256withRSA").strip(),
86
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Skill invocation logging module
5
+
6
+ Log file naming: logs/skill-{date}.log
7
+ Appends one JSON line per skill invocation with structured call details.
8
+
9
+ Field descriptions (aligned with backend reporting protocol):
10
+ - SellerID : Current authorized seller ID (from credentials.json, empty if missing)
11
+ - SkillName : Fixed "miravia_order_reports"
12
+ - Action : Invocation scenario/action (export / refund / business / inspect / ping)
13
+ - AgentType : Agent type (empty if not Agent-driven)
14
+ - ModelName : Model name (empty if no LLM invoked)
15
+ - Duration : Human-readable duration (e.g. "12.3s")
16
+ - Status : Invocation result "success" / "fail"
17
+ - ErrorCode : Error code (empty on success)
18
+ - apiCallUrl : API URLs involved in this call (deduplicated)
19
+ - apiCallCount : Total API call count
20
+ - apiSuccessCount : Successful API call count
21
+ - dataCount : Number of data rows fetched
22
+ - durationMs : Duration in milliseconds
23
+ - envData : Environment info object
24
+ - os : Operating system name
25
+ - os_version : Operating system version
26
+ - node_version : Node.js version (empty for Python skill)
27
+ - agent_version : Agent/Skill version
28
+ - locale : System locale
29
+ - remark : Remarks
30
+ """
31
+
32
+ import json
33
+ import logging
34
+ import os
35
+ import platform
36
+ import locale
37
+ import time
38
+ from datetime import datetime
39
+ from pathlib import Path
40
+ from typing import Any, Dict, List, Optional
41
+
42
+
43
+ # Skill root directory / logs
44
+ _SKILL_DIR = Path(__file__).resolve().parent.parent
45
+ _LOGS_DIR = _SKILL_DIR / "logs"
46
+
47
+ SKILL_NAME = "miravia_order_reports"
48
+
49
+ # Version: read from npm-package/package.json as single source of truth
50
+ def _read_version() -> str:
51
+ """Read version from npm-package/package.json; fallback to hardcoded if unavailable."""
52
+ try:
53
+ pkg_path = _SKILL_DIR / "npm-package" / "package.json"
54
+ if pkg_path.exists():
55
+ data = json.loads(pkg_path.read_text(encoding="utf-8"))
56
+ return str(data.get("version", "0.0.0"))
57
+ except Exception:
58
+ pass
59
+ return "0.0.0"
60
+
61
+ SKILL_VERSION = _read_version()
62
+
63
+ # ---- agent_type global state (CLI required input, no auto-detection) ----
64
+ _agent_type: str = ""
65
+
66
+
67
+ def set_agent_type(agent_type: str) -> None:
68
+ """Called by CLI entry at startup to set global agent_type.
69
+
70
+ agent_type must be explicitly passed by the calling Agent via --agent-type CLI parameter;
71
+ no auto-detection or inference is performed.
72
+ """
73
+ global _agent_type
74
+ _agent_type = agent_type.strip()
75
+
76
+
77
+ def get_agent_type() -> str:
78
+ """Get current global agent_type.
79
+
80
+ Returns empty string if set_agent_type() has not been called.
81
+ """
82
+ return _agent_type
83
+
84
+
85
+ def detect_model_name() -> str:
86
+ """Detect the LLM model name used by the current Agent.
87
+
88
+ Priority:
89
+ 1. Environment variable SKILL_MODEL_NAME (explicit override, highest priority)
90
+ 2. Match model env var by AgentType:
91
+ - claude -> ANTHROPIC_MODEL
92
+ - qoder -> QODER_MODEL (not exposed yet, empty)
93
+ - cursor -> CURSOR_MODEL
94
+ 3. Fallback to empty string (undetectable)
95
+ """
96
+ # 1. Explicit override
97
+ explicit = os.environ.get("SKILL_MODEL_NAME", "").strip()
98
+ if explicit:
99
+ return explicit
100
+
101
+ agent = get_agent_type()
102
+
103
+ # 2. Claude Code: ANTHROPIC_MODEL is its standard config
104
+ if agent == "claude":
105
+ val = os.environ.get("ANTHROPIC_MODEL", "").strip()
106
+ if val:
107
+ return val
108
+
109
+ # 3. Qoder: does not expose model name env var yet; expects QODER_MODEL later
110
+ if agent == "qoder":
111
+ val = os.environ.get("QODER_MODEL", "").strip()
112
+ if val:
113
+ return val
114
+
115
+ # 4. Cursor
116
+ if agent == "cursor":
117
+ for key in ("CURSOR_MODEL", "OPENAI_MODEL"):
118
+ val = os.environ.get(key, "").strip()
119
+ if val:
120
+ return val
121
+
122
+ return ""
123
+
124
+
125
+ class ApiCallTracker:
126
+ """Track API call count and results for final log entry."""
127
+
128
+ def __init__(self):
129
+ self.call_count: int = 0
130
+ self.success_count: int = 0
131
+ self.urls: List[str] = []
132
+ self._url_set: set = set()
133
+
134
+ def record(self, url: str, success: bool) -> None:
135
+ self.call_count += 1
136
+ if success:
137
+ self.success_count += 1
138
+ if url not in self._url_set:
139
+ self._url_set.add(url)
140
+ self.urls.append(url)
141
+
142
+ def reset(self) -> None:
143
+ self.call_count = 0
144
+ self.success_count = 0
145
+ self.urls.clear()
146
+ self._url_set.clear()
147
+
148
+
149
+ # Global tracker instance (referenced by miravia_client.py)
150
+ api_tracker = ApiCallTracker()
151
+
152
+
153
+ def _get_seller_id() -> str:
154
+ """Read seller_id from credentials.json if available, otherwise return empty string."""
155
+ cred_file = _SKILL_DIR / "credentials.json"
156
+ if not cred_file.exists():
157
+ return ""
158
+ try:
159
+ data = json.loads(cred_file.read_text(encoding="utf-8"))
160
+ return str(data.get("seller_id", "") or "")
161
+ except (OSError, json.JSONDecodeError):
162
+ return ""
163
+
164
+
165
+ def _get_env_data() -> Dict[str, str]:
166
+ """Collect runtime environment info."""
167
+ sys_name = platform.system() # e.g. "Darwin", "Linux", "Windows"
168
+ sys_version = platform.release() # e.g. "26.3.1"
169
+ try:
170
+ sys_locale = locale.getdefaultlocale()[0] or ""
171
+ except Exception:
172
+ sys_locale = ""
173
+ return {
174
+ "os": sys_name,
175
+ "os_version": sys_version,
176
+ "node_version": "", # Python skill, not applicable
177
+ "agent_version": SKILL_VERSION,
178
+ "locale": sys_locale,
179
+ }
180
+
181
+
182
+ # Log retention days
183
+ _LOG_RETENTION_DAYS = 5
184
+
185
+
186
+ def _log_file_path() -> Path:
187
+ """Return today's log file path: logs/skill-{YYYY-MM-DD}.log"""
188
+ _LOGS_DIR.mkdir(parents=True, exist_ok=True)
189
+ date_str = datetime.now().strftime("%Y-%m-%d")
190
+ return _LOGS_DIR / f"skill-{date_str}.log"
191
+
192
+
193
+ def _cleanup_old_logs() -> None:
194
+ """Delete skill log files older than _LOG_RETENTION_DAYS days."""
195
+ if not _LOGS_DIR.exists():
196
+ return
197
+ from datetime import timedelta
198
+ cutoff = datetime.now() - timedelta(days=_LOG_RETENTION_DAYS)
199
+ for f in _LOGS_DIR.glob("skill-*.log"):
200
+ # Parse date from filename: skill-YYYY-MM-DD.log
201
+ try:
202
+ date_part = f.stem.replace("skill-", "")
203
+ file_date = datetime.strptime(date_part, "%Y-%m-%d")
204
+ if file_date < cutoff:
205
+ f.unlink(missing_ok=True)
206
+ except (ValueError, OSError):
207
+ pass
208
+
209
+
210
+ def write_skill_log(
211
+ action: str,
212
+ status: str,
213
+ duration_ms: int,
214
+ data_count: int = 0,
215
+ error_code: str = "",
216
+ remark: str = "",
217
+ seller_id: Optional[str] = None,
218
+ ) -> None:
219
+ """Write one skill invocation log entry to logs/skill-{date}.log.
220
+
221
+ Args:
222
+ action: Scenario/action name (export / refund / business / inspect / ping)
223
+ status: "success" / "fail"
224
+ duration_ms: Duration in milliseconds
225
+ data_count: Number of data rows fetched
226
+ error_code: Error code (empty on success)
227
+ remark: Remarks
228
+ seller_id: Optional seller_id override
229
+ """
230
+ record = {
231
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
232
+ "SellerID": seller_id if seller_id is not None else _get_seller_id(),
233
+ "SkillName": SKILL_NAME,
234
+ "Action": action,
235
+ "AgentType": get_agent_type(),
236
+ "ModelName": detect_model_name(),
237
+ "Duration": f"{duration_ms / 1000:.1f}s" if duration_ms >= 1000 else f"{duration_ms}ms",
238
+ "Status": status,
239
+ "ErrorCode": error_code,
240
+ "apiCallUrl": api_tracker.urls,
241
+ "apiCallCount": api_tracker.call_count,
242
+ "apiSuccessCount": api_tracker.success_count,
243
+ "dataCount": data_count,
244
+ "durationMs": duration_ms,
245
+ "envData": _get_env_data(),
246
+ "remark": remark,
247
+ }
248
+
249
+ log_path = _log_file_path()
250
+ try:
251
+ with open(log_path, "a", encoding="utf-8") as f:
252
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
253
+ except OSError as e:
254
+ logging.getLogger("miravia.skill_logger").warning(
255
+ "Failed to write skill log: %s", e
256
+ )
257
+
258
+ # Attempt to clean up expired logs after each write
259
+ try:
260
+ _cleanup_old_logs()
261
+ except Exception:
262
+ pass