@miraiva_test/miravia-order-report 0.1.13 → 0.1.15
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.
- package/dist/SKILL.md +47 -26
- package/dist/VERSION +1 -1
- package/dist/scripts/auth_cli.py +18 -3
- package/dist/scripts/export_orders.py +23 -7
- package/dist/scripts/render_final.py +297 -0
- package/dist/scripts/report_v4.py +86 -7
- package/dist/templates/report_v4.html +10 -1
- package/lib/build-zip.js +91 -0
- package/lib/installer.js +58 -12
- package/package.json +3 -2
package/dist/SKILL.md
CHANGED
|
@@ -5,6 +5,21 @@ description: Query Miravia seller order data via Open API, export filtered order
|
|
|
5
5
|
|
|
6
6
|
# Miravia Order Report
|
|
7
7
|
|
|
8
|
+
## ⚠ Agent Self-Check Protocol (MANDATORY before task completion)
|
|
9
|
+
|
|
10
|
+
Before telling the user "report is ready", the Agent MUST answer ALL of the following:
|
|
11
|
+
|
|
12
|
+
| # | Question | Required Answer |
|
|
13
|
+
|---|----------|----------------|
|
|
14
|
+
| 1 | Is this a report scenario (refund/business/auto)? | If NO → skip to Step 6. If YES → continue. |
|
|
15
|
+
| 2 | Did I read `metrics.json` and generate `insights.json`? | Must be YES |
|
|
16
|
+
| 3 | Did I run `render_final.py` and see `RENDER_CHECK: OK`? | Must be YES |
|
|
17
|
+
| 4 | Is the final HTML path shown in stdout? | Must be YES |
|
|
18
|
+
|
|
19
|
+
**If any answer is NO for a report scenario → return to Step 5. Do NOT present the report.**
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
8
23
|
A Skill for Miravia (M2A) sellers. Pulls order, sub-order item and reverse-order data from the official Miravia Open API, then produces **different outputs dynamically based on scenario**:
|
|
9
24
|
|
|
10
25
|
| Scenario | Output | Use Case |
|
|
@@ -50,7 +65,7 @@ These rules are referenced throughout the workflow. Each is defined **once** her
|
|
|
50
65
|
| C3 | **Time estimate first** | Formula: `ceil(orders / 6000)` minutes. Show only time, not count. |
|
|
51
66
|
| C4 | **Status filter confirmation** | Natural language status → present supported status table, ask user to confirm. Technical values (e.g., `--status canceled`) → no confirmation needed. |
|
|
52
67
|
| C5 | **Auto-open browser** | Always auto-open (auth page, HTML report). Only provide URL as fallback if `open` command fails. |
|
|
53
|
-
| C6 | **Executive Summary = LLM-generated** |
|
|
68
|
+
| C6 | **Executive Summary = LLM-generated (BLOCKING)** | Report scenarios (refund/business/auto) do NOT produce HTML until AI insights pass validation. Pipeline: export_orders.py → metrics.json + report_data.json → Agent generates insights.json → render_final.py validates & renders HTML. Invalid insights = no HTML output. |
|
|
54
69
|
| C7 | **API retry** | Up to 3 retries per API call; report error only after all 3 fail. |
|
|
55
70
|
| C8 | **Volume limit** | Hard cap 200,000 sub-orders. Exceeded → suggest splitting by month/week. |
|
|
56
71
|
| C9 | **Zero data early-stop** | `no_data: true` → stop immediately, inform merchant, do NOT generate files. |
|
|
@@ -203,47 +218,49 @@ python scripts/export_orders.py \
|
|
|
203
218
|
- Detect `metrics :` line → **stop polling, enter Step 5** (do NOT announce "report complete" yet).
|
|
204
219
|
- `export` scenario (no `metrics :`) → skip to Step 6.
|
|
205
220
|
|
|
206
|
-
|
|
221
|
+
**Output for report scenarios (refund/business/auto):**
|
|
222
|
+
- `report_data :` → pre-computed dashboard data (used by render_final.py)
|
|
223
|
+
- `metrics :` → structured metrics for AI analysis
|
|
224
|
+
- `TASK_INCOMPLETE:` → HTML is NOT yet generated; proceed to Step 5
|
|
225
|
+
|
|
226
|
+
### Step 5: AI Insights Generation & Final Render (MANDATORY for refund/business/auto)
|
|
207
227
|
|
|
208
228
|
> **Full specification**: [executive_summary_prompt_v2.md](executive_summary_prompt_v2.md)
|
|
209
229
|
|
|
210
230
|
**① Read** `metrics.json` (path from stdout `metrics :` line).
|
|
211
231
|
|
|
212
232
|
**② Generate** `insights.json` following ALL rules in `executive_summary_prompt_v2.md`:
|
|
213
|
-
- Filename:
|
|
214
|
-
- Schema: `{ "
|
|
233
|
+
- Filename: `insights.json` in the output directory.
|
|
234
|
+
- Schema: `{ "presets": { "0": { "summary", "highlights[]", "actions[]" }, ... } }`
|
|
215
235
|
- Field constraints: `severity` ∈ {`high`, `medium`, `low`}; `priority` ∈ {`P0`, `P1`, `P2`}; `highlights` need `title` + `detail` + `severity`; `actions` need `text` + `priority`.
|
|
216
236
|
- JSON encoding: ASCII quotes only, no smart quotes/em-dashes/currency symbols. See prompt file §1 for full rules.
|
|
237
|
+
- **Top-level has ONLY `presets`** — do NOT add `lang`, `generated_at`, or other fields.
|
|
217
238
|
|
|
218
239
|
**③ Validate** JSON:
|
|
219
240
|
```bash
|
|
220
241
|
python3 -c "import json; json.load(open('<insights_path>'))" && echo 'OK'
|
|
221
242
|
```
|
|
222
243
|
|
|
223
|
-
**④
|
|
244
|
+
**④ Render final HTML** (insights validation is built-in — invalid insights = render refused):
|
|
224
245
|
```bash
|
|
225
|
-
python3 scripts/
|
|
246
|
+
python3 scripts/render_final.py --report-data <report_data_path> --insights <insights_path> --output <DIR> --lang <LANG>
|
|
226
247
|
```
|
|
227
248
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
**⑥ Open report** (C5):
|
|
240
|
-
```bash
|
|
241
|
-
open <report_path> # macOS
|
|
242
|
-
xdg-open <report_path> # Linux
|
|
243
|
-
```
|
|
249
|
+
**Expected stdout signals:**
|
|
250
|
+
- `insights : validated OK` → structure passed
|
|
251
|
+
- `report :` → HTML file path
|
|
252
|
+
- `RENDER_CHECK: OK` → ✅ proceed to Step 6
|
|
253
|
+
- Exit code 2 = insights validation failed → regenerate insights.json and retry
|
|
254
|
+
|
|
255
|
+
**⑤ If `RENDER_CHECK: OK`** → report is complete, proceed to Step 6.
|
|
256
|
+
|
|
257
|
+
**⑤b If exit code 2** (validation failed) → fix insights.json and re-run render_final.py.
|
|
258
|
+
Common fixes: ensure all `highlights` are objects (not strings), check `severity`/`priority` values.
|
|
244
259
|
|
|
245
260
|
### Step 6: Present Results
|
|
246
261
|
|
|
262
|
+
**Precondition (refund/business/auto):** `RENDER_CHECK: OK` observed in Step 5 stdout. If not met → return to Step 5.
|
|
263
|
+
|
|
247
264
|
- `export` → "Export file: `<path>`"
|
|
248
265
|
- `refund`/`business`/`auto` → "Report opened in your browser. File path: `<path>`" (only show path if open fails).
|
|
249
266
|
- **Never mention**: pagination, API internals, offset/limit, time-window slicing.
|
|
@@ -300,9 +317,12 @@ See [reference.md](reference.md) for field mapping and Excel column definitions.
|
|
|
300
317
|
| `progress: fetched X orders` | Batch progress | "Fetched X orders..." (strip total) |
|
|
301
318
|
| `progress: M main, N sub-order rows` | Final aggregation | "Retrieved M orders, N records" (safe to show) |
|
|
302
319
|
| `output :` | Excel/CSV path | Show to user |
|
|
303
|
-
| `
|
|
304
|
-
| `metrics :` | Metrics JSON path |
|
|
305
|
-
| `
|
|
320
|
+
| `report_data :` | Pre-computed data JSON path | Internal: used by render_final.py |
|
|
321
|
+
| `metrics :` | Metrics JSON path | **STOP polling → enter Step 5** (do NOT announce completion) |
|
|
322
|
+
| `NEXT_ACTION_REQUIRED:` | render_final.py command | Agent MUST generate insights then execute this |
|
|
323
|
+
| `TASK_INCOMPLETE:` | Hard gate signal | HTML not yet generated — insights + render pending |
|
|
324
|
+
| `report :` | Final HTML path (from render_final.py) | Show to user, open in browser |
|
|
325
|
+
| `RENDER_CHECK: OK` | HTML validated | Step 5 complete ✅ (proceed to Step 6) |
|
|
306
326
|
| `no_data :` | Zero orders | Inform merchant, stop (C9) |
|
|
307
327
|
| `estimate :` | Time estimate JSON | Show only time (C2, C3) |
|
|
308
328
|
| `ping OK/FAIL` | Credential check | Step 2 flow |
|
|
@@ -320,8 +340,9 @@ Features: Platform filter (All/Miravia/M2A), time filter (all/7d/30d), Chart.js
|
|
|
320
340
|
## Related Documents
|
|
321
341
|
|
|
322
342
|
| Document | Purpose |
|
|
323
|
-
|
|
343
|
+
|----------|--------|
|
|
324
344
|
| [executive_summary_prompt_v2.md](executive_summary_prompt_v2.md) | Authoritative prompt for AI insights generation |
|
|
345
|
+
| [scripts/render_final.py](scripts/render_final.py) | Final HTML renderer (validates insights before rendering) |
|
|
325
346
|
| [reference.md](reference.md) | API fields & Excel column mapping |
|
|
326
347
|
| [docs/windows_compat.md](docs/windows_compat.md) | Windows Constrained Language Mode bypass |
|
|
327
348
|
| [docs/vm_fallback.md](docs/vm_fallback.md) | VM/sandbox authorization fallback |
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.15
|
package/dist/scripts/auth_cli.py
CHANGED
|
@@ -507,7 +507,13 @@ def _decrypt_cipher_to_creds(
|
|
|
507
507
|
|
|
508
508
|
|
|
509
509
|
def _fmt_expires(expires_str: str) -> str:
|
|
510
|
-
"""Format expiry time string (YYYY-MM-DD HH:MM) with remaining time description.
|
|
510
|
+
"""Format expiry time string (YYYY-MM-DD HH:MM) with remaining time description.
|
|
511
|
+
|
|
512
|
+
Since expires_at is stored at minute precision (YYYY-MM-DD HH:MM, i.e. seconds=0),
|
|
513
|
+
we align 'now' to the same minute granularity (truncate seconds to 0) before computing
|
|
514
|
+
the remaining delta. This prevents display artifacts like '29d 23h 59m' when the actual
|
|
515
|
+
remaining time is effectively 30 full days.
|
|
516
|
+
"""
|
|
511
517
|
if not expires_str:
|
|
512
518
|
return "<unknown>"
|
|
513
519
|
# Try parsing YYYY-MM-DD HH:MM format
|
|
@@ -521,7 +527,10 @@ def _fmt_expires(expires_str: str) -> str:
|
|
|
521
527
|
ts = int(expires_str)
|
|
522
528
|
except (ValueError, TypeError):
|
|
523
529
|
return expires_str
|
|
524
|
-
|
|
530
|
+
# Align now to minute boundary (truncate seconds) to match expires_at precision
|
|
531
|
+
now_ts = int(time.time())
|
|
532
|
+
now_ts = now_ts - (now_ts % 60)
|
|
533
|
+
delta = ts - now_ts
|
|
525
534
|
iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
|
526
535
|
if delta <= 0:
|
|
527
536
|
return f"{iso} (EXPIRED {-delta}s ago)"
|
|
@@ -717,6 +726,9 @@ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
|
717
726
|
|
|
718
727
|
Parses ``YYYY-MM-DD HH:MM`` format (produced by _expires_at()).
|
|
719
728
|
Falls back to fixed 30-day text on failure, ensuring callback page is not blocked.
|
|
729
|
+
|
|
730
|
+
Since expires_at is minute-precision, aligns 'now' to the same granularity
|
|
731
|
+
to avoid '29 days' display when the actual validity is effectively 30 days.
|
|
720
732
|
"""
|
|
721
733
|
t = _I18N_TEXTS.get(lang, _I18N_TEXTS[_DEFAULT_LANG])
|
|
722
734
|
if not expires_at:
|
|
@@ -732,7 +744,10 @@ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
|
732
744
|
ts = int(expires_at)
|
|
733
745
|
except (TypeError, ValueError):
|
|
734
746
|
return t["token_valid_30days"]
|
|
735
|
-
|
|
747
|
+
# Align now to minute boundary to match expires_at precision
|
|
748
|
+
now_ts = int(time.time())
|
|
749
|
+
now_ts = now_ts - (now_ts % 60)
|
|
750
|
+
delta = ts - now_ts
|
|
736
751
|
if delta <= 0:
|
|
737
752
|
return t["token_expired"]
|
|
738
753
|
iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
|
@@ -1334,13 +1334,28 @@ def run(args: argparse.Namespace) -> int:
|
|
|
1334
1334
|
print(f"output : {excel_path}")
|
|
1335
1335
|
|
|
1336
1336
|
if do_report:
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1337
|
+
# Phase 1: Compute report data payload and metrics (NO HTML yet).
|
|
1338
|
+
# HTML rendering is deferred to render_final.py AFTER Agent generates
|
|
1339
|
+
# and validates insights.json. This ensures the final HTML always
|
|
1340
|
+
# contains a complete AI Executive Summary.
|
|
1341
|
+
import report_v4
|
|
1342
|
+
|
|
1343
|
+
# Compute pre-aggregated report data (window.DATA payload)
|
|
1344
|
+
report_data = report_v4.prepare_report_data(
|
|
1345
|
+
rows=rows,
|
|
1346
|
+
range_label=range_label,
|
|
1347
|
+
export_columns=EXPORT_COLUMNS,
|
|
1348
|
+
lang=getattr(args, 'lang', 'en'),
|
|
1349
|
+
)
|
|
1350
|
+
report_data_path = out_dir / f"report_data_{fname_range}_{ts}.json"
|
|
1351
|
+
report_data_path = _resolve_collision(report_data_path)
|
|
1352
|
+
report_data_path.write_text(
|
|
1353
|
+
json.dumps(report_data, ensure_ascii=False, indent=2, default=str),
|
|
1354
|
+
encoding="utf-8",
|
|
1355
|
+
)
|
|
1356
|
+
print(f"report_data : {report_data_path}")
|
|
1341
1357
|
|
|
1342
1358
|
# Export structured metrics for Agent LLM analysis
|
|
1343
|
-
import report_v4
|
|
1344
1359
|
metrics_data = report_v4.export_metrics(
|
|
1345
1360
|
rows=rows,
|
|
1346
1361
|
export_columns=EXPORT_COLUMNS,
|
|
@@ -1355,8 +1370,9 @@ def run(args: argparse.Namespace) -> int:
|
|
|
1355
1370
|
)
|
|
1356
1371
|
print(f"metrics : {metrics_path}")
|
|
1357
1372
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1373
|
+
# ★ Hard gate: Agent MUST generate insights and call render_final.py
|
|
1374
|
+
print(f"NEXT_ACTION_REQUIRED: python3 scripts/render_final.py --report-data {report_data_path} --insights <insights_path> --output {out_dir} --lang {getattr(args, 'lang', 'en')}")
|
|
1375
|
+
print(f"TASK_INCOMPLETE: Data exported. Agent must now generate insights.json from metrics, then call render_final.py to produce the final HTML report.")
|
|
1360
1376
|
|
|
1361
1377
|
# Write skill invocation log
|
|
1362
1378
|
_write_run_log(
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Render final HTML report with pre-validated AI insights (SKILL.md Step 5 terminal).
|
|
4
|
+
|
|
5
|
+
This script is the MANDATORY final step for report scenarios (refund/business/auto).
|
|
6
|
+
It performs strict validation of insights.json before rendering HTML, ensuring
|
|
7
|
+
the delivered report always contains a complete AI Executive Summary.
|
|
8
|
+
|
|
9
|
+
Workflow:
|
|
10
|
+
1. export_orders.py generates: report_data.json + metrics.json
|
|
11
|
+
2. Agent reads metrics.json, generates insights.json (LLM analysis)
|
|
12
|
+
3. THIS SCRIPT validates insights.json structure → renders final HTML
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 scripts/render_final.py \
|
|
16
|
+
--report-data ./out/report_data_xxx.json \
|
|
17
|
+
--insights ./out/insights.json \
|
|
18
|
+
--output ./out \
|
|
19
|
+
--lang en
|
|
20
|
+
|
|
21
|
+
Prerequisites:
|
|
22
|
+
- report_data.json: produced by export_orders.py (pre-computed window.DATA payload)
|
|
23
|
+
- insights.json: produced by Agent per executive_summary_prompt_v2.md
|
|
24
|
+
|
|
25
|
+
Exit codes:
|
|
26
|
+
0 = success (HTML rendered and opened)
|
|
27
|
+
1 = argument/file error
|
|
28
|
+
2 = insights validation failed (Agent must regenerate)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict
|
|
39
|
+
|
|
40
|
+
# Allow direct execution from scripts/ directory
|
|
41
|
+
THIS_DIR = Path(__file__).resolve().parent
|
|
42
|
+
sys.path.insert(0, str(THIS_DIR))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# -------- Insights Validation --------
|
|
46
|
+
|
|
47
|
+
def _validate_insight_object(insight: Any, preset_key: str) -> list[str]:
|
|
48
|
+
"""Validate a single InsightObject against frontend _validateInsight() rules.
|
|
49
|
+
|
|
50
|
+
Returns a list of error messages (empty = valid).
|
|
51
|
+
"""
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
prefix = f"presets.{preset_key}"
|
|
54
|
+
|
|
55
|
+
if not insight or not isinstance(insight, dict):
|
|
56
|
+
errors.append(f"{prefix}: must be a non-null object")
|
|
57
|
+
return errors
|
|
58
|
+
|
|
59
|
+
# summary: required string
|
|
60
|
+
summary = insight.get("summary")
|
|
61
|
+
if not isinstance(summary, str) or not summary.strip():
|
|
62
|
+
errors.append(f"{prefix}.summary: must be a non-empty string")
|
|
63
|
+
|
|
64
|
+
# highlights: required array of objects
|
|
65
|
+
highlights = insight.get("highlights")
|
|
66
|
+
if not isinstance(highlights, list):
|
|
67
|
+
errors.append(f"{prefix}.highlights: must be an array")
|
|
68
|
+
else:
|
|
69
|
+
for i, h in enumerate(highlights):
|
|
70
|
+
if not isinstance(h, dict):
|
|
71
|
+
errors.append(f"{prefix}.highlights[{i}]: must be an object, got {type(h).__name__}")
|
|
72
|
+
continue
|
|
73
|
+
if not isinstance(h.get("title"), str):
|
|
74
|
+
errors.append(f"{prefix}.highlights[{i}].title: must be a string")
|
|
75
|
+
if not isinstance(h.get("detail"), str):
|
|
76
|
+
errors.append(f"{prefix}.highlights[{i}].detail: must be a string")
|
|
77
|
+
sev = h.get("severity")
|
|
78
|
+
if sev not in ("high", "medium", "low"):
|
|
79
|
+
errors.append(f"{prefix}.highlights[{i}].severity: must be high/medium/low, got {sev!r}")
|
|
80
|
+
|
|
81
|
+
# actions: required array of objects
|
|
82
|
+
actions = insight.get("actions")
|
|
83
|
+
if not isinstance(actions, list):
|
|
84
|
+
errors.append(f"{prefix}.actions: must be an array")
|
|
85
|
+
else:
|
|
86
|
+
for i, ac in enumerate(actions):
|
|
87
|
+
if not isinstance(ac, dict):
|
|
88
|
+
errors.append(f"{prefix}.actions[{i}]: must be an object, got {type(ac).__name__}")
|
|
89
|
+
continue
|
|
90
|
+
if not isinstance(ac.get("text"), str):
|
|
91
|
+
errors.append(f"{prefix}.actions[{i}].text: must be a string")
|
|
92
|
+
pri = ac.get("priority")
|
|
93
|
+
if pri not in ("P0", "P1", "P2"):
|
|
94
|
+
errors.append(f"{prefix}.actions[{i}].priority: must be P0/P1/P2, got {pri!r}")
|
|
95
|
+
|
|
96
|
+
return errors
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_insights(insights_data: Any) -> tuple[bool, list[str]]:
|
|
100
|
+
"""Validate insights.json structure per executive_summary_prompt_v2.md §8.
|
|
101
|
+
|
|
102
|
+
Checks:
|
|
103
|
+
1. Top-level has "presets" key (dict)
|
|
104
|
+
2. Each preset value is a valid InsightObject
|
|
105
|
+
3. At least one non-empty preset exists
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
(is_valid, error_messages)
|
|
109
|
+
"""
|
|
110
|
+
errors: list[str] = []
|
|
111
|
+
|
|
112
|
+
if not isinstance(insights_data, dict):
|
|
113
|
+
return False, ["Root must be a JSON object"]
|
|
114
|
+
|
|
115
|
+
presets = insights_data.get("presets")
|
|
116
|
+
if not isinstance(presets, dict):
|
|
117
|
+
return False, ["Root must contain 'presets' key as an object"]
|
|
118
|
+
|
|
119
|
+
if not presets:
|
|
120
|
+
return False, ["'presets' is empty — no analysis generated"]
|
|
121
|
+
|
|
122
|
+
for key, value in presets.items():
|
|
123
|
+
errs = _validate_insight_object(value, key)
|
|
124
|
+
errors.extend(errs)
|
|
125
|
+
|
|
126
|
+
is_valid = len(errors) == 0
|
|
127
|
+
return is_valid, errors
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -------- Rendering --------
|
|
131
|
+
|
|
132
|
+
def _jinja_env():
|
|
133
|
+
from jinja2 import Environment, FileSystemLoader
|
|
134
|
+
return Environment(
|
|
135
|
+
loader=FileSystemLoader(str(THIS_DIR.parent / "templates")),
|
|
136
|
+
autoescape=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_html(report_data: Dict[str, Any], insights_data: Dict[str, Any],
|
|
141
|
+
out_dir: Path, lang: str = "en") -> Path:
|
|
142
|
+
"""Inject validated insights into report data and render final HTML.
|
|
143
|
+
|
|
144
|
+
Returns the path to the generated HTML file.
|
|
145
|
+
"""
|
|
146
|
+
import report_v4
|
|
147
|
+
|
|
148
|
+
# Inject insights into the data payload
|
|
149
|
+
report_data["agentInsights"] = insights_data
|
|
150
|
+
|
|
151
|
+
# Determine output filename from meta
|
|
152
|
+
range_start = report_data.get("meta", {}).get("range_start", "")
|
|
153
|
+
range_end = report_data.get("meta", {}).get("range_end", "")
|
|
154
|
+
range_label = f"{range_start} ~ {range_end}" if range_end else range_start
|
|
155
|
+
|
|
156
|
+
# Generate filename
|
|
157
|
+
ts = time.strftime("%Y%m%d%H%M%S")
|
|
158
|
+
|
|
159
|
+
def _safe_fn(s: str) -> str:
|
|
160
|
+
import re
|
|
161
|
+
m = re.match(r'^(.*?)([+-]\d{2}:\d{2}|Z)?$', s)
|
|
162
|
+
if m:
|
|
163
|
+
base, tz = m.group(1), m.group(2) or ""
|
|
164
|
+
base = base.replace(":", "").replace("T", "_")
|
|
165
|
+
tz = tz.replace(":", "").replace("+", "p").replace("-", "m")
|
|
166
|
+
return base + tz
|
|
167
|
+
return s.replace(":", "").replace("+", "p").replace("-", "m").replace("T", "_")
|
|
168
|
+
|
|
169
|
+
fname_range = f"{_safe_fn(range_start)}_{_safe_fn(range_end)}" if range_end else _safe_fn(range_start)
|
|
170
|
+
html_path = out_dir / f"miravia_order_report_{fname_range}_{ts}.html"
|
|
171
|
+
|
|
172
|
+
# Avoid collision
|
|
173
|
+
if html_path.exists():
|
|
174
|
+
for i in range(1, 100):
|
|
175
|
+
cand = html_path.parent / f"{html_path.stem}_{i}{html_path.suffix}"
|
|
176
|
+
if not cand.exists():
|
|
177
|
+
html_path = cand
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
# Render via report_v4.render_from_data()
|
|
181
|
+
jinja_env = _jinja_env()
|
|
182
|
+
report_v4.render_from_data(report_data, html_path, range_label, jinja_env, lang)
|
|
183
|
+
return html_path
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _open_in_browser(html_path: Path) -> None:
|
|
187
|
+
"""Open HTML report in default browser (best-effort)."""
|
|
188
|
+
import webbrowser
|
|
189
|
+
import subprocess
|
|
190
|
+
import platform
|
|
191
|
+
|
|
192
|
+
url = html_path.resolve().as_uri()
|
|
193
|
+
try:
|
|
194
|
+
if webbrowser.open(url, new=2):
|
|
195
|
+
print(f"opened : {url}")
|
|
196
|
+
return
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"warning: webbrowser.open failed: {e}", file=sys.stderr)
|
|
199
|
+
try:
|
|
200
|
+
sysname = platform.system()
|
|
201
|
+
if sysname == "Darwin":
|
|
202
|
+
subprocess.Popen(["open", str(html_path)])
|
|
203
|
+
elif sysname == "Windows":
|
|
204
|
+
os.startfile(str(html_path)) # type: ignore[attr-defined]
|
|
205
|
+
else:
|
|
206
|
+
subprocess.Popen(["xdg-open", str(html_path)])
|
|
207
|
+
print(f"opened : {url}")
|
|
208
|
+
except Exception as e:
|
|
209
|
+
print(f"warning: failed to open browser ({e}); open manually: {html_path}",
|
|
210
|
+
file=sys.stderr)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -------- CLI --------
|
|
214
|
+
|
|
215
|
+
def main() -> int:
|
|
216
|
+
p = argparse.ArgumentParser(
|
|
217
|
+
prog="render_final",
|
|
218
|
+
description=(
|
|
219
|
+
"Render final HTML report with validated AI insights. "
|
|
220
|
+
"This is the terminal step of the report pipeline — HTML is only "
|
|
221
|
+
"produced after insights pass structural validation."
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
p.add_argument("--report-data", required=True,
|
|
225
|
+
help="Path to report_data JSON (from export_orders.py)")
|
|
226
|
+
p.add_argument("--insights", required=True,
|
|
227
|
+
help="Path to insights.json (Agent-generated)")
|
|
228
|
+
p.add_argument("--output", default="./out",
|
|
229
|
+
help="Output directory for the HTML file")
|
|
230
|
+
p.add_argument("--lang", default="en", choices=["en", "es", "it", "zh"],
|
|
231
|
+
help="Report language")
|
|
232
|
+
p.add_argument("--no-open", dest="open_browser", action="store_false",
|
|
233
|
+
default=True, help="Do not auto-open browser after render")
|
|
234
|
+
args = p.parse_args()
|
|
235
|
+
|
|
236
|
+
# 1. Load report_data
|
|
237
|
+
report_data_path = Path(args.report_data).expanduser().resolve()
|
|
238
|
+
if not report_data_path.exists():
|
|
239
|
+
print(f"error: report_data file not found: {report_data_path}", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
try:
|
|
242
|
+
report_data = json.loads(report_data_path.read_text(encoding="utf-8"))
|
|
243
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
244
|
+
print(f"error: failed to read report_data: {e}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
# 2. Load insights
|
|
248
|
+
insights_path = Path(args.insights).expanduser().resolve()
|
|
249
|
+
if not insights_path.exists():
|
|
250
|
+
print(f"error: insights file not found: {insights_path}", file=sys.stderr)
|
|
251
|
+
return 1
|
|
252
|
+
try:
|
|
253
|
+
insights_data = json.loads(insights_path.read_text(encoding="utf-8"))
|
|
254
|
+
except json.JSONDecodeError as e:
|
|
255
|
+
print(f"error: insights.json is not valid JSON: {e}", file=sys.stderr)
|
|
256
|
+
print(" Agent must regenerate insights.json from scratch.", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
|
|
259
|
+
# 3. Validate insights structure (BLOCKING — invalid insights = no HTML)
|
|
260
|
+
is_valid, errors = validate_insights(insights_data)
|
|
261
|
+
if not is_valid:
|
|
262
|
+
print("error: insights.json failed structural validation:", file=sys.stderr)
|
|
263
|
+
for err in errors[:10]: # show first 10 errors
|
|
264
|
+
print(f" - {err}", file=sys.stderr)
|
|
265
|
+
if len(errors) > 10:
|
|
266
|
+
print(f" ... and {len(errors) - 10} more errors", file=sys.stderr)
|
|
267
|
+
print("\nAgent must fix insights.json and retry. See executive_summary_prompt_v2.md §8.",
|
|
268
|
+
file=sys.stderr)
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
print(f"insights : validated OK ({len(insights_data.get('presets', {}))} presets)")
|
|
272
|
+
|
|
273
|
+
# 4. Render final HTML
|
|
274
|
+
out_dir = Path(args.output).expanduser().resolve()
|
|
275
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
html_path = render_html(report_data, insights_data, out_dir, lang=args.lang)
|
|
277
|
+
print(f"report : {html_path}")
|
|
278
|
+
|
|
279
|
+
# 5. Validate rendering (lightweight check: file exists and has reasonable size)
|
|
280
|
+
if html_path.exists():
|
|
281
|
+
size_kb = html_path.stat().st_size / 1024
|
|
282
|
+
if size_kb < 10:
|
|
283
|
+
print(f"warning: HTML file suspiciously small ({size_kb:.1f} KB)", file=sys.stderr)
|
|
284
|
+
print(f"RENDER_CHECK: OK (size={size_kb:.0f}KB)")
|
|
285
|
+
else:
|
|
286
|
+
print("RENDER_CHECK: FAIL (file not created)", file=sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
|
|
289
|
+
# 6. Open in browser
|
|
290
|
+
if args.open_browser:
|
|
291
|
+
_open_in_browser(html_path)
|
|
292
|
+
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
sys.exit(main())
|
|
@@ -1608,8 +1608,15 @@ _PRESET_DAYS = [7, 30, 90, 180, 365]
|
|
|
1608
1608
|
|
|
1609
1609
|
def render(rows: List[Dict[str, Any]], out_path: Path,
|
|
1610
1610
|
range_label: str, jinja_env, export_columns,
|
|
1611
|
-
default_max_orders: int, lang: str = "en"
|
|
1612
|
-
|
|
1611
|
+
default_max_orders: int, lang: str = "en",
|
|
1612
|
+
insights: Any = None) -> Path:
|
|
1613
|
+
"""Render v4 unified dashboard HTML with embedded AI-generated insights.
|
|
1614
|
+
|
|
1615
|
+
Args:
|
|
1616
|
+
insights: Optional pre-validated insights dict. If provided, it is
|
|
1617
|
+
embedded directly into window.DATA.agentInsights, eliminating
|
|
1618
|
+
the need for post-hoc inject_insights.py injection.
|
|
1619
|
+
"""
|
|
1613
1620
|
import pandas as pd
|
|
1614
1621
|
|
|
1615
1622
|
t = get_translations(lang)
|
|
@@ -1638,11 +1645,11 @@ def render(rows: List[Dict[str, Any]], out_path: Path,
|
|
|
1638
1645
|
else:
|
|
1639
1646
|
preset_data[str(days)] = all_dims # same as all, share reference
|
|
1640
1647
|
|
|
1641
|
-
# ---- agentInsights
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1648
|
+
# ---- agentInsights: use provided insights or empty placeholder ----
|
|
1649
|
+
if insights and isinstance(insights, dict):
|
|
1650
|
+
agent_insights = insights
|
|
1651
|
+
else:
|
|
1652
|
+
agent_insights = {"presets": {}}
|
|
1646
1653
|
|
|
1647
1654
|
# ---- assemble data payload ----
|
|
1648
1655
|
range_start = range_label.split(" ~ ")[0] if " ~ " in range_label else range_label
|
|
@@ -1664,6 +1671,78 @@ def render(rows: List[Dict[str, Any]], out_path: Path,
|
|
|
1664
1671
|
**all_dims,
|
|
1665
1672
|
}
|
|
1666
1673
|
|
|
1674
|
+
return render_from_data(data, out_path, range_label, jinja_env, lang)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def prepare_report_data(rows: List[Dict[str, Any]], range_label: str,
|
|
1678
|
+
export_columns, lang: str = "en") -> Dict[str, Any]:
|
|
1679
|
+
"""Compute report data payload WITHOUT rendering HTML.
|
|
1680
|
+
|
|
1681
|
+
Returns the complete window.DATA dict (minus agentInsights) that can be
|
|
1682
|
+
serialized to JSON and later passed to render_from_data() along with
|
|
1683
|
+
validated insights.
|
|
1684
|
+
|
|
1685
|
+
This enables the two-phase workflow:
|
|
1686
|
+
Phase 1: export_orders.py → rows → prepare_report_data() → report_data.json + metrics.json
|
|
1687
|
+
Phase 2: Agent generates insights.json
|
|
1688
|
+
Phase 3: render_final.py → validates insights + render_from_data() → final HTML
|
|
1689
|
+
"""
|
|
1690
|
+
import pandas as pd
|
|
1691
|
+
|
|
1692
|
+
t = get_translations(lang)
|
|
1693
|
+
|
|
1694
|
+
df = pd.DataFrame(rows, columns=export_columns) if rows else pd.DataFrame(columns=export_columns)
|
|
1695
|
+
currency = _common_currency(df)
|
|
1696
|
+
overall = _compute_overall(df)
|
|
1697
|
+
|
|
1698
|
+
all_dims = _compute_dims(df, currency, t=t)
|
|
1699
|
+
preset_data: Dict[str, Any] = {"0": all_dims}
|
|
1700
|
+
|
|
1701
|
+
if not df.empty:
|
|
1702
|
+
dates = pd.to_datetime(df["created_at"], errors="coerce")
|
|
1703
|
+
max_date = dates.max()
|
|
1704
|
+
for days in _PRESET_DAYS:
|
|
1705
|
+
cutoff = max_date.normalize() - pd.Timedelta(days=days - 1)
|
|
1706
|
+
subset = df[dates >= cutoff]
|
|
1707
|
+
if len(subset) < len(df):
|
|
1708
|
+
preset_data[str(days)] = _compute_dims(subset, currency, t=t)
|
|
1709
|
+
else:
|
|
1710
|
+
preset_data[str(days)] = all_dims
|
|
1711
|
+
|
|
1712
|
+
range_start = range_label.split(" ~ ")[0] if " ~ " in range_label else range_label
|
|
1713
|
+
range_end = range_label.split(" ~ ")[1] if " ~ " in range_label else ""
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
"meta": {
|
|
1717
|
+
"range_start": range_start,
|
|
1718
|
+
"range_end": range_end,
|
|
1719
|
+
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1720
|
+
"currency": currency,
|
|
1721
|
+
"total_orders": overall["orders"],
|
|
1722
|
+
"total_items": overall["items"],
|
|
1723
|
+
"buyer_id_available": _check_buyer_id_available(df),
|
|
1724
|
+
},
|
|
1725
|
+
"dailyData": _build_daily_data(df, range_start=range_start, range_end=range_end),
|
|
1726
|
+
"presetData": preset_data,
|
|
1727
|
+
"agentInsights": {"presets": {}}, # placeholder; replaced at render time
|
|
1728
|
+
# Top-level dimensional fields (backward compat)
|
|
1729
|
+
**all_dims,
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def render_from_data(data: Dict[str, Any], out_path: Path,
|
|
1734
|
+
range_label: str, jinja_env,
|
|
1735
|
+
lang: str = "en") -> Path:
|
|
1736
|
+
"""Render HTML from a pre-computed data payload.
|
|
1737
|
+
|
|
1738
|
+
This is the final rendering step. Accepts an already-assembled data dict
|
|
1739
|
+
(from prepare_report_data or render) and produces the HTML file.
|
|
1740
|
+
If data['agentInsights'] contains validated insights, they will be embedded
|
|
1741
|
+
directly — no post-hoc injection needed.
|
|
1742
|
+
"""
|
|
1743
|
+
t = get_translations(lang)
|
|
1744
|
+
currency = data.get("meta", {}).get("currency", "EUR")
|
|
1745
|
+
|
|
1667
1746
|
# ---- load static assets for inline embedding ----
|
|
1668
1747
|
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
|
1669
1748
|
_chartjs_path = _static_dir / "chart.umd.min.js"
|
|
@@ -974,9 +974,18 @@ function switchLang(lang){
|
|
|
974
974
|
document.documentElement.lang=T.lang_code||lang;
|
|
975
975
|
document.title=T.page_title||document.title;
|
|
976
976
|
// Update all static elements with data-i18n attribute
|
|
977
|
+
// Preserve child elements (e.g. ai-badge spans) by only updating text nodes
|
|
977
978
|
document.querySelectorAll('[data-i18n]').forEach(el=>{
|
|
978
979
|
const key=el.dataset.i18n;
|
|
979
|
-
if(T[key]
|
|
980
|
+
if(T[key]==null)return;
|
|
981
|
+
if(el.children.length>0){
|
|
982
|
+
// Element has child elements — only replace the leading text node
|
|
983
|
+
const first=el.firstChild;
|
|
984
|
+
if(first&&first.nodeType===3){first.textContent=T[key]+' ';}
|
|
985
|
+
else{el.insertBefore(document.createTextNode(T[key]+' '),el.firstChild);}
|
|
986
|
+
}else{
|
|
987
|
+
el.textContent=T[key];
|
|
988
|
+
}
|
|
980
989
|
});
|
|
981
990
|
// Re-init time filter buttons & platform filters with new labels
|
|
982
991
|
initTF();initPlatformFilters();
|
package/lib/build-zip.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-zip.js — Generate miravia-order-report.zip for OSS upload.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node lib/build-zip.js
|
|
5
|
+
*
|
|
6
|
+
* Steps:
|
|
7
|
+
* 1. Run build-dist.js to regenerate dist/ (with VERSION stamp)
|
|
8
|
+
* 2. Zip dist/ contents → <workspace>/dist/miravia-order-report.zip
|
|
9
|
+
*
|
|
10
|
+
* This script is the SINGLE source of truth for zip packaging.
|
|
11
|
+
* Do NOT manually run zip commands — always use this script.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
// ---------- Paths ----------
|
|
21
|
+
const LIB_DIR = __dirname;
|
|
22
|
+
const PACKAGE_ROOT = path.join(LIB_DIR, '..');
|
|
23
|
+
const DIST_DIR = path.join(PACKAGE_ROOT, 'dist');
|
|
24
|
+
|
|
25
|
+
// Workspace root: traverse up to find pom.xml at project root
|
|
26
|
+
function findWorkspaceRoot(start) {
|
|
27
|
+
let dir = start;
|
|
28
|
+
while (dir !== path.dirname(dir)) {
|
|
29
|
+
if (fs.existsSync(path.join(dir, 'pom.xml')) && fs.existsSync(path.join(dir, 'ai-skill'))) {
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
dir = path.dirname(dir);
|
|
33
|
+
}
|
|
34
|
+
// Fallback: 10 levels up from npm-package/lib
|
|
35
|
+
return path.resolve(PACKAGE_ROOT, '..', '..', '..', '..', '..', '..', '..', '..');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WORKSPACE_ROOT = findWorkspaceRoot(PACKAGE_ROOT);
|
|
39
|
+
const OUTPUT_DIR = path.join(WORKSPACE_ROOT, 'dist');
|
|
40
|
+
const OUTPUT_ZIP = path.join(OUTPUT_DIR, 'miravia-order-report.zip');
|
|
41
|
+
|
|
42
|
+
// ---------- Step 1: Build dist/ ----------
|
|
43
|
+
console.log('━━━ Step 1: Building dist/ via build-dist.js ━━━\n');
|
|
44
|
+
execSync(`node ${path.join(LIB_DIR, 'build-dist.js')}`, { stdio: 'inherit' });
|
|
45
|
+
|
|
46
|
+
// Verify dist/ exists and has VERSION
|
|
47
|
+
if (!fs.existsSync(path.join(DIST_DIR, 'VERSION'))) {
|
|
48
|
+
console.error('\n✗ ERROR: dist/VERSION not found after build. Aborting.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- Step 2: Create zip ----------
|
|
53
|
+
console.log('\n━━━ Step 2: Creating zip package ━━━\n');
|
|
54
|
+
|
|
55
|
+
// Ensure output directory exists
|
|
56
|
+
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
57
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Remove old zip if exists
|
|
61
|
+
if (fs.existsSync(OUTPUT_ZIP)) {
|
|
62
|
+
fs.unlinkSync(OUTPUT_ZIP);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Create zip from dist/ contents (no wrapping folder, flat structure)
|
|
66
|
+
try {
|
|
67
|
+
execSync(`zip -r "${OUTPUT_ZIP}" . -x "*.DS_Store"`, {
|
|
68
|
+
cwd: DIST_DIR,
|
|
69
|
+
stdio: 'inherit',
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('\n✗ ERROR: zip command failed. Ensure zip is installed.');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- Step 3: Verify ----------
|
|
77
|
+
console.log('\n━━━ Step 3: Verification ━━━\n');
|
|
78
|
+
|
|
79
|
+
const stat = fs.statSync(OUTPUT_ZIP);
|
|
80
|
+
const sizeKB = (stat.size / 1024).toFixed(1);
|
|
81
|
+
const version = fs.readFileSync(path.join(DIST_DIR, 'VERSION'), 'utf8').trim();
|
|
82
|
+
|
|
83
|
+
console.log(` ✓ Output: ${OUTPUT_ZIP}`);
|
|
84
|
+
console.log(` ✓ Size: ${sizeKB} KB`);
|
|
85
|
+
console.log(` ✓ Version: ${version}`);
|
|
86
|
+
|
|
87
|
+
// ---------- Step 4: Clean up dist/ ----------
|
|
88
|
+
fs.rmSync(DIST_DIR, { recursive: true });
|
|
89
|
+
console.log(` ✓ Cleaned: dist/ removed`);
|
|
90
|
+
|
|
91
|
+
console.log(`\n✔ Done. Ready to upload to OSS.`);
|
package/lib/installer.js
CHANGED
|
@@ -28,6 +28,9 @@ const AGENT_DIRS = {
|
|
|
28
28
|
path.join(process.cwd(), '.qoder', 'skills'),
|
|
29
29
|
path.join(os.homedir(), '.qoder', 'skills'),
|
|
30
30
|
],
|
|
31
|
+
qoderwork: [
|
|
32
|
+
path.join(os.homedir(), '.qoderwork', 'skills'),
|
|
33
|
+
],
|
|
31
34
|
cursor: [
|
|
32
35
|
path.join(process.cwd(), '.cursor', 'skills'),
|
|
33
36
|
path.join(os.homedir(), '.cursor', 'skills'),
|
|
@@ -36,8 +39,16 @@ const AGENT_DIRS = {
|
|
|
36
39
|
path.join(os.homedir(), '.claude', 'skills'),
|
|
37
40
|
],
|
|
38
41
|
codex: [
|
|
42
|
+
path.join(os.homedir(), '.agents', 'skills'),
|
|
43
|
+
path.join(process.cwd(), '.agents', 'skills'),
|
|
44
|
+
],
|
|
45
|
+
antigravity: [
|
|
46
|
+
path.join(os.homedir(), '.agents', 'skills'),
|
|
39
47
|
path.join(process.cwd(), '.agents', 'skills'),
|
|
48
|
+
],
|
|
49
|
+
opencode: [
|
|
40
50
|
path.join(os.homedir(), '.agents', 'skills'),
|
|
51
|
+
path.join(process.cwd(), '.agents', 'skills'),
|
|
41
52
|
],
|
|
42
53
|
gemini: [
|
|
43
54
|
path.join(process.cwd(), '.gemini', 'skills'),
|
|
@@ -55,6 +66,8 @@ function getAllCandidateDirs() {
|
|
|
55
66
|
// Cross-platform standard (highest priority)
|
|
56
67
|
path.join(process.cwd(), '.agents', 'skills'),
|
|
57
68
|
path.join(os.homedir(), '.agents', 'skills'),
|
|
69
|
+
// QoderWork user-global skill directory (UI panel scans here)
|
|
70
|
+
path.join(os.homedir(), '.qoderwork', 'skills'),
|
|
58
71
|
// Platform-specific directories
|
|
59
72
|
path.join(process.cwd(), '.qoder', 'skills'),
|
|
60
73
|
path.join(process.cwd(), '.cursor', 'skills'),
|
|
@@ -101,10 +114,38 @@ function detectSkillDir(flags) {
|
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
// Fresh install:
|
|
117
|
+
// Fresh install: detect runtime environment and choose the appropriate default.
|
|
118
|
+
// Key concern: avoid installing into transient sandbox/workspace directories that
|
|
119
|
+
// the platform's UI/scanner cannot discover.
|
|
120
|
+
|
|
121
|
+
// QoderWork: CWD is inside ~/.qoderwork/workspace/<session>/ (ephemeral sandbox).
|
|
122
|
+
// Install to ~/.qoderwork/skills/ so the UI panel can find it.
|
|
123
|
+
const qoderworkWsPrefix = path.join(os.homedir(), '.qoderwork', 'workspace');
|
|
124
|
+
if (process.cwd().startsWith(qoderworkWsPrefix)) {
|
|
125
|
+
return path.join(os.homedir(), '.qoderwork', 'skills');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Cloud sandbox detection (Codex / Antigravity / OpenCode):
|
|
129
|
+
// These run inside ephemeral containers. ~/.agents/skills is correct for the session
|
|
130
|
+
// but won't persist. Prefer project-level .agents/skills so user can commit to repo.
|
|
131
|
+
// Detect cloud sandbox: CWD is not under user's real home, or typical cloud workspace roots.
|
|
132
|
+
const cwd = process.cwd();
|
|
133
|
+
const home = os.homedir();
|
|
134
|
+
const isCloudSandbox = (
|
|
135
|
+
cwd === '/workspace' || cwd.startsWith('/workspace/') ||
|
|
136
|
+
cwd === '/home/user' || cwd.startsWith('/home/user/') ||
|
|
137
|
+
process.env.CODEX_SANDBOX === '1' ||
|
|
138
|
+
process.env.CLOUD_ENVIRONMENT === 'true'
|
|
139
|
+
);
|
|
140
|
+
if (isCloudSandbox) {
|
|
141
|
+
// Project-level: persists if user commits to git
|
|
142
|
+
return path.join(cwd, '.agents', 'skills');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Local environment (Qoder, Cursor, Claude Code, etc.):
|
|
146
|
+
// Default to user-global ~/.agents/skills (cross-platform standard).
|
|
105
147
|
// This is the universal convention supported by Codex, Gemini, Antigravity, OpenCode, etc.
|
|
106
|
-
|
|
107
|
-
return path.join(process.cwd(), '.agents', 'skills');
|
|
148
|
+
return path.join(home, '.agents', 'skills');
|
|
108
149
|
}
|
|
109
150
|
|
|
110
151
|
/**
|
|
@@ -314,7 +355,7 @@ function install(flags) {
|
|
|
314
355
|
}
|
|
315
356
|
|
|
316
357
|
console.log('\n\x1b[1m📋 Next steps:\x1b[0m');
|
|
317
|
-
console.log(' 1. Open your AI Agent (Qoder / Cursor / Claude Code)');
|
|
358
|
+
console.log(' 1. Open your AI Agent (Qoder / QoderWork / Cursor / Claude Code / Codex / Antigravity)');
|
|
318
359
|
console.log(' 2. Say: "authorize miravia skill"');
|
|
319
360
|
console.log(' 3. Complete browser authorization');
|
|
320
361
|
console.log(' 4. Then ask: "export last week Miravia orders"\n');
|
|
@@ -345,10 +386,11 @@ function upgrade(flags) {
|
|
|
345
386
|
let upgraded = 0;
|
|
346
387
|
for (const skillDir of skillDirs) {
|
|
347
388
|
const targetDir = path.join(skillDir, SKILL_NAME);
|
|
348
|
-
const agentLabel = skillDir.includes('.
|
|
389
|
+
const agentLabel = skillDir.includes('.qoderwork') ? 'QoderWork'
|
|
390
|
+
: skillDir.includes('.qoder') ? 'Qoder'
|
|
349
391
|
: skillDir.includes('.cursor') ? 'Cursor'
|
|
350
392
|
: skillDir.includes('.claude') ? 'Claude'
|
|
351
|
-
: skillDir.includes('.agents') ? 'Codex'
|
|
393
|
+
: skillDir.includes('.agents') ? 'Codex/Antigravity/OpenCode'
|
|
352
394
|
: skillDir.includes('.gemini') ? 'Gemini' : 'Unknown';
|
|
353
395
|
const scope = skillDir.startsWith(os.homedir() + path.sep + '.') ? 'user' : 'project';
|
|
354
396
|
|
|
@@ -415,7 +457,7 @@ Commands:
|
|
|
415
457
|
|
|
416
458
|
Options:
|
|
417
459
|
--target <path> Specify custom installation directory
|
|
418
|
-
--agent <type> Agent type: qoder | cursor | claude | codex | gemini
|
|
460
|
+
--agent <type> Agent type: qoder | qoderwork | cursor | claude | codex | antigravity | opencode | gemini
|
|
419
461
|
--no-venv Skip venv creation, use system pip
|
|
420
462
|
-v, --version Show version
|
|
421
463
|
-h, --help Show this help
|
|
@@ -423,13 +465,17 @@ Options:
|
|
|
423
465
|
Notes:
|
|
424
466
|
'upgrade' without --agent will update ALL detected agent platforms.
|
|
425
467
|
Use --agent to limit upgrade to a single platform.
|
|
468
|
+
Cloud sandboxes (Codex/Antigravity): skill installed to project .agents/skills/ for git persistence.
|
|
426
469
|
|
|
427
470
|
Supported platforms:
|
|
428
|
-
Qoder
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
471
|
+
Qoder (.qoder/skills)
|
|
472
|
+
QoderWork (~/.qoderwork/skills)
|
|
473
|
+
Cursor (.cursor/skills)
|
|
474
|
+
Claude Code (~/.claude/skills)
|
|
475
|
+
OpenAI Codex (~/.agents/skills)
|
|
476
|
+
Google Antigravity (~/.agents/skills)
|
|
477
|
+
OpenCode (~/.agents/skills)
|
|
478
|
+
Google Gemini (.gemini/skills)
|
|
433
479
|
|
|
434
480
|
Examples:
|
|
435
481
|
npx ${PKG.name} install
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraiva_test/miravia-order-report",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Miravia Order Report Skill installer — one command to install the Miravia order export & analytics skill into any AI Agent (Qoder / Cursor / Claude Code).",
|
|
5
5
|
"bin": {
|
|
6
6
|
"miravia-skill": "./bin/cli.js"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"prepublishOnly": "node lib/build-dist.js",
|
|
15
|
-
"postpublish": "node lib/clean-dist.js"
|
|
15
|
+
"postpublish": "node lib/clean-dist.js",
|
|
16
|
+
"build:zip": "node lib/build-zip.js"
|
|
16
17
|
},
|
|
17
18
|
"keywords": [
|
|
18
19
|
"miravia",
|