@clawhub-wotaso-dev-0bb4e98683
OpenClaw-first AI product manager for turning analytics, revenue, crash, store, and feedback signals into execution-ready proposals and backlog work.
---
name: product-manager-skill
description: OpenClaw-first AI product manager for turning analytics, revenue, crash, store, and feedback signals into execution-ready proposals and backlog work.
license: MIT
homepage: https://github.com/wotaso/analyticscli-skills
metadata: {"author":"wotaso","version":"1.0.13","analyticscli-target":"@analyticscli/cli","analyticscli-supported-range":">=0.1.2-preview.0 <0.2.0","openclaw":{"emoji":"📌","homepage":"https://github.com/wotaso/analyticscli-skills","requires":{"bins":["node","analyticscli"]},"install":[{"id":"analyticscli-cli","kind":"node","package":"@analyticscli/cli@preview","bins":["analyticscli"],"label":"Install/update AnalyticsCLI CLI (npm package @analyticscli/cli@preview)"}]}}
---
# AI Product Manager
## Use This Skill When
- you want OpenClaw to turn product signals into execution-ready backlog work
- you need one workflow across analytics, RevenueCat, Sentry/GlitchTip, feedback, store signals, and repo context
- you want the deterministic work to live in a standalone `openclaw` CLI and OpenClaw to stay the AI/chat layer
- you want delivery configurable between OpenClaw chat handoff, GitHub issues, and draft pull requests
## Preferred Runtime
Prefer the standalone `openclaw` CLI as the runtime surface.
- Setup path: `openclaw setup --config openclaw.config.json`
- Primary path: `openclaw start --config openclaw.config.json`
- Local monorepo path: `pnpm --filter @analyticscli/openclaw-cli dev -- start`
- Legacy copied-runtime scripts under `scripts/openclaw-growth-*.mjs` remain fallback-only for older OpenClaw workspaces
The CLI is intentionally non-AI. OpenClaw should stay the only conversational and implementation layer.
Use the CLI to gather signals, generate proposals, schedule checks, and send deliveries.
If the user later asks OpenClaw to implement a proposal, OpenClaw should inspect the generated drafts and then use OpenClaw itself to do the work.
## Mandatory Baseline
Before autopilot runs, these are non-negotiable:
- `analyticscli` CLI available
- target repo checkout readable via `project.repoRoot`
- a writable `openclaw.config.json`
- `sources.analytics` enabled
GitHub is optional unless GitHub delivery is enabled.
`project.githubRepo` and `GITHUB_TOKEN` become hard requirements only when the CLI should auto-create GitHub issues or pull requests.
## AnalyticsCLI CLI Install Protocol
If `analyticscli` is missing, OpenClaw must install it directly from the scoped npm package.
Do not search npm for `analyticscli`.
Do not search npm for `analyticsscli`.
Those are binary names or typos, not package names.
Use this exact package:
```bash
npm install -g @analyticscli/cli@preview
```
Then verify:
```bash
command -v analyticscli
analyticscli --help
```
If global npm installs are blocked, use the bundled helper from the installed skill root:
```bash
bash skills/ai-product-manager/scripts/install-analyticscli-cli.sh
```
The bundled helper automatically falls back from global npm install to a user-local npm prefix at `~/.local` when global install fails with permissions errors.
Only ask the user for help if both direct npm install and the bundled helper fail with a concrete permission or network error.
## Delivery Modes
The CLI can write proposals to one or more targets:
- `deliveries.openclawChat.enabled = true`: write `.openclaw/chat/latest.md` and `.openclaw/chat/latest.json` for OpenClaw to pick up in chat
- `deliveries.github.mode = "issue"` with `deliveries.github.autoCreate = true`: create implementation-ready GitHub issues
- `deliveries.github.mode = "pull_request"` with `deliveries.github.autoCreate = true`: create draft PRs that add `.openclaw/proposals/...md` proposal files to the repo
## Connector Model
Built-in channels:
- `analytics`
- `revenuecat`
- `sentry`
- `feedback`
default command path: `analyticscli feedback summary --format json`
default cursor behavior: first run `--last 30d`, later runs `--since <lastCollectedAt>` unless the command already sets explicit time flags
Additional connectors:
- configure `sources.extra[]`
- each extra connector can use `mode=file` or `mode=command`
- preferred output is shared `signals[]`
- crash-style tools may use `issues[]`
- feedback-style tools may use `items[]`
## Feedback Rules
- Always include a stable `locationId` for feedback collection points
- Always include a human-readable `originName` for where the feedback originated in the product
- Prefer AnalyticsCLI feedback retrieval via `analyticscli feedback summary --format json` instead of maintaining a second feedback definition
- The SDK should track lightweight feedback submission events without sending raw feedback text into analytics events
## Feedback Source Memory
- The CLI should persist per-source cursor state, especially for the built-in `feedback` source
- Default behavior must avoid accidental historical re-fetches
- If `sources.feedback.cursorMode = "auto_since_last_fetch"` and the command has no explicit `--since`, `--until`, or `--last`, the CLI should auto-append a bounded window
- Re-fetching older history should always be a conscious action by changing the command or resetting cursor state
## Startup Protocol
When the user says `start`, `run`, or `kick off`:
1. First make sure `analyticscli` exists. If not, install it with `npm install -g @analyticscli/cli@preview` or run `bash skills/ai-product-manager/scripts/install-analyticscli-cli.sh`.
2. Prefer the CLI entrypoint:
- `openclaw setup --config openclaw.config.json`
3. Then run:
- `openclaw start --config openclaw.config.json`
4. If the standalone `openclaw` CLI is unavailable but this ClawHub skill is installed, bootstrap the bundled runtime once:
- `bash skills/ai-product-manager/scripts/bootstrap-openclaw-workspace.sh`
- confirm `scripts/openclaw-growth-start.mjs` now exists
- `node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json`
5. In this monorepo, use the workspace dev entrypoint when `openclaw` is not installed globally:
- `pnpm --filter @analyticscli/openclaw-cli dev -- start`
6. Run portable checks first when setup is incomplete:
- `command -v analyticscli`
- `analyticscli projects list`
- detect `project.githubRepo` from git remote when possible
- verify `GITHUB_TOKEN` only if GitHub delivery is enabled
7. If preflight fails, return only a concrete blocker checklist
8. If preflight passes, continue with `openclaw run --config openclaw.config.json`
## Proposal Strategy
The CLI config should expose `strategy.proposalMode`:
- `mandatory`: only strongest, clearly evidenced fixes and must-have requests
- `balanced`: default mix of necessary fixes and moderate product ideas
- `creative`: still evidence-led, but more willing to suggest bolder experiments or feature ideas
## Output Rules
- max 3-5 proposals per pass
- each proposal must include measurable impact and file/module hypotheses
- each proposal must say what should change
- low-confidence findings must be marked explicitly
- when GitHub delivery is disabled, proposals should still be fully usable via the OpenClaw chat outbox
## Required Secrets
- `GITHUB_TOKEN`
required only when GitHub issue or pull-request delivery is enabled
- `ANALYTICSCLI_ACCESS_TOKEN`
recommended for AnalyticsCLI command/API mode when no local CLI login exists
- `REVENUECAT_API_KEY`
recommended for RevenueCat command/API mode
- `SENTRY_AUTH_TOKEN`
recommended for Sentry command/API mode
- optional connector-specific `secretEnv` per `sources.extra[]`
## References
- [README](README.md)
- [Setup And Scheduling](references/setup-and-scheduling.md)
- [Required Secrets](references/required-secrets.md)
- [Input Schema](references/input-schema.md)
- [Issue Template](references/issue-template.md)
FILE:README.md
# Product Manager Skill
Use this skill to turn product signals into prioritized decisions, execution plans, and implementation-ready tasks.
It also supports a growth-autopilot workflow that can generate and optionally create GitHub issues or draft pull requests from analytics + code context.
## Start Here
### 1) Prerequisites
- A coding agent (Codex, Claude Code, or OpenClaw).
- If you want automatic/autopilot execution, prefer OpenClaw.
- `node` + `npx` installed.
- For analytics source preparation: `analyticscli` CLI.
- `analyticscli-cli` skill installed/fetched.
- GitHub repo configured + `GITHUB_TOKEN` available (least privilege).
- For charting: `python3` + `matplotlib`.
### 2) Install
Install from the dedicated repository:
```bash
npx skills add Wotaso/ai-product-manager-skill
```
### 3) Init Prompt (copy/paste)
```text
Use product-manager-skill.
Analyze this week-over-week funnel drop (signup -> activation),
propose top 3 opportunities by expected impact, and output:
1) hypothesis
2) KPI target
3) implementation scope
4) acceptance criteria
5) release risk
```
### 4) OpenClaw "Start Skill" Behavior
When user says "start/run the skill", the agent should not ask generic intake questions first.
It should execute:
1. Run portable startup checks first-class:
- validate `analyticscli` + auth + `GITHUB_TOKEN` + repo detection
- run bounded `analyticscli` queries
- generate prioritized issue drafts by default
- create GitHub issues only when `actions.autoCreateIssues=true` is explicitly configured
2. Missing local repo scripts must never block startup.
3. If blockers exist: stop and return concrete missing/failing items (no manual summary intake).
Important:
- In `start/run` mode, missing prerequisites should be returned as a blocker checklist (config/API keys/access), not as a request for manual analytics summaries.
- Missing local repo scripts must not be treated as hard stop; portable mode is required.
- Missing workspace files under `scripts/` or `data/` must not be treated as blockers in portable mode.
## Required Tooling And Data Connectors
Install AnalyticsCLI tools:
```bash
npx -y @analyticscli/cli@preview --help
npm i @analyticscli/sdk@preview
```
Notes:
- The CLI npm package is `@analyticscli/cli@preview`; `analyticscli` is only the installed binary name.
- The SDK package is currently published on `@preview`.
- When stable releases are available, use `@analyticscli/sdk` without a dist-tag.
- RevenueCat data is expected via RevenueCat MCP/agent export to `revenuecat_summary.json`.
- Sentry data is expected via Sentry MCP/agent export to `sentry_summary.json`.
## What This Skill Does
Use this skill when you want an AI assistant that behaves like a hands-on product manager.
It can help you:
- turn raw metrics into prioritized opportunities
- write concise PRDs with clear scope and tradeoffs
- define measurable goals, KPIs, and success criteria
- generate experiment plans (hypothesis, variants, guardrails, rollout)
- create release plans and cross-functional handoff docs
- convert product ideas into implementation-ready backlog items
- generate prioritized GitHub issue drafts from analytics + code context
- generate draft pull requests with `.openclaw/proposals/...` files when issue mode is too lightweight
- draft stakeholder updates in plain business language
## Best Use Cases
- weekly product review from analytics dashboards
- feature discovery and scope definition
- conversion funnel optimization planning
- roadmap prioritization under limited engineering capacity
- post-launch readouts and next-step recommendations
## Quick Start
After installation, run the init prompt above with your real analytics and product context.
## Recommended Inputs
For best results, provide at least one of these:
- product analytics summary (events, funnel steps, retention, conversion)
- customer feedback themes (support tickets, interviews, app reviews)
- business constraints (timeline, team size, revenue target)
- technical context (known limitations, dependencies, legacy areas)
## Typical Outputs
You should expect structured PM artifacts such as:
- ranked opportunity list (impact x confidence x effort)
- PRD draft with scope and non-goals
- execution plan with milestones and owners
- experiment brief with stop/go decision criteria
- post-release KPI review template
- ranked issue drafts with file/module hypotheses and implementation steps
## Required Secrets (What We Need + Where To Get It)
| Env var | Required when | Where to get it | Minimum scope |
| --- | --- | --- | --- |
| `GITHUB_TOKEN` | baseline requirement for this workflow | GitHub -> Settings -> Developer settings -> Fine-grained PAT | Repository Issues: Read/Write, Contents: Read (no full token needed) |
| `ANALYTICSCLI_ACCESS_TOKEN` | analytics source in command mode (or explicit token use) | `dash.analyticscli.com` -> API Keys -> access token | `read:queries` |
| `REVENUECAT_API_KEY` | RevenueCat source refresh | RevenueCat dashboard -> Project -> API Keys -> Secret API key | Read-only where possible |
| `SENTRY_AUTH_TOKEN` | Sentry source refresh | Sentry -> User Settings -> Auth Tokens | Read-only issue/event scopes |
| `FEEDBACK_API_TOKEN` | optional public feedback API | generate random secret (`openssl rand -hex 32`) | Token match only |
Detailed secret guidance: [`references/required-secrets.md`](references/required-secrets.md)
## Secure Secret Storage Standard (OpenClaw + VPS)
These rules are mandatory:
- Never store tokens in repo files, JSON config, command arguments, or logs.
- Store secrets in OpenClaw secret storage and inject only at runtime.
- On VPS, keep secrets in a root-owned env file outside the repository.
- Restrict file permissions (`0600`) and owner (`root:root`).
Recommended VPS baseline:
1. Create `/etc/openclaw-growth/env` (outside git checkout), owned by root, mode `600`.
2. Put only `KEY=VALUE` lines there.
3. Reference it from `systemd` via `EnvironmentFile=/etc/openclaw-growth/env`.
4. Run the service under a dedicated non-root user with repo read access.
Full setup examples: [`references/setup-and-scheduling.md`](references/setup-and-scheduling.md)
## Ensure Code Can Be Read (For File/Module Mapping)
Issue quality depends on code scanning.
- Always set `--repo-root` to the target repository root.
- Ensure the runner user can read that directory recursively.
- Optionally restrict scanning with `--code-roots apps,packages` for speed and relevance.
- If code is not readable, the analyzer falls back to low-confidence module hypotheses.
## Bundled Runtime (ClawHub / OpenClaw installs)
The published skill ships the same growth-engineer scripts and `data/openclaw-growth-engineer/*.example.json` templates as the Agentic Analytics monorepo. ClawHub installs them under **`skills/product-manager-skill/`**, while OpenClaw and the docs assume **`scripts/`** and **`data/`** at the **workspace root**.
After install, run this **once** from the workspace root (it copies into `./scripts` and `./data/openclaw-growth-engineer`):
```bash
bash skills/product-manager-skill/scripts/bootstrap-openclaw-workspace.sh
```
Then:
```bash
node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json
```
In the upstream monorepo, files under `skills/product-manager-skill/` are mirrored from `scripts/` and `data/openclaw-growth-engineer/` via `pnpm pm-skill:sync-runtime` whenever the canonical scripts change.
## Local Monorepo Workflow (Optional, not required for OpenClaw start/run)
This skill includes the local MVP autopilot flow via:
- `scripts/openclaw-growth-engineer.mjs`
- `scripts/openclaw-growth-wizard.mjs`
- `scripts/openclaw-growth-runner.mjs`
Preflight checks (dependencies, files, secrets):
```bash
node scripts/openclaw-growth-preflight.mjs --config data/openclaw-growth-engineer/config.json --test-connections
```
Unified setup + first run:
```bash
node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json
```
Generate issue drafts:
```bash
node scripts/openclaw-growth-engineer.mjs \
--analytics data/openclaw-growth-engineer/analytics_summary.example.json \
--revenuecat data/openclaw-growth-engineer/revenuecat_summary.example.json \
--sentry data/openclaw-growth-engineer/sentry_summary.example.json \
--repo-root . \
--max-issues 4
```
Generate charts from analytics signals (`matplotlib`):
```bash
python3 -m pip install matplotlib
python3 scripts/openclaw-growth-charts.py \
--analytics data/openclaw-growth-engineer/analytics_summary.example.json \
--out-dir data/openclaw-growth-engineer/charts \
--manifest data/openclaw-growth-engineer/charts.manifest.json
```
Generate and explicitly auto-create GitHub issues:
```bash
GITHUB_TOKEN=ghp_xxx node scripts/openclaw-growth-engineer.mjs \
--analytics data/openclaw-growth-engineer/analytics_summary.example.json \
--revenuecat data/openclaw-growth-engineer/revenuecat_summary.example.json \
--sentry data/openclaw-growth-engineer/sentry_summary.example.json \
--repo-root . \
--chart-manifest data/openclaw-growth-engineer/charts.manifest.json \
--create-issues \
--repo owner/repo \
--labels ai-growth,autogenerated,product
```
Generate draft proposal pull requests instead of issues:
```bash
GITHUB_TOKEN=ghp_xxx node scripts/openclaw-growth-engineer.mjs \
--analytics data/openclaw-growth-engineer/analytics_summary.example.json \
--repo-root . \
--create-pull-requests \
--repo owner/repo \
--branch-prefix openclaw/proposals
```
## What This Skill Is Not
- It does not replace product strategy ownership.
- It does not guarantee causality from observational analytics alone.
- It should not be used without validation for high-risk decisions.
## Team Workflow Suggestion
1. Run the skill after weekly metrics refresh.
2. Validate top recommendations with engineering + design.
3. Convert approved outputs into tickets.
4. Review KPI movement after release and rerun the skill.
## References
- [`SKILL.md`](SKILL.md)
- [`references/setup-and-scheduling.md`](references/setup-and-scheduling.md)
- [`references/required-secrets.md`](references/required-secrets.md)
- [`references/input-schema.md`](references/input-schema.md)
- [`references/issue-template.md`](references/issue-template.md)
## Versioning
Keep instruction-pack changes in `SKILL.md` metadata versioning in the dedicated public repo.
## License
MIT (inherits the skill-pack repository license).
FILE:data/openclaw-growth-engineer/analytics_summary.example.json
{
"project": "ring-sizer",
"window": "last_30d",
"signals": [
{
"id": "retention_d3_drop",
"title": "Day-3 retention dropped after onboarding changes",
"area": "onboarding",
"priority": "high",
"metric": "d3_retention",
"current_value": 0.17,
"baseline_value": 0.26,
"delta_percent": -34.6,
"evidence": [
"Biggest drop between onboarding step 2 and paywall",
"Drop began after release 1.8.0"
],
"suggested_actions": [
"Move paywall after first successful ring measurement",
"Shorten onboarding step 2 to one required input"
],
"keywords": ["onboarding", "paywall", "measurement"]
},
{
"id": "feature_usage_high_no_upgrade",
"title": "High feature usage but low upgrade conversion in premium flow",
"area": "conversion",
"priority": "medium",
"metric": "premium_feature_usage_to_upgrade",
"current_value": 0.06,
"baseline_value": 0.09,
"delta_percent": -33.3,
"evidence": [
"Users repeat free core action but do not enter checkout",
"Conversion loss concentrated on first paywall view"
],
"keywords": ["premium", "upgrade", "checkout", "paywall"]
}
]
}
FILE:data/openclaw-growth-engineer/config.example.json
{
"version": 2,
"generatedAt": "2026-03-21T00:00:00.000Z",
"project": {
"githubRepo": "owner/repo",
"repoRoot": ".",
"outFile": "data/openclaw-growth-engineer/issues.generated.json",
"maxIssues": 4,
"titlePrefix": "[Growth]",
"labels": ["ai-growth", "autogenerated", "product"]
},
"sources": {
"analytics": {
"enabled": true,
"mode": "command",
"command": "node scripts/export-analytics-summary.mjs"
},
"revenuecat": {
"enabled": false,
"mode": "file",
"path": "data/openclaw-growth-engineer/revenuecat_summary.example.json"
},
"sentry": {
"enabled": false,
"mode": "file",
"path": "data/openclaw-growth-engineer/sentry_summary.example.json"
},
"feedback": {
"enabled": true,
"mode": "command",
"command": "analyticscli feedback summary --format json",
"cursorMode": "auto_since_last_fetch",
"initialLookback": "30d"
},
"extra": [
{
"key": "glitchtip",
"label": "glitchtip",
"service": "glitchtip",
"enabled": false,
"mode": "file",
"path": "data/openclaw-growth-engineer/glitchtip_summary.json",
"secretEnv": "GLITCHTIP_API_TOKEN"
},
{
"key": "asc_cli",
"label": "asc-cli",
"service": "asc-cli",
"enabled": false,
"mode": "command",
"command": "node scripts/export-asc-summary.mjs",
"secretEnv": "ASC_KEY_ID"
}
]
},
"schedule": {
"intervalMinutes": 1440,
"skipIfNoDataChange": true,
"skipIfIssueSetUnchanged": true
},
"actions": {
"mode": "issue",
"autoCreateIssues": false,
"autoCreatePullRequests": false,
"draftPullRequests": true,
"proposalBranchPrefix": "openclaw/proposals"
},
"charting": {
"enabled": false,
"command": null
},
"secrets": {
"githubTokenEnv": "GITHUB_TOKEN",
"analyticsTokenEnv": "ANALYTICSCLI_ACCESS_TOKEN",
"revenuecatTokenEnv": "REVENUECAT_API_KEY",
"sentryTokenEnv": "SENTRY_AUTH_TOKEN"
}
}
FILE:data/openclaw-growth-engineer/feedback_summary.example.json
{
"window": "last_30d",
"items": [
{
"id": "fb_paywall_confusing",
"title": "Users say paywall pricing is confusing",
"area": "paywall",
"priority": "medium",
"count": 18,
"channel": "app_store_reviews",
"comment": "Multiple users mention they do not understand weekly vs yearly plan differences.",
"locations": [
{ "location_id": "paywall/onboarding_offer", "count": 11 },
{ "location_id": "paywall/settings_restore", "count": 7 }
],
"keywords": ["paywall", "pricing", "plans", "subscription"],
"suggested_actions": [
"Simplify plan copy and highlight annual savings",
"Add one sentence on what is included in premium"
]
},
{
"id": "fb_onboarding_too_long",
"title": "Onboarding feels too long for first value",
"area": "onboarding",
"priority": "medium",
"count": 12,
"channel": "support_tickets",
"comment": "Users ask for faster start and less friction before first result.",
"locations": [
{ "location_id": "onboarding/profile_step", "count": 9 },
{ "location_id": "onboarding/permissions_gate", "count": 3 }
],
"keywords": ["onboarding", "friction", "first value"]
}
]
}
FILE:data/openclaw-growth-engineer/revenuecat_summary.example.json
{
"project": "ring-sizer",
"window": "last_30d",
"signals": [
{
"id": "trial_to_paid_weekly_drop",
"title": "Trial-to-paid conversion dropped on weekly package",
"area": "paywall",
"priority": "high",
"metric": "trial_to_paid_weekly",
"current_value": 0.08,
"baseline_value": 0.12,
"delta_percent": -33.3,
"evidence": [
"Strongest drop on iOS onboarding entry point",
"Cancellation spikes before trial ends"
],
"suggested_actions": [
"Clarify weekly package value and show annual savings side-by-side",
"Add post-trial reminder before renewal date"
],
"keywords": ["subscription", "weekly", "trial", "pricing", "paywall"]
}
]
}
FILE:data/openclaw-growth-engineer/sentry_summary.example.json
{
"project": "ring-sizer",
"window": "last_30d",
"issues": [
{
"id": "sentry_2145",
"title": "TypeError in purchase callback when closing paywall quickly",
"priority": "high",
"impact": "Crash blocks purchase completion",
"events": 286,
"users": 103,
"stack_keywords": ["paywall", "purchase", "callback", "close"],
"evidence": [
"Crash appears mostly in first session",
"Happens within 2-4 seconds after paywall is shown"
],
"suggested_actions": [
"Guard callback state transitions on unmounted paywall components",
"Add deterministic test for rapid close + purchase tap race condition"
]
}
]
}
FILE:references/input-schema.md
# Input Schema (MVP)
The analyzer accepts flexible JSON, but this shape is recommended for reliable issue quality.
## `analytics_summary.json` (required)
```json
{
"project": "my-product",
"window": "last_30d",
"signals": [
{
"id": "retention_d3_drop",
"title": "Day-3 retention dropped after onboarding paywall changes",
"area": "onboarding",
"priority": "high",
"metric": "d3_retention",
"current_value": 0.18,
"baseline_value": 0.27,
"delta_percent": -33.3,
"evidence": [
"Drop started after release 1.4.2",
"Largest loss between onboarding step 2 and paywall view"
],
"suggested_actions": [
"Move paywall after first core value event",
"Simplify onboarding step 2 form"
],
"keywords": ["onboarding", "paywall", "trial"]
}
]
}
```
## `revenuecat_summary.json` (recommended)
```json
{
"signals": [
{
"id": "trial_to_paid_down",
"title": "Trial-to-paid conversion dropped in weekly package",
"area": "paywall",
"priority": "high",
"metric": "trial_to_paid",
"current_value": 0.08,
"baseline_value": 0.12,
"delta_percent": -33.0,
"evidence": ["Drop is strongest on iOS and onboarding entry paywall"],
"keywords": ["subscription", "pricing", "trial", "weekly"]
}
]
}
```
## `sentry_summary.json` (recommended)
```json
{
"issues": [
{
"id": "sentry_1431",
"title": "TypeError in paywall purchase callback",
"priority": "high",
"impact": "Conversion blocker in purchase flow",
"events": 312,
"users": 119,
"stack_keywords": ["paywall", "purchase", "subscription", "callback"],
"evidence": ["Crash occurs within 3s after paywall shown"]
}
]
}
```
## `feedback_summary.json` (optional)
```json
{
"window": "last_30d",
"items": [
{
"id": "fb_onboarding_too_long",
"title": "Onboarding feels too long before first value",
"area": "onboarding",
"priority": "medium",
"count": 14,
"channel": "support_tickets",
"comment": "Users ask for a faster path to first result",
"locations": [
{ "location_id": "onboarding/profile_step", "count": 9 },
{ "location_id": "onboarding/permissions_gate", "count": 5 }
],
"keywords": ["onboarding", "friction", "first value"]
}
]
}
```
## `sources.extra[]` connectors
Extra connectors should prefer the shared `signals[]` shape.
Crash-style tools may use `issues[]`; feedback/review tools may use `items[]`.
FILE:references/issue-template.md
# Generated GitHub Issue Template
Use this template for deterministic, implementation-ready issue bodies.
```md
## Problem
<short problem statement>
## Evidence
- <metric or source evidence line 1>
- <metric or source evidence line 2>
## Affected Files / Modules
- `path/to/fileA.ts`
- `path/to/fileB.tsx`
## Proposed Implementation
- <action 1>
- <action 2>
- <action 3>
## Expected Impact
<single sentence with KPI intention>
## Confidence
High | Medium | Low
## Optional Next-Step PR Prompt
```text
Implement issue: <title>
Focus area: <area>
Likely files: <file list>
Requirements:
- <action 1>
- <tests>
- <instrumentation>
```
```
FILE:references/required-secrets.md
# Required Secrets
Use this checklist before running autopilot mode.
## Secret Inventory
| Env var | Purpose | Required | Where to get it |
| --- | --- | --- | --- |
| `GITHUB_TOKEN` | GitHub baseline access + issue/PR creation via API | Yes (baseline requirement) | GitHub -> Settings -> Developer settings -> Fine-grained PAT |
| `ANALYTICSCLI_ACCESS_TOKEN` | Read analytics data with CLI commands | Recommended | [dash.analyticscli.com](https://dash.analyticscli.com) -> API Keys -> access token |
| `REVENUECAT_API_KEY` | Pull RevenueCat monetization data | Recommended | RevenueCat -> Project -> API Keys (Secret key) |
| `SENTRY_AUTH_TOKEN` | Pull Sentry issue/event summaries | Recommended | Sentry -> User Settings -> Auth Tokens |
| `FEEDBACK_API_TOKEN` | Protect optional public feedback endpoint | Optional | Generate locally, e.g. `openssl rand -hex 32` |
## Minimum Scopes
- `GITHUB_TOKEN`:
- Fine-grained PAT is enough (no full/account-wide token required)
- Issue mode: Repository `Issues`: Read and Write, `Contents`: Read
- Pull-request mode: Repository `Pull requests`: Read and Write, `Contents`: Read and Write
- `ANALYTICSCLI_ACCESS_TOKEN`:
- `read:queries` access for analytics CLI reads and exports
- `REVENUECAT_API_KEY`:
- Read-only access where supported
- `SENTRY_AUTH_TOKEN`:
- Read scopes for issues/events/projects in the target org/project
## Red Lines
- Never commit secrets to git.
- Never store secrets in `data/openclaw-growth-engineer/config.json`.
- Never pass secrets in CLI arguments.
- Never print full secrets in logs.
FILE:references/setup-and-scheduling.md
# Setup And Scheduling (OpenClaw + VPS)
This is the recommended production-safe baseline for running growth autopilot on a VPS with OpenClaw.
## 1) Install Dependencies
```bash
npx -y @analyticscli/cli@preview --help
python3 -m pip install matplotlib
```
If SDK instrumentation is part of the target app, install in that app repository:
```bash
npm i @analyticscli/sdk@preview
```
## 2) Generate Non-Secret Config
Run:
```bash
node scripts/openclaw-growth-wizard.mjs
```
This writes non-secret configuration to:
- `data/openclaw-growth-engineer/config.json`
## 3) Store Secrets Securely
### OpenClaw Secret Storage (Preferred)
- Save all tokens in OpenClaw secret storage.
- Inject them into runtime environment variables.
- Keep config files and prompts secret-free.
Expected environment variable names:
- `GITHUB_TOKEN` (required baseline; fine-grained PAT with `Issues: Read/Write` + `Contents: Read`)
- `ANALYTICSCLI_ACCESS_TOKEN`
- `REVENUECAT_API_KEY`
- `SENTRY_AUTH_TOKEN`
- optional `FEEDBACK_API_TOKEN`
### VPS Fallback Baseline (When Running Via systemd)
1. Create a root-owned directory and env file outside your repository:
- `/etc/openclaw-growth/`
- `/etc/openclaw-growth/env`
2. Set strict permissions:
- directory `0700`
- env file `0600`
3. Run services as a dedicated non-root user.
4. Load secrets only through `EnvironmentFile`.
One-time setup commands:
```bash
sudo install -d -m 700 -o root -g root /etc/openclaw-growth
sudo install -m 600 -o root -g root /dev/null /etc/openclaw-growth/env
sudoedit /etc/openclaw-growth/env
```
Verification:
```bash
sudo ls -l /etc/openclaw-growth/env
```
Example `systemd` unit:
```ini
[Unit]
Description=OpenClaw Growth Runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=openclaw
WorkingDirectory=/opt/agentic-analytics
EnvironmentFile=/etc/openclaw-growth/env
ExecStart=/usr/bin/node scripts/openclaw-growth-runner.mjs --config data/openclaw-growth-engineer/config.json --loop
Restart=always
RestartSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/agentic-analytics/data
[Install]
WantedBy=multi-user.target
```
Do not place secret values in unit files, repository files, shell history, or command arguments.
## 4) Validate Before Running
OpenClaw/ClawHub installs can run in two modes:
- Repo mode: local `scripts/openclaw-growth-start.mjs` is available at the workspace root.
- ClawHub layout: the skill lives under `skills/product-manager-skill/`; copy runtime to the workspace root once with `bash skills/product-manager-skill/scripts/bootstrap-openclaw-workspace.sh`, then use the commands below.
- Portable mode: no repo scripts; run setup + first pass directly from `analyticscli` + GitHub API checks.
Portable mode must not ask for manual analytics summary files during `start/run`.
Missing repo scripts or other workspace-local helper files alone must not block execution.
Preferred (auto setup + first run orchestration):
```bash
node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json
```
Setup-only (no first run):
```bash
node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json --setup-only
```
Run preflight:
```bash
node scripts/openclaw-growth-preflight.mjs --config data/openclaw-growth-engineer/config.json --test-connections
```
The check validates:
- config file readability
- source paths/commands
- required binaries (`analyticscli`, `python3`)
- required skill availability (`analyticscli-cli`)
- optional chart dependency (`matplotlib`)
- required env vars for baseline execution (`GITHUB_TOKEN`) and enabled channels
- live connector/API smoke tests for enabled channels
The config also supports:
- `actions.mode = "issue"`
- `actions.mode = "pull_request"`
- extra connectors via `sources.extra[]` for tools such as GlitchTip, ASC CLI, or store review exports
## 5) Data Refresh Workflow
The runner consumes summary files. Update them before each cycle:
- `analytics_summary.json` from AnalyticsCLI queries
- `revenuecat_summary.json` from RevenueCat MCP/agent export
- `sentry_summary.json` from Sentry MCP/agent export
- optional `feedback_summary.json` from support/review aggregation
## 6) Code Readiness Requirements
To ensure reliable file/module mapping:
- run analyzer with `--repo-root <target-repo-root>`
- ensure runner user has read access to that repo
- optionally scope scan with `--code-roots apps,packages`
If code cannot be read, generated issue file mappings are low-confidence.
## 7) Charts (matplotlib)
Generate chart manifest:
```bash
python3 scripts/openclaw-growth-charts.py \
--analytics data/openclaw-growth-engineer/analytics_summary.json \
--out-dir data/openclaw-growth-engineer/charts \
--manifest data/openclaw-growth-engineer/charts.manifest.json
```
Pass manifest to analyzer:
```bash
node scripts/openclaw-growth-engineer.mjs \
--analytics data/openclaw-growth-engineer/analytics_summary.json \
--repo-root . \
--chart-manifest data/openclaw-growth-engineer/charts.manifest.json \
--max-issues 4
```
## 8) GitHub Creation Modes
Use a fine-grained `GITHUB_TOKEN` (no full-account token needed):
```bash
node scripts/openclaw-growth-engineer.mjs \
--analytics data/openclaw-growth-engineer/analytics_summary.json \
--revenuecat data/openclaw-growth-engineer/revenuecat_summary.json \
--sentry data/openclaw-growth-engineer/sentry_summary.json \
--repo-root . \
--create-issues \
--repo owner/repo \
--labels ai-growth,autogenerated,product
```
Recommended guardrails:
- max 3-5 issues per run
- `skipIfNoDataChange=true`
- `skipIfIssueSetUnchanged=true`
Draft pull-request mode is also supported. In that mode the runner creates proposal branches and draft PRs with `.openclaw/proposals/...md` files instead of issues.
FILE:scripts/bootstrap-openclaw-workspace.sh
#!/usr/bin/env bash
# Copy growth-engineer runtime from a ClawHub skill install into the OpenClaw workspace root
# so `node scripts/openclaw-growth-start.mjs` works (paths match docs + OpenClaw run).
#
# Typical layout after installing a bundled growth runtime skill:
# <workspace>/skills/<skill-slug>/scripts/*.mjs
# This script creates:
# <workspace>/scripts/*.mjs
# <workspace>/data/openclaw-growth-engineer/*.json
#
# Idempotent. Safe to re-run after skill updates.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(cd "SCRIPT_DIR/.." && pwd)"
skill_slug="$(basename "SKILL_ROOT")"
if [[ "skill_slug" != "product-manager-skill" && "skill_slug" != "ai-product-manager" && "skill_slug" != "openclaw-growth-engineer" ]]; then
echo "bootstrap-openclaw-workspace.sh: expected to live under skills/ai-product-manager/scripts/, skills/product-manager-skill/scripts/, or skills/openclaw-growth-engineer/scripts/ (ClawHub install)." >&2
echo "In the Agentic Analytics monorepo, scripts already live at repo root; nothing to copy." >&2
exit 0
fi
WORKSPACE="$(cd "SKILL_ROOT/../.." && pwd)"
mkdir -p "WORKSPACE/scripts" "WORKSPACE/data/openclaw-growth-engineer"
shopt -s nullglob
for f in "SCRIPT_DIR/"*.mjs "SCRIPT_DIR/"*.py; do
base="$(basename "$f")"
if [[ "base" == "bootstrap-openclaw-workspace.sh" ]]; then
continue
fi
cp "f" "WORKSPACE/scripts/base"
done
for f in "SKILL_ROOT/data/openclaw-growth-engineer/"*.json; do
cp "f" "WORKSPACE/data/openclaw-growth-engineer/$(basename "f")"
done
echo "Copied skill_slug runtime into workspace:"
echo " WORKSPACE/scripts"
echo " WORKSPACE/data/openclaw-growth-engineer"
echo "Next: node scripts/openclaw-growth-start.mjs --config data/openclaw-growth-engineer/config.json"
FILE:scripts/export-analytics-summary.mjs
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import process from 'node:process';
import { buildAnalyticsSummary, writeJsonOutput } from './openclaw-exporters-lib.mjs';
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
Export Analytics Summary
Builds an OpenClaw-compatible analytics_summary JSON by querying analyticscli.
Usage:
node scripts/export-analytics-summary.mjs [options]
Options:
--project <id> AnalyticsCLI project ID (optional when a default project is selected)
--last <duration> Relative time window like 30d (default: 30d)
--out <file> Write JSON to file instead of stdout
--include-debug Include development/debug data
--max-signals <n> Maximum signals to emit (default: 4)
--help, -h Show help
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = {
project: '',
last: '30d',
out: '',
includeDebug: false,
maxSignals: 4,
};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
const next = argv[index + 1];
if (token === '--') {
continue;
}
else if (token === '--project') {
args.project = String(next || '').trim();
index += 1;
}
else if (token === '--last') {
args.last = String(next || '30d').trim() || '30d';
index += 1;
}
else if (token === '--out') {
args.out = String(next || '').trim();
index += 1;
}
else if (token === '--include-debug') {
args.includeDebug = true;
}
else if (token === '--max-signals') {
const parsed = Number.parseInt(String(next || ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
printHelpAndExit(1, `Invalid value for --max-signals: String(next || '')`);
}
args.maxSignals = parsed;
index += 1;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function runJsonCommand(command, commandArgs) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(Object.assign(new Error(stderr.trim() || `command exited with code code`), { exitCode: code }));
return;
}
try {
resolve(JSON.parse(stdout));
}
catch (error) {
reject(new Error(`command returned non-JSON output`));
}
});
});
}
function buildBaseArgs(input) {
const args = ['--format', 'json'];
if (input.project) {
args.push('--project', input.project);
}
if (input.includeDebug) {
args.push('--include-debug');
}
return args;
}
async function runOptionalAnalyticsQuery(label, args) {
try {
return await runJsonCommand('analyticscli', args);
}
catch (error) {
const exitCode = error && typeof error === 'object' && 'exitCode' in error ? error.exitCode : null;
if (exitCode === 2) {
return null;
}
throw new Error(`label failed: String(error)`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const baseArgs = buildBaseArgs(args);
const onboardingJourney = await runOptionalAnalyticsQuery('onboarding journey query', [
...baseArgs,
'get',
'onboarding-journey',
'--within',
'user',
'--last',
args.last,
'--with-trends',
]);
const retention = await runOptionalAnalyticsQuery('retention query', [
...baseArgs,
'retention',
'--anchor-event',
'onboarding:start',
'--days',
'1,3,7',
'--max-age-days',
'90',
'--last',
args.last,
]);
const summary = buildAnalyticsSummary({
projectId: args.project,
last: args.last,
onboardingJourney,
retention,
maxSignals: args.maxSignals,
});
await writeJsonOutput(args.out, summary);
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/export-asc-summary.mjs
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import process from 'node:process';
import { buildAscSummary, writeJsonOutput } from './openclaw-exporters-lib.mjs';
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
Export ASC Summary
Builds an OpenClaw-compatible store/release summary JSON from the asc CLI.
Usage:
node scripts/export-asc-summary.mjs [options]
Options:
--app <id> App Store Connect app ID (defaults to ASC_APP_ID)
--out <file> Write JSON to file instead of stdout
--country <code> Ratings country override (default: all countries)
--reviews-limit <n> Review summarizations limit (default: 20)
--feedback-limit <n> TestFlight feedback limit (default: 20)
--max-signals <n> Maximum signals to emit (default: 4)
--help, -h Show help
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = {
app: String(process.env.ASC_APP_ID || '').trim(),
out: '',
country: '',
reviewsLimit: 20,
feedbackLimit: 20,
maxSignals: 4,
};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
const next = argv[index + 1];
if (token === '--') {
continue;
}
else if (token === '--app') {
args.app = String(next || '').trim();
index += 1;
}
else if (token === '--out') {
args.out = String(next || '').trim();
index += 1;
}
else if (token === '--country') {
args.country = String(next || '').trim();
index += 1;
}
else if (token === '--reviews-limit') {
const parsed = Number.parseInt(String(next || ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
printHelpAndExit(1, `Invalid value for --reviews-limit: String(next || '')`);
}
args.reviewsLimit = parsed;
index += 1;
}
else if (token === '--feedback-limit') {
const parsed = Number.parseInt(String(next || ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
printHelpAndExit(1, `Invalid value for --feedback-limit: String(next || '')`);
}
args.feedbackLimit = parsed;
index += 1;
}
else if (token === '--max-signals') {
const parsed = Number.parseInt(String(next || ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
printHelpAndExit(1, `Invalid value for --max-signals: String(next || '')`);
}
args.maxSignals = parsed;
index += 1;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
if (!args.app) {
printHelpAndExit(1, 'Missing app ID. Pass --app <id> or set ASC_APP_ID.');
}
return args;
}
function runJsonCommand(command, commandArgs) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(Object.assign(new Error(stderr.trim() || `command exited with code code`), { exitCode: code }));
return;
}
try {
resolve(JSON.parse(stdout));
}
catch {
reject(new Error(`command returned non-JSON output`));
}
});
});
}
async function runOptionalAscQuery(label, args) {
try {
return await runJsonCommand('asc', args);
}
catch (error) {
const exitCode = error && typeof error === 'object' && 'exitCode' in error ? error.exitCode : null;
if (exitCode === 2 || exitCode === 3) {
return null;
}
throw new Error(`label failed: String(error)`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const statusPayload = await runJsonCommand('asc', [
'status',
'--app',
args.app,
'--include',
'builds,testflight,submission,review,appstore',
]);
const ratingsArgs = ['reviews', 'ratings', '--app', args.app];
if (args.country) {
ratingsArgs.push('--country', args.country);
}
else {
ratingsArgs.push('--all');
}
const ratingsPayload = await runOptionalAscQuery('ASC ratings query', ratingsArgs);
const reviewSummariesPayload = await runOptionalAscQuery('ASC review summarizations query', [
'reviews',
'summarizations',
'--app',
args.app,
'--platform',
'IOS',
'--limit',
String(args.reviewsLimit),
'--fields',
'text,createdDate,locale',
]);
const feedbackPayload = await runOptionalAscQuery('ASC beta feedback query', [
'feedback',
'--app',
args.app,
'--limit',
String(args.feedbackLimit),
'--sort',
'-createdDate',
]);
const summary = buildAscSummary({
appId: args.app,
statusPayload,
ratingsPayload,
reviewSummariesPayload,
feedbackPayload,
maxSignals: args.maxSignals,
});
await writeJsonOutput(args.out, summary);
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/install-analyticscli-cli.sh
#!/usr/bin/env bash
set -euo pipefail
package="-@analyticscli/cli@preview"
home_dir="-$(pwd)"
user_prefix="-${home_dir/.local}"
print_path_hint() {
local bin_dir="$1"
cat <<EOF
analyticscli was installed into:
bin_dir
For future shells, make sure this directory is on PATH:
export PATH="bin_dir:\$PATH"
For the current shell/session, run:
export PATH="bin_dir:\$PATH"
EOF
}
if ! command -v npm >/dev/null 2>&1; then
if command -v analyticscli >/dev/null 2>&1; then
echo "analyticscli already available: $(command -v analyticscli)"
echo "npm is not available, so the package update check was skipped." >&2
analyticscli --help >/dev/null
exit 0
fi
echo "npm is required to install package, but npm was not found." >&2
exit 1
fi
echo "Ensuring AnalyticsCLI CLI from npm package package"
set +e
global_output="$(npm install -g "package" 2>&1)"
global_exit=$?
set -e
if [[ global_exit -ne 0 ]]; then
if printf '%s' "global_output" | grep -Eiq 'EACCES|permission denied|access denied|operation not permitted'; then
echo "Global npm install failed because of permissions. Falling back to user-local prefix: user_prefix" >&2
mkdir -p "user_prefix"
npm install -g --prefix "user_prefix" "package"
export PATH="user_prefix/bin:PATH"
else
printf '%s\n' "global_output" >&2
exit "global_exit"
fi
fi
if ! command -v analyticscli >/dev/null 2>&1; then
echo "Installed package, but analyticscli is still not on PATH." >&2
if [[ -x "user_prefix/bin/analyticscli" ]]; then
print_path_hint "user_prefix/bin" >&2
else
echo "Check your npm global bin directory with: npm prefix -g" >&2
fi
exit 1
fi
echo "analyticscli available: $(command -v analyticscli)"
analyticscli --help >/dev/null
case "$(command -v analyticscli)" in
"user_prefix/bin/"*)
print_path_hint "user_prefix/bin"
;;
esac
FILE:scripts/openclaw-exporters-lib.mjs
import { promises as fs } from 'node:fs';
import path from 'node:path';
function coerceNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function coerceRatioFromPercent(value) {
const numeric = coerceNumber(value);
if (numeric === null)
return null;
return numeric / 100;
}
function round(value, digits = 4) {
if (!Number.isFinite(value))
return 0;
return Number(value.toFixed(digits));
}
function computeDeltaPercent(currentValue, baselineValue) {
if (!Number.isFinite(currentValue) || !Number.isFinite(baselineValue)) {
return null;
}
if (Math.abs(baselineValue) < 1e-9) {
if (Math.abs(currentValue) < 1e-9)
return 0;
return currentValue > 0 ? 100 : -100;
}
return round(((currentValue - baselineValue) / Math.abs(baselineValue)) * 100, 2);
}
function normalizeWindow(last) {
const normalized = String(last || '30d').trim().toLowerCase();
if (!normalized)
return 'last_30d';
if (normalized.startsWith('last_'))
return normalized;
return `last_normalized`;
}
function priorityRank(priority) {
if (priority === 'high')
return 3;
if (priority === 'medium')
return 2;
return 1;
}
function sortSignals(signals) {
return [...signals].sort((a, b) => {
const priorityDelta = priorityRank(String(b.priority || 'low')) - priorityRank(String(a.priority || 'low'));
if (priorityDelta !== 0)
return priorityDelta;
const deltaA = Math.abs(coerceNumber(a.delta_percent ?? a.deltaPercent) || 0);
const deltaB = Math.abs(coerceNumber(b.delta_percent ?? b.deltaPercent) || 0);
return deltaB - deltaA;
});
}
function hasMinimumSample(value, minimum = 20) {
const numeric = coerceNumber(value);
return numeric !== null && numeric >= minimum;
}
function maybePushSignal(signals, signal) {
if (!signal)
return;
signals.push(signal);
}
function buildAnalyticsTrendEvidence(label, trend) {
if (!trend || typeof trend !== 'object')
return null;
const direction = String(trend.direction || '').trim();
const percentChange = coerceNumber(trend.percentChange);
const startValue = coerceNumber(trend.startValue);
const currentValue = coerceNumber(trend.currentValue);
if (!direction || percentChange === null || startValue === null || currentValue === null) {
return null;
}
const signed = percentChange > 0 ? `+percentChange%` : `percentChange%`;
return `label trend: direction signed (start=startValue, current=currentValue)`;
}
export function buildAnalyticsSummary(input) {
const last = String(input?.last || '30d');
const onboardingJourney = input?.onboardingJourney || null;
const retention = input?.retention || null;
const project = String(onboardingJourney?.projectId ||
input?.projectId ||
input?.project ||
'analyticscli-project').trim() || 'analyticscli-project';
const signals = [];
const starters = coerceNumber(onboardingJourney?.starters) || 0;
const paywallReachedUsers = coerceNumber(onboardingJourney?.paywallReachedUsers) || 0;
const completionRate = coerceRatioFromPercent(onboardingJourney?.completionRate);
const paywallSkipRate = coerceRatioFromPercent(onboardingJourney?.paywallSkipRateFromPaywall);
const purchaseRateFromPaywall = coerceRatioFromPercent(onboardingJourney?.purchaseRateFromPaywall);
if (hasMinimumSample(starters)) {
const completionBaseline = 0.6;
if (completionRate !== null && completionRate < completionBaseline) {
maybePushSignal(signals, {
id: 'onboarding_completion_below_target',
title: 'Onboarding completion rate is below target',
area: 'onboarding',
priority: completionRate < 0.45 ? 'high' : 'medium',
metric: 'onboarding_completion_rate',
current_value: round(completionRate),
baseline_value: completionBaseline,
delta_percent: computeDeltaPercent(completionRate, completionBaseline),
evidence: [
`onboardingJourney?.completedUsers || 0 of starters onboarding starters completed successfully`,
onboardingJourney?.paywallAnchorEvent
? `Paywall anchor event in the flow: onboardingJourney.paywallAnchorEvent`
: 'No stable paywall anchor event detected in the onboarding journey payload',
buildAnalyticsTrendEvidence('Completion rate', onboardingJourney?.trends?.completionRate),
].filter(Boolean),
suggested_actions: [
'Shorten the onboarding path before the first value moment',
'Delay monetization or permission friction until after the first core success event',
'Inspect the heaviest drop-off steps in the onboarding journey and simplify one of them',
],
keywords: ['onboarding', 'completion', 'dropoff', 'first_value'],
});
}
}
if (hasMinimumSample(paywallReachedUsers)) {
const paywallSkipBaseline = 0.45;
if (paywallSkipRate !== null && paywallSkipRate > paywallSkipBaseline) {
maybePushSignal(signals, {
id: 'paywall_skip_rate_above_target',
title: 'Paywall skip rate is above target',
area: 'paywall',
priority: paywallSkipRate > 0.6 ? 'high' : 'medium',
metric: 'paywall_skip_rate',
current_value: round(paywallSkipRate),
baseline_value: paywallSkipBaseline,
delta_percent: computeDeltaPercent(paywallSkipRate, paywallSkipBaseline),
evidence: [
`onboardingJourney?.paywallSkippedUsers || 0 users skipped after paywallReachedUsers reached the paywall`,
onboardingJourney?.paywallSkipEvent
? `Most visible skip event: onboardingJourney.paywallSkipEvent`
: 'No stable skip event detected in the onboarding journey payload',
buildAnalyticsTrendEvidence('Paywall reached rate', onboardingJourney?.trends?.paywallReachedRate),
].filter(Boolean),
suggested_actions: [
'Clarify the premium value proposition and annual-vs-monthly trade-off',
'Reduce cognitive load on the first paywall view and tighten the CTA hierarchy',
'Test a later paywall placement after a stronger proof-of-value moment',
],
keywords: ['paywall', 'skip', 'pricing', 'conversion'],
});
}
const purchaseBaseline = 0.12;
if (purchaseRateFromPaywall !== null && purchaseRateFromPaywall < purchaseBaseline) {
maybePushSignal(signals, {
id: 'paywall_purchase_rate_below_target',
title: 'Paywall-to-purchase conversion is below target',
area: 'conversion',
priority: purchaseRateFromPaywall < 0.06 ? 'high' : 'medium',
metric: 'purchase_rate_from_paywall',
current_value: round(purchaseRateFromPaywall),
baseline_value: purchaseBaseline,
delta_percent: computeDeltaPercent(purchaseRateFromPaywall, purchaseBaseline),
evidence: [
`onboardingJourney?.purchasedUsers || 0 purchases from paywallReachedUsers paywall exposures`,
onboardingJourney?.purchaseEvent
? `Purchase success event observed: onboardingJourney.purchaseEvent`
: 'No stable purchase success event detected in the onboarding journey payload',
buildAnalyticsTrendEvidence('Purchase rate', onboardingJourney?.trends?.purchaseRate),
].filter(Boolean),
suggested_actions: [
'Simplify the paywall package comparison and highlight the default recommended offer',
'Reduce ambiguity around trial terms, pricing cadence, and restore flow',
'Test a stronger trust/benefit section near the purchase CTA',
],
keywords: ['purchase', 'paywall', 'subscription', 'conversion'],
});
}
}
const retentionByDay = new Map(Array.isArray(retention?.days)
? retention.days
.map((entry) => {
const day = coerceNumber(entry?.day);
const rate = coerceNumber(entry?.retentionRate);
if (day === null || rate === null)
return null;
return [day, rate];
})
.filter((entry) => entry !== null)
: []);
const retentionTargets = [
{ day: 7, baseline: 0.1 },
{ day: 3, baseline: 0.2 },
{ day: 1, baseline: 0.35 },
];
if (hasMinimumSample(retention?.cohortSize)) {
for (const target of retentionTargets) {
const actual = retentionByDay.get(target.day);
if (actual === undefined || actual >= target.baseline) {
continue;
}
maybePushSignal(signals, {
id: `retention_dtarget.day_below_target`,
title: `Day-target.day retention is below target`,
area: 'retention',
priority: target.day >= 3 ? 'high' : 'medium',
metric: `dtarget.day_retention`,
current_value: round(actual),
baseline_value: target.baseline,
delta_percent: computeDeltaPercent(actual, target.baseline),
evidence: [
`Retention cohort size: retention.cohortSize`,
`Observed Dtarget.day retention: (actual * 100).toFixed(2)%`,
retention?.avgActiveDays !== undefined
? `Average active days in the cohort: retention.avgActiveDays`
: null,
].filter(Boolean),
suggested_actions: [
'Revisit the first-session value loop and ensure the core action completes quickly',
'Add targeted re-entry prompts or reminders after the first session',
'Instrument the major early-session drop-off points to isolate which step drives the retention loss',
],
keywords: ['retention', 'engagement', 'activation', `dtarget.day`],
});
break;
}
}
return {
project,
window: normalizeWindow(last),
signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 4)),
meta: {
generatedAt: new Date().toISOString(),
source: 'analyticscli',
starters,
paywallReachedUsers,
retentionCohortSize: coerceNumber(retention?.cohortSize) || 0,
},
};
}
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function walk(value, visitor, pathParts = []) {
if (Array.isArray(value)) {
value.forEach((entry, index) => {
walk(entry, visitor, [...pathParts, String(index)]);
});
return;
}
if (!isObject(value)) {
visitor(value, pathParts);
return;
}
for (const [key, entry] of Object.entries(value)) {
const nextPath = [...pathParts, key];
visitor(entry, nextPath, key);
walk(entry, visitor, nextPath);
}
}
function collectStatusEntries(payload) {
const entries = [];
walk(payload, (value, pathParts, key) => {
if (typeof value !== 'string')
return;
const normalizedKey = String(key || '').toLowerCase();
if (!['state', 'status', 'processingstate', 'reviewstate'].includes(normalizedKey)) {
return;
}
entries.push({
path: pathParts.join('.'),
value: value.trim(),
});
});
return entries;
}
function classifyAscStatus(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized)
return null;
if (/(reject|rejected|fail|failed|error|invalid|missing|remove|blocked|denied|cancel)/.test(normalized)) {
return 'blocking';
}
if (/(processing|pending|waiting|prepare_for_submission|ready_for_review|in_review)/.test(normalized)) {
return 'watch';
}
if (/(ready_for_sale|approved|active|available|complete|passed|ok)/.test(normalized)) {
return 'healthy';
}
return null;
}
function findNumbersByCandidateKeys(payload, candidateKeys) {
const matches = [];
walk(payload, (value, pathParts, key) => {
if (!key)
return;
const normalizedKey = String(key).toLowerCase();
if (!candidateKeys.includes(normalizedKey))
return;
const numeric = coerceNumber(value);
if (numeric === null)
return;
matches.push({ path: pathParts.join('.'), value: numeric });
});
return matches;
}
function extractReviewTexts(payload) {
const texts = [];
walk(payload, (value, pathParts, key) => {
if (typeof value !== 'string')
return;
const normalizedKey = String(key || '').toLowerCase();
if (!['text', 'comment', 'summary', 'body', 'title', 'feedback'].includes(normalizedKey)) {
return;
}
const trimmed = value.trim();
if (!trimmed)
return;
texts.push({
path: pathParts.join('.'),
text: trimmed,
});
});
return texts;
}
function rankKeywordThemes(texts) {
const themeDefinitions = [
{
id: 'stability',
area: 'stability',
keywords: ['crash', 'crashes', 'crashing', 'freeze', 'frozen', 'bug', 'broken'],
suggestedActions: [
'Review recent crash and review signals together to isolate the highest-impact regression',
'Prioritize the failing flow in the next patch release and add deterministic regression coverage',
],
},
{
id: 'pricing',
area: 'paywall',
keywords: ['subscription', 'subscribe', 'paywall', 'price', 'pricing', 'trial', 'premium', 'restore'],
suggestedActions: [
'Clarify package differences and restore messaging in the paywall flow',
'Use review phrasing directly to rewrite confusing pricing copy',
],
},
{
id: 'auth',
area: 'authentication',
keywords: ['login', 'log in', 'sign in', 'account', 'password'],
suggestedActions: [
'Audit authentication entry points and reduce avoidable sign-in friction',
'Surface clearer account state and recovery messaging in the first-session path',
],
},
{
id: 'onboarding',
area: 'onboarding',
keywords: ['onboarding', 'tutorial', 'signup', 'sign up', 'permission', 'too long'],
suggestedActions: [
'Trim the onboarding path and move optional steps later',
'Match onboarding copy more closely to the first-value promise from the store listing',
],
},
{
id: 'performance',
area: 'performance',
keywords: ['slow', 'lag', 'loading', 'stuck', 'wait'],
suggestedActions: [
'Measure the slowest startup and primary interaction paths that users mention',
'Ship a focused performance pass on the worst-loading user journeys',
],
},
];
return themeDefinitions
.map((theme) => {
let hits = 0;
for (const entry of texts) {
const normalized = entry.text.toLowerCase();
for (const keyword of theme.keywords) {
if (normalized.includes(keyword)) {
hits += 1;
}
}
}
return { ...theme, hits };
})
.filter((theme) => theme.hits > 0)
.sort((a, b) => b.hits - a.hits);
}
export function buildAscSummary(input) {
const appId = String(input?.appId || 'ASC_APP_ID').trim() || 'ASC_APP_ID';
const statusEntries = collectStatusEntries(input?.statusPayload);
const blockingStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'blocking');
const watchStatuses = statusEntries.filter((entry) => classifyAscStatus(entry.value) === 'watch');
const averageRatingCandidates = findNumbersByCandidateKeys(input?.ratingsPayload, [
'averagerating',
'averageuserrating',
'ratingaverage',
'avgrating',
]).filter((entry) => entry.value >= 0 && entry.value <= 5);
const ratingCountCandidates = findNumbersByCandidateKeys(input?.ratingsPayload, [
'ratingcount',
'userratingcount',
'ratingscount',
'count',
]).filter((entry) => entry.value >= 0);
const averageRating = averageRatingCandidates[0]?.value ?? null;
const ratingCount = ratingCountCandidates[0]?.value ?? null;
const reviewTexts = [
...extractReviewTexts(input?.reviewSummariesPayload),
...extractReviewTexts(input?.feedbackPayload),
];
const topThemes = rankKeywordThemes(reviewTexts).slice(0, 2);
const signals = [];
if (blockingStatuses.length > 0) {
maybePushSignal(signals, {
id: 'asc_release_blockers_detected',
title: 'App Store Connect reports blocking release states',
area: 'release',
priority: 'high',
metric: 'asc_release_blockers',
current_value: blockingStatuses.length,
baseline_value: 0,
delta_percent: blockingStatuses.length > 0 ? 100 : 0,
evidence: blockingStatuses.slice(0, 5).map((entry) => `entry.path: entry.value`),
suggested_actions: [
'Open the failing ASC section and resolve the blocking review, submission, or build issue',
'Link the blocking ASC state to the corresponding release checklist item before the next submission',
],
keywords: ['asc', 'review', 'submission', 'release', 'blocker'],
});
}
else if (watchStatuses.length > 0) {
maybePushSignal(signals, {
id: 'asc_release_in_progress',
title: 'App Store Connect still shows in-progress release states',
area: 'release',
priority: 'medium',
metric: 'asc_release_watch_states',
current_value: watchStatuses.length,
baseline_value: 0,
delta_percent: watchStatuses.length > 0 ? 100 : 0,
evidence: watchStatuses.slice(0, 5).map((entry) => `entry.path: entry.value`),
suggested_actions: [
'Monitor build processing and review transitions until they reach a terminal healthy state',
'Avoid scheduling a coordinated release action until ASC processing has finished',
],
keywords: ['asc', 'processing', 'review', 'submission'],
});
}
if (averageRating !== null && ratingCount !== null && ratingCount >= 20 && averageRating < 4.2) {
const ratingBaseline = 4.2;
maybePushSignal(signals, {
id: 'asc_rating_below_target',
title: 'App Store rating is below target',
area: 'store',
priority: averageRating < 3.8 ? 'high' : 'medium',
metric: 'app_store_average_rating',
current_value: round(averageRating),
baseline_value: ratingBaseline,
delta_percent: computeDeltaPercent(averageRating, ratingBaseline),
evidence: [
`Average rating: averageRating.toFixed(2) from Math.round(ratingCount) ratings`,
'Ratings came from the ASC review ratings command output',
],
suggested_actions: [
'Read recent review summaries to identify the dominant complaint before changing store copy',
'Tie the next release notes and onboarding/paywall adjustments to the main rating complaint themes',
],
keywords: ['app_store', 'rating', 'reviews', 'aso'],
});
}
for (const theme of topThemes) {
maybePushSignal(signals, {
id: `asc_review_theme_theme.id`,
title: `Store and beta feedback repeatedly mention theme.area issues`,
area: theme.area,
priority: theme.hits >= 4 ? 'high' : 'medium',
metric: `feedback_theme_theme.id`,
current_value: theme.hits,
baseline_value: 0,
delta_percent: theme.hits > 0 ? 100 : 0,
evidence: reviewTexts.slice(0, 3).map((entry) => entry.text).filter(Boolean),
suggested_actions: theme.suggestedActions,
keywords: ['reviews', 'feedback', theme.area, ...theme.keywords.slice(0, 3)],
});
}
return {
project: `app-store-connect:appId`,
window: 'latest',
signals: sortSignals(signals).slice(0, Math.max(1, Number(input?.maxSignals) || 4)),
meta: {
generatedAt: new Date().toISOString(),
source: 'asc',
appId,
ratingCount: ratingCount ?? 0,
feedbackTextCount: reviewTexts.length,
},
};
}
export async function writeJsonOutput(outPath, payload) {
const serialized = `JSON.stringify(payload, null, 2)\n`;
if (outPath) {
const resolved = path.resolve(String(outPath));
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, serialized, 'utf8');
return resolved;
}
process.stdout.write(serialized);
return null;
}
FILE:scripts/openclaw-feedback-api.mjs
#!/usr/bin/env node
import { createServer } from 'node:http';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const DEFAULT_PORT = 4310;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_DIR = 'data/openclaw-growth-engineer/feedback-api';
const FEEDBACK_HEADERS = ['x-feedback-token', 'x-feedback-key'];
function parseArgs(argv) {
const args = {
port: DEFAULT_PORT,
host: DEFAULT_HOST,
dir: DEFAULT_DIR,
allowUnauthenticated: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
else if (token === '--port') {
args.port = Number.parseInt(next, 10) || DEFAULT_PORT;
i += 1;
}
else if (token === '--host') {
args.host = next || DEFAULT_HOST;
i += 1;
}
else if (token === '--dir') {
args.dir = next;
i += 1;
}
else if (token === '--allow-unauthenticated') {
args.allowUnauthenticated = true;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
OpenClaw Feedback API (MVP)
Usage:
node scripts/openclaw-feedback-api.mjs [--host 127.0.0.1] [--port 4310] [--dir data/openclaw-growth-engineer/feedback-api]
Auth:
Required env FEEDBACK_API_TOKEN.
Clients must send header: x-feedback-token: <token>
The API also accepts: x-feedback-key: <token>
Local-only development can opt out with: --allow-unauthenticated
`);
process.exit(exitCode);
}
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(payload));
}
async function readBody(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.on('data', (chunk) => {
raw += String(chunk);
if (raw.length > 1_000_000) {
reject(new Error('Payload too large'));
}
});
req.on('end', () => resolve(raw));
req.on('error', reject);
});
}
function normalizeItem(input) {
const now = new Date().toISOString();
const metadata = input && typeof input.metadata === 'object' && !Array.isArray(input.metadata) ? input.metadata : {};
const rawFeedback = String(input.feedback || input.message || input.comment || input.summary || '').trim();
const locationId = String(input.locationId || input.location || metadata.locationId || '').trim();
const surface = String(input.appSurface || input.surface || metadata.surface || '').trim();
const originName = String(input.originName || metadata.originName || '').trim();
const title = String(input.title ||
input.summary ||
metadata.title ||
(rawFeedback ? rawFeedback.split(/[.!?]/)[0] : '') ||
'User feedback').trim() || 'User feedback';
return {
id: String(input.id || `fb_Date.now()`),
title,
comment: rawFeedback,
area: String(input.area || metadata.area || 'general').toLowerCase(),
channel: String(input.channel || surface || 'unknown'),
surface: surface || null,
location_id: locationId || null,
origin_name: originName || null,
priority: String(input.priority || metadata.priority || 'medium').toLowerCase(),
tags: Array.isArray(input.tags) ? input.tags.map(String) : [],
metadata,
app_id: input.appId ? String(input.appId) : null,
user_id: input.userId ? String(input.userId) : null,
created_at: String(input.created_at || now),
};
}
async function appendEvent(filePath, item) {
await fs.appendFile(filePath, `JSON.stringify(item)\n`, 'utf8');
}
async function readEvents(filePath) {
try {
const raw = await fs.readFile(filePath, 'utf8');
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
catch {
return [];
}
}
function buildSummary(events) {
const grouped = new Map();
for (const event of events) {
const key = `event.area:event.origin_name || 'unknown':event.title.toLowerCase()`;
const current = grouped.get(key) || {
id: key.replace(/[^a-z0-9:_-]/gi, '_'),
title: event.title,
area: event.area,
origin_name: event.origin_name || null,
priority: event.priority || 'medium',
count: 0,
channel: event.channel || 'mixed',
surface: event.surface || null,
comment: event.comment || '',
keywords: [],
locations: new Map(),
};
current.count += 1;
current.comment = event.comment || current.comment;
const tags = Array.isArray(event.tags) ? event.tags : [];
current.keywords = [...new Set([...current.keywords, ...tags])];
if (event.location_id) {
current.locations.set(event.location_id, (current.locations.get(event.location_id) || 0) + 1);
}
if (event.priority === 'high') {
current.priority = 'high';
}
grouped.set(key, current);
}
const items = [...grouped.values()]
.map((item) => ({
...item,
locations: [...item.locations.entries()]
.map(([location_id, count]) => ({ location_id, count }))
.sort((a, b) => b.count - a.count || a.location_id.localeCompare(b.location_id)),
}))
.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title));
return {
window: 'rolling',
generated_at: new Date().toISOString(),
items,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const storageDir = path.resolve(args.dir);
const eventsFile = path.join(storageDir, 'events.ndjson');
const summaryFile = path.join(storageDir, 'feedback_summary.json');
const requiredToken = process.env.FEEDBACK_API_TOKEN || null;
if (!requiredToken && !args.allowUnauthenticated) {
throw new Error('FEEDBACK_API_TOKEN is required. For local-only development, rerun with --allow-unauthenticated.');
}
await ensureDir(storageDir);
const server = createServer(async (req, res) => {
try {
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': `content-type,FEEDBACK_HEADERS.join(',')`,
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
});
res.end();
return;
}
if (requiredToken) {
const token = FEEDBACK_HEADERS.map((header) => req.headers[header]).find(Boolean);
if (token !== requiredToken) {
sendJson(res, 401, { ok: false, error: 'unauthorized' });
return;
}
}
if (req.method === 'GET' && req.url === '/health') {
sendJson(res, 200, { ok: true, status: 'healthy' });
return;
}
if (req.method === 'POST' && req.url === '/feedback') {
const rawBody = await readBody(req);
const payload = rawBody ? JSON.parse(rawBody) : {};
const item = normalizeItem(payload);
await appendEvent(eventsFile, item);
const events = await readEvents(eventsFile);
const summary = buildSummary(events);
await fs.writeFile(summaryFile, JSON.stringify(summary, null, 2), 'utf8');
sendJson(res, 200, { ok: true, item });
return;
}
if (req.method === 'GET' && req.url === '/summary') {
const summary = await fs
.readFile(summaryFile, 'utf8')
.then((raw) => JSON.parse(raw))
.catch(async () => {
const events = await readEvents(eventsFile);
return buildSummary(events);
});
sendJson(res, 200, summary);
return;
}
sendJson(res, 404, { ok: false, error: 'not_found' });
}
catch (error) {
sendJson(res, 500, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
server.listen(args.port, args.host, () => {
process.stdout.write(`Feedback API listening on http://args.host:args.port\n`);
process.stdout.write(`Data dir: storageDir\n`);
if (requiredToken) {
process.stdout.write('Auth: enabled (x-feedback-token or x-feedback-key required)\n');
}
else {
process.stdout.write('Auth: disabled by explicit --allow-unauthenticated\n');
}
});
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/openclaw-growth-charts.py
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(description="Generate OpenClaw growth charts from analytics summary JSON.")
parser.add_argument("--analytics", required=True, help="Path to analytics summary JSON")
parser.add_argument("--out-dir", required=True, help="Directory for generated PNG files")
parser.add_argument("--manifest", required=True, help="Output chart manifest JSON path")
return parser.parse_args()
def load_json(path: Path):
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def sanitize_slug(value: str) -> str:
safe = []
for ch in value.lower():
if ch.isalnum():
safe.append(ch)
elif ch in ("-", "_"):
safe.append(ch)
else:
safe.append("_")
return "".join(safe).strip("_") or "signal"
def to_float(value):
try:
number = float(value)
if number != number: # NaN check
return None
return number
except (TypeError, ValueError):
return None
def render_signal_chart(signal, out_file: Path):
try:
import matplotlib.pyplot as plt
except Exception as exc:
raise RuntimeError(
"matplotlib is required. Install with: python3 -m pip install matplotlib"
) from exc
current = to_float(signal.get("current_value") or signal.get("currentValue"))
baseline = to_float(signal.get("baseline_value") or signal.get("baselineValue"))
metric_name = str(signal.get("metric") or "metric")
title = str(signal.get("title") or "Signal")
delta = to_float(signal.get("delta_percent") or signal.get("deltaPercent"))
labels = []
values = []
if baseline is not None:
labels.append("baseline")
values.append(baseline)
if current is not None:
labels.append("current")
values.append(current)
if not values:
return False
colors = ["#9ca3af" if label == "baseline" else "#2563eb" for label in labels]
fig, ax = plt.subplots(figsize=(7.5, 4.2))
bars = ax.bar(labels, values, color=colors)
ax.set_title(title)
ax.set_ylabel(metric_name)
for bar, value in zip(bars, values):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height(),
f"{value:.3g}",
ha="center",
va="bottom",
fontsize=9,
)
if delta is not None:
ax.text(
0.99,
0.95,
f"delta: {delta:.1f}%",
transform=ax.transAxes,
ha="right",
va="top",
fontsize=10,
color="#b91c1c" if delta < 0 else "#166534",
)
fig.tight_layout()
fig.savefig(out_file, dpi=160)
plt.close(fig)
return True
def main():
args = parse_args()
analytics_path = Path(args.analytics).resolve()
out_dir = Path(args.out_dir).resolve()
manifest_path = Path(args.manifest).resolve()
payload = load_json(analytics_path)
signals = payload.get("signals", [])
if not isinstance(signals, list):
raise RuntimeError("analytics summary must contain an array at `signals`")
out_dir.mkdir(parents=True, exist_ok=True)
charts = []
for idx, signal in enumerate(signals):
if not isinstance(signal, dict):
continue
signal_id = str(signal.get("id") or f"signal_{idx + 1}")
slug = sanitize_slug(signal_id)
title = str(signal.get("title") or signal_id)
file_path = out_dir / f"{slug}.png"
rendered = render_signal_chart(signal, file_path)
if not rendered:
continue
charts.append(
{
"signal_id": signal_id,
"file_path": str(file_path),
"caption": f"{title} ({signal.get('metric') or 'metric'})",
}
)
manifest = {
"generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"source_file": str(analytics_path),
"chart_count": len(charts),
"charts": charts,
}
manifest_path.parent.mkdir(parents=True, exist_ok=True)
with manifest_path.open("w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2)
print(str(manifest_path))
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
FILE:scripts/openclaw-growth-engineer.mjs
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { classifyServiceKind, normalizeServiceType } from './openclaw-growth-shared.mjs';
const PRIORITY_WEIGHT = {
high: 3,
medium: 2,
low: 1,
};
const AREA_KEYWORDS = {
onboarding: ['onboarding', 'welcome', 'signup', 'register', 'tutorial', 'first_session'],
paywall: ['paywall', 'pricing', 'subscription', 'purchase', 'premium', 'trial', 'revenuecat'],
retention: ['retention', 'streak', 'come_back', 'session', 'habit', 'reminder', 'notification'],
conversion: ['checkout', 'purchase', 'billing', 'trial', 'subscribe', 'price'],
crash: ['error', 'exception', 'crash', 'stack', 'sentry', 'fatal'],
marketing: ['store', 'metadata', 'keyword', 'seo', 'landing', 'copy', 'conversion_copy'],
};
const DEFAULT_PROPOSALS = {
onboarding: [
'Move highest-friction step later in onboarding and reduce required fields in first session.',
'Add event-level instrumentation around each onboarding step to verify exact drop-off.',
'Ship one A/B variant with shorter copy and a clearer progression indicator.',
],
paywall: [
'Reposition paywall after first user value moment instead of at first launch.',
'Simplify price presentation and add one primary CTA with explicit trial wording.',
'Track `paywall:shown`, `purchase:started`, and terminal outcomes for each flow.',
],
retention: [
'Add one reactivation trigger before the identified retention cliff.',
'Instrument a cohort-specific funnel from first value action to repeat session.',
'Run an experiment on feature nudges tied to the top retained cohort behavior.',
],
conversion: [
'Reduce purchase flow steps and remove optional inputs before checkout.',
'Clarify plan differentiation and default to the strongest value package.',
'Track abandonment reasons at each step to drive follow-up fixes.',
],
crash: [
'Reproduce the issue with a deterministic test case and lock a failing assertion.',
'Harden null/undefined boundaries around failing callsites.',
'Add telemetry breadcrumbs to isolate the exact pre-crash state.',
],
marketing: [
'Update App Store/landing copy to align value proposition with strongest usage signal.',
'Refresh screenshot ordering so first two screenshots mirror top user jobs.',
'Run one metadata experiment focused on high-intent keyword coverage.',
],
general: [
'Instrument the target flow with bounded analytics to validate causality.',
'Ship the smallest possible fix behind a feature flag and compare cohorts.',
'Document rollout and success metric before implementation starts.',
],
};
const DEFAULT_IGNORE_DIRS = new Set([
'.git',
'.github',
'.docusaurus',
'node_modules',
'dist',
'build',
'.next',
'.turbo',
'coverage',
'.pnpm-store',
]);
const TEXT_FILE_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.py',
'.swift',
'.kt',
'.java',
'.go',
'.rb',
'.php',
'.yml',
'.yaml',
'.toml',
'.sql',
'.sh',
'.astro',
'.html',
'.css',
'.scss',
]);
function parseArgs(argv) {
const args = {
analytics: null,
revenuecat: null,
sentry: null,
feedback: null,
sources: [],
repoRoot: process.cwd(),
out: 'data/openclaw-growth-engineer/issues.generated.json',
maxIssues: 5,
createIssues: false,
createPullRequests: false,
repo: null,
titlePrefix: '[Growth]',
labels: ['ai-growth', 'autogenerated'],
codeRoots: null,
chartManifest: null,
uploadCharts: true,
branchPrefix: 'openclaw/proposals',
draftPullRequests: true,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
if (token === '--analytics') {
args.analytics = next;
i += 1;
}
else if (token === '--revenuecat') {
args.revenuecat = next;
i += 1;
}
else if (token === '--sentry') {
args.sentry = next;
i += 1;
}
else if (token === '--feedback') {
args.feedback = next;
i += 1;
}
else if (token === '--source') {
args.sources.push(next || '');
i += 1;
}
else if (token === '--repo-root') {
args.repoRoot = next;
i += 1;
}
else if (token === '--out') {
args.out = next;
i += 1;
}
else if (token === '--max-issues') {
args.maxIssues = Math.max(1, Number.parseInt(next, 10) || 5);
i += 1;
}
else if (token === '--create-issues') {
args.createIssues = true;
}
else if (token === '--create-pull-requests') {
args.createPullRequests = true;
}
else if (token === '--repo') {
args.repo = next;
i += 1;
}
else if (token === '--title-prefix') {
args.titlePrefix = next;
i += 1;
}
else if (token === '--labels') {
args.labels = (next || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
i += 1;
}
else if (token === '--code-roots') {
args.codeRoots = (next || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
i += 1;
}
else if (token === '--chart-manifest') {
args.chartManifest = next;
i += 1;
}
else if (token === '--no-upload-charts') {
args.uploadCharts = false;
}
else if (token === '--branch-prefix') {
args.branchPrefix = next || args.branchPrefix;
i += 1;
}
else if (token === '--no-draft-pull-requests') {
args.draftPullRequests = false;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
if (!args.analytics) {
printHelpAndExit(1, 'Missing required argument: --analytics <file>');
}
if (args.createIssues && args.createPullRequests) {
printHelpAndExit(1, 'Use either --create-issues or --create-pull-requests, not both.');
}
return args;
}
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
const help = `
OpenClaw Growth Engineer MVP
Usage:
node scripts/openclaw-growth-engineer.mjs --analytics <file> [options]
Required:
--analytics <file> Analytics summary JSON
Optional:
--revenuecat <file> RevenueCat summary JSON
--sentry <file> Sentry summary JSON
--feedback <file> User feedback summary JSON (optional)
--source <k=file> Additional connector summary JSON (repeatable)
--repo-root <dir> Repository root to scan (default: current directory)
--out <file> Output JSON with generated issue drafts
--max-issues <n> Max number of issues to generate (default: 5)
--create-issues Create issues directly on GitHub
--create-pull-requests Create draft implementation proposal pull requests on GitHub
--repo <owner/name> GitHub repository (required with GitHub creation mode)
--title-prefix <text> Title prefix (default: [Growth])
--labels <a,b,c> Comma-separated GitHub labels
--code-roots <a,b> Prioritized code roots (default: auto-detect apps,packages)
--chart-manifest <f> Optional chart manifest JSON (signal_id -> png path)
--no-upload-charts Skip uploading charts to GitHub repo during issue creation
--branch-prefix <p> Branch prefix for generated proposal PRs (default: openclaw/proposals)
--no-draft-pull-requests Create non-draft PRs when using --create-pull-requests
--help Show this help
Environment:
GITHUB_TOKEN GitHub token with issue or pull-request write permissions
`;
process.stdout.write(help);
process.exit(exitCode);
}
async function readJson(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
}
function normalizeSignals(payload, source, service = source) {
if (!payload || typeof payload !== 'object') {
return [];
}
const result = [];
const serviceKind = classifyServiceKind(service);
if (Array.isArray(payload.signals)) {
for (const signal of payload.signals) {
result.push({
source,
id: String(signal.id || `source_result.length + 1`),
title: String(signal.title || signal.summary || 'Untitled signal'),
area: String(signal.area || inferAreaFromText(signal.title || signal.summary || '')),
priority: String(signal.priority || 'medium').toLowerCase(),
evidence: toStringArray(signal.evidence),
suggestedActions: toStringArray(signal.suggested_actions || signal.suggestedActions),
keywords: toStringArray(signal.keywords),
metric: signal.metric ? String(signal.metric) : null,
deltaPercent: coerceNumber(signal.delta_percent ?? signal.deltaPercent),
currentValue: coerceNumber(signal.current_value ?? signal.currentValue),
baselineValue: coerceNumber(signal.baseline_value ?? signal.baselineValue),
confidence: signal.confidence ? String(signal.confidence) : null,
});
}
}
if (serviceKind === 'crash' && Array.isArray(payload.issues)) {
for (const issue of payload.issues) {
result.push({
source,
id: String(issue.id || `sentry_result.length + 1`),
title: String(issue.title || issue.issue || 'Untitled sentry issue'),
area: String(issue.area || 'crash'),
priority: String(issue.priority || issue.level || 'high').toLowerCase(),
evidence: toStringArray([
issue.impact ? `Impact: issue.impact` : null,
issue.events ? `Events: issue.events` : null,
issue.users ? `Affected users: issue.users` : null,
...(Array.isArray(issue.evidence) ? issue.evidence : []),
]),
suggestedActions: toStringArray(issue.suggested_actions || issue.suggestedActions),
keywords: toStringArray(issue.stack_keywords || issue.keywords),
metric: issue.metric ? String(issue.metric) : 'crash_rate',
deltaPercent: coerceNumber(issue.delta_percent ?? issue.deltaPercent),
currentValue: null,
baselineValue: null,
confidence: issue.confidence ? String(issue.confidence) : null,
});
}
}
if (serviceKind === 'feedback') {
const items = Array.isArray(payload.items)
? payload.items
: Array.isArray(payload.feedback)
? payload.feedback
: Array.isArray(payload.signals)
? payload.signals
: [];
for (const item of items) {
result.push({
source,
id: String(item.id || `feedback_result.length + 1`),
title: String(item.title || item.summary || item.request || 'User feedback signal'),
area: String(item.area || inferAreaFromText(item.title || item.summary || item.request || '')),
priority: String(item.priority || inferFeedbackPriority(item)).toLowerCase(),
evidence: toStringArray([
item.comment ? `Comment: item.comment` : null,
item.count ? `Mentions: item.count` : null,
item.channel ? `Channel: item.channel` : null,
...(Array.isArray(item.locations)
? item.locations.map((location) => {
if (!location || typeof location !== 'object')
return null;
const locationId = location.location_id ?? location.locationId ?? location.id ?? location.name ?? null;
const count = location.count ?? null;
if (!locationId)
return null;
return count ? `Location: locationId (count)` : `Location: locationId`;
})
: []),
item.location ? `Location: item.location` : null,
item.locationId ? `Location: item.locationId` : null,
...(Array.isArray(item.evidence) ? item.evidence : []),
]),
suggestedActions: toStringArray(item.suggested_actions || item.suggestedActions),
keywords: toStringArray(item.keywords || item.tags),
metric: item.metric ? String(item.metric) : 'feedback_mentions',
deltaPercent: coerceNumber(item.delta_percent ?? item.deltaPercent),
currentValue: coerceNumber(item.count ?? item.current_value ?? item.currentValue),
baselineValue: coerceNumber(item.baseline_count ?? item.baseline_value ?? item.baselineValue),
confidence: item.confidence ? String(item.confidence) : null,
});
}
}
return result;
}
function inferFeedbackPriority(item) {
const mentionCount = Number(item.count ?? 0);
const lower = `item.title ?? '' item.comment ?? ''`.toLowerCase();
if (mentionCount >= 25)
return 'high';
if (lower.includes('crash') || lower.includes('cannot') || lower.includes('blocked'))
return 'high';
if (mentionCount >= 10)
return 'medium';
return 'low';
}
function toStringArray(value) {
if (!value)
return [];
if (Array.isArray(value)) {
return value
.filter((item) => item !== undefined && item !== null)
.map((item) => String(item).trim())
.filter(Boolean);
}
const normalized = String(value).trim();
return normalized ? [normalized] : [];
}
function coerceNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function inferAreaFromText(text) {
const lower = text.toLowerCase();
for (const [area, keywords] of Object.entries(AREA_KEYWORDS)) {
if (keywords.some((keyword) => lower.includes(keyword))) {
return area;
}
}
return 'general';
}
async function collectRepoFiles(repoRoot) {
const files = [];
const root = path.resolve(repoRoot);
async function walk(current) {
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
const relativePath = path.relative(root, fullPath);
if (!relativePath || relativePath.startsWith('..')) {
continue;
}
if (entry.isDirectory()) {
if (DEFAULT_IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) {
continue;
}
await walk(fullPath);
}
else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (TEXT_FILE_EXTENSIONS.has(ext)) {
files.push(relativePath);
}
}
}
}
await walk(root);
return files;
}
async function readFileSnippet(repoRoot, relativePath, bytes = 20000) {
const fullPath = path.join(repoRoot, relativePath);
try {
const content = await fs.readFile(fullPath, 'utf8');
return content.slice(0, bytes);
}
catch {
return '';
}
}
async function findRelevantFiles(repoRoot, allFiles, signal, maxFiles = 5) {
const baseKeywords = new Set([
...(AREA_KEYWORDS[signal.area] || []),
...signal.keywords,
...tokenize(signal.title),
...(signal.metric ? tokenize(signal.metric) : []),
]);
const keywords = [...baseKeywords].filter((word) => word.length > 2);
const scored = [];
for (const file of allFiles) {
let score = pathScoreBias(file);
const lowerPath = file.toLowerCase();
for (const keyword of keywords) {
if (lowerPath.includes(keyword.toLowerCase())) {
score += 4;
}
}
if (score > 0) {
scored.push({ file, score });
continue;
}
const snippet = await readFileSnippet(repoRoot, file);
if (!snippet)
continue;
const lowerContent = snippet.toLowerCase();
for (const keyword of keywords) {
const re = new RegExp(`\\bescapeRegExp(keyword.toLowerCase())\\b`, 'g');
const matches = lowerContent.match(re);
if (matches) {
score += matches.length;
}
}
if (score > 0) {
scored.push({ file, score });
}
}
scored.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
return scored.slice(0, maxFiles).map((entry) => entry.file);
}
function pathScoreBias(relativePath) {
let score = 0;
if (relativePath.startsWith('apps/'))
score += 6;
if (relativePath.startsWith('packages/'))
score += 6;
if (relativePath.includes('/docs/') || relativePath.startsWith('apps/docs/'))
score -= 10;
if (relativePath.includes('/.github/') || relativePath.startsWith('.github/'))
score -= 10;
if (relativePath.startsWith('skills/') ||
relativePath.startsWith('docs/') ||
relativePath.startsWith('agent/') ||
relativePath.startsWith('data/')) {
score -= 8;
}
if (relativePath === 'scripts/openclaw-growth-engineer.mjs') {
score -= 12;
}
return score;
}
function pickCodeRoots(files, explicitRoots) {
if (explicitRoots && explicitRoots.length > 0) {
return explicitRoots;
}
const defaults = ['apps', 'packages'];
const available = defaults.filter((root) => files.some((file) => file.startsWith(`root/`)));
return available.length > 0 ? available : [];
}
function filterFilesByRoots(files, roots) {
if (!roots || roots.length === 0) {
return files;
}
return files.filter((file) => roots.some((root) => file.startsWith(`root/`)));
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function tokenize(text) {
return String(text)
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
function buildIssueDraft(signal, matchedFiles, titlePrefix) {
const title = `titlePrefix signal.title`.trim();
const proposals = signal.suggestedActions.length > 0
? signal.suggestedActions
: DEFAULT_PROPOSALS[signal.area] || DEFAULT_PROPOSALS.general;
const evidence = [...signal.evidence];
if (signal.metric && signal.currentValue !== null && signal.baselineValue !== null) {
evidence.push(`Metric \`signal.metric\`: current=signal.currentValue, baseline=signal.baselineValue`);
}
if (signal.deltaPercent !== null) {
evidence.push(`Delta: signal.deltaPercent%`);
}
if (evidence.length === 0) {
evidence.push('No explicit evidence provided in source payload.');
}
const expectedImpact = inferExpectedImpact(signal);
const confidence = signal.confidence || inferConfidence(signal);
const nextStepPrompt = buildPrPrompt(signal, matchedFiles, proposals);
const body = [
'## Problem',
signal.title,
'',
'## Evidence',
...evidence.map((line) => `- line`),
'',
'## Affected Files / Modules',
...(matchedFiles.length > 0
? matchedFiles.map((file) => `- \`file\``)
: ['- No high-confidence file match found. Start from flow owner modules.']),
'',
'## Proposed Implementation',
...proposals.map((line) => `- line`),
'',
'## Expected Impact',
expectedImpact,
'',
'## Confidence',
confidence,
'',
'## Optional Next-Step PR Prompt',
'```text',
nextStepPrompt,
'```',
].join('\n');
return {
source: signal.source,
signal_id: signal.id,
title,
body,
priority: signal.priority,
area: signal.area,
files: matchedFiles,
expected_impact: expectedImpact,
confidence,
};
}
function inferExpectedImpact(signal) {
if (signal.source === 'sentry' || signal.area === 'crash') {
return 'Reduce crash-driven funnel exits and recover blocked conversions.';
}
if (signal.area === 'marketing') {
return 'Increase top-of-funnel acquisition quality and store listing conversion.';
}
if (signal.area === 'paywall' || signal.area === 'conversion') {
return 'Increase trial start and paid conversion rates in primary monetization flow.';
}
if (signal.area === 'onboarding' || signal.area === 'retention') {
return 'Lift activation and short-term retention in first user sessions.';
}
return 'Improve product performance on the tracked KPI with measurable experimentation.';
}
function inferConfidence(signal) {
if (signal.priority === 'high' && signal.evidence.length >= 2) {
return 'High';
}
if (signal.evidence.length >= 1) {
return 'Medium';
}
return 'Low';
}
function buildPrPrompt(signal, files, proposals) {
const primaryFiles = files.length > 0 ? files.slice(0, 4).join(', ') : 'flow owner files to be identified';
const actions = proposals.slice(0, 3).map((item) => `- item`).join('\n');
return [
`Implement issue: signal.title.`,
`Focus area: signal.area.`,
`Likely files: primaryFiles.`,
'Requirements:',
actions,
'- Add or update tests for the changed behavior.',
'- Add analytics instrumentation to verify post-release impact.',
'- Keep changes scoped and production-safe.',
].join('\n');
}
function rankSignals(signals) {
return [...signals].sort((a, b) => {
const pa = PRIORITY_WEIGHT[a.priority] || 1;
const pb = PRIORITY_WEIGHT[b.priority] || 1;
if (pa !== pb)
return pb - pa;
const da = Math.abs(a.deltaPercent ?? 0);
const db = Math.abs(b.deltaPercent ?? 0);
if (da !== db)
return db - da;
return a.title.localeCompare(b.title);
});
}
function dedupeSignals(signals) {
const seen = new Set();
const result = [];
for (const signal of signals) {
const key = `signal.area:signal.title.toLowerCase()`;
if (seen.has(key))
continue;
seen.add(key);
result.push(signal);
}
return result;
}
function indexChartsBySignal(manifest, manifestFilePath) {
const bySignal = new Map();
if (!manifest || typeof manifest !== 'object') {
return bySignal;
}
const charts = Array.isArray(manifest.charts) ? manifest.charts : [];
for (const chart of charts) {
const signalId = String(chart.signal_id || chart.signalId || '').trim();
if (!signalId)
continue;
const filePathRaw = String(chart.file_path || chart.filePath || '').trim();
if (!filePathRaw)
continue;
const resolvedPath = path.isAbsolute(filePathRaw)
? filePathRaw
: manifestFilePath
? path.resolve(path.dirname(manifestFilePath), filePathRaw)
: path.resolve(filePathRaw);
const entry = {
signal_id: signalId,
file_path: resolvedPath,
caption: String(chart.caption || chart.title || 'Data chart'),
};
const existing = bySignal.get(signalId) || [];
existing.push(entry);
bySignal.set(signalId, existing);
}
return bySignal;
}
function appendChartsSection(body, charts, mode) {
if (!charts || charts.length === 0) {
return body;
}
const lines = ['', '## Data Chart'];
for (const chart of charts) {
if (mode === 'remote') {
lines.push(``);
}
else {
lines.push(`- chart.caption: \`chart.file_path\``);
}
}
return `body\nlines.join('\n')`;
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function stripDataChartSection(body) {
return body.replace(/\n## Data Chart[\s\S]*$/m, '');
}
function buildProposalMarkdown(draft, proposalPath) {
return [
`# draft.title`,
'',
'This proposal file was generated by OpenClaw growth autopilot.',
`It describes the requested change before implementation starts.`,
'',
`- Signal ID: \`draft.signal_id\``,
`- Source: \`draft.source\``,
`- Proposal path: \`proposalPath\``,
'',
stripDataChartSection(draft.body),
'',
].join('\n');
}
async function githubApiRequest(url, token, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer token`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': 'openclaw-growth-engineer-mvp',
...(options.headers || {}),
},
});
return response;
}
async function getRepoDefaultBranch(repo, token) {
const response = await githubApiRequest(`https://api.github.com/repos/repo`, token);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch repo metadata (response.status): text`);
}
const payload = await response.json();
return String(payload.default_branch || 'main');
}
async function getBranchRefSha(repo, token, branch) {
const response = await githubApiRequest(`https://api.github.com/repos/repo/git/ref/heads/encodeURIComponent(branch)`, token, { method: 'GET' });
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch branch ref (response.status): text`);
}
const payload = await response.json();
const sha = payload?.object?.sha;
if (!sha || typeof sha !== 'string') {
throw new Error(`Branch ref for branch did not contain a commit SHA.`);
}
return sha;
}
async function createBranchRef(repo, token, branch, sha) {
const response = await githubApiRequest(`https://api.github.com/repos/repo/git/refs`, token, {
method: 'POST',
body: JSON.stringify({
ref: `refs/heads/branch`,
sha,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to create branch branch (response.status): text`);
}
}
async function getContentSha(repo, token, branch, targetPath) {
const encoded = targetPath.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await githubApiRequest(`https://api.github.com/repos/repo/contents/encoded?ref=encodeURIComponent(branch)`, token, { method: 'GET' });
if (response.status === 404) {
return null;
}
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to fetch content SHA (response.status): text`);
}
const payload = await response.json();
return payload.sha ? String(payload.sha) : null;
}
async function putRepoTextFile({ repo, token, branch, targetPath, content, message }) {
const contentBase64 = Buffer.from(content, 'utf8').toString('base64');
const existingSha = await getContentSha(repo, token, branch, targetPath);
const encodedPath = targetPath.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await githubApiRequest(`https://api.github.com/repos/repo/contents/encodedPath`, token, {
method: 'PUT',
body: JSON.stringify({
message,
content: contentBase64,
branch,
...(existingSha ? { sha: existingSha } : {}),
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to write proposal file (response.status): text`);
}
}
async function uploadFileToRepo({ repo, token, branch, targetPath, sourcePath }) {
const fileBytes = await fs.readFile(sourcePath);
const contentBase64 = fileBytes.toString('base64');
const existingSha = await getContentSha(repo, token, branch, targetPath);
const body = {
message: `chore(openclaw): add chart asset path.basename(targetPath)`,
content: contentBase64,
branch,
};
if (existingSha) {
body.sha = existingSha;
}
const encodedPath = targetPath.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await githubApiRequest(`https://api.github.com/repos/repo/contents/encodedPath`, token, {
method: 'PUT',
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to upload chart (response.status): text`);
}
return `https://raw.githubusercontent.com/repo/branch/targetPath`;
}
async function createGithubIssue({ repo, title, body, labels, token }) {
const response = await fetch(`https://api.github.com/repos/repo/issues`, {
method: 'POST',
headers: {
Authorization: `Bearer token`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': 'openclaw-growth-engineer-mvp',
},
body: JSON.stringify({
title,
body,
labels,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub issue creation failed (response.status): text`);
}
return response.json();
}
async function createGithubPullRequest({ repo, title, body, head, base, draft, token }) {
const response = await githubApiRequest(`https://api.github.com/repos/repo/pulls`, token, {
method: 'POST',
body: JSON.stringify({
title,
body,
head,
base,
draft,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub pull request creation failed (response.status): text`);
}
return response.json();
}
async function addLabelsToGithubIssue({ repo, number, labels, token }) {
if (!labels || labels.length === 0) {
return;
}
const response = await githubApiRequest(`https://api.github.com/repos/repo/issues/number/labels`, token, {
method: 'POST',
body: JSON.stringify({
labels,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub label apply failed (response.status): text`);
}
}
async function ensureParentDir(filePath) {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
}
async function createProposalPullRequest({ repo, token, draft, labels, branchPrefix, draftPullRequests, }) {
const dateSegment = new Date().toISOString().slice(0, 10);
const slug = slugify(draft.signal_id || draft.title || `proposal-Date.now()`) || `proposal-Date.now()`;
const baseBranch = await getRepoDefaultBranch(repo, token);
const baseSha = await getBranchRefSha(repo, token, baseBranch);
const branchName = `String(branchPrefix || 'openclaw/proposals').replace(/\/+$/g, '')/dateSegment/slug-Date.now().toString(36)`;
await createBranchRef(repo, token, branchName, baseSha);
const proposalPath = `.openclaw/proposals/dateSegment/slug.md`;
const proposalMarkdown = buildProposalMarkdown(draft, proposalPath);
await putRepoTextFile({
repo,
token,
branch: branchName,
targetPath: proposalPath,
content: proposalMarkdown,
message: `docs(openclaw): add proposal for draft.title`,
});
const prBody = [
'## Why this PR exists',
'This draft PR was generated automatically from product signals and repo context.',
'It adds a proposal file that describes the requested implementation before code changes begin.',
'',
'## Requested change',
`- Proposal file: \`proposalPath\``,
`- Source connector: \`draft.source\``,
`- Confidence: draft.confidence`,
'',
'## Suggested implementation scope',
...(draft.files.length > 0
? draft.files.map((file) => `- \`file\``)
: ['- No high-confidence file match yet. Start from the owning flow modules.']),
'',
'## Next step',
'Implement the change on top of this branch, keep the proposal file for traceability, and replace it with production code changes in the same PR.',
].join('\n');
const pr = await createGithubPullRequest({
repo,
token,
title: draft.title,
body: prBody,
head: branchName,
base: baseBranch,
draft: draftPullRequests,
});
await addLabelsToGithubIssue({
repo,
number: pr.number,
labels,
token,
});
return {
title: pr.title,
number: pr.number,
url: pr.html_url,
branch: branchName,
proposalPath,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(args.repoRoot);
const outputPath = path.resolve(args.out);
const analytics = await readJson(path.resolve(args.analytics));
const revenuecat = args.revenuecat ? await readJson(path.resolve(args.revenuecat)) : null;
const sentry = args.sentry ? await readJson(path.resolve(args.sentry)) : null;
const feedback = args.feedback ? await readJson(path.resolve(args.feedback)) : null;
const extraSources = [];
for (const entry of args.sources) {
const [rawKey, rawFilePath] = String(entry || '').split('=');
const service = normalizeServiceType(rawKey || '');
const key = service.replace(/-/g, '_');
const filePath = String(rawFilePath || '').trim();
if (!key || !filePath) {
throw new Error(`Invalid --source value: entry. Expected <key>=<file>.`);
}
extraSources.push({
key,
service,
payload: await readJson(path.resolve(filePath)),
});
}
const chartManifestPath = args.chartManifest ? path.resolve(args.chartManifest) : null;
const chartManifest = chartManifestPath ? await readJson(chartManifestPath) : null;
const chartsBySignal = indexChartsBySignal(chartManifest, chartManifestPath);
const signals = dedupeSignals(rankSignals([
...normalizeSignals(analytics, 'analytics', 'analytics'),
...normalizeSignals(revenuecat, 'revenuecat', 'revenuecat'),
...normalizeSignals(sentry, 'sentry', 'sentry'),
...normalizeSignals(feedback, 'feedback', 'feedback'),
...extraSources.flatMap((source) => normalizeSignals(source.payload, source.key, source.service)),
])).slice(0, args.maxIssues);
if (signals.length === 0) {
throw new Error('No signals found. Check input JSON shape (expected: signals[] or sentry issues[]).');
}
const files = await collectRepoFiles(repoRoot);
const codeRoots = pickCodeRoots(files, args.codeRoots);
const scopedFiles = filterFilesByRoots(files, codeRoots);
const issueDrafts = [];
for (const signal of signals) {
const matchedFiles = await findRelevantFiles(repoRoot, scopedFiles, signal, 6);
const draft = buildIssueDraft(signal, matchedFiles, args.titlePrefix);
const localCharts = chartsBySignal.get(draft.signal_id) || [];
if (localCharts.length > 0) {
draft.body = appendChartsSection(draft.body, localCharts, 'local');
draft.charts = localCharts;
}
issueDrafts.push(draft);
}
const output = {
generated_at: new Date().toISOString(),
repo_root: repoRoot,
issue_count: issueDrafts.length,
issues: issueDrafts,
};
await ensureParentDir(outputPath);
await fs.writeFile(outputPath, JSON.stringify(output, null, 2), 'utf8');
process.stdout.write(`Generated issueDrafts.length issue draft(s): outputPath\n`);
if (!args.createIssues && !args.createPullRequests) {
process.stdout.write('Dry run only. Re-run with --create-issues or --create-pull-requests --repo <owner/name> to create GitHub artifacts.\n');
return;
}
if (!args.repo) {
throw new Error('Missing --repo <owner/name> while using GitHub creation mode.');
}
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('Missing GITHUB_TOKEN environment variable.');
}
const defaultBranch = args.createIssues && args.uploadCharts ? await getRepoDefaultBranch(args.repo, token) : null;
const created = [];
if (args.createIssues) {
for (const draft of issueDrafts) {
let body = draft.body;
if (args.uploadCharts && defaultBranch) {
const localCharts = chartsBySignal.get(draft.signal_id) || [];
if (localCharts.length > 0) {
const remoteCharts = [];
for (const [index, chart] of localCharts.entries()) {
try {
const extension = path.extname(chart.file_path) || '.png';
const chartTargetPath = `.openclaw/charts/new Date().toISOString().slice(0, 10)/draft.signal_id_index + 1extension`;
const url = await uploadFileToRepo({
repo: args.repo,
token,
branch: defaultBranch,
targetPath: chartTargetPath,
sourcePath: chart.file_path,
});
remoteCharts.push({ caption: chart.caption, url });
}
catch (error) {
process.stderr.write(`Chart upload failed for chart.file_path: String(error)\n`);
}
}
if (remoteCharts.length > 0) {
body = appendChartsSection(stripDataChartSection(body), remoteCharts, 'remote');
}
}
}
const issue = await createGithubIssue({
repo: args.repo,
title: draft.title,
body,
labels: args.labels,
token,
});
created.push({
type: 'issue',
title: issue.title,
number: issue.number,
url: issue.html_url,
});
process.stdout.write(`Created issue #issue.number: issue.html_url\n`);
}
}
else {
for (const draft of issueDrafts) {
const pr = await createProposalPullRequest({
repo: args.repo,
token,
draft,
labels: args.labels,
branchPrefix: args.branchPrefix,
draftPullRequests: args.draftPullRequests,
});
created.push({
type: 'pull_request',
...pr,
});
process.stdout.write(`Created pull request #pr.number: pr.url\n`);
}
}
const createdPath = outputPath.replace(/\.json$/i, '.created.json');
await fs.writeFile(createdPath, JSON.stringify({
generated_at: new Date().toISOString(),
repo: args.repo,
mode: args.createPullRequests ? 'pull_request' : 'issue',
created,
}, null, 2), 'utf8');
process.stdout.write(`Saved created issue metadata to createdPath\n`);
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/openclaw-growth-preflight.mjs
#!/usr/bin/env node
import { existsSync, promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { classifyServiceKind, getActionMode, getAllSourceEntries, getDefaultSourceCommand, getGitHubActionNoun, getGitHubConnectionSummary, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
const ANALYTICSCLI_PACKAGE_SPEC = process.env.ANALYTICSCLI_CLI_PACKAGE || '@analyticscli/cli@preview';
const ANALYTICSCLI_NPM_PREFIX = process.env.ANALYTICSCLI_NPM_PREFIX ||
(process.env.HOME ? path.join(process.env.HOME, '.local') : path.join(process.cwd(), '.analyticscli-npm'));
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
OpenClaw Growth Preflight
Validates local dependencies, configured sources, and required secrets.
Usage:
node scripts/openclaw-growth-preflight.mjs [options]
Options:
--config <file> Config path (default: DEFAULT_CONFIG_PATH)
--test-connections Run live API/connector smoke checks for enabled channels
--timeout-ms <ms> Connection test timeout in milliseconds (default: DEFAULT_CONNECTION_TIMEOUT_MS)
--json Print JSON only (default)
--help, -h Show help
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = {
config: DEFAULT_CONFIG_PATH,
json: true,
testConnections: false,
timeoutMs: DEFAULT_CONNECTION_TIMEOUT_MS,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
else if (token === '--config') {
args.config = next || args.config;
i += 1;
}
else if (token === '--test-connections') {
args.testConnections = true;
}
else if (token === '--timeout-ms') {
const parsed = Number.parseInt(String(next || ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
printHelpAndExit(1, `Invalid value for --timeout-ms: String(next || '')`);
}
args.timeoutMs = parsed;
i += 1;
}
else if (token === '--json') {
args.json = true;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function shellQuote(value) {
if (/^[a-zA-Z0-9_./:-]+$/.test(String(value))) {
return String(value);
}
return `'String(value).replace(/'/g, `'\\''`)'`;
}
function resolveShellCommand() {
const candidates = [
process.env.OPENCLAW_SHELL,
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
'/usr/bin/bash',
'/bin/sh',
'/usr/bin/sh',
].filter((value) => typeof value === 'string' && value.trim().length > 0);
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return 'sh';
}
function runShell(command) {
return new Promise((resolve) => {
const child = spawn(resolveShellCommand(), ['-c', command], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('close', (code) => {
resolve({
ok: code === 0,
code,
stdout,
stderr,
});
});
});
}
async function commandExists(commandName) {
const result = await runShell(`command -v shellQuote(commandName) >/dev/null 2>&1`);
return result.ok;
}
async function resolveCommandPath(commandName) {
const result = await runShell(`command -v shellQuote(commandName)`);
return result.ok ? result.stdout.trim() : null;
}
function prependToPath(binDir) {
process.env.PATH = `binDirpath.delimiterprocess.env.PATH || ''`;
}
function isPermissionFailure(output) {
return /EACCES|permission denied|access denied|operation not permitted/i.test(String(output || ''));
}
async function ensureAnalyticsCliInstalled() {
const beforePath = await resolveCommandPath('analyticscli');
const npmExists = await commandExists('npm');
if (!npmExists) {
return beforePath
? {
ok: true,
detail: `analyticscli binary found at beforePath; npm unavailable, so package update was skipped`,
}
: {
ok: false,
detail: `analyticscli binary missing and npm is unavailable; install ANALYTICSCLI_PACKAGE_SPEC`,
};
}
const globalInstall = await runShell(`npm install -g shellQuote(ANALYTICSCLI_PACKAGE_SPEC)`);
if (!globalInstall.ok) {
const installOutput = `globalInstall.stderr\nglobalInstall.stdout`;
if (isPermissionFailure(installOutput)) {
await fs.mkdir(ANALYTICSCLI_NPM_PREFIX, { recursive: true });
const localInstall = await runShell(`npm install -g --prefix shellQuote(ANALYTICSCLI_NPM_PREFIX) shellQuote(ANALYTICSCLI_PACKAGE_SPEC)`);
if (!localInstall.ok) {
return beforePath
? {
ok: true,
detail: `analyticscli binary found at beforePath; update failed globally and in user-local prefix (truncate(localInstall.stderr || localInstall.stdout))`,
}
: {
ok: false,
detail: `npm install failed globally and in user-local prefix ANALYTICSCLI_NPM_PREFIX: truncate(localInstall.stderr || localInstall.stdout)`,
};
}
prependToPath(path.join(ANALYTICSCLI_NPM_PREFIX, 'bin'));
}
else {
return beforePath
? {
ok: true,
detail: `analyticscli binary found at beforePath; package update failed (truncate(installOutput))`,
}
: {
ok: false,
detail: `npm install -g ANALYTICSCLI_PACKAGE_SPEC failed: truncate(installOutput)`,
};
}
}
const afterPath = await resolveCommandPath('analyticscli');
return afterPath
? {
ok: true,
detail: `analyticscli package ensured via ANALYTICSCLI_PACKAGE_SPEC; binary found at afterPath`,
}
: {
ok: false,
detail: `Installed ANALYTICSCLI_PACKAGE_SPEC, but analyticscli is still not on PATH`,
};
}
function parseCommandHead(command) {
if (!command || typeof command !== 'string')
return null;
const trimmed = command.trim();
if (!trimmed)
return null;
const parts = trimmed.split(/\s+/).filter(Boolean);
return parts.length > 0 ? parts[0] : null;
}
function isPortableCommandDefault(sourceName, command) {
const expected = getDefaultSourceCommand(sourceName);
if (!expected)
return false;
return String(command || '').trim().startsWith(expected);
}
function truncate(value, max = 240) {
const text = String(value || '');
if (text.length <= max)
return text;
return `text.slice(0, max)…`;
}
async function readJson(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
}
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
}
catch {
return false;
}
}
function addCheck(checks, name, ok, detail, severity = 'fail') {
checks.push({
name,
status: ok ? 'pass' : severity,
detail,
});
}
function getSecretName(config, key, fallback) {
const value = config?.secrets?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function sourceEnabled(config, sourceName) {
return Boolean(config?.sources?.[sourceName] && config.sources[sourceName].enabled !== false);
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
const body = await response.text();
return { ok: response.ok, status: response.status, body };
}
finally {
clearTimeout(timer);
}
}
async function testAnalyticsConnection(analyticsToken) {
const hasCli = await commandExists('analyticscli');
if (!hasCli) {
return {
ok: false,
detail: 'analyticscli binary missing',
};
}
const command = analyticsToken
? `analyticscli --access-token shellQuote(analyticsToken) projects list`
: 'analyticscli projects list';
const result = await runShell(command);
if (!result.ok) {
return {
ok: false,
detail: truncate(result.stderr || `exit result.code`),
};
}
return {
ok: true,
detail: analyticsToken
? 'analyticscli token auth check passed (`projects list`)'
: 'analyticscli auth check passed (`projects list`)',
};
}
async function testRevenueCatConnection(revenuecatToken, timeoutMs) {
if (!revenuecatToken) {
return {
ok: false,
detail: 'missing token',
};
}
try {
const response = await fetchWithTimeout('https://api.revenuecat.com/v2/projects?limit=1', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer revenuecatToken`,
},
}, timeoutMs);
if (!response.ok) {
return {
ok: false,
detail: `HTTP response.status: truncate(response.body)`,
};
}
return {
ok: true,
detail: `HTTP response.status`,
};
}
catch (error) {
return {
ok: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}
function describeAnalyticsConnectionFailure(detail, analyticsTokenEnv, hasAnalyticsToken) {
if (!hasAnalyticsToken) {
return `Nearly done: I only need \`analyticsTokenEnv\` from you to continue setup. Create or copy an AnalyticsCLI access token in dash.analyticscli.com -> API Keys, then run \`analyticscli --api-url https://api.analyticscli.com login --access-token <access_token>\` or set \`analyticsTokenEnv\`. Raw error: detail`;
}
return `AnalyticsCLI connection failed with \`analyticsTokenEnv\` set. Verify the token, selected project, and \`ANALYTICSCLI_API_URL=https://api.analyticscli.com\`. Raw error: detail`;
}
async function testSentryConnection(sentryToken, timeoutMs) {
if (!sentryToken) {
return {
ok: false,
detail: 'missing token',
};
}
try {
const response = await fetchWithTimeout('https://sentry.io/api/0/organizations/', {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer sentryToken`,
},
}, timeoutMs);
if (!response.ok) {
return {
ok: false,
detail: `HTTP response.status: truncate(response.body)`,
};
}
return {
ok: true,
detail: `HTTP response.status`,
};
}
catch (error) {
return {
ok: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}
async function testGitHubConnection(githubToken, githubRepo, timeoutMs, actionMode) {
if (!githubToken) {
return {
ok: false,
detail: 'missing token',
};
}
try {
const response = await fetchWithTimeout('https://api.github.com/user', {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer githubToken`,
},
}, timeoutMs);
if (!response.ok) {
return {
ok: false,
detail: `HTTP response.status: truncate(response.body)`,
};
}
const repo = String(githubRepo || '').trim();
if (!repo) {
return {
ok: false,
detail: 'project.githubRepo is missing',
};
}
const repoResponse = await fetchWithTimeout(`https://api.github.com/repos/repo`, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer githubToken`,
},
}, timeoutMs);
if (!repoResponse.ok) {
return {
ok: false,
detail: `repo access check failed (HTTP repoResponse.status: truncate(repoResponse.body))`,
};
}
const artifactPath = actionMode === 'pull_request'
? `pulls?state=all&per_page=1`
: `issues?state=all&per_page=1`;
const artifactsResponse = await fetchWithTimeout(`https://api.github.com/repos/repo/artifactPath`, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer githubToken`,
},
}, timeoutMs);
if (!artifactsResponse.ok) {
return {
ok: false,
detail: `getGitHubActionNoun(actionMode) API check failed (HTTP artifactsResponse.status: truncate(artifactsResponse.body))`,
};
}
return {
ok: true,
detail: `getGitHubConnectionSummary(actionMode) (getGitHubRequirementText(actionMode))`,
};
}
catch (error) {
return {
ok: false,
detail: error instanceof Error ? error.message : String(error),
};
}
}
async function testCommandSourceJson(command) {
const result = await runShell(command);
if (!result.ok) {
return {
ok: false,
detail: truncate(result.stderr || `exit result.code`),
};
}
try {
JSON.parse(result.stdout);
}
catch {
return {
ok: false,
detail: 'command succeeded but returned non-JSON output',
};
}
return {
ok: true,
detail: 'command returned JSON',
};
}
async function runConnectionChecks({ checks, config, timeoutMs }) {
const analyticsTokenEnv = getSecretName(config, 'analyticsTokenEnv', 'ANALYTICSCLI_ACCESS_TOKEN');
const revenuecatTokenEnv = getSecretName(config, 'revenuecatTokenEnv', 'REVENUECAT_API_KEY');
const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
const feedbackTokenEnv = getSecretName(config, 'feedbackTokenEnv', 'FEEDBACK_API_TOKEN');
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
const githubRepo = String(config?.project?.githubRepo || '').trim();
const actionMode = getActionMode(config);
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
const analyticsSource = config.sources?.analytics;
if (sourceEnabled(config, 'analytics')) {
const analyticsToken = process.env[analyticsTokenEnv] || process.env.ANALYTICSCLI_ACCESS_TOKEN || '';
const hasAnalyticsToken = Boolean(analyticsToken);
const analyticsConnection = await testAnalyticsConnection(analyticsToken);
addCheck(checks, 'connection:analytics', analyticsConnection.ok, analyticsConnection.ok
? analyticsConnection.detail
: describeAnalyticsConnectionFailure(analyticsConnection.detail, analyticsTokenEnv, hasAnalyticsToken), analyticsConnection.ok ? 'pass' : analyticsSource?.mode === 'command' ? 'fail' : 'warn');
if (analyticsSource?.mode === 'command') {
const command = String(analyticsSource.command || '').trim();
if (!command) {
addCheck(checks, 'connection:analytics-command', false, 'analytics source uses command mode but no command configured');
}
else {
const commandCheck = await testCommandSourceJson(command);
addCheck(checks, 'connection:analytics-command', commandCheck.ok, commandCheck.ok
? 'analytics command smoke test passed'
: `analytics command smoke test failed (commandCheck.detail)`);
}
}
}
else {
addCheck(checks, 'connection:analytics', true, 'source disabled');
}
const revenuecatSource = config.sources?.revenuecat;
if (sourceEnabled(config, 'revenuecat')) {
const token = process.env[revenuecatTokenEnv] || '';
if (!token) {
addCheck(checks, `connection:revenuecat`, false, `revenuecatTokenEnv missing (required for live RevenueCat API test)`, revenuecatSource?.mode === 'command' ? 'fail' : 'warn');
}
else {
const revenuecatConnection = await testRevenueCatConnection(token, timeoutMs);
addCheck(checks, 'connection:revenuecat', revenuecatConnection.ok, revenuecatConnection.ok
? `RevenueCat auth check passed (revenuecatConnection.detail)`
: `RevenueCat auth check failed (revenuecatConnection.detail)`);
}
}
else {
addCheck(checks, 'connection:revenuecat', true, 'source disabled');
}
const sentrySource = config.sources?.sentry;
if (sourceEnabled(config, 'sentry')) {
const token = process.env[sentryTokenEnv] || '';
if (!token) {
addCheck(checks, `connection:sentry`, false, `sentryTokenEnv missing (required for live Sentry API test)`, sentrySource?.mode === 'command' ? 'fail' : 'warn');
}
else {
const sentryConnection = await testSentryConnection(token, timeoutMs);
addCheck(checks, 'connection:sentry', sentryConnection.ok, sentryConnection.ok
? `Sentry auth check passed (sentryConnection.detail)`
: `Sentry auth check failed (sentryConnection.detail)`);
}
}
else {
addCheck(checks, 'connection:sentry', true, 'source disabled');
}
const feedbackSource = config.sources?.feedback;
if (sourceEnabled(config, 'feedback') && feedbackSource?.mode === 'command') {
const command = String(feedbackSource.command || '').trim();
if (!command) {
addCheck(checks, 'connection:feedback', false, 'feedback source uses command mode but no command configured');
}
else {
const feedbackConnection = await testCommandSourceJson(command);
addCheck(checks, 'connection:feedback', feedbackConnection.ok, feedbackConnection.ok
? 'Feedback command smoke test passed'
: `Feedback command smoke test failed (feedbackConnection.detail)`);
}
}
else if (sourceEnabled(config, 'feedback')) {
if (process.env[feedbackTokenEnv]) {
addCheck(checks, 'connection:feedback', true, 'source in file mode; FEEDBACK_API_TOKEN is present');
}
else {
addCheck(checks, 'connection:feedback', true, 'source in file mode (no direct API smoke test required)');
}
}
else {
addCheck(checks, 'connection:feedback', true, 'source disabled');
}
for (const extraSource of getAllSourceEntries(config).filter((source) => !source.builtIn)) {
const serviceKind = classifyServiceKind(extraSource.service || extraSource.key);
const checkName = `connection:extraSource.key`;
if (extraSource.enabled === false) {
addCheck(checks, checkName, true, 'source disabled');
continue;
}
if (extraSource.mode === 'command') {
const command = String(extraSource.command || '').trim();
if (!command) {
addCheck(checks, checkName, false, 'source uses command mode but no command configured');
continue;
}
const commandCheck = await testCommandSourceJson(command);
addCheck(checks, checkName, commandCheck.ok, commandCheck.ok
? `extraSource.key command smoke test passed`
: `extraSource.key command smoke test failed (commandCheck.detail)`);
continue;
}
if (extraSource.secretEnv) {
const hasSecret = Boolean(process.env[extraSource.secretEnv]);
addCheck(checks, checkName, hasSecret || serviceKind === 'feedback', hasSecret
? `extraSource.secretEnv set`
: serviceKind === 'feedback'
? 'file mode without direct API test'
: `extraSource.secretEnv not set (required for this extra connector)`, hasSecret || serviceKind === 'feedback' ? 'pass' : 'warn');
continue;
}
addCheck(checks, checkName, true, 'file mode (no live API smoke test configured)');
}
const githubToken = process.env[githubTokenEnv] || '';
const githubCheckName = actionMode === 'pull_request' ? 'connection:github-pull-requests' : 'connection:github';
if (!requiresGitHubDelivery) {
addCheck(checks, githubCheckName, true, 'skipped because GitHub artifact creation is disabled');
}
else if (!githubToken) {
addCheck(checks, githubCheckName, false, `githubTokenEnv missing (required; getGitHubRequirementText(actionMode))`);
}
else {
const githubConnection = await testGitHubConnection(githubToken, githubRepo, timeoutMs, actionMode);
addCheck(checks, githubCheckName, githubConnection.ok, githubConnection.ok
? `GitHub auth check passed (githubConnection.detail)`
: `GitHub auth check failed (githubConnection.detail)`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config);
const checks = [];
addCheck(checks, 'node-runtime', true, `Node process.version`);
let config = null;
try {
config = await readJson(configPath);
addCheck(checks, 'config-file', true, `Loaded configPath`);
}
catch (error) {
addCheck(checks, 'config-file', false, `Could not read config at configPath: String(error)`);
}
if (config) {
const actionMode = getActionMode(config);
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
const analyticsEnabled = sourceEnabled(config, 'analytics');
addCheck(checks, 'source:analytics:required', analyticsEnabled, analyticsEnabled ? 'enabled' : 'analytics source is required and cannot be disabled');
const analyticscliEnsure = await ensureAnalyticsCliInstalled();
addCheck(checks, 'dependency:analyticscli', analyticscliEnsure.ok, analyticscliEnsure.detail);
const githubRepo = String(config.project?.githubRepo || '').trim();
addCheck(checks, 'project:github-repo', Boolean(githubRepo) || !requiresGitHubDelivery, githubRepo
? `configured (githubRepo)`
: requiresGitHubDelivery
? 'project.githubRepo is required'
: 'not configured (optional when GitHub delivery is disabled)', requiresGitHubDelivery ? 'fail' : 'warn');
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
const hasGithubToken = Boolean(process.env[githubTokenEnv]);
addCheck(checks, `secret:githubTokenEnv`, hasGithubToken || !requiresGitHubDelivery, hasGithubToken
? requiresGitHubDelivery
? `set (required; getGitHubRequirementText(actionMode))`
: 'set (optional when GitHub delivery is disabled)'
: requiresGitHubDelivery
? `missing (required; getGitHubRequirementText(actionMode))`
: 'optional when GitHub delivery is disabled', requiresGitHubDelivery ? 'fail' : 'warn');
for (const source of getAllSourceEntries(config)) {
const sourceName = source.key;
if (!source || source.enabled === false) {
addCheck(checks, `source:sourceName`, sourceName !== 'analytics', sourceName === 'analytics' ? 'disabled (not allowed)' : 'disabled');
continue;
}
if (source.mode === 'file') {
const sourcePath = source.path ? path.resolve(String(source.path)) : null;
if (!sourcePath) {
addCheck(checks, `source:sourceName:file`, false, 'mode=file but no path configured');
continue;
}
try {
await fs.access(sourcePath);
addCheck(checks, `source:sourceName:file`, true, `Found sourcePath`);
}
catch {
addCheck(checks, `source:sourceName:file`, false, `Missing file sourcePath`);
}
continue;
}
if (source.mode === 'command') {
const command = String(source.command || '').trim();
if (!command) {
addCheck(checks, `source:sourceName:command`, false, 'mode=command but no command configured');
continue;
}
const usesPortableDefault = isPortableCommandDefault(sourceName, command);
addCheck(checks, `source:sourceName:mode`, usesPortableDefault, usesPortableDefault
? 'mode=command configured with built-in portable exporter'
: 'mode=command configured (allowed, but file mode is the recommended default)', 'warn');
const head = parseCommandHead(command);
if (!head) {
addCheck(checks, `source:sourceName:command`, false, 'Could not parse command head');
continue;
}
const exists = await commandExists(head);
addCheck(checks, `source:sourceName:command-head`, exists, exists ? `Found command head: head` : `Missing command head: head`);
if (sourceName === 'revenuecat') {
const revenuecatTokenEnv = getSecretName(config, 'revenuecatTokenEnv', 'REVENUECAT_API_KEY');
const hasRevenuecatToken = Boolean(process.env[revenuecatTokenEnv]);
addCheck(checks, `secret:revenuecatTokenEnv`, hasRevenuecatToken, hasRevenuecatToken ? 'set (required for RevenueCat command mode)' : 'missing (required for RevenueCat command mode)');
}
if (sourceName === 'sentry') {
const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
const hasSentryToken = Boolean(process.env[sentryTokenEnv]);
addCheck(checks, `secret:sentryTokenEnv`, hasSentryToken, hasSentryToken ? 'set (required for Sentry command mode)' : 'missing (required for Sentry command mode)');
}
if (!source.builtIn && source.secretEnv) {
const hasConnectorToken = Boolean(process.env[source.secretEnv]);
addCheck(checks, `secret:source.secretEnv`, hasConnectorToken, hasConnectorToken
? `set (required for sourceName command mode)`
: `missing (required for sourceName command mode)`);
}
continue;
}
addCheck(checks, `source:sourceName`, false, `Unsupported source mode: String(source.mode || 'undefined')`);
}
addCheck(checks, actionMode === 'pull_request' ? 'github-pull-request-create' : 'github-issue-create', actionMode === 'pull_request'
? config.actions?.autoCreatePullRequests === true
: config.actions?.autoCreateIssues === true, actionMode === 'pull_request'
? config.actions?.autoCreatePullRequests === true
? 'enabled'
: 'disabled by default (drafts only; enable explicitly to create GitHub artifacts)'
: config.actions?.autoCreateIssues === true
? 'enabled'
: 'disabled by default (drafts only; enable explicitly to create GitHub artifacts)', (actionMode === 'pull_request'
? config.actions?.autoCreatePullRequests === true
: config.actions?.autoCreateIssues === true)
? 'pass'
: 'warn');
if (config.charting?.enabled) {
const pythonExists = await commandExists('python3');
addCheck(checks, 'dependency:python3', pythonExists, pythonExists ? 'python3 found' : 'python3 missing');
if (pythonExists) {
const matplotlibCheck = await runShell("python3 -c 'import matplotlib'");
addCheck(checks, 'dependency:matplotlib', matplotlibCheck.ok, matplotlibCheck.ok ? 'matplotlib import ok' : 'matplotlib missing (install with: python3 -m pip install matplotlib)');
}
}
else {
addCheck(checks, 'charting', true, 'disabled');
}
if (sourceEnabled(config, 'analytics') && config.sources?.analytics?.mode === 'command') {
const analyticsTokenEnv = getSecretName(config, 'analyticsTokenEnv', 'ANALYTICSCLI_ACCESS_TOKEN');
const hasAnalyticsToken = Boolean(process.env[analyticsTokenEnv] || process.env.ANALYTICSCLI_ACCESS_TOKEN);
addCheck(checks, `secret:analyticsTokenEnv`, hasAnalyticsToken, hasAnalyticsToken
? 'set (optional if analyticscli uses stored login)'
: `nearly done: set analyticsTokenEnv or run analyticscli login --access-token <access_token>`, hasAnalyticsToken ? 'pass' : 'warn');
}
if (args.testConnections) {
await runConnectionChecks({
checks,
config,
timeoutMs: args.timeoutMs,
});
}
}
const failCount = checks.filter((check) => check.status === 'fail').length;
const warnCount = checks.filter((check) => check.status === 'warn').length;
const passCount = checks.filter((check) => check.status === 'pass').length;
const result = {
ok: failCount === 0,
summary: {
pass: passCount,
warn: warnCount,
fail: failCount,
},
checks,
};
process.stdout.write(`JSON.stringify(result, null, 2)\n`);
if (!result.ok) {
process.exitCode = 1;
}
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/openclaw-growth-runner.mjs
#!/usr/bin/env node
import { existsSync, promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { getActionMode, getAllSourceEntries, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
function parseArgs(argv) {
const args = {
config: DEFAULT_CONFIG_PATH,
state: DEFAULT_STATE_PATH,
loop: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
else if (token === '--config') {
args.config = next;
i += 1;
}
else if (token === '--state') {
args.state = next;
i += 1;
}
else if (token === '--loop') {
args.loop = true;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
OpenClaw Growth Runner
Usage:
node scripts/openclaw-growth-runner.mjs [--config <file>] [--state <file>] [--loop]
Default config: DEFAULT_CONFIG_PATH
Default state: DEFAULT_STATE_PATH
`);
process.exit(exitCode);
}
async function readJson(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
}
async function readJsonOptional(filePath, fallback) {
try {
return await readJson(filePath);
}
catch {
return fallback;
}
}
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
function sha256(input) {
return createHash('sha256').update(input).digest('hex');
}
function stableStringify(value) {
return JSON.stringify(value, Object.keys(value).sort(), 2);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function commandExists(commandName) {
const result = await runShellCommand(`command -v quote(commandName) >/dev/null 2>&1`, 10_000);
return result.ok;
}
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
}
catch {
return false;
}
}
function resolveAnalyticsSkillCandidates(config) {
const repoRoot = path.resolve(String(config?.project?.repoRoot || '.'));
const home = os.homedir();
return [
path.join(repoRoot, 'skills/analyticscli-cli/SKILL.md'),
path.join(repoRoot, 'agent/skills/analyticscli-cli-skill.md'),
path.join(process.cwd(), 'skills/analyticscli-cli/SKILL.md'),
path.join(process.cwd(), 'agent/skills/analyticscli-cli-skill.md'),
path.join(home, '.openclaw/skills/analyticscli-cli/SKILL.md'),
path.join(home, '.codex/skills/analyticscli-cli/SKILL.md'),
];
}
async function findAnalyticsSkillPath(config) {
const candidates = resolveAnalyticsSkillCandidates(config);
for (const candidate of candidates) {
if (await fileExists(candidate)) {
return { path: candidate, checked: candidates };
}
}
return { path: null, checked: candidates };
}
function resolveShellCommand() {
const candidates = [
process.env.OPENCLAW_SHELL,
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
'/usr/bin/bash',
'/bin/sh',
'/usr/bin/sh',
].filter((value) => typeof value === 'string' && value.trim().length > 0);
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return 'sh';
}
function runShellCommand(command, timeoutMs = 120_000) {
return new Promise((resolve) => {
const child = spawn(resolveShellCommand(), ['-c', command], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
child.kill('SIGTERM');
resolve({ ok: false, code: null, stdout, stderr: `stderr\nTimed out after timeoutMsms` });
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('close', (code) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve({
ok: code === 0,
code,
stdout,
stderr,
});
});
});
}
function getSecretName(config, key, fallback) {
const value = config?.secrets?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
async function assertHardRequirements(config) {
const missing = [];
const analyticsSource = config?.sources?.analytics;
const actionMode = getActionMode(config);
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
if (!analyticsSource || analyticsSource.enabled === false) {
missing.push('sources.analytics must be enabled');
}
const analyticscliExists = await commandExists('analyticscli');
if (!analyticscliExists) {
missing.push('analyticscli binary is required');
}
if (requiresGitHubDelivery) {
const githubRepo = String(config?.project?.githubRepo || '').trim();
if (!githubRepo) {
missing.push('project.githubRepo is required when GitHub auto-create is enabled');
}
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
if (!process.env[githubTokenEnv]) {
missing.push(`githubTokenEnv env var is required (getGitHubRequirementText(actionMode))`);
}
}
if (missing.length > 0) {
const message = `Hard requirements missing:\n- missing.join('\n- ')`;
throw new Error(message);
}
}
async function resolveSourcePayload(sourceConfig, sourceName) {
if (!sourceConfig || sourceConfig.enabled === false) {
return null;
}
if (sourceConfig.mode === 'command') {
if (!sourceConfig.command) {
throw new Error(`Source "sourceName" has mode=command but no command configured.`);
}
const result = await runShellCommand(String(sourceConfig.command));
if (!result.ok) {
throw new Error(`Source "sourceName" command failed: result.stderr || `exit ${result.code`}`);
}
try {
return JSON.parse(result.stdout);
}
catch (error) {
throw new Error(`Source "sourceName" returned non-JSON output.`);
}
}
if (!sourceConfig.path) {
throw new Error(`Source "sourceName" has mode=file but no path configured.`);
}
const payload = await readJson(path.resolve(String(sourceConfig.path)));
return payload;
}
function buildIssueFingerprint(issuesPayload) {
const titles = Array.isArray(issuesPayload?.issues)
? issuesPayload.issues.map((issue) => `issue.title|issue.priority|issue.area`).sort()
: [];
return sha256(titles.join('\n'));
}
async function runAnalyzer({ config, runtimeDir, sourceFiles, createGitHubArtifact, chartManifestPath, }) {
await ensureDir(runtimeDir);
if (!sourceFiles.analytics) {
throw new Error('Analytics source is required (enable and configure `sources.analytics`).');
}
const outFile = path.resolve(config.project?.outFile || 'data/openclaw-growth-engineer/issues.generated.json');
const args = [
'scripts/openclaw-growth-engineer.mjs',
'--analytics',
sourceFiles.analytics,
'--repo-root',
path.resolve(config.project?.repoRoot || '.'),
'--out',
outFile,
'--max-issues',
String(config.project?.maxIssues || 4),
'--title-prefix',
String(config.project?.titlePrefix || '[Growth]'),
];
if (sourceFiles.revenuecat) {
args.push('--revenuecat', sourceFiles.revenuecat);
}
if (sourceFiles.sentry) {
args.push('--sentry', sourceFiles.sentry);
}
if (sourceFiles.feedback) {
args.push('--feedback', sourceFiles.feedback);
}
for (const source of getAllSourceEntries(config).filter((entry) => !entry.builtIn)) {
if (sourceFiles[source.key]) {
args.push('--source', `source.key=sourceFiles[source.key]`);
}
}
if (createGitHubArtifact) {
const repo = String(config.project?.githubRepo || '').trim();
if (!repo) {
throw new Error(`actions.mode=getActionMode(config) requires project.githubRepo.`);
}
args.push(getActionMode(config) === 'pull_request' ? '--create-pull-requests' : '--create-issues', '--repo', repo);
const labels = Array.isArray(config.project?.labels) ? config.project.labels : [];
if (labels.length > 0) {
args.push('--labels', labels.join(','));
}
if (config.actions?.proposalBranchPrefix) {
args.push('--branch-prefix', String(config.actions.proposalBranchPrefix));
}
if (config.actions?.draftPullRequests === false) {
args.push('--no-draft-pull-requests');
}
}
if (chartManifestPath) {
args.push('--chart-manifest', chartManifestPath);
}
const analyzer = await runShellCommand(`node args.map(quote).join(' ')`);
if (!analyzer.ok) {
throw new Error(`Analyzer failed: analyzer.stderr || `exit ${analyzer.code`}`);
}
const issuesPayload = await readJson(outFile);
return {
outFile,
sourceFiles,
issuesPayload,
analyzerStdout: analyzer.stdout.trim(),
};
}
async function maybeGenerateCharts({ config, payloads, runtimeDir }) {
if (!config.charting?.enabled) {
return null;
}
const analyticsPayload = payloads.analytics;
if (!analyticsPayload) {
return null;
}
await ensureDir(runtimeDir);
const chartsDir = path.join(runtimeDir, 'charts');
await ensureDir(chartsDir);
const analyticsForChartsPath = path.join(runtimeDir, 'analytics_for_charts.json');
const manifestPath = path.join(chartsDir, 'manifest.json');
await fs.writeFile(analyticsForChartsPath, JSON.stringify(analyticsPayload, null, 2), 'utf8');
const defaultCommand = [
'python3',
'scripts/openclaw-growth-charts.py',
'--analytics',
analyticsForChartsPath,
'--out-dir',
chartsDir,
'--manifest',
manifestPath,
]
.map(quote)
.join(' ');
const command = String(config.charting?.command || defaultCommand);
const result = await runShellCommand(command);
if (!result.ok) {
process.stderr.write(`[new Date().toISOString()] Chart generation failed: result.stderr || `exit ${result.code`}\n`);
return null;
}
return manifestPath;
}
function quote(value) {
if (/^[a-zA-Z0-9_./:-]+$/.test(value)) {
return value;
}
return `'String(value).replace(/'/g, `'\\''`)'`;
}
function computeSourceHashes(sourcePayloadMap) {
const hashes = {};
for (const [key, value] of Object.entries(sourcePayloadMap)) {
hashes[key] = sha256(stableStringify(value));
}
return hashes;
}
function normalizeLookback(value, fallback = '30d') {
const normalized = String(value || fallback).trim();
return /^[0-9]+[dhm]$/.test(normalized) ? normalized : fallback;
}
function commandHasExplicitTimeBounds(command) {
return /(^|\s)--(?:since|until|last)\b/.test(String(command));
}
function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
const rawCommand = String(command || '').trim();
if (!rawCommand) {
return rawCommand;
}
if (sourceConfig?.cursorMode !== 'auto_since_last_fetch') {
return rawCommand;
}
if (commandHasExplicitTimeBounds(rawCommand)) {
return rawCommand;
}
const lastCollectedAt = String(cursorState?.lastCollectedAt || '').trim();
if (lastCollectedAt) {
return `rawCommand --since quote(lastCollectedAt)`;
}
const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
return `rawCommand --last quote(lookback)`;
}
async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState) {
if (!sourceConfig || sourceConfig.enabled === false) {
return {
payload: null,
nextCursor: cursorState || null,
resolvedCommand: null,
};
}
if (sourceConfig.mode === 'command') {
if (!sourceConfig.command) {
throw new Error(`Source "sourceName" has mode=command but no command configured.`);
}
const resolvedCommand = resolveCursorAwareCommand(sourceConfig.command, sourceConfig, cursorState);
const result = await runShellCommand(String(resolvedCommand));
if (!result.ok) {
throw new Error(`Source "sourceName" command failed: result.stderr || `exit ${result.code`}`);
}
const fetchedAt = new Date().toISOString();
try {
return {
payload: JSON.parse(result.stdout),
nextCursor: sourceConfig.cursorMode === 'auto_since_last_fetch'
? {
lastCollectedAt: fetchedAt,
updatedAt: fetchedAt,
lastCommand: resolvedCommand,
}
: cursorState || null,
resolvedCommand,
};
}
catch (error) {
throw new Error(`Source "sourceName" returned non-JSON output.`);
}
}
if (!sourceConfig.path) {
throw new Error(`Source "sourceName" has mode=file but no path configured.`);
}
return {
payload: await readJson(path.resolve(String(sourceConfig.path))),
nextCursor: cursorState || null,
resolvedCommand: null,
};
}
async function loadSourcePayloads(config, state) {
const payloads = {};
const sourceCursors = { ...(state?.sourceCursors || {}) };
for (const source of getAllSourceEntries(config)) {
const currentCursor = sourceCursors[source.key] || null;
const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor);
const payload = result.payload;
if (payload) {
payloads[source.key] = payload;
}
if (result.nextCursor) {
sourceCursors[source.key] = result.nextCursor;
}
}
return {
payloads,
sourceCursors,
};
}
async function materializeSourceFiles(config, payloads, runtimeDir) {
await ensureDir(runtimeDir);
const sourceFiles = {};
for (const source of getAllSourceEntries(config)) {
const payload = payloads[source.key];
if (!payload) {
continue;
}
const filePath = path.join(runtimeDir, `source.key.json`);
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
sourceFiles[source.key] = filePath;
}
return sourceFiles;
}
function hasSourceChanges(previousHashes, currentHashes) {
const allKeys = new Set([...Object.keys(previousHashes || {}), ...Object.keys(currentHashes || {})]);
for (const key of allKeys) {
if ((previousHashes || {})[key] !== (currentHashes || {})[key]) {
return true;
}
}
return false;
}
async function runOnce(configPath, statePath) {
const config = await readJson(configPath);
await assertHardRequirements(config);
const state = await readJsonOptional(statePath, {
sourceHashes: {},
lastIssueFingerprint: null,
lastRunAt: null,
sourceCursors: {},
});
const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
const { payloads, sourceCursors } = await loadSourcePayloads(config, state);
const currentHashes = computeSourceHashes(payloads);
const changed = hasSourceChanges(state.sourceHashes, currentHashes);
if (!changed && config.schedule?.skipIfNoDataChange !== false) {
process.stdout.write(`[new Date().toISOString()] No data changes. Skip run.\n`);
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, JSON.stringify({
...state,
sourceHashes: currentHashes,
sourceCursors,
lastRunAt: new Date().toISOString(),
skippedReason: 'no_data_change',
}, null, 2), 'utf8');
return;
}
const createGitHubArtifact = shouldAutoCreateGitHubArtifact(config);
const sourceFiles = await materializeSourceFiles(config, payloads, runtimeDir);
const chartManifestPath = await maybeGenerateCharts({
config,
payloads,
runtimeDir,
});
const dryRun = await runAnalyzer({
config,
runtimeDir,
sourceFiles,
createGitHubArtifact: false,
chartManifestPath,
});
const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
const unchangedIssueSet = issueFingerprint === state.lastIssueFingerprint;
if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
process.stdout.write(`[new Date().toISOString()] Issue set unchanged. Skip GitHub creation.\n`);
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, JSON.stringify({
...state,
sourceHashes: currentHashes,
sourceCursors,
lastIssueFingerprint: issueFingerprint,
lastRunAt: new Date().toISOString(),
lastOutFile: dryRun.outFile,
skippedReason: 'issue_set_unchanged',
}, null, 2), 'utf8');
return;
}
if (createGitHubArtifact) {
await runAnalyzer({
config,
runtimeDir,
sourceFiles,
createGitHubArtifact: true,
chartManifestPath,
});
process.stdout.write(`[new Date().toISOString()] Created GitHub 'issues'.\n`);
}
else {
process.stdout.write(`[new Date().toISOString()] Drafts generated only (getActionMode(config) auto-create disabled).\n`);
}
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, JSON.stringify({
sourceHashes: currentHashes,
sourceCursors,
lastIssueFingerprint: issueFingerprint,
lastRunAt: new Date().toISOString(),
lastOutFile: dryRun.outFile,
skippedReason: null,
}, null, 2), 'utf8');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config);
const statePath = path.resolve(args.state);
if (!args.loop) {
await runOnce(configPath, statePath);
return;
}
const config = await readJson(configPath);
const intervalMinutes = Math.max(1, Number(config.schedule?.intervalMinutes || 1440));
process.stdout.write(`Starting loop. Interval: intervalMinutes minute(s)\n`);
while (true) {
try {
await runOnce(configPath, statePath);
}
catch (error) {
process.stderr.write(`[new Date().toISOString()] Run failed: String(error)\n`);
}
await sleep(intervalMinutes * 60_000);
}
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/openclaw-growth-shared.mjs
const BUILTIN_SOURCE_NAMES = ['analytics', 'revenuecat', 'sentry', 'feedback'];
const SERVICE_KIND_ALIASES = {
analytics: [
'analytics',
'analyticscli',
'mixpanel',
'amplitude',
'firebase',
'posthog',
'telemetry',
],
revenue: ['revenuecat', 'stripe', 'purchases', 'billing', 'adapty', 'superwall'],
crash: ['sentry', 'glitchtip', 'crashlytics', 'bugsnag', 'datadog', 'rollbar'],
feedback: [
'feedback',
'support',
'intercom',
'zendesk',
'app-store-reviews',
'app_store_reviews',
'play-store-reviews',
'play_console_reviews',
],
store: [
'asc',
'asc-cli',
'app-store-connect',
'app_store_connect',
'play-console',
'play_console',
'google-play',
'google_play',
'aso',
],
};
export function getBuiltinSourceNames() {
return [...BUILTIN_SOURCE_NAMES];
}
export function normalizeServiceType(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[\s/]+/g, '-')
.replace(/[^a-z0-9._-]/g, '');
}
export function classifyServiceKind(service) {
const normalized = normalizeServiceType(service);
for (const [kind, aliases] of Object.entries(SERVICE_KIND_ALIASES)) {
if (aliases.includes(normalized)) {
return kind;
}
}
return 'custom';
}
export function normalizeSourceKey(value, fallback = 'source') {
const normalized = normalizeServiceType(value).replace(/[.-]+/g, '_');
return normalized || fallback;
}
export function getDefaultSourcePath(key) {
return `data/openclaw-growth-engineer/normalizeSourceKey(key)_summary.json`;
}
export function getDefaultSourceHint(service) {
const kind = classifyServiceKind(service);
if (kind === 'analytics') {
return '- Preferred: AnalyticsCLI bounded query/export written to JSON.\n- For command mode, output summary JSON in the shared signals[] shape.';
}
if (kind === 'revenue') {
return '- Revenue provider summary with monetization deltas, package/offering signals, and churn notes.\n- Command mode should output JSON in the shared signals[] shape.';
}
if (kind === 'crash') {
return '- Crash/error provider summary with top regressions, affected users, and issue evidence.\n- `issues[]` or shared `signals[]` payloads are both accepted.';
}
if (kind === 'feedback') {
return '- Aggregate app reviews, support tickets, or in-app feedback into recurring themes.\n- `items[]` or shared `signals[]` payloads are both accepted.';
}
if (kind === 'store') {
return '- Store/distribution summary from ASC CLI, Play Console exports, or release tooling.\n- Focus on review trends, release blockers, ratings, and ASO signals.';
}
return '- Any connector is supported when it can produce JSON in the shared `signals[]` shape.\n- Use `issues[]` for crash tools or `items[]` for feedback-like tools when that fits better.';
}
export function getDefaultSourceCommand(service) {
const normalized = normalizeServiceType(service);
if (normalized === 'analytics' || normalized === 'analyticscli') {
return 'node scripts/export-analytics-summary.mjs';
}
if (normalized === 'feedback') {
return 'analyticscli feedback summary --format json';
}
if (normalized === 'asc' ||
normalized === 'asc-cli' ||
normalized === 'app-store-connect' ||
normalized === 'app_store_connect') {
return 'node scripts/export-asc-summary.mjs';
}
return null;
}
export function buildExtraSourceConfig(service, options = {}) {
const normalizedService = normalizeServiceType(service);
const key = normalizeSourceKey(options.key || normalizedService || `extra_Date.now()`);
const defaultCommand = getDefaultSourceCommand(normalizedService || key);
const mode = options.mode || (defaultCommand ? 'command' : 'file');
return {
key,
label: options.label || normalizedService || key,
service: normalizedService || key,
enabled: options.enabled !== false,
mode,
...(mode === 'command'
? { command: options.command || defaultCommand || '' }
: { path: options.path || getDefaultSourcePath(key) }),
hint: options.hint || getDefaultSourceHint(normalizedService || key),
secretEnv: options.secretEnv || null,
};
}
export function getExtraSources(config) {
const extra = Array.isArray(config?.sources?.extra) ? config.sources.extra : [];
const seen = new Set();
const result = [];
for (const [index, source] of extra.entries()) {
if (!source || typeof source !== 'object') {
continue;
}
const key = normalizeSourceKey(source.key || source.name || source.service || `extra_index + 1`);
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({
...source,
key,
label: String(source.label || source.name || source.service || key),
service: normalizeServiceType(source.service || source.name || key),
enabled: source.enabled !== false,
mode: String(source.mode || 'file').toLowerCase() === 'command' ? 'command' : 'file',
secretEnv: typeof source.secretEnv === 'string' && source.secretEnv.trim()
? source.secretEnv.trim()
: null,
hint: typeof source.hint === 'string' && source.hint.trim()
? source.hint
: getDefaultSourceHint(source.service || key),
});
}
return result;
}
export function getAllSourceEntries(config) {
const builtins = getBuiltinSourceNames()
.filter((name) => Boolean(config?.sources?.[name]))
.map((name) => {
const source = config.sources[name];
return {
key: name,
label: name,
service: normalizeServiceType(source?.service || name),
builtIn: true,
...(source || {}),
};
});
return [...builtins, ...getExtraSources(config).map((source) => ({ ...source, builtIn: false }))];
}
export function getActionMode(config) {
const configured = normalizeServiceType(config?.actions?.mode || '');
if (configured === 'pull-request' || configured === 'pull_request' || configured === 'pr') {
return 'pull_request';
}
if (config?.actions?.autoCreatePullRequests === true) {
return 'pull_request';
}
return 'issue';
}
export function shouldAutoCreateGitHubArtifact(config) {
const mode = getActionMode(config);
if (mode === 'pull_request') {
return config?.actions?.autoCreatePullRequests === true;
}
return config?.actions?.autoCreateIssues === true;
}
export function getGitHubRequirementText(actionMode) {
if (actionMode === 'pull_request') {
return 'fine-grained PAT with Pull requests: Read/Write and Contents: Read/Write';
}
return 'fine-grained PAT with Issues: Read/Write and Contents: Read';
}
export function getGitHubConnectionSummary(actionMode) {
if (actionMode === 'pull_request') {
return 'GitHub auth, repository access, pull-request API read checks, and default-branch metadata checks passed';
}
return 'GitHub auth, repository access, and issues API read checks passed';
}
export function getGitHubActionNoun(actionMode) {
return actionMode === 'pull_request' ? 'pull requests' : 'issues';
}
FILE:scripts/openclaw-growth-start.mjs
#!/usr/bin/env node
import { existsSync, promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { getActionMode, getDefaultSourceCommand } from './openclaw-growth-shared.mjs';
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
const DEFAULT_TEMPLATE_PATH = 'data/openclaw-growth-engineer/config.example.json';
const ANALYTICSCLI_PACKAGE_SPEC = process.env.ANALYTICSCLI_CLI_PACKAGE || '@analyticscli/cli@preview';
const ANALYTICSCLI_NPM_PREFIX = process.env.ANALYTICSCLI_NPM_PREFIX ||
(process.env.HOME ? path.join(process.env.HOME, '.local') : path.join(process.cwd(), '.analyticscli-npm'));
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
OpenClaw Growth Start
Bootstraps setup and first run in one deterministic flow:
1) Ensure config exists (auto-bootstrap from template when missing)
2) Run preflight
3) If preflight passes, run first pass
Usage:
node scripts/openclaw-growth-start.mjs [options]
Options:
--config <file> Config path (default: DEFAULT_CONFIG_PATH)
--setup-only Run bootstrap + preflight only (skip first run)
--no-test-connections Skip live API smoke checks in preflight
--help, -h Show help
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = {
config: DEFAULT_CONFIG_PATH,
run: true,
testConnections: true,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
else if (token === '--config') {
args.config = next || args.config;
i += 1;
}
else if (token === '--setup-only') {
args.run = false;
}
else if (token === '--no-test-connections') {
args.testConnections = false;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function quote(value) {
if (/^[a-zA-Z0-9_./:-]+$/.test(String(value))) {
return String(value);
}
return `'String(value).replace(/'/g, `'\\''`)'`;
}
function truncate(value, max = 240) {
const text = String(value || '');
if (text.length <= max)
return text;
return `text.slice(0, max)...`;
}
function resolveShellCommand() {
const candidates = [
process.env.OPENCLAW_SHELL,
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
'/usr/bin/bash',
'/bin/sh',
'/usr/bin/sh',
].filter((value) => typeof value === 'string' && value.trim().length > 0);
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return 'sh';
}
function runShellCommand(command, timeoutMs = 120_000) {
return new Promise((resolve) => {
const child = spawn(resolveShellCommand(), ['-c', command], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled)
return;
settled = true;
child.kill('SIGTERM');
resolve({
ok: false,
code: null,
stdout,
stderr: `stderr\nTimed out after timeoutMsms`,
});
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('close', (code) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve({
ok: code === 0,
code,
stdout,
stderr,
});
});
});
}
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
}
catch {
return false;
}
}
async function commandExists(commandName) {
const result = await runShellCommand(`command -v quote(commandName) >/dev/null 2>&1`, 30_000);
return result.ok;
}
async function resolveCommandPath(commandName) {
const result = await runShellCommand(`command -v quote(commandName)`, 30_000);
return result.ok ? result.stdout.trim() : null;
}
function prependToPath(binDir) {
process.env.PATH = `binDirpath.delimiterprocess.env.PATH || ''`;
}
function isPermissionFailure(output) {
return /EACCES|permission denied|access denied|operation not permitted/i.test(String(output || ''));
}
async function ensureAnalyticsCliInstalled() {
const beforePath = await resolveCommandPath('analyticscli');
const npmExists = await commandExists('npm');
if (!npmExists) {
if (beforePath) {
return {
ok: true,
detail: `analyticscli binary found at beforePath; npm unavailable, so package update was skipped`,
};
}
return {
ok: false,
detail: `analyticscli binary missing and npm is unavailable; install ANALYTICSCLI_PACKAGE_SPEC`,
};
}
const globalInstall = await runShellCommand(`npm install -g quote(ANALYTICSCLI_PACKAGE_SPEC)`, 180_000);
if (!globalInstall.ok) {
const installOutput = `globalInstall.stderr\nglobalInstall.stdout`;
if (isPermissionFailure(installOutput)) {
await fs.mkdir(ANALYTICSCLI_NPM_PREFIX, { recursive: true });
const localInstall = await runShellCommand(`npm install -g --prefix quote(ANALYTICSCLI_NPM_PREFIX) quote(ANALYTICSCLI_PACKAGE_SPEC)`, 180_000);
if (!localInstall.ok) {
return beforePath
? {
ok: true,
detail: `analyticscli binary found at beforePath; update failed globally and in user-local prefix (truncate(localInstall.stderr || localInstall.stdout))`,
}
: {
ok: false,
detail: `npm install failed globally and in user-local prefix ANALYTICSCLI_NPM_PREFIX: truncate(localInstall.stderr || localInstall.stdout)`,
};
}
prependToPath(path.join(ANALYTICSCLI_NPM_PREFIX, 'bin'));
}
else {
return beforePath
? {
ok: true,
detail: `analyticscli binary found at beforePath; package update failed (truncate(installOutput))`,
}
: {
ok: false,
detail: `npm install -g ANALYTICSCLI_PACKAGE_SPEC failed: truncate(installOutput)`,
};
}
}
const afterPath = await resolveCommandPath('analyticscli');
return afterPath
? {
ok: true,
detail: `analyticscli package ensured via ANALYTICSCLI_PACKAGE_SPEC; binary found at afterPath`,
}
: {
ok: false,
detail: `Installed ANALYTICSCLI_PACKAGE_SPEC, but analyticscli is still not on PATH`,
};
}
async function readJson(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
}
async function writeJson(filePath, value) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `JSON.stringify(value, null, 2)\n`, 'utf8');
}
function parseGitHubRepoFromRemote(remoteUrl) {
const value = String(remoteUrl || '').trim();
if (!value)
return null;
const sshMatch = value.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
if (sshMatch)
return sshMatch[1];
const httpsMatch = value.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i);
if (httpsMatch)
return httpsMatch[1];
return null;
}
async function detectGitHubRepo() {
const explicit = String(process.env.OPENCLAW_GITHUB_REPO || '').trim();
if (explicit)
return explicit;
const remoteResult = await runShellCommand('git config --get remote.origin.url', 10_000);
if (!remoteResult.ok)
return null;
return parseGitHubRepoFromRemote(remoteResult.stdout.trim());
}
async function ensureConfig(configPath) {
if (await fileExists(configPath)) {
return {
created: false,
configPath,
githubRepo: null,
};
}
const templatePath = path.resolve(DEFAULT_TEMPLATE_PATH);
const template = await readJson(templatePath);
const detectedRepo = await detectGitHubRepo();
const githubRepo = detectedRepo || String(template.project?.githubRepo || 'owner/repo');
const config = {
...template,
generatedAt: new Date().toISOString(),
project: {
...template.project,
githubRepo,
repoRoot: '.',
},
sources: {
...template.sources,
analytics: {
enabled: true,
mode: 'command',
command: getDefaultSourceCommand('analytics'),
},
revenuecat: {
...(template.sources?.revenuecat || {}),
enabled: false,
},
sentry: {
...(template.sources?.sentry || {}),
enabled: false,
},
feedback: {
...(template.sources?.feedback || {}),
enabled: false,
},
extra: Array.isArray(template.sources?.extra) ? template.sources.extra : [],
},
actions: {
...template.actions,
mode: 'issue',
autoCreateIssues: false,
autoCreatePullRequests: false,
draftPullRequests: true,
proposalBranchPrefix: 'openclaw/proposals',
},
};
await writeJson(configPath, config);
return {
created: true,
configPath,
githubRepo,
};
}
function parseJsonFromStdout(stdout) {
const raw = String(stdout || '').trim();
if (!raw)
return null;
const firstBrace = raw.indexOf('{');
if (firstBrace < 0)
return null;
try {
return JSON.parse(raw.slice(firstBrace));
}
catch {
return null;
}
}
function remediationForCheck(checkName, configPath) {
if (checkName === 'dependency:analyticscli') {
return 'Run AnalyticsCLI CLI with `npx -y @analyticscli/cli@preview --help`, or use `@analyticscli/cli` after stable release.';
}
if (checkName === 'project:github-repo') {
return `Set \`project.githubRepo\` in configPath (owner/repo).`;
}
if (checkName.startsWith('secret:GITHUB_TOKEN')) {
return 'Set `GITHUB_TOKEN` (fine-grained PAT with repository `Issues: Read/Write` and `Contents: Read`).';
}
if (checkName === 'source:analytics:file') {
return 'Write `data/openclaw-growth-engineer/analytics_summary.json` via your analytics refresh step (API-key based source command/file generation).';
}
if (checkName === 'connection:analytics') {
return 'Nearly done: I only need `ANALYTICSCLI_ACCESS_TOKEN` from you to continue setup. Create or copy it in dash.analyticscli.com -> API Keys, then run `analyticscli --api-url https://api.analyticscli.com login --access-token <access_token>` and `analyticscli projects select`.';
}
if (checkName === 'connection:github') {
return 'Verify `GITHUB_TOKEN` and repo access to `/repos/<owner>/<repo>` + issues API.';
}
if (checkName === 'connection:github-pull-requests') {
return 'Verify `GITHUB_TOKEN` and repo access to `/repos/<owner>/<repo>/pulls`, plus `Pull requests: Read/Write` and `Contents: Read/Write` scopes.';
}
return 'Fix this blocker and rerun start.';
}
async function runPreflight(configPath, testConnections) {
const commandParts = [
'node',
'scripts/openclaw-growth-preflight.mjs',
'--config',
quote(configPath),
];
if (testConnections) {
commandParts.push('--test-connections');
}
const command = commandParts.join(' ');
const result = await runShellCommand(command, 180_000);
const payload = parseJsonFromStdout(result.stdout);
return {
shell: result,
payload,
};
}
async function runFirstPass(configPath) {
const command = `node scripts/openclaw-growth-runner.mjs --config quote(configPath)`;
return runShellCommand(command, 300_000);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config);
const configResult = await ensureConfig(configPath);
const analyticscliEnsure = await ensureAnalyticsCliInstalled();
if (!analyticscliEnsure.ok) {
process.stdout.write(`false,
phase: 'dependency_setup',
configCreated: configResult.created,
configPath,
blockers: [
{
check: 'dependency:analyticscli',
detail: analyticscliEnsure.detail,
remediation: `Install the npm package with \`npm install -g ${ANALYTICSCLI_PACKAGE_SPEC\` or set ANALYTICSCLI_NPM_PREFIX to a writable prefix.`,
},
],
}, null, 2)}\n`);
process.exitCode = 1;
return;
}
const preflightResult = await runPreflight(configPath, args.testConnections);
const preflightPayload = preflightResult.payload;
if (!preflightPayload) {
throw new Error(`Preflight returned invalid output.\nstdout:\npreflightResult.shell.stdout\nstderr:\npreflightResult.shell.stderr`);
}
const failures = Array.isArray(preflightPayload.checks)
? preflightPayload.checks.filter((check) => check.status === 'fail')
: [];
if (failures.length > 0) {
const blockers = failures.map((check) => ({
check: check.name,
detail: check.detail,
remediation: remediationForCheck(check.name, configPath),
}));
process.stdout.write(`false,
phase: 'preflight',
configCreated: configResult.created,
configPath,
githubRepo: configResult.githubRepo,
blockers,, null, 2)}\n`);
process.exitCode = 1;
return;
}
if (!args.run) {
process.stdout.write(`true,
phase: 'setup_complete',
configCreated: configResult.created,
configPath,
message: 'Preflight passed. First run skipped due to --setup-only.',, null, 2)}\n`);
return;
}
const runResult = await runFirstPass(configPath);
if (!runResult.ok) {
process.stdout.write(`false,
phase: 'first_run',
configCreated: configResult.created,
configPath,
error: runResult.stderr || `exit ${runResult.code`,
}, null, 2)}\n`);
process.exitCode = 1;
return;
}
const actionMode = getActionMode(await readJson(configPath));
process.stdout.write(`true,
phase: 'first_run_complete',
configCreated: configResult.created,
configPath,
actionMode,
runnerOutput: runResult.stdout.trim(),, null, 2)}\n`);
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});
FILE:scripts/openclaw-growth-wizard.mjs
#!/usr/bin/env node
import { promises as fs } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { createInterface } from 'node:readline/promises';
import { buildExtraSourceConfig, getDefaultSourceCommand, getDefaultSourceHint, getDefaultSourcePath, } from './openclaw-growth-shared.mjs';
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
async function ensureDirForFile(filePath) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
function parseArgs(argv) {
const args = {
out: DEFAULT_CONFIG_PATH,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
const next = argv[i + 1];
if (token === '--') {
continue;
}
else if (token === '--out') {
args.out = next;
i += 1;
}
else if (token === '--help' || token === '-h') {
printHelpAndExit(0);
}
else {
printHelpAndExit(1, `Unknown argument: token`);
}
}
return args;
}
function printHelpAndExit(exitCode, reason = null) {
if (reason) {
process.stderr.write(`reason\n\n`);
}
process.stdout.write(`
OpenClaw Growth Setup Wizard
Usage:
node scripts/openclaw-growth-wizard.mjs [--out <config-path>]
`);
process.exit(exitCode);
}
async function ask(rl, label, defaultValue = '') {
const suffix = defaultValue ? ` (defaultValue)` : '';
const answer = (await rl.question(`labelsuffix: `)).trim();
return answer || defaultValue;
}
async function askYesNo(rl, label, defaultYes = true) {
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
while (true) {
const answer = (await rl.question(`label suffix `)).trim().toLowerCase();
if (!answer)
return defaultYes;
if (answer === 'y' || answer === 'yes')
return true;
if (answer === 'n' || answer === 'no')
return false;
}
}
async function askChoice(rl, label, options, defaultValue) {
const normalizedDefault = options.includes(defaultValue) ? defaultValue : options[0];
while (true) {
const answer = (await rl.question(`label (options.join('/')) [normalizedDefault]: `))
.trim()
.toLowerCase();
if (!answer) {
return normalizedDefault;
}
if (options.includes(answer)) {
return answer;
}
}
}
async function askSourceConfig(rl, sourceName, defaultPath, hint, options = {}) {
const forceEnabled = Boolean(options.forceEnabled);
const defaultCommand = String(options.defaultCommand || '').trim();
const defaultMode = defaultCommand ? 'command' : 'file';
const defaultEnabled = options.defaultEnabled ?? sourceName === 'analytics';
const enabled = forceEnabled
? true
: await askYesNo(rl, `Enable source "sourceName"?`, defaultEnabled);
if (!enabled) {
return {
enabled: false,
mode: 'file',
path: defaultPath,
hint,
};
}
process.stdout.write(`Where to get sourceName data:\nhint\n`);
const modeInput = await ask(rl, 'Mode (file/command)', defaultMode);
const mode = modeInput.toLowerCase() === 'command' ? 'command' : 'file';
const value = await ask(rl, mode === 'file' ? `sourceName JSON file path` : `sourceName command`, mode === 'file' ? defaultPath : defaultCommand);
if (mode === 'file') {
return {
enabled: true,
mode,
path: value,
hint,
};
}
return {
enabled: true,
mode,
command: value,
hint,
...(options.cursorMode ? { cursorMode: options.cursorMode } : {}),
...(options.initialLookback ? { initialLookback: options.initialLookback } : {}),
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.out);
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('Wizard requires an interactive terminal.');
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
process.stdout.write('OpenClaw Growth Engineer - Setup Wizard\n');
process.stdout.write('This wizard writes non-secret config only.\n\n');
let githubRepo = '';
while (!githubRepo) {
githubRepo = await ask(rl, 'GitHub repo (owner/name, required)', '');
if (!githubRepo) {
process.stdout.write('GitHub repo is required for this workflow.\n');
}
}
const labelsRaw = await ask(rl, 'Issue labels (comma-separated)', 'ai-growth,autogenerated,product');
const labels = labelsRaw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const maxIssues = Number.parseInt(await ask(rl, 'Max issues per run', '4'), 10) || 4;
const intervalMinutes = Number.parseInt(await ask(rl, 'Check interval in minutes', '1440'), 10) || 1440;
const actionMode = await askChoice(rl, 'Preferred GitHub output', ['issue', 'pull_request'], 'issue');
const analytics = await askSourceConfig(rl, 'analytics', 'data/openclaw-growth-engineer/analytics_summary.example.json', getDefaultSourceHint('analytics'), {
forceEnabled: true,
defaultCommand: getDefaultSourceCommand('analytics'),
});
const revenuecat = await askSourceConfig(rl, 'revenuecat', 'data/openclaw-growth-engineer/revenuecat_summary.example.json', getDefaultSourceHint('revenuecat'));
const sentry = await askSourceConfig(rl, 'sentry', 'data/openclaw-growth-engineer/sentry_summary.example.json', getDefaultSourceHint('sentry'));
const feedback = await askSourceConfig(rl, 'feedback', 'data/openclaw-growth-engineer/feedback_summary.example.json', getDefaultSourceHint('feedback'), {
defaultEnabled: true,
defaultCommand: getDefaultSourceCommand('feedback'),
cursorMode: 'auto_since_last_fetch',
initialLookback: '30d',
});
const extraSourcesRaw = await ask(rl, 'Extra connectors (comma-separated, e.g. glitchtip,asc-cli,app-store-reviews)', '');
const extraSources = extraSourcesRaw
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.map((service) => {
const defaultCommand = getDefaultSourceCommand(service);
return buildExtraSourceConfig(service, defaultCommand ? {} : { mode: 'file', path: getDefaultSourcePath(service) });
});
const autoCreateIssues = actionMode === 'issue'
? await askYesNo(rl, 'Create GitHub issues automatically when new ideas are found?', false)
: false;
const autoCreatePullRequests = actionMode === 'pull_request'
? await askYesNo(rl, 'Create draft pull requests with implementation proposal files automatically?', false)
: false;
const enableCharting = await askYesNo(rl, 'Generate matplotlib charts from analytics signals and include them in generated GitHub artifacts?', false);
const chartCommand = enableCharting
? await ask(rl, 'Optional chart command override (leave empty for default python script)', '')
: '';
const config = {
version: 1,
generatedAt: new Date().toISOString(),
project: {
githubRepo,
repoRoot: '.',
outFile: 'data/openclaw-growth-engineer/issues.generated.json',
maxIssues,
titlePrefix: '[Growth]',
labels,
},
sources: {
analytics,
revenuecat,
sentry,
feedback,
extra: extraSources,
},
schedule: {
intervalMinutes,
skipIfNoDataChange: true,
skipIfIssueSetUnchanged: true,
},
actions: {
autoCreateIssues,
autoCreatePullRequests,
mode: actionMode,
draftPullRequests: true,
proposalBranchPrefix: 'openclaw/proposals',
},
charting: {
enabled: enableCharting,
command: chartCommand || null,
},
secrets: {
githubTokenEnv: 'GITHUB_TOKEN',
analyticsTokenEnv: 'ANALYTICSCLI_ACCESS_TOKEN',
revenuecatTokenEnv: 'REVENUECAT_API_KEY',
sentryTokenEnv: 'SENTRY_AUTH_TOKEN',
},
};
await ensureDirForFile(configPath);
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
process.stdout.write(`\nSaved config: configPath\n`);
process.stdout.write('\nNext steps:\n');
process.stdout.write(`1) Set secrets in OpenClaw secret store (env var names in config.secrets)\n`);
if (extraSources.length > 0) {
process.stdout.write(`2) Fill each extra connector under \`sources.extra[]\` with the final file path or command and optional \`secretEnv\`\n`);
process.stdout.write(`3) Run once: node scripts/openclaw-growth-runner.mjs --config configPath\n`);
process.stdout.write(`4) Run interval loop: node scripts/openclaw-growth-runner.mjs --config configPath --loop\n`);
return;
}
process.stdout.write(`2) Run once: node scripts/openclaw-growth-runner.mjs --config configPath\n`);
process.stdout.write(`3) Run interval loop: node scripts/openclaw-growth-runner.mjs --config configPath --loop\n`);
}
finally {
rl.close();
}
}
main().catch((error) => {
process.stderr.write(`String(error)\n`);
process.exitCode = 1;
});