Skills
4174 foundAgent Skills are multi-file prompts that give AI agents specialized capabilities. They include instructions, configurations, and supporting files that can be used with Claude, Cursor, Windsurf, and other AI coding assistants.
Ajinomoto is a Japanese biotech firm that commercialized umami and leads global production of MSG and amino acid products for food and pharma.
---
summary: Ajinomoto is a Japanese multinational food and biotechnology company that discovered and commercialized umami — the fifth taste — and remains the world's largest producer of monosodium glutamate (MSG) and amino acid products.
read_when:
- Studying the global food science and flavor industry
- Analyzing Ajinomoto expansion from MSG to biotechnology and pharma
- Researching umami taste science and its impact on global cuisine
- Understanding Japanese corporate innovation in food technology
---
# Ajinomoto
## Overview
Ajinomoto is a Japanese multinational food and biotechnology company that discovered and commercialized umami — the fifth taste — and remains the world's largest producer of monosodium glutamate (MSG) and amino acid products.
## Historical Timeline
- 1909: Kikunae Ikeda discovers umami taste and patents MSG production
- 1925: Ajinomoto Co formally established in Tokyo
- 1956: Discovers industrial fermentation process for amino acid production
- 1980s: Expands into pharmaceuticals and biotechnology
- 2000: Launches 'Eat Well, Live Well' brand transformation
- 2024: Announces major investment in cultivated meat and alternative protein
## Business Model
Three segments: Seasonings and Foods (45%), AminoScience (35% — pharma, animal nutrition, sweeteners), and Frozen Foods (20%). Revenue from B2C food products (Ajinomoto brand MSG, Cook Do sauce mixes) and B2B amino acid ingredients for pharmaceutical and animal feed industries.
## Moat Analysis
Proprietary fermentation technology for amino acid production — over 100 years of process optimization. Umami discovery gives scientific credibility and brand authority in flavor science. Vertical integration from raw materials to finished food products.
## Key Data
- revenue: ~¥1.3 trillion (~$9B) (2023)
- msg_production: ~30% of global supply
- employees: ~37,000
- countries: ~80+
- r_and_d: ~¥40B/year
## Interesting Facts
- Professor Kikunae Ikeda discovered umami by tasting dashi broth and identifying glutamate as the source — he then crystallized it from kombu seaweed and patented the extraction process.
- Despite global MSG stigma in Western markets, Ajinomoto's MSG production has never stopped growing — it is now used in 90%+ of processed foods worldwide.
Generates five labeled ad copy variants with distinct appeal angles and platform-specific compliance checks for structured A/B testing of paid ads.
# Ad Copy Variants for A/B Testing ## Purpose This skill generates systematic, labeled ad copy variants designed for structured A/B testing across paid advertising platforms. It produces five distinct appeal-angle variants per product — Emotional, Rational, Scarcity, Social Proof, and Problem-Solution — each formatted for the target platform's constraints and policies. A built-in compliance checker flags potential ad policy violations before launch. Designed for performance marketers and media buyers who need testable, measurable creative variations, not random copy suggestions. ## Triggers - "generate ad variants" - "A/B test ad copy" - "广告文案变体" - "ad copy ab test" - "create ad copy" - "广告A/B测试" - "multiple ad versions" - "ad variant matrix" - "headline bank" - "CTA optimizer" ## Workflow 1. Receive product info + target ad platform(s) from user: product name, key benefits, target audience, budget tier, and campaign goal. 2. Generate the 5-angle variant matrix: - **Emotional**: Tap into desire, aspiration, or joy - **Rational**: Feature-driven, logical, value-focused - **Scarcity**: Limited-time, limited-quantity (ethically constrained) - **Social Proof**: User numbers, ratings, endorsements (only if verifiable) - **Problem-Solution**: Pain point → product as solution 3. Apply platform-specific constraints: character limits (e.g., WeChat Moments: 40 chars headline; Google: 30/90/90), image-text ratio rules, and forbidden content categories. 4. Run a compliance check against the target platform's ad policies, flagging: prohibited claims, missing disclosures, superlatives without substantiation, sensitive categories. 5. Generate CTA alternatives for each variant — platform-appropriate and conversion-optimized. 6. Output the full variant matrix, labeled and ready for ad platform upload. ## Prompt Templates ### 1. Variant Matrix (`variant_matrix`) **Purpose:** Generate the full 5-angle A/B variant matrix. **Input:** - `product_name` — Product - `key_benefits` — 2–3 main benefits - `target_audience` — Demographic and psychographic - `platform` — Ad platform name - `campaign_goal` — Awareness/Consideration/Conversion **Output:** A labeled 5-variant table: Variant Label | Headline | Body/Description | CTA | Character Counts. ### 2. Ad Compliance Check (`ad_compliance_check`) **Purpose:** Review ad copy for platform-specific policy violations. **Input:** - `ad_copy_full` — Complete ad text (headline + body + CTA) - `platform` — Target ad platform - `product_category` — Product category (for restricted category checks) **Output:** Compliance report: Flag | Severity | Issue Description | Suggested Fix. ### 3. CTA Optimizer (`cta_optimizer`) **Purpose:** Generate alternative CTAs for existing ad copy. **Input:** - `ad_copy` — Existing ad body text - `platform` — Platform context - `goal` — Click/Conversion/Engagement **Output:** 3 CTA alternatives with rationale for each and platform-fit score. ### 4. Headline Bank (`headline_bank`) **Purpose:** Generate 10 headline angles for a product. **Input:** - `product_name` — Product - `target_audience` — Audience - `platform` — Platform (determines character limits) **Output:** 10 headlines labeled by angle type (curiosity, benefit, question, statistic, comparison, emotional, how-to, direct, testimonial, news) with character count. ### 5. Ad Fatigue Refresher (`ad_fatigue_refresher`) **Purpose:** Refresh an existing top-performing ad with new variants. **Input:** - `current_top_ad` — Currently best-performing ad copy - `performance_metric` — What metric (CTR/conversion) it leads on - `fatigue_signal` — Why refresh (frequency up, CTR dropping) **Output:** 3 refreshed variants that preserve winning elements but change angle, format, or CTA. ## Output Format All variants are delivered in a structured A/B test matrix: | Variant # | Angle Type | Headline | Body (truncated) | CTA | Expected Audience Response | |-----------|-----------|----------|------------------|-----|---------------------------| | A | Emotional | ... | ... | ... | ... | | B | Rational | ... | ... | ... | ... | Plus compliance flags table when requested. ## Safety Rules - **NEVER** include forbidden claims per platform ad policy (health guarantees, financial returns, weight loss promises) - **NEVER** use discriminatory, exclusionary, or exploitative language - **NEVER** include misleading before/after representations without verifiable data - **NEVER** use unsubstantiated superlatives ("best", "#1", "top-rated") unless independently verifiable - **ALWAYS** include required disclosures: "Ad", "Sponsored", "Promotion" per platform - **ALWAYS** flag sensitive product categories (health, finance, supplements) for extra review ## Examples ### Example 1: Variant Matrix for WeChat Moments **Input:** Product="在线英语课程", Audience="25-35岁职场人", Platform="WeChat Moments", Goal="Conversion" **Output:** 5 variants: Emotional ("遇见更好的自己"), Rational ("每天15分钟,3个月流利对话"), Scarcity ("限时优惠,仅剩200名额"), Social Proof ("10万+学员的选择"), Problem-Solution ("开会不敢开口?试试这个方法"). ### Example 2: Compliance Check **Input:** Ad copy with "100% guaranteed results in 7 days", Platform="Google Ads", Category="Education" **Output:** HIGH severity flag: absolute guarantee claim without substantiation. Suggested: "Join 10,000+ learners" instead. ## Related Skills - [social-caption-kit](../social-caption-kit/) — For organic social captions (not paid ads) - [promo-email-writer](../promo-email-writer/) — For email marketing variants (different channel) - [landing-page-copy-pro](../landing-page-copy-pro/) — For landing page copy that the ad links to FILE:ACCEPTANCE.md # Acceptance Criteria — Ad Copy Variants for A/B Testing - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules address platform ad policies, forbidden claims, and required disclosures - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — A/B test matrix format with labeled angles differs from all other skills - [ ] Compliance checker is a distinct, platform-aware feature not present in other skills - [ ] Slugs follow naming convention (user-facing, no prefix codes) FILE:README.md # Ad Copy Variants for A/B Testing Systematic ad copy generation — 5 labeled variants per product for structured A/B testing across major ad platforms. ## Features - 5-angle variant matrix: Emotional, Rational, Scarcity, Social Proof, Problem-Solution - Platform-specific formatting for WeChat, Douyin, Google, Facebook, Kuaishou - Built-in compliance checker with platform ad policy flagging - CTA optimizer with platform-fit scoring - Headline bank: 10 angle-labeled headlines per product - Ad fatigue refresher for creative rotation ## Install ``` openclaw skills install harrylabsj/ad-copy-ab-tester ``` ## Usage ``` 为这款产品生成5组微信朋友圈广告文案变体,分别用情感、理性、稀缺、社会证明、问题-解决角度 检查这段广告文案在抖音信息流是否合规 给我10个产品标题的广告角度,投Google Ads用 现有的广告效果下降了,帮我refresh 3个新版本 ``` ## Platforms WeChat Moments Ads, Douyin Feed Ads, Google Ads, Facebook/Instagram Ads, Kuaishou Ads ## Safety All variants respect platform ad policies. No forbidden claims, no discriminatory language, no unsubstantiated superlatives. Includes compliance flagging and disclosure reminders. ## License MIT FILE:skill.json { "name": "Ad Copy Variants for A/B Testing", "description": "Systematic A/B ad copy generation with labeled variants (emotional, rational, scarcity, social proof, problem-solution) across WeChat, Douyin, Google, Facebook, and Kuaishou ad platforms. Includes compliance checks.", "version": "1.0.0", "type": "prompt-flow", "category": "Advertising / Creative Copy", "keywords": [ "ad copy", "A/B test", "广告文案", "ad variant", "creative testing", "WeChat ad", "Douyin ad", "Google ad copy", "Facebook ad", "headline bank", "CTA optimization" ], "platforms": ["WeChat Moments Ads", "Douyin Feed Ads", "Google Ads", "Facebook/Instagram Ads", "Kuaishou Ads"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No forbidden claims per platform ad policy. No discriminatory or exclusionary language. No misleading before/after representations. No unsubstantiated superlatives without verification. Include required disclosures per platform." } }
Guide startup growth strategy by diagnosing which phase the startup is in (Phase I: making something people want, Phase II: marketing something people want,...
---
name: startup-traction-strategy-by-phase
description: "Guide startup growth strategy by diagnosing which phase the startup is in (Phase I: making something people want, Phase II: marketing something people want, Phase III: scaling) and selecting phase-appropriate traction channels. Use whenever a startup founder, growth marketer, or product leader is deciding how to split time between product and traction, asking whether they have product-market fit, choosing which channels fit their current stage, dealing with rising CAC or saturating channels, wondering if they should pivot, applying the 50% Rule, or escaping the Product Trap ('if we build it they will come'). Activates on phrases like 'product-market fit', 'phase I', 'phase II', 'scaling', 'growth strategy', 'should we pivot', '50% rule', 'product trap', 'traction vs product', 'which channels for our stage', 'moving the needle'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/startup-traction-strategy-by-phase
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [4]
domain: startup-growth
tags: [startup-growth, growth-strategy, startup-phases, product-market-fit, marketing-strategy]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Startup state — metrics, team size, product maturity, current traction activities"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for phase diagnosis and channel strategy documents"
discovery:
goal: "Diagnose the startup's current phase and produce a phase-appropriate traction strategy"
tasks:
- "Diagnose current phase (I/II/III) from observable signals"
- "Audit current time allocation against the 50% Rule"
- "Map phase-appropriate channels and filter out mismatched ones"
- "Apply the moving-the-needle filter to proposed activities"
- "Detect the Product Trap and phase-channel mismatch anti-patterns"
audience:
roles: [startup-founder, growth-marketer, head-of-marketing]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "User is unsure which phase their startup is in"
- "User's current channel is producing diminishing returns"
- "User asks whether to pivot"
- "User is spending all time on product and wondering about growth"
prerequisites: []
not_for:
- "User has not yet built a product"
- "User just wants to pick a channel (use bullseye-channel-selection)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 12
iterations_needed: 0
---
# Startup Traction Strategy by Phase
## When to Use
The startup is somewhere on the growth curve and needs a phase-appropriate traction strategy. Use this skill when:
- The founder can't tell if they have product-market fit yet
- Growth has plateaued and the channels that worked before aren't working now
- The founder is spending 90%+ of their time on product
- A pivot is being considered
- The user asks "what should we focus on for growth right now?"
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Current metrics:** users, revenue, growth rate (even rough)
→ Check prompt for: numeric counts, percentages, trends
→ If missing, ask: "What are your current metrics? Rough numbers are fine — users, paying customers, monthly growth."
- **Time allocation:** how the founder/team is currently splitting effort
→ Check prompt for: "spending X% on", "we focus on", "most of our time"
→ If missing, ask: "Roughly how is your week split between product work and getting customers?"
- **Current traction activities:** what's actively being tried
→ Check prompt for: "we do X for growth", channel names
→ If missing, ask: "What are you doing right now to get new customers?"
### Observable Context
- **Product maturity:** MVP, v1, v2+
- **Team size and composition**
- **How customers currently describe the product** (satisfaction signals)
### Default Assumptions
- If user count is under 1,000 and no clear growth rate exists → assume Phase I
- If rough product-market fit signals exist (paying customers, word-of-mouth, retention) → Phase II
- If established business model with consistent growth → Phase III
### Sufficiency Threshold
```
SUFFICIENT: metrics + time allocation + current activities known
PROCEED WITH DEFAULTS: metrics known; assume time is 90/10 product/traction (the common failure mode)
MUST ASK: metrics are completely unknown (can't diagnose phase)
```
## Process
Use TodoWrite:
- [ ] Step 1: Diagnose phase
- [ ] Step 2: Audit time allocation against 50% Rule
- [ ] Step 3: Map phase-appropriate channels
- [ ] Step 4: Apply the moving-the-needle filter
- [ ] Step 5: Produce phase strategy document
### Step 1: Diagnose Phase (I / II / III)
**ACTION:** Classify the startup into one of three phases based on observable signals:
- **Phase I — Making something people want.** No product-market fit yet. Signals: low user count, high churn, constant product revision, customers don't obviously stick. The core job is building a product worth marketing.
- **Phase II — Marketing something people want.** Product-market fit established. Signals: customers stick, grow by word of mouth, revenue or engagement climbs. The core job is building a sustainable customer-acquisition engine.
- **Phase III — Scaling the business.** Business model established, market position significant. Signals: consistent growth rate, unit economics work, the question is how to dominate the market. The core job is scaling proven channels.
Write the diagnosis with one paragraph of evidence to `phase-diagnosis.md`.
**WHY:** Every downstream decision depends on phase. A Phase I startup doing Phase III tactics (mass advertising, PR campaigns, full sales teams) wastes money on channels that can't compound without a sticky product. A Phase III startup doing Phase I tactics (personal outreach, hand-holding each customer) underuses scale. Phase mismatch is the most common strategy error.
**IF** signals are mixed between Phase I and II → default to the earlier phase. The cost of over-investing in traction before fit is higher than the cost of under-investing briefly after fit.
### Step 2: Audit Time Allocation Against the 50% Rule
**ACTION:** Calculate how the founder/team is actually splitting time between product work and traction work. Compare to the 50% Rule: **50% of time on product, 50% on traction — at all times, in parallel, regardless of phase.**
If the split is 90/10 product/traction (the common default), name it explicitly. Quote the Product Trap warning: the #1 reason investors pass on otherwise-good founders is focus on product to the exclusion of everything else.
**WHY:** Most founders wildly over-invest in product. Marc Andreessen: "Almost every failed startup has a product. What failed startups don't have are enough customers." The Product Trap is the belief that "if we build it, they will come." Without explicit time-budget accountability, traction work gets crowded out by product work that always feels more urgent. The 50% Rule is a forcing function, not a guideline.
**IF** the user resists 50/50 because "the product isn't ready" → that's exactly when you need traction experiments, because channel feedback shapes the product.
**IF** the user is 50/50 already → excellent, skip to Step 3.
### Step 3: Map Phase-Appropriate Channels
**ACTION:** Based on the diagnosed phase, list which channels typically work and which typically don't. Use the mapping in [references/phase-channel-fit.md](references/phase-channel-fit.md).
Flag any current channel that's mismatched with the phase. Common mismatches:
- Phase I startup running SEM ads without product-market fit → burning budget on churning users
- Phase II startup still relying only on personal outreach → hitting volume ceiling
- Phase III startup ignoring PR → missing biggest growth lever
**WHY:** Channels have phase fit. "Some traction channels will move the needle early on but fail to work later. Others are hard to get working in Phase I but are major sources of traction in the later phases." Running a Phase I playbook in Phase II means growth stalls. Running a Phase III playbook in Phase I means spending on customers you can't retain. Matching phase to channel is the core of the book's strategy advice.
### Step 4: Apply the Moving-the-Needle Filter
**ACTION:** For each proposed or current traction activity, ask: "Can this plausibly deliver enough new customers to meaningfully advance our traction goal at our current scale?"
Do a back-of-envelope calculation: (target new customers) ÷ (realistic conversion rate, 1-5%) = audience you need to reach. Compare that to the channel's realistic reach. If the math doesn't work, the activity is off the needle.
Phase I needle ≠ Phase III needle:
- In Phase I, a tweet from a respected person or a speech to 300 people *can* move the needle.
- In Phase III, if you have 10,000 visitors/day, a blog post that sends 200 visitors is noise.
**WHY:** Founders waste time on activities that feel productive but can't meaningfully affect growth. The moving-the-needle filter is a math check: does the channel even have the volume to matter? Running a Facebook ad with $100 budget in Phase III is not a test — it's rounding error.
**IF** an activity can't pass the needle filter → cut it. Put the time back into the 50% traction budget.
### Step 5: Produce the Phase Strategy Document
**ACTION:** Write `phase-strategy.md` containing:
1. **Phase diagnosis** with evidence
2. **Current time allocation** vs 50% Rule (and the correction needed)
3. **Phase-appropriate channels** — which to pursue, which to cut
4. **Moving-the-needle audit** — activities cut, activities kept
5. **Next 4 weeks of traction experiments**, sized to the phase
**WHY:** A written strategy is a forcing function for accountability. "We're Phase I and the 50% Rule says we need more unscalable outreach" is easier to hold the team to than a verbal agreement. The document also makes phase transitions legible — in 3 months, re-read it and ask "what phase are we in now?"
## Inputs
- Startup metrics (users, revenue, growth rate)
- Current time allocation (product vs traction)
- Current traction activities
- Traction goal (if user has one)
## Outputs
Three markdown files:
1. **`phase-diagnosis.md`** — Phase (I/II/III) with evidence
2. **`phase-strategy.md`** — Complete strategy with time allocation correction and channel map
3. **`weekly-traction-plan.md`** — Next 4 weeks of phase-appropriate experiments
## Key Principles
- **Phase determines everything.** A channel that's a hit in Phase II can be a disaster in Phase I. WHY: The same tactic at the wrong time is a waste. Speed and volume needs change dramatically across phases — Phase I rewards unscalable tactics, Phase III punishes them.
- **50/50 is non-negotiable.** Not 80/20 in favor of product "because we're early". Not 20/80 "because we need customers fast". Always 50/50. WHY: Product and traction co-evolve. Traction experiments reveal what customers actually want. Product changes shape what traction channels work. Decoupling them is how startups die with "a great product nobody wanted."
- **The Product Trap has a specific detection signal.** If the founder says "the product isn't ready for marketing yet", that's the trap. WHY: The product is never "ready." Marc Andreessen: "The number one reason we pass on entrepreneurs is focusing on product to the exclusion of everything else." Ready for marketing means ready for feedback, not ready for perfection.
- **Re-diagnose phase quarterly.** Phases aren't permanent. What was Phase I six months ago might be Phase II now. WHY: Phase transitions are easy to miss from the inside. The channels that served you in Phase I will saturate as you enter Phase II. If you don't re-diagnose, you'll keep running Phase I tactics and watch growth flatten.
- **Unscalable tactics are a Phase I *strategy*, not a failure mode.** Paul Graham's "do things that don't scale" is phase-specific advice. In Phase I, it's correct. In Phase III, it's a trap. WHY: The same advice applied in the wrong phase produces opposite outcomes. Don't let "unscalable = bad" reflexes push you to premature scaling in Phase I.
## Examples
**Scenario: "We're 3 months in, 200 users, growth has stalled"**
Trigger: "Built a note-taking app for lawyers. 200 users in 3 months, mostly from Twitter. Growth has stalled the last 4 weeks. Only I'm doing marketing; 2 engineers on product."
Process: (1) Diagnose Phase I — low user count, no repeat customer signals, team still iterating product. (2) Time audit: founder estimates 70% product, 30% traction → flag the gap. Apply 50% Rule → founder needs to reclaim 20% of product time for traction. (3) Phase-appropriate channels: unscalable tactics work best here — targeting blogs (legal industry blogs), speaking at small legal conferences, direct outreach to named lawyers. Cut: any paid ads (wrong phase), no SEO (too slow for Phase I). (4) Moving-the-needle filter: founder was about to run $500 Facebook ads — kill that. $500 goes to sponsoring a legal-industry newsletter instead. (5) Produce 4-week plan: 10 cold emails/week to named lawyers, 1 guest post on a legal blog, outreach to 2 legal podcast hosts.
Output: Clear Phase I diagnosis, Product Trap flagged (70/30 instead of 50/50), and a concrete unscalable-first plan.
**Scenario: "Great growth for 18 months, now slowing"**
Trigger: "B2B SaaS, $200k MRR, 30% YoY growth. Content marketing drove most of our growth. Last 3 months growth has flattened to 5%. What's happening?"
Process: (1) Diagnose: likely Phase II → Phase III transition. Product-market fit clearly there. Content marketing is saturating (the Law of Shitty Click-Throughs). (2) Time audit: 50/50 seems maintained — that's good. (3) Phase-appropriate channels: Phase III should leverage channels with bigger volume ceilings. Consider PR (first big feature), paid ads at scale, BD with integration partners. (4) Moving-the-needle filter: a new blog post that sends 500 visitors no longer moves the needle at this scale. (5) Produce plan: kick off PR push (3 pitches to industry media), add SEM for bottom-funnel keywords, negotiate 2 integration partnerships.
Output: Phase II→III transition identified; next-phase channels selected; content remains but isn't the growth engine anymore.
**Scenario: The classic Product Trap**
Trigger: "We've been building for 8 months, launching soon, want to plan a big marketing push for launch day."
Process: (1) Diagnose Phase I — not launched, no customers. (2) Time audit: user says "we haven't done marketing yet because the product isn't ready" → Product Trap diagnosis, quote Andreessen. (3) 50% Rule applied retroactively — what traction experiments should have been running for the last 8 months? At minimum: building an email list, talking to 20 prospective customers weekly, finding 10 blogs where the audience lives. (4) Moving-the-needle: a "big launch day push" without a list or audience is a guaranteed flop. (5) Strategy: delay launch by 4 weeks, spend those weeks building traction groundwork (email list, blog relationships, 20 customer conversations), so launch lands on an audience that already cares.
Output: Product Trap named and corrected; launch plan now has traction preamble; founder understands the rule going forward.
## References
- For the full phase-channel fit mapping, see [references/phase-channel-fit.md](references/phase-channel-fit.md)
- For signs of each phase and transition signals, see [references/phase-signals.md](references/phase-signals.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select specific channels within your phase strategy
- `clawhub install bookforge-traction-channel-testing` — Run cheap tests on the channels you pick
- `clawhub install bookforge-startup-critical-path-planning` — Set quantified traction goals by phase
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/phase-channel-fit.md
# Phase-Channel Fit Map
Which channels typically work in which phase. Use as a starting point — every startup is different, but this captures the patterns from the book.
## Phase I: Making Something People Want
**Goal:** Find product-market fit. Small, highly-engaged customer base.
**Channels that typically work:**
- **Targeting Blogs** — Mid-level niche blogs give Phase I startups an audience without needing scale.
- **Sales (direct/enterprise)** — Personal outreach is expected and necessary. First customers come from relationships.
- **Speaking Engagements** — Small talks in front of the right audience (200 engaged people > 20k unengaged).
- **Community Building** — Seed a community of early believers who become co-creators.
- **Engineering as Marketing** — Free tools that solve one specific problem for one specific audience.
- **Business Development (focused)** — One strategic partnership can define the early story.
**Channels that typically don't work:**
- **SEM at scale** — Paid ads to churning users burn budget.
- **Offline Ads** — No scale to justify cost.
- **Trade Shows (big ones)** — Cost doesn't match the small audience they can actually reach.
## Phase II: Marketing Something People Want
**Goal:** Build a scalable customer-acquisition engine. Growth from repeatable channels.
**Channels that typically work:**
- **Content Marketing** — Compounds over time. Phase II is where the returns kick in.
- **SEO** — If you invested in Phase I, ranks now.
- **SEM** — Unit economics work because product-market fit gives you retention.
- **Email Marketing** — Lifecycle emails convert the audience built in Phase I.
- **Viral Marketing** — Only valuable if baked into the product early.
- **Affiliate Programs** — Need product-market fit so affiliates are willing to promote.
**Channels that may stop working:**
- **Personal outreach** — Hit volume ceiling. Can't scale with 2 founders.
- **Small targeted blogs** — Audience exhausted.
## Phase III: Scaling the Business
**Goal:** Dominate the market. Compound across multiple channels.
**Channels that typically work:**
- **Public Relations (PR)** — Feature stories drive the biggest single-day spikes.
- **Content Marketing (at scale)** — Publication-level content operations.
- **SEM (big budgets)** — Unit economics clear, just buy more.
- **Offline Ads** — TV/radio make sense at this scale.
- **Existing Platforms** — Day-1 presence on new platforms.
- **Trade Shows (major)** — Mass meetups with qualified buyers.
- **Speaking Engagements (marquee)** — Keynotes, not small meetups.
**Channels that typically can't keep up:**
- **Any Phase I unscalable tactic** — The math stops working.
## Source
Chapter 3 ("Traction Thinking") of *Traction* by Gabriel Weinberg and Justin Mares.
FILE:references/phase-signals.md
# Phase Signals and Transition Markers
How to tell which phase a startup is actually in, and when it's transitioning.
## Phase I Signals
- User count < 1,000 (soft threshold, varies by product)
- Product still actively being rewritten based on each user conversation
- Customers churn quickly (retention weak)
- Founder can name every customer
- Growth is bumpy and unpredictable
- "Traction goal" would be something like "first 100 paying customers"
## Phase I → II Transition
- Customers start sticking without prompting
- Word-of-mouth begins ("my friend told me about this")
- Founder stops needing to hand-hold every new customer
- Product stops being rewritten at the fundamental level
- Growth rate becomes more predictable month-over-month
## Phase II Signals
- Product-market fit is clear in retention data
- A channel is producing consistent leads
- Team is hiring to scale marketing/sales functions
- Traction goal is something like "reach break-even revenue" or "100k users"
- The question is "how do we grow the channel" not "do we have a channel"
## Phase II → III Transition
- The channel that worked starts to saturate (rising CAC, diminishing volume)
- Growth rate from the primary channel flattens
- Team has resources to pursue multiple channels in parallel
- Market is now aware of the company
## Phase III Signals
- Established business model with known unit economics
- Multiple channels contributing meaningfully
- Growth rate is more about scaling than discovery
- Traction goal is about market share or dominance
- Strategic concerns (competition, category definition) matter more than tactical channel selection
## Common Misdiagnoses
- **Phase I looking like Phase II:** Founder thinks they have product-market fit because a few customers love the product. Check retention: do customers come back, or were those a one-time spike?
- **Phase II looking like Phase III:** Founder thinks they're scaling because revenue grew, but the channel is actually saturating — they just haven't noticed CAC climbing.
- **Phase III looking like Phase I:** Founder acts like a scrappy startup at $10M ARR, refusing to hire scaled marketing. The unscalable tactics that got them here aren't enough anymore.
## Source
Chapter 3 ("Traction Thinking") of *Traction* by Gabriel Weinberg and Justin Mares.
Generate UUIDs in versions v1, v4, and v5 with options for count, namespace, name, and output format.
# uuid-generator
Generate UUIDs in various formats. Supports UUID v1, v4, v5, and custom patterns.
## Features
- **UUID v4** (default): Cryptographically random UUIDs
- **UUID v1**: Time-based UUIDs with timestamp and MAC address
- **UUID v5**: Namespace-based deterministic UUIDs (SHA-1)
- **Bulk generate**: Generate multiple UUIDs at once
- **Format options**: Standard, uppercase, no-dashes, URL-safe
## Usage
```
uuid
uuid v4
uuid v4 --count 10
uuid v1
uuid v5 ns:url "https://example.com"
uuid --format no-dashes
uuid --format uppercase
```
## Parameters
- `version`: UUID version to generate (v1/v4/v5, default: v4)
- `count`: Number of UUIDs to generate (default: 1)
- `namespace`: (v5 only) Namespace: url, dns, oid, x500, or custom
- `name`: (v5 only) Name within the namespace
- `format`: Output format: standard/uppercase/nodashes/urlsafe
## ⚠️ Disclaimer
This tool is provided "as is" for informational purposes only. Data accuracy is not guaranteed. Not financial, legal, or professional advice. Always verify critical information from official sources.
本工具仅供信息参考,不保证数据完全准确,不构成任何金融/法律/专业建议。请以官方来源为准。
FILE:scripts/uuid_gen.py
#!/usr/bin/env python3
"""UUID Generator - Generate UUID v1, v4, v5 in various formats"""
import uuid, sys
def generate_v4(count=1, fmt='standard'):
uuids = [uuid.uuid4() for _ in range(count)]
return format_uuids(uuids, fmt)
def generate_v1(count=1, fmt='standard'):
uuids = [uuid.uuid1() for _ in range(count)]
return format_uuids(uuids, fmt)
def generate_v5(namespace, name, count=1, fmt='standard'):
ns_map = {'url': uuid.UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8'),
'dns': uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8'),
'oid': uuid.UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8'),
'x500': uuid.UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8')}
ns = ns_map.get(namespace.lower(), uuid.UUID(namespace))
uuids = [uuid.uuid5(ns, name) for _ in range(count)]
return format_uuids(uuids, fmt)
def format_uuids(uuids, fmt):
results = []
for u in uuids:
s = str(u)
if fmt == 'uppercase':
s = s.upper()
elif fmt == 'nodashes':
s = s.replace('-', '')
elif fmt == 'urlsafe':
s = u.urlsafe().decode() if hasattr(u, 'urlsafe') else s.replace('-', '')
results.append(s)
return '\n'.join(results)
def main():
args = sys.argv[1:]
version = 'v4'
count = 1
fmt = 'standard'
namespace = None
name = None
i = 0
while i < len(args):
if args[i] == 'v1' or args[i] == 'v4' or args[i] == 'v5':
version = args[i]
elif args[i] == '--count' and i + 1 < len(args):
count = int(args[i+1]); i += 1
elif args[i] == '--format' and i + 1 < len(args):
fmt = args[i+1]; i += 1
elif args[i] == 'ns:url' or args[i] == 'ns:dns' or args[i].startswith('ns:'):
namespace = args[i][3:]
elif i > 0 and args[i-1].startswith('ns:'):
name = args[i]
i += 1
# Find name in args
for arg in args:
if not arg.startswith('-') and not arg.startswith('v') and arg not in ['url', 'dns', 'oid', 'x500']:
if namespace and not name:
name = arg
try:
if version == 'v4':
print(generate_v4(count, fmt))
elif version == 'v1':
print(generate_v1(count, fmt))
elif version == 'v5':
if not namespace or not name:
print("UUID v5 requires namespace and name", file=sys.stderr)
sys.exit(1)
print(generate_v5(namespace, name, count, fmt))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
ITIL 5 Manager - Elite IT Service Management Advisor specializing in ITSM, FinOps, and IT governance using ITIL 5 DPSM framework.
---
name: li_itil_manager
description: ITIL 5 Manager - Elite IT Service Management Advisor specializing in ITSM, FinOps, and IT governance using ITIL 5 DPSM framework.
risk: safe
source: community
date_added: "2026-04-27"
triggers:
- "itil manager"
- "itil5"
- "itil 5"
- "it service management"
- "itsm advice"
- "service desk"
- "incident management"
- "problem management"
- "change management"
- "itil advisor"
- "itil consultant"
- "service lifecycle"
- "itil framework"
---
# ITIL 5 Manager (li_itil_manager)
## Purpose
A comprehensive ITIL 5 advisor combining Digital Product and Service Management (DPSM) with modern IT management practices. Provides strategic and operational guidance for IT managers, service desk leads, and digital leaders.
## When to Use
- Need ITIL 5 implementation guidance
- Managing IT service delivery and support
- Building or improving ITSM processes
- Implementing FinOps in IT operations
- Bridging IT and business communication
## Core Capabilities
- **ITIL 5 DPSM:** Digital Product and Service Management approach
- **Service Value Chain:** Plan, Engage, Design & Transform, Obtain/Build, Deliver & Support, Improve
- **Process Optimization:** Incident, Problem, Change, Knowledge, and Service Request Management
- **Executive Communication:** C-level storytelling and ROI reporting
- **FinOps Integration:** Connecting service cost to business value
## ITIL 5 Guiding Principles
1. Focus on value
2. Progress iteratively
3. Collaborate and promote visibility
4. Think and work holistically
5. Keep it simple
6. Optimize and automate
7. Everything is a relationship
## Mandatory Instructional Protocol (IMPORTANT)
**Before providing extended insights, case studies, or detailed examples of applicability, you MUST ask for user consent.**
* **Protocol:** Provide the core answer/solution first. Then, conclude with: *"Would you like deep insights into the applicability of this solution or a real-world resolution example?"*
* **Action:** Only provide the extra depth if the user explicitly confirms.
## Expert Instructions
### 1. Service Strategy & Value Co-creation
- Treat all IT services as Digital Products
- Define Service Offerings that support customer outcomes
- Establish Service Relationships with stakeholders
- Map service value to business outcomes
### 2. Service Design & Transformation
- Design service offerings that meet customer needs
- Define service levels and KPIs
- Create service catalogs
- Implement service quality metrics
### 3. Service Transition
- Manage changes effectively
- Implement release management
- Knowledge management practices
- Service validation and testing
### 4. Service Operation
- Incident Management lifecycle
- Problem Management for root cause
- Request Fulfilment
- Event Management and monitoring
- Access Management
### 5. Continual Improvement
- 7-step improvement model
- Process measurement and metrics
- CSI register for improvements
- Value realization tracking
### 6. FinOps for IT Services
- Connect spend to service value
- Unit economics for services
- Right-sizing and optimization
- Cloud and AI cost management
### 7. Communication Bridge
- Executive reporting with SIR (Situation-Impact-Resolution)
- Stakeholder management
- ROI-focused narratives
## Applicability Scenarios
- Implementing ITIL 5 from scratch
- Migrating from ITIL v4 to ITIL 5
- Incident escalation and resolution
- Change management best practices
- Service desk optimization
- IT budget and cost optimization
## References
- [IT Manager's Handbook](./references/it-manager-handbook.md)
- [Management Scenarios](./examples/management-scenarios.md)
- [IT Frameworks Guide](./references/it-management-frameworks.md)
## Limitations
- Strategic advisory only, not legal/financial auditing
- Advice quality depends on provided context
- Always verify against local regulations
FILE:README.de.md
# ITIL 5 Manager (li_itil_manager)
Elite IT-Service-Management-Berater, spezialisiert auf ITSM, FinOps und IT-Governance unter Verwendung des ITIL 5 DPSM-Frameworks.
## Überblick
Ein umfassender ITIL 5-Berater, der Digitale Produkt- und Service-Management (DPSM) mit modernen IT-Management-Praktiken kombiniert. Bietet strategische und operative Anleitung für IT-Manager, Service-Desk-Leiter und digitale Führungskräfte.
## Funktionen
- **ITIL 5 DPSM:** Digitales Produkt- und Service-Management-Ansatz
- **Service-Wertschöpfungskette:** Planen, Engagieren, Gestalten & Transformieren, Beschaffen/Bauen, Liefern & Unterstützen, Verbessern
- **Prozessoptimierung:** Incident-, Problem-, Änderungs-, Wissens- und Serviceanfrage-Management
- **Führungskommunikation:** C-Level Storytelling und ROI-Berichterstattung
- **FinOps-Integration:** Verbindung von Servicekosten mit Geschäftswert
## ITIL 5 Leitprinzipien
1. Auf Wert fokussieren
2. Iterativ vorgehen
3. Zusammenarbeiten und Sichtbarkeit fördern
4. Ganzheitlich denken und arbeiten
5. Einfachheit bewahren
6. Optimieren und automatisieren
7. Alles ist Beziehung
## Verwendung
Auslösen mit Schlüsselwörtern:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Struktur
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Version
- Aktuell: 1.0.0
- Datum: 2026-04-27
- Framework: ITIL 5 DPSM
## Lizenz
Community-Skill - MIT
FILE:README.en.md
# ITIL 5 Manager (li_itil_manager)
Elite IT Service Management Advisor specializing in ITSM, FinOps, and IT governance using ITIL 5 DPSM framework.
## Overview
A comprehensive ITIL 5 advisor combining Digital Product and Service Management (DPSM) with modern IT management practices. Provides strategic and operational guidance for IT managers, service desk leads, and digital leaders.
## Features
- **ITIL 5 DPSM:** Digital Product and Service Management approach
- **Service Value Chain:** Plan, Engage, Design & Transform, Obtain/Build, Deliver & Support, Improve
- **Process Optimization:** Incident, Problem, Change, Knowledge, and Service Request Management
- **Executive Communication:** C-level storytelling and ROI reporting
- **FinOps Integration:** Connecting service cost to business value
## ITIL 5 Guiding Principles
1. Focus on value
2. Progress iteratively
3. Collaborate and promote visibility
4. Think and work holistically
5. Keep it simple
6. Optimize and automate
7. Everything is a relationship
## Usage
Trigger with keywords:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Structure
```
li_itil_manager/
├── SKILL.md # Skill definition
├── README.md # This file
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Version
- Current: 1.0.0
- Date: 2026-04-27
- Framework: ITIL 5 DPSM
## License
Community skill - MIT
## Author
ClawHub Community
FILE:README.es.md
# ITIL 5 Manager (li_itil_manager)
Asesor élite de gestión de servicios de TI especializado en ITSM, FinOps y gobernanza de TI utilizando el marco ITIL 5 DPSM.
## Descripción
Un asesor completo de ITIL 5 que combina la Gestión de Productos y Servicios Digitales (DPSM) con prácticas modernas de gestión de TI. Proporciona orientación estratégica y operativa para gerentes de TI, líderes de mesa de servicio y líderes digitales.
## Características
- **ITIL 5 DPSM:** Enfoque de Gestión de Productos y Servicios Digitales
- **Cadena de Valor del Servicio:** Planificar, Involucrar, Diseñar y Transformar, Obtener/Construir, Entregar y Apoyar, Mejorar
- **Optimización de Procesos:** Gestión de Incidentes, Problemas, Cambios, Conocimiento y Solicitudes de Servicio
- **Comunicación Ejecutiva:** Narrativa para C-level e informes ROI
- **Integración FinOps:** Conectar costo del servicio con valor empresarial
## Principios Guia ITIL 5
1. Enfocarse en el valor
2. Progresar iterativamente
3. Colaborar y promover visibilidad
4. Pensar y trabajar holísticamente
5. Mantenerlo simple
6. Optimizar y automatizar
7. Todo es una relación
## Uso
Dispara con palabras clave:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Estructura
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Versión
- Actual: 1.0.0
- Fecha: 2026-04-27
- Framework: ITIL 5 DPSM
## Licencia
Habilidad comunitaria - MIT
FILE:README.fr.md
# ITIL 5 Manager (li_itil_manager)
Conseiller Elite en Gestion des Services IT spécialisé en ITSM, FinOps et Gouvernance IT utilisant le framework ITIL 5 DPSM.
## Aperçu
Un conseiller complet ITIL 5 combinant la Gestion des Produits et Services Numériques (DPSM) avec les pratiques modernes de gestion IT. Fournit des orientations stratégiques et opérationnelles pour les responsables IT, les responsables du service desk et les leaders numériques.
## Caractéristiques
- **ITIL 5 DPSM:** Approche de Gestion des Produits et Services Numériques
- **Chaîne de Valeur du Service:** Planifier, Engager, Concevoir et Transformer, Obtenir/Construire, Livrer et Supporter, Améliorer
- **Optimisation des Processus:** Gestion des Incidents, Problèmes, Changements, Connaissances et Demandes de Service
- **Communication Exécutive:** Storytelling pour C-level et rapports ROI
- **Intégration FinOps:** Connecter le coût du service à la valeur métier
## Principes Directeurs ITIL 5
1. Se concentrer sur la valeur
2. Progresser de manière itérative
3. Collaborer et promouvoir la visibilité
4. Penser et travailler holistiquement
5. Garder simple
6. Optimiser et automatiser
7. Tout est une relation
## Utilisation
Déclencher avec mots-clés:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Structure
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Version
- Actuelle: 1.0.0
- Date: 2026-04-27
- Framework: ITIL 5 DPSM
## Licence
Compétence communautaire - MIT
FILE:README.ja.md
# ITIL 5 Manager (li_itil_manager)
ITSM、FinOps、ITガバナンスにおけるエリートITサービス管理アドバイザー。ITIL 5 DPSMフレームワークを専門とします。
## 概要
デジタルプロダクト&サービス管理(DPSM)と最新のIT管理プラクティスを組み合わせた総合的なITIL 5アドバイザー。ITマネージャー、サービスデスクリーダー、デジタルリーダーへの戦略的および運用ガイダンスを提供します。
## 機能
- **ITIL 5 DPSM:** デジタルプロダクト&サービス管理アプローチ
- **サービスバリューチェーン:** プラン、エンゲージ、設計・変革、取得・構築、配信・支援、改善
- **プロセス最適化:** インシデント、問題、変更、ナレッジ、サービスリクエスト管理
- **エグゼクティブコミュニケーション:** CレベルストーリーテリングとROIレポート
- **FinOps統合:** サービスコストとビジネス価値の連携
## ITIL 5指導原則
1. 価値に焦点を当てる
2. 反復的に進捗する
3. コラボレーションと可視性の促進
4. holisticallyに考える
5. シンプルに保つ
6. 最適化と自動化
7. すべてが関係である
## 使用方法
以下のキーワードでトリガー:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## 構造
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## バージョン
- 現行: 1.0.0
- 日付: 2026-04-27
- フレームワーク: ITIL 5 DPSM
## ライセンス
コミュニティスキル - MIT
FILE:README.ko.md
# ITIL 5 Manager (li_itil_manager)
ITSM, FinOps 및 IT 거버넌스를 전문으로 하는 엘리트 IT 서비스 관리 자문관 ITIL 5 DPSM 프레임워크를 사용합니다.
## 개요
디지털 제품 및 서비스 관리(DPSM)와 최신 IT 관리 관행을 결합한 종합 ITIL 5 자문관입니다. IT 관리자, 서비스 데스크 리더 및 디지털 리더에게 전략적 및 운영 지침을 제공합니다.
## 기능
- **ITIL 5 DPSM:** 디지털 제품 및 서비스 관리 접근 방식
- **서비스 가치 사슬:** 계획, 참여, 설계 및 전환, 획득/구축, 제공 및 지원, 개선
- **프로세스 최적화:** 인시던트, 문제, 변경, 지식 및 서비스 요청 관리
- **임원 커뮤니케이션:** C 레벨 스토리텔링 및 ROI 보고
- **FinOps 통합:** 서비스 비용을 비즈니스 가치에 연결
## ITIL 5 지침 원칙
1. 가치에 집중
2. 반복적으로 진행
3. 협업 및 가시성 촉진
4. 전체적으로 생각하고 작업
5. 단순하게 유지
6. 최적화 및 자동화
7. 모든 것은 관계
## 사용 방법
키워드로 트리거:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## 구조
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## 버전
- 현재: 1.0.0
- 날짜: 2026-04-27
- 프레임워크: ITIL 5 DPSM
## 라이선스
커뮤니티 스킬 - MIT
FILE:README.md
# ITIL 5 Manager (li_itil_manager)
Elite IT Service Management Advisor specializing in ITSM, FinOps, and IT governance using ITIL 5 DPSM framework.
## Overview
A comprehensive ITIL 5 advisor combining Digital Product and Service Management (DPSM) with modern IT management practices. Provides strategic and operational guidance for IT managers, service desk leads, and digital leaders.
## Features
- **ITIL 5 DPSM:** Digital Product and Service Management approach
- **Service Value Chain:** Plan, Engage, Design & Transform, Obtain/Build, Deliver & Support, Improve
- **Process Optimization:** Incident, Problem, Change, Knowledge, and Service Request Management
- **Executive Communication:** C-level storytelling and ROI reporting
- **FinOps Integration:** Connecting service cost to business value
## ITIL 5 Guiding Principles
1. Focus on value
2. Progress iteratively
3. Collaborate and promote visibility
4. Think and work holistically
5. Keep it simple
6. Optimize and automate
7. Everything is a relationship
## Usage
Trigger with keywords:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Structure
```
li_itil_manager/
├── SKILL.md # Skill definition
├── README.md # This file
├── references/
│ ├── it-manager-handbook.md # IT Management Handbook
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Version
- Current: 1.0.0
- Date: 2026-04-27
- Framework: ITIL 5 DPSM
## License
Community skill - MIT
## Author
ClawHub Community
FILE:README.pt.md
# ITIL 5 Manager (li_itil_manager)
Assessor Élite de Gerenciamento de Serviços de TI especializado em ITSM, FinOps e Governança de TI usando o framework ITIL 5 DPSM.
## Visão Geral
Um assessor abrangente de ITIL 5 combinando Gerenciamento de Produtos e Serviços Digitais (DPSM) com práticas modernas de gestão de TI. Fornece orientação estratégica e operacional para gerentes de TI, líderes de service desk e líderes digitais.
## Recursos
- **ITIL 5 DPSM:** Abordagem de Gerenciamento de Produtos e Serviços Digitais
- **Cadeia de Valor de Serviço:** Planejar, Engajar, Projetar e Transformar, Obter/Construir, Entregar e Suportar, Melhorar
- **Otimização de Processos:** Gerenciamento de Incidentes, Problemas, Mudanças, Conhecimento e Solicitações de Serviço
- **Comunicação Executiva:** Storytelling para C-level e relatórios de ROI
- **Integração FinOps:** Conectar custo de serviço com valor de negócio
## Princípios Guia ITIL 5
1. Focar no valor
2. Progredir iterativamente
3. Colaborar e promover visibilidade
4. Pensar e trabalhar holísticamente
5. Manter simples
6. Otimizar e automatizar
7. Tudo é uma relação
## Uso
Dispare com palavras-chave:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## Estrutura
```
li_itil_manager/
├── SKILL.md
├── README.md
├── references/
│ ├── it-manager-handbook.md
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## Versão
- Atual: 1.0.0
- Data: 2026-04-27
- Framework: ITIL 5 DPSM
## Licença
Habilidade comunitária - MIT
FILE:README.zh-CN.md
# ITIL 5 Manager (li_itil_manager)
精英IT服务管理顾问,专注于使用ITIL 5 DPSM框架的ITSM、FinOps和IT治理。
## 概述
一个综合性的ITIL 5顾问,结合数字产品和服务管理(DPSM)与现代IT管理实践。为IT经理、服务台负责人和数字领导者提供战略和运营指导。
## 功能特点
- **ITIL 5 DPSM:** 数字产品和服务管理方法
- **服务价值链:** 计划、参与、设计与转型、获取/构建、交付与支持、改进
- **流程优化:** 事件、问题、变更、知识和 服务请求管理
- **高管沟通:** C级别故事化叙述和ROI报告
- **FinOps集成:** 连接服务成本与业务价值
## ITIL 5指导原则
1. 聚焦价值
2. 迭代推进
3. 协作并提升透明度
4. 全局思考和工作
5. 保持简洁
6. 优化和自动化
7. 一切都是关系
## 使用方法
使用以下关键词触发:
- `itil manager`
- `itil5` / `itil 5`
- `it service management`
- `incident management`
- `problem management`
- `change management`
- `service desk`
## 目录结构
```
li_itil_manager/
├── SKILL.md # Skill定义
├── README.md # 说明文档
├── references/
│ ├── it-manager-handbook.md # IT管理手册
│ └── it-management-frameworks.md
└── examples/
└── management-scenarios.md
```
## 版本信息
- 当前版本: 1.0.0
- 日期: 2026-04-27
- 框架: ITIL 5 DPSM
## 许可证
社区技能 - MIT
## 作者
ClawHub 社区
FILE:examples/management-scenarios.md
# ITIL Manager Scenarios
Common real-world ITIL management scenarios with expert-driven advice.
## Scenario 1: Implementing ITIL 5 from Scratch
**Situation:** Organization wants to adopt ITIL 5 DPSM approach.
**Expert Advice:**
- Start with ITIL 5 Guiding Principles - focus on value and collaboration
- Map current services to Digital Products
- Identify service relationships with stakeholders
- Implement Service Value Chain activities progressively
- Use "Progress iteratively" - start small, iterate
- **Question:** Would you like deep insights into implementation steps?
## Scenario 2: Major Incident Management
**Situation:** Critical system outage affecting business operations.
**Expert Advice (ITIL 5 Incident Management):**
- **Detect & Log:** Immediate incident creation
- **Categorize & Prioritize:** Impact and urgency assessment
- **Diagnose:** Technical investigation
- **Resolve:** Fix and restore service
- **Close:** Formal closure with customer sign-off
- Communication: Use SIR (Situation-Impact-Resolution) for updates
- Post-incident: Blameless review within 24 hours
- **Question:** Would you like deep insights into escalation procedures?
## Scenario 3: Change Management
**Situation:** Need to deploy major infrastructure change with minimum risk.
**Expert Advice (ITIL 5 Change Management):**
- **RFC:** Complete Request for Change with justification
- **Assessment:** Evaluate risk, impact, and cost
- ** CAB Review:** Present to Change Advisory Board
- **Planning:** Define rollback procedures
- **Implementation:** Execute in change window
- **Review:** Post-implementation review
- Follow "Think and work holistically" - consider all dependencies
- **Question:** Would you like deep insights into risk assessment?
## Scenario 4: Service Desk Optimization
**Situation:** High volume of tickets, low customer satisfaction.
**Expert Advice:**
- Analyze ticket categories and root causes
- Implement Service Request Management for repetitive tasks
- Build Knowledge Base for self-service
- Use "Optimize and automate" - automate routine requests
- Track FCR (First Contact Resolution) and CSAT metrics
- **Question:** Would you like deep insights into KPI optimization?
## Scenario 5: Problem Management
**Situation:** Recurring incidents from underlying root cause.
**Expert Advice:**
- Use Problem Management to find root cause
- Create Problem Record linked to related Incidents
- Analyze trends usingKeppler Incident Analysis
- Implement permanent fix through Change Management
- Update Knowledge Base with workarounds
- **Question:** Would you like deep insights into problem analysis techniques?
## Scenario 6: IT Budget and Cost Optimization
**Situation:** Need to optimize IT spend while maintaining service quality.
**Expert Advice (FinOps + ITIL 5):**
- Map service costs using value chain activities
- Identify under-utilized services
- Implement consumption-based pricing where possible
- Use "Focus on value" - cut low-value services
- Track Cost per Service and Cost per User metrics
- **Question:** Would you like deep insights into FinOps practices?
---
*Reference scenarios for ITIL 5 Manager skill.*
FILE:references/it-management-frameworks.md
# IT Management Frameworks Guide (2026)
Comprehensive guide for aligning IT with business objectives using world-class frameworks.
## 1. IT Governance & Strategy
* **COBIT (Control Objectives for Information and Related Technologies):** Focused on IT corporate governance. Helps align technology with business strategic objectives, manage risks, and ensure regulatory compliance.
* **ISO/IEC 38500:** Provides basic principles for efficient, effective, and acceptable use of IT within organizations, focusing on director responsibilities.
## 2. IT Service Management (ITSM) - ITIL 5
* **ITIL (Information Technology Infrastructure Library):** The global standard for service management. ITIL 5 focuses on the service lifecycle and Digital Product and Service Management (DPSM).
* **ITIL 5 DPSM (Digital Product and Service Management):** New approach treating all IT services as digital products, emphasizing continuous value creation.
* **ISO/IEC 20000:** International standard for IT service management, serving as a basis for organizational quality certifications.
* **MOF (Microsoft Operations Framework):** Adaptation of ITIL practices focused specifically on Microsoft technology ecosystems.
## 3. Enterprise Architecture
* **TOGAF (The Open Group Architecture Framework):** Specialized in designing, planning, and implementing enterprise architectures to ensure technology foundation supports business scalability.
## 4. Project Management & Agile
* **PMBOK (Project Management Body of Knowledge):** Guide for traditional project management (Waterfall/Predictive).
* **PRINCE2 (Projects in Controlled Environments):** Structured method focused on control, organization, and ongoing business justification.
* **Scrum / Agile:** Frameworks for complex project management with focus on rapid, iterative, adaptive delivery.
* **SAFe (Scaled Agile Framework):** Methodology for scaling agile practices in large organizations.
## 5. Security & Risk
* **NIST Cybersecurity Framework:** Guidelines for reducing cybersecurity risks in critical infrastructure and government.
* **ISO/IEC 27001:** International standard for implementing an Information Security Management System (ISMS).
* **FAIR (Factor Analysis of Information Risk):** Quantitative model for understanding and measuring information risk in financial terms.
## 6. Modern Operations & Innovation
* **DevOps Framework:** Full integration between development and operations to accelerate value delivery cycle.
* **SRE (Site Reliability Engineering):** Google's approach using software engineering to solve operations and scalability problems.
* **AIOps:** Use of Artificial Intelligence and Machine Learning to automate incident detection and optimize operational performance.
## Framework Selection Guide
| Need | Recommended Framework |
|------|----------------------|
| IT Governance | COBIT |
| Service Management | ITIL 5 DPSM |
| Enterprise Architecture | TOGAF |
| Traditional Projects | PMBOK/PRINCE2 |
| Agile Projects | Scrum/SAFe |
| Security | ISO 27001/NIST |
| Operations Optimization | DevOps/SRE/AIOps |
FILE:references/it-manager-handbook.md
# IT Manager Handbook (2026 Edition) - ITIL 5 Edition
A strategic reference for managing modern digital technical organizations with ITIL 5 foundation.
## 1. Leadership in a VUCA World
IT Management is now characterized by Volatility, Uncertainty, Complexity, and Ambiguity.
- **Adaptive Strategy:** Move from rigid 5-year plans to "Rolling 12-month Value Roadmaps."
- **Psychological Safety:** The foundation of high-performance engineering teams. Encourage blameless post-mortems and celebrate "smart failures."
- **ITIL 5 Guiding Principles:** Apply "Progress iteratively," "Collaborate and promote visibility," and "Think and work holistically" in leadership approach.
## 2. FinOps 2.0: Value over Cost
Sustainable cloud and AI growth require a FinOps mindset that connects spend to revenue and P&L impact.
- **Unit Economics:** Calculate the "Cost per Transaction" or "Cost per Active AI Agent."
- **Waste Identification:** Historically, 30% of cloud spend is waste. Use AI-driven right-sizing and spot-instance automation.
- **ITIL 5 Service Value Chain:** Use the "Obtain/Build" and "Deliver & Support" practices to optimize technology spend.
## 3. Data-Driven Management (DDM)
Stop making decisions based on intuition or the "Highest Paid Person's Opinion" (HIPPO).
- **Process Mining:** Extract value stream maps from system logs to find actual cycle times and hidden bottlenecks.
- **KPIs that Matter:** Deployment Frequency, Mean Time to Recovery (MTTR), and Service Value Realization (SVR).
- **ITIL 5 Continual Improvement:** Use the 7-step improvement model to drive data-driven optimization.
## 4. AI-Native Governance & Ethics
Governing a symbiotic human-AI workspace where agents are coworkers.
- **Ethical Audit:** Quarterly reviews of AI decision-making bias and algorithmic transparency.
- **Security:** Managing the broad attack surface of LLM integrations and retrieval-augmented generation (RAG) systems.
- **ITIL 5 Risk Management:** Integrate AI governance into the overall service risk management practice.
## 5. ITIL 5 Digital Product and Service Management (DPSM)
### Core Concepts
- **Digital Product (DP):** Any technology-enabled service that delivers value to customers
- **Service Offering:** The totality of how a service supports customer outcomes
- **Service Relationship:** The cooperation between provider and consumer
- **Value Co-creation:** Working with stakeholders to create value
### Service Value Chain Activities
- **Plan:** Define the vision, roadmap, and architecture
- **Engage:** Understand stakeholder needs and expectations
- **Design & Transform:** Create new services and improvements
- **Obtain/Build:** Acquire or develop components and capabilities
- **Deliver & Support:** Service delivery and operational support
- **Improve:** Continual improvement of services
### The 7 Guiding Principles
1. Focus on value
2. Progress iteratively
3. Collaborate and promote visibility
4. Think and work holistically
5. Keep it simple
6. Optimize and automate
7. Everything is a relationship
---
*Reference source for ITIL 5 Manager (li_itil_manager) skill.*Randomly select or split team members with options for weighted choice, exclusions, and fair distribution over multiple rounds.
# random-team-picker
Randomly select team members for meetings, code reviews, or activities. Supports weighted selection, exclusion lists, and team splitting.
## Features
- Pick N random members from a list
- Split a group into N teams
- Weighted random selection (higher weight = more likely to be picked)
- Exclude certain members (e.g., on vacation)
- Ensure fair distribution over multiple rounds
## Usage
```
pick --from "Alice,Bob,Charlie,Dave,Eve" --count 2
pick --teams "Alice,Bob,Charlie,Dave" --num-teams 2
pick --from "Alice,Bob,Charlie" --weighted "Alice:3,Bob:2,Charlie:1"
pick --from "Alice,Bob,Charlie" --exclude "Alice" --count 1
```
## Parameters
- `from`: Comma-separated list of member names
- `count`: Number of members to pick (default: 1)
- `num_teams`: Number of teams to split into
- `weighted`: Weighted selection in format "name:weight" pairs
- `exclude`: Members to exclude from selection
## ⚠️ Disclaimer
This tool is provided "as is" for informational purposes only. Data accuracy is not guaranteed. Not financial, legal, or professional advice. Always verify critical information from official sources.
本工具仅供信息参考,不保证数据完全准确,不构成任何金融/法律/专业建议。请以官方来源为准。
FILE:scripts/pick.py
#!/usr/bin/env python3
"""Random Team Picker - Pick random members or split into teams"""
import random, sys, argparse
def pick_members(members, count=1, weights=None, exclude=None):
excluded = set(exclude.split(',')) if exclude else set()
pool = [m for m in members if m not in excluded]
if not pool:
return []
if weights:
weight_list = []
weight_map = dict(w.split(':') for w in weights.split(','))
for m in pool:
w = int(weight_map.get(m, 1))
weight_list.extend([m] * w)
return random.sample(weight_list, min(count, len(pool)))
return random.sample(pool, min(count, len(pool)))
def split_teams(members, num_teams):
shuffled = list(members)
random.shuffle(shuffled)
teams = [[] for _ in range(num_teams)]
for i, m in enumerate(shuffled):
teams[i % num_teams].append(m)
return teams
def main():
parser = argparse.ArgumentParser(description='Random Team Picker')
parser.add_argument('--from', dest='members', required=True)
parser.add_argument('--count', type=int, default=1)
parser.add_argument('--num-teams', type=int, default=0)
parser.add_argument('--weighted', default='')
parser.add_argument('--exclude', default='')
args = parser.parse_args()
members = [m.strip() for m in args.members.split(',')]
if args.num_teams > 0:
teams = split_teams(members, args.num_teams)
for i, team in enumerate(teams):
print(f"Team {i+1}: {', '.join(team)}")
else:
picked = pick_members(members, args.count, args.weighted, args.exclude)
if args.count == 1:
print(picked[0] if picked else "No members available")
else:
print(', '.join(picked))
if __name__ == "__main__":
main()
实时汇率换算专家。支持150+货币实时汇率、批量换算、多货币对比、历史汇率查询。零API Key,免费数据源。When user asks about currency exchange, conversion rates, USD to CNY, forex, or money conversion.
---
name: currency-converter-pro
description: 实时汇率换算专家。支持150+货币实时汇率、批量换算、多货币对比、历史汇率查询。零API Key,免费数据源。When user asks about currency exchange, conversion rates, USD to CNY, forex, or money conversion.
---
# Currency Converter Pro
**实时汇率换算专家** | Author: Lin Hui | Version 1.0.0 | MIT License
支持150+货币实时汇率查询、批量换算、历史汇率对比。零API Key,免费数据源。
## 核心功能
- ✅ 实时汇率查询(150+货币)
- ✅ 任意金额多货币换算
- ✅ 多货币横向对比(1000美元能换多少各货币)
- ✅ 历史汇率查询
- ✅ 零API Key,免费数据源
- ✅ 支持主要货币:CNY、USD、EUR、GBP、JPY、KRW、HKD、TWD、SGD、AUD、CAD、CHF、INR等
## 数据来源
- **open.er-api.com** — 免费汇率API,无需注册,无需Key
- 数据更新:每日多次自动更新
- 覆盖范围:150+全球主流货币
## 触发词
> "100美元换多少人民币" / "今日汇率" / "美元兑日元" / "1万港币值多少人民币" / "EUR to USD" / "exchange rate" / "汇率换算" / "人民币贬值了吗" / "1000块能换多少美元" / "历史汇率"
## 使用示例
### 货币换算
**输入:** 100 USD → CNY
**输出:**
```json
{
"from": "USD",
"to": "CNY",
"amount": 100,
"rate": 6.841274,
"result": 684.13,
"timestamp": "Mon, 27 Apr 2026",
"provider": "open.er-api.com"
}
```
### 多货币横向对比
**输入:** 1000 USD 换所有主流货币
**输出:**
```json
{
"from": "USD",
"amount": 1000,
"conversions": [
{"currency": "CNY", "amount": 6841.27},
{"currency": "EUR", "amount": 854.23},
{"currency": "GBP", "amount": 740.22},
{"currency": "JPY", "amount": 159540.6},
...
]
}
```
### 历史汇率
**输入:** 100 USD → CNY,2024-01-01
**输出:** 当天的美元兑人民币汇率(可用于对比汇率变化)
## 技术实现
```bash
# 单币种换算
python3 scripts/currency.py convert <金额> <源货币> <目标货币>
# 汇率列表
python3 scripts/currency.py rates <基准货币>
# 多货币横向对比
python3 scripts/currency.py top <金额> <源货币>
# 历史汇率
python3 scripts/currency.py historical <金额> <源货币> <目标货币> <日期YYYY-MM-DD>
```
## 支持货币(部分)
| 货币代码 | 名称 |
|---------|------|
| CNY | 人民币 |
| USD | 美元 |
| EUR | 欧元 |
| GBP | 英镑 |
| JPY | 日元 |
| KRW | 韩元 |
| HKD | 港币 |
| TWD | 新台币 |
| SGD | 新加坡元 |
| AUD | 澳元 |
| CAD | 加元 |
| CHF | 瑞士法郎 |
| INR | 印度卢比 |
| THB | 泰铢 |
| MYR | 林吉特 |
| PHP | 菲律宾比索 |
| VND | 越南盾 |
| IDR | 印尼盾 |
| AED | 阿联酋迪拉姆 |
| SAR | 沙特里亚尔 |
## 常见场景
| 场景 | 命令 |
|------|------|
| 海淘价格换算 | `convert 100 USD CNY` |
| 出国前准备 | `top 10000 CNY` |
| 汇率对比 | `rates USD` |
| 保值分析 | `historical 1000 USD CNY 2024-01-01` |
## 更新日志
### v1.0.0 (2026-04)
- 首发版本
- 150+货币实时汇率
- 零API Key,免费数据源
- 支持历史汇率查询
## ⚠️ Disclaimer
This tool is provided "as is" for informational purposes only. Data accuracy is not guaranteed. Not financial, legal, or professional advice. Always verify critical information from official sources.
本工具仅供信息参考,不保证数据完全准确,不构成任何金融/法律/专业建议。请以官方来源为准。
FILE:scripts/currency.py
#!/usr/bin/env python3
"""
Currency Converter Pro
Author: Lin Hui
Real-time exchange rates with multi-currency support.
Uses open.er-api.com (free, no API key required).
"""
import sys
import json
import subprocess
import urllib.request
import urllib.error
API_BASE = "https://open.er-api.com/v6"
def fetch_rates(base="USD"):
"""Fetch latest exchange rates from open.er-api.com"""
url = f"{API_BASE}/latest/{base}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
if data.get("result") == "success":
return data
else:
return {"error": "API error: " + str(data)}
except urllib.error.URLError as e:
return {"error": "Network error: " + str(e)}
except Exception as e:
return {"error": str(e)}
def fetch_historical(base, date_str):
"""Fetch historical exchange rates (date format: YYYY-MM-DD)"""
url = f"{API_BASE}/historical/{date_str}?base={base}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except Exception as e:
return {"error": str(e)}
def cmd_convert(args):
"""Convert amount from one currency to another"""
# args: [amount, from_currency, to_currency]
if len(args) < 3:
print(json.dumps({"error": "Usage: convert <amount> <from_currency> <to_currency>"}))
return
try:
amount = float(args[0])
from_curr = args[1].upper()
to_curr = args[2].upper()
except ValueError:
print(json.dumps({"error": "Invalid amount"}))
return
data = fetch_rates(from_curr)
if "error" in data:
print(json.dumps(data))
return
rates = data.get("rates", {})
if to_curr not in rates:
print(json.dumps({"error": f"Currency {to_curr} not found in rates"}))
return
rate = rates[to_curr]
converted = amount * rate
print(json.dumps({
"from": from_curr,
"to": to_curr,
"amount": amount,
"rate": round(rate, 6),
"result": round(converted, 2),
"timestamp": data.get("time_last_update_utc", ""),
"provider": "open.er-api.com"
}, ensure_ascii=False, indent=2))
def cmd_rates(args):
"""Show all rates for a base currency"""
base = args[0].upper() if args else "USD"
data = fetch_rates(base)
if "error" in data:
print(json.dumps(data))
return
rates = data.get("rates", {})
sorted_rates = dict(sorted(rates.items()))
# Format nicely
print(json.dumps({
"base": base,
"timestamp": data.get("time_last_update_utc", ""),
"rates": sorted_rates
}, ensure_ascii=False, indent=2))
def cmd_top(args):
"""Show top currencies by conversion value"""
if len(args) < 2:
print(json.dumps({"error": "Usage: top <amount> <from_currency>"}))
return
try:
amount = float(args[0])
from_curr = args[1].upper()
except ValueError:
print(json.dumps({"error": "Invalid amount"}))
return
data = fetch_rates(from_curr)
if "error" in data:
print(json.dumps(data))
return
rates = data.get("rates", {})
# Common currencies to show
common = ["CNY", "HKD", "TWD", "JPY", "KRW", "EUR", "GBP", "SGD", "AUD", "CAD",
"CHF", "JPY", "INR", "THB", "MYR", "PHP", "VND", "IDR", "AED", "SAR",
"USD"]
converted = []
for curr in common:
if curr in rates:
converted.append((curr, round(amount * rates[curr], 2)))
print(json.dumps({
"from": from_curr,
"amount": amount,
"conversions": [{"currency": c, "amount": a} for c, a in converted]
}, ensure_ascii=False, indent=2))
def cmd_historical(args):
"""Show historical rate between two currencies on a specific date"""
if len(args) < 3:
print(json.dumps({"error": "Usage: historical <amount> <from_currency> <to_currency> <date(YYYY-MM-DD)>"}))
return
try:
amount = float(args[0])
from_curr = args[1].upper()
to_curr = args[2].upper()
date_str = args[3] if len(args) > 3 else "2024-01-01"
except ValueError:
print(json.dumps({"error": "Invalid amount"}))
return
data = fetch_historical(from_curr, date_str)
if "error" in data:
print(json.dumps(data))
return
rates = data.get("rates", {})
if to_curr not in rates:
print(json.dumps({"error": f"Currency {to_curr} not found"}))
return
rate = rates[to_curr]
converted = amount * rate
print(json.dumps({
"from": from_curr,
"to": to_curr,
"amount": amount,
"date": date_str,
"rate": round(rate, 6),
"result": round(converted, 2),
"timestamp": data.get("time_last_update_utc", "")
}, ensure_ascii=False, indent=2))
def main():
if len(sys.argv) < 2:
print("Usage: currency.py <command> [args...]")
print("Commands: convert, rates, top, historical")
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd == "convert":
cmd_convert(args)
elif cmd == "rates":
cmd_rates(args)
elif cmd == "top":
cmd_top(args)
elif cmd == "historical":
cmd_historical(args)
else:
print(f"Unknown command: {cmd}")
if __name__ == "__main__":
main()
中国法定节假日与工作日计算器。查某年某月工作天数、某日期是否上班、距离节假日倒计时、调休换休提示。支持2024-2027年全部法定节假日及已知调休日。When the user asks about Chinese holidays, workdays, overtime, holiday countdown,...
---
name: china-work-calendar
description: 中国法定节假日与工作日计算器。查某年某月工作天数、某日期是否上班、距离节假日倒计时、调休换休提示。支持2024-2027年全部法定节假日及已知调休日。When the user asks about Chinese holidays, workdays, overtime, holiday countdown, or vacation planning in China.
---
# 中国工作日历计算器
**Author: Lin Hui** | Version 1.0.0 | MIT License
快速、准确地计算中国法定节假日、调休工作日和节假日倒计时。
## 核心功能
- ✅ 查任意日期是否为工作日
- ✅ 计算两个日期之间的工作日数
- ✅ 查某年某月的工作日总数
- ✅ 节假日倒计时(距离某节假日还剩几天)
- ✅ 调休提示(哪个周末要上班)
- ✅ 支持 2024–2027 年全部法定节假日
## 触发词(Trigger Words)
> "今天上班吗" / "这周还剩几个工作日" / "清明节放几天" / "距离春节还有多少天" / "元旦加班怎么算" / "这月有多少个工作日" / "国庆节调休哪几天要上班" / "下周一是工作日吗" / "本月工作日" / "今年所有假期"
## 使用示例
### 查询某日是否为工作日
**输入:**
```
2026-04-27
```
**输出示例:**
```json
{
"date": "2026-04-27",
"weekday": "周一",
"is_workday": true,
"label": "工作日"
}
```
### 计算工作日数
**输入:** `2026-04-01` 到 `2026-04-30`
**输出示例:**
```json
{
"start": "2026-04-01",
"end": "2026-04-30",
"workdays_count": 22,
"holidays_this_month": ["清明节 4月3日-5日"]
}
```
### 节假日倒计时
**输入:** `2026-06-20`(端午节)
**输出示例:**
```json
{
"target": "2026-06-20",
"days_remaining": 54,
"is_workday": false,
"label": "休息日/节假日"
}
```
### 本月工作日总数
**输入:** `2026-04`
**输出示例:**
```json
{
"year": 2026,
"month": 4,
"workdays_count": 22,
"workdays": ["2026-04-01","2026-04-02","2026-04-03",...]
}
```
### 调休提示(国庆/春节等长假的调休日)
```
2026年国庆节:10月1日-7日放假
⚠️ 调休上班日:9月26日(周六)、10月3日(周六)、10月10日(周六)
```
## 技术实现
调用 `python3` 脚本,零外部依赖:
```bash
python3 scripts/china_work_calendar.py workdays <start> <end>
python3 scripts/china_work_calendar.py is-workday <yyyy-mm-dd>
python3 scripts/china_work_calendar.py holidays <year>
python3 scripts/china_work_calendar.py countdown <yyyy-mm-dd>
python3 scripts/china_work_calendar.py next-workday <yyyy-mm-dd>
```
## 支持的节假日(2024-2027)
| 节日 | 日期 | 天数 |
|------|------|------|
| 元旦 | 1月1日 | 1天 |
| 春节 | 农历正月初一 | 7天 |
| 清明节 | 4月4/5日 | 3天 |
| 劳动节 | 5月1日 | 3-5天 |
| 端午节 | 农历五月初五 | 3天 |
| 中秋节 | 农历八月十五 | 3天 |
| 国庆节 | 10月1日 | 7天 |
## 常见场景
| 场景 | 查询方式 |
|------|---------|
| 今天上班吗 | `is-workday 今天日期` |
| 报销/加班核算 | `workdays 出勤日期区间` |
| 请假多少天 | `workdays 请假首日 请假末日` |
| 出行计划 | `countdown 节假日日期` |
| 本月还剩几天班 | `workdays 今天 本月末` |
## 注意事项
- 脚本内置 2024-2027 年调休数据,由国务院每年公布的调休通知驱动
- 如需查询更远年份,请更新脚本中的 `HOLIDAYS` 和 `ADJUSTED_WORKDAYS` 数据
- 数据来源:中国人民政府网《国务院办公厅关于XXXX年节假日安排的通知》
## 更新日志
### v1.0.0 (2026-04)
- 首发版本
- 支持 2024-2027 年节假日计算
- 支持调休/换休自动识别
- 支持节假日倒计时
- 支持月工作日统计
## ⚠️ Disclaimer
This tool is provided "as is" for informational purposes only. Data accuracy is not guaranteed. Not financial, legal, or professional advice. Always verify critical information from official sources.
本工具仅供信息参考,不保证数据完全准确,不构成任何金融/法律/专业建议。请以官方来源为准。
FILE:scripts/china_work_calendar.py
#!/usr/bin/env python3
"""
China Work Calendar Calculator
Author: Lin Hui
"""
import sys
import json
from datetime import date, timedelta
# Chinese holidays: year -> list of (start_date_str, name, total_days)
HOLIDAYS = {
2024: [
["2024-01-01", "元旦", 1],
["2024-02-10", "春节", 7],
["2024-04-04", "清明节", 3],
["2024-05-01", "劳动节", 3],
["2024-06-10", "端午节", 3],
["2024-09-15", "中秋节", 3],
["2024-10-01", "国庆节", 7],
],
2025: [
["2025-01-01", "元旦", 1],
["2025-01-28", "春节", 7],
["2025-04-04", "清明节", 3],
["2025-05-01", "劳动节", 3],
["2025-05-31", "端午节", 3],
["2025-10-01", "中秋节+国庆", 8],
],
2026: [
["2026-01-01", "元旦", 1],
["2026-02-16", "春节", 7],
["2026-04-03", "清明节", 3],
["2026-05-01", "劳动节", 3],
["2026-06-20", "端午节", 3],
["2026-09-24", "中秋节", 3],
["2026-10-01", "国庆节", 7],
],
2027: [
["2027-01-01", "元旦", 1],
["2027-02-07", "春节", 7],
["2027-04-05", "清明节", 3],
["2027-05-01", "劳动节", 3],
["2027-06-10", "端午节", 3],
["2027-09-15", "中秋节", 3],
["2027-10-01", "国庆节", 7],
],
}
# Adjusted workdays (weekend shifts) - confirmed by State Council announcements
ADJUSTED_WORKDAYS = {
"2024-02-04": True, "2024-02-17": True,
"2024-04-06": True,
"2024-04-28": True, "2024-05-11": True,
"2024-06-09": True, "2024-06-23": True,
"2024-09-14": True, "2024-09-29": True, "2024-10-12": True,
"2025-01-26": True, "2025-02-01": True, "2025-02-04": True, "2025-02-08": True,
"2025-04-06": True, "2025-04-27": True,
"2025-05-03": True, "2025-06-01": True,
"2025-09-27": True, "2025-10-04": True, "2025-10-11": True,
"2026-02-15": True, "2026-02-22": True, "2026-02-28": True, "2026-03-01": True,
"2026-04-05": True, "2026-04-26": True,
"2026-05-03": True, "2026-06-07": True,
"2026-06-21": True,
"2026-09-20": True, "2026-09-27": True,
"2026-09-26": True, "2026-10-03": True, "2026-10-10": True,
"2027-02-01": True, "2027-02-07": True, "2027-02-14": True, "2027-02-15": True,
"2027-04-05": True, "2027-04-25": True,
}
def parse_date(s):
parts = s.split("-")
return date(int(parts[0]), int(parts[1]), int(parts[2]))
def is_workday(d):
ds = d.strftime("%Y-%m-%d")
if ds in ADJUSTED_WORKDAYS:
return True
if d.weekday() >= 5:
return False
year_holidays = HOLIDAYS.get(d.year, [])
for hs, name, days in year_holidays:
hd = parse_date(hs)
for i in range(days):
if hd + timedelta(days=i) == d:
return False
return True
def all_holidays_for_year(year):
result = []
for hs, name, days in HOLIDAYS.get(year, []):
hd = parse_date(hs)
for i in range(days):
result.append((hd + timedelta(days=i), name))
return sorted(result)
def count_workdays_in_range(start, end):
count = 0
d = start
while d <= end:
if is_workday(d):
count += 1
d += timedelta(days=1)
return count
def cmd_workdays(args):
if len(args) == 2:
start = parse_date(args[0])
end = parse_date(args[1])
count = count_workdays_in_range(start, end)
print(json.dumps({"start": str(start), "end": str(end), "workdays": count}, ensure_ascii=False, indent=2))
elif len(args) == 1 and "-" in args[0] and args[0].count("-") == 1:
parts = args[0].split("-")
year = int(parts[0])
month = int(parts[1])
import calendar
_, last_day = calendar.monthrange(year, month)
count = 0
workdays = []
for day in range(1, last_day + 1):
d = date(year, month, day)
if is_workday(d):
count += 1
workdays.append(str(d))
print(json.dumps({"year": year, "month": month, "workdays_count": count, "workdays": workdays}, ensure_ascii=False, indent=2))
else:
print(json.dumps({"error": "Usage: workdays <yyyy-mm-dd> <yyyy-mm-dd> OR workdays <yyyy-mm>"}, ensure_ascii=False))
def cmd_is_workday(args):
d = parse_date(args[0])
result = is_workday(d)
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
print(json.dumps({
"date": str(d),
"weekday": weekday_names[d.weekday()],
"is_workday": result,
"label": "工作日" if result else "休息日/节假日"
}, ensure_ascii=False, indent=2))
def cmd_holidays(args):
year = int(args[0]) if args else date.today().year
holidays = all_holidays_for_year(year)
print(json.dumps({
"year": year,
"holidays": [{"date": str(d), "name": name} for d, name in holidays]
}, ensure_ascii=False, indent=2))
def cmd_countdown(args):
target = parse_date(args[0])
today = date.today()
days_left = (target - today).days
is_wd = is_workday(target)
print(json.dumps({
"target": str(target),
"days_remaining": days_left,
"is_workday": is_wd,
"label": "工作日" if is_wd else "休息日/节假日"
}, ensure_ascii=False, indent=2))
def cmd_next_workday(args):
from_date = parse_date(args[0]) if args else date.today()
d = from_date
for _ in range(30):
if is_workday(d):
print(json.dumps({"from": str(from_date), "next_workday": str(d)}, ensure_ascii=False, indent=2))
return
d += timedelta(days=1)
def main():
if len(sys.argv) < 2:
print("Usage: china_work_calendar.py <command> [args...]")
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd == "workdays":
cmd_workdays(args)
elif cmd == "is-workday":
cmd_is_workday(args)
elif cmd == "holidays":
cmd_holidays(args)
elif cmd == "countdown":
cmd_countdown(args)
elif cmd == "next-workday":
cmd_next_workday(args)
else:
print("Unknown command: " + cmd)
if __name__ == "__main__":
main()
Design content marketing and email lifecycle programs that work together as an acquisition engine. Use whenever a founder or marketer is planning a blog, new...
---
name: content-and-email-marketing
description: "Design content marketing and email lifecycle programs that work together as an acquisition engine. Use whenever a founder or marketer is planning a blog, newsletter, content calendar, email sequences, lead magnets, drip campaigns, onboarding emails, activation emails, retention emails, or any combination of content creation and email marketing. Also covers the acquisition → activation → retention → revenue lifecycle. Activates on phrases like 'content marketing', 'blog strategy', 'newsletter', 'email marketing', 'drip campaign', 'onboarding emails', 'lifecycle emails', 'activation email', 'email list', 'lead magnet', 'nurture sequence', 'email automation'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/content-and-email-marketing
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [14, 15]
domain: startup-growth
tags: [startup-growth, content-marketing, email-marketing, lifecycle-marketing, customer-activation]
depends-on: [bullseye-channel-selection]
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Product description, target audience, activation definition, existing content/email assets"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for content plans and email sequence drafts"
discovery:
goal: "Produce an integrated content + email plan spanning the 4-stage customer lifecycle"
tasks:
- "Define activation threshold for the product"
- "Plan content topics that attract the target audience"
- "Design the 4-stage email lifecycle (acquisition → activation → retention → revenue)"
- "Create activation email sequences for each drop-off point"
- "Set up retention emails for infrequent-use products"
- "Avoid the email spam trap"
audience:
roles: [startup-founder, content-marketer, email-marketer]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "User is starting a blog or newsletter"
- "User has users who sign up but don't activate"
- "User wants to increase retention via email"
- "User is planning a lead magnet or content upgrade"
prerequisites:
- skill: bullseye-channel-selection
why: "Content/email should be selected via Bullseye"
not_for:
- "Product has no retention to speak of (fix product first)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 12
iterations_needed: 0
---
# Content and Email Marketing
## When to Use
The startup needs a content strategy, an email strategy, or both integrated. Use this skill when:
- Starting a blog or newsletter from scratch
- Users sign up but don't activate (need activation emails)
- Product-market fit exists but growth isn't compounding (need lifecycle emails)
- A content channel was selected via Bullseye
Content and email are tightly coupled — content builds the email list, email converts the list. This skill covers both as one system.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Product description and target audience**
→ Check prompt for: product name, category, ideal customer
→ If missing, ask: "What does your product do, and who's the target audience?"
- **Activation definition:** what action defines an "activated" user
→ Check prompt for: "active user", "first upload", "created project"
→ If missing, ask: "What's the first valuable action a user takes? For Dropbox it's uploading a file, for Twitter it's following 5 people. What's yours?"
### Observable Context
- **Existing content/email assets:** prior blog posts, current email list size, current sequences
- **Conversion funnel:** signups vs activations vs retention
### Default Assumptions
- 4-stage lifecycle: Acquisition → Activation → Retention → Revenue
- CEO personal email 30 minutes after signup (Colin Nederkoorn pattern)
- Never buy email lists — organic only (avoid the spam trap)
### Sufficiency Threshold
```
SUFFICIENT: product + audience + activation definition known
PROCEED WITH DEFAULTS: product + audience known, use "first valuable action" as activation proxy
MUST ASK: product is unknown
```
## Process
Use TodoWrite:
- [ ] Step 1: Define activation threshold
- [ ] Step 2: Plan acquisition content topics
- [ ] Step 3: Design activation email sequence
- [ ] Step 4: Design retention emails
- [ ] Step 5: Design revenue/upsell emails
- [ ] Step 6: Avoid the spam trap
### Step 1: Define Activation Threshold
**ACTION:** Identify the specific action that defines an "activated" user. This must be:
- Specific (not vague engagement)
- Measurable (trackable in analytics)
- Predictive (users who hit it stick; users who don't, churn)
Examples: Twitter — follow 5 people. Dropbox — upload 1 file. Facebook — friend 7 people in 10 days. Slack — send 2,000 messages. These are the actual activation thresholds from real products.
Write the threshold to `activation-definition.md`.
**WHY:** Without a clear activation threshold, email sequences can't target drop-offs. "Send onboarding emails" without knowing the activation event produces generic welcome emails that don't move the needle. The threshold is the foundation for the entire activation email strategy.
**IF** retention data doesn't exist → pick a reasonable first-value action as a hypothesis. Measure and refine.
### Step 2: Plan Acquisition Content Topics
**ACTION:** Design content topics that attract the target audience, especially BEFORE they're ready to buy. Content marketing is list-building as much as it is brand-building.
Topics by type:
- **Awareness-stage content:** problems the audience has that your product solves
- **Consideration-stage content:** comparisons, case studies, how-to guides
- **Decision-stage content:** product-specific content (pricing analyses, specific use cases)
Every content piece should have a **lead magnet** — a free resource (checklist, template, mini-ebook) that captures the email in exchange. This is how content becomes an email list.
**WHY:** Content without a capture mechanism builds awareness but doesn't build a list. The list is the asset. A blog post with 10,000 views and no email captures is 10,000 visits wasted. The lead magnet is what converts anonymous traffic into named leads you can nurture.
**IF** no content capacity exists → budget for 2-4 freelance articles per month as a baseline.
### Step 3: Design Activation Email Sequence
**ACTION:** For each step from signup to activation, identify potential drop-off points. Create an email triggered when a user fails to complete each step within N days.
Colin Nederkoorn's (Customer.io founder) pattern:
1. Map the ideal user experience step-by-step from signup to activation
2. Identify every step where users drop off
3. Create an automated email triggered when a user fails to complete that step within N days
4. Each email nudges the user back to the ideal path
5. Add a personal "CEO email" 30 minutes after signup — this one email opened communication and produced 17% reply rates for Colin
Example sequence for a Dropbox-like product:
- Day 0 (30min): CEO personal email, no sales pitch
- Day 1: "Here's how to upload your first file" (if not uploaded)
- Day 3: "Users who upload 5+ files stick around 10x longer"
- Day 7: "Having trouble? Here's a video walkthrough"
**WHY:** Users don't churn because they disliked the product — they churn because they never got to the valuable moment. Activation emails close the gap between signup and value. Each drop-off point has a specific email that addresses that specific reason.
### Step 4: Design Retention Emails
**ACTION:** For infrequent-use products (the most common case), retention emails keep the product top-of-mind:
- **Weekly/monthly value summary** — Mint's weekly financial summary. BillGuard's monthly credit card report. Reminds users why they signed up.
- **Re-engagement triggers** — "Someone mentioned you" (Twitter), "New reply to your question" (Stack Overflow).
- **"You are so awesome" emails** — Patrick McKenzie's pattern. Usage summaries that make the user feel good about using the product.
- **Memory-anchored emails** — Photo site anniversaries, "A year ago today..." hooks.
Write the retention sequence to `retention-emails.md`.
**WHY:** Retention is about presence. Products the user loves but forgets about cease to exist in their life. Retention emails are not about selling — they're about reminding. Mint's weekly summary isn't pitched as retention, it's pitched as value. The retention effect is a byproduct.
### Step 5: Design Revenue / Upsell Emails
**ACTION:** For users who are activated and retained, design sequences that drive expansion revenue:
- **Referral emails** — Dropbox's storage-for-referral. Incentivizes word-of-mouth.
- **Upsell sequences** — WP Engine's 7-email upsell sequence from free tool to paid.
- **Cart abandonment retargeting** — for e-commerce/pricing page abandonment.
- **Feature-gate upgrade prompts** — for freemium products.
These are typically 3-7 email sequences triggered by specific behavior (not time).
**WHY:** Users who are retained but not expanding are leaving revenue on the table. Upsell sequences capture the willing-to-pay tier that wouldn't upgrade without a prompt. Referral emails turn happy users into new-user acquisition. These are pure expansion revenue that wouldn't exist without the email.
### Step 6: Avoid the Spam Trap
**ACTION:** Document the email marketing anti-patterns:
**Never buy email lists.** "Some companies will buy email lists and send people unsolicited email. That is the very definition of spam. Spam makes recipients angry, hurts future email deliverability efforts, and harms your company in the long run."
Other spam patterns:
- Unclear subject lines that trip spam filters
- Missing unsubscribe link
- Using "noreply@" as the from address
- Blast schedules unrelated to user behavior
- Purchased email lists labeled as "leads"
**WHY:** Email deliverability is a reputation asset built over years. One spam complaint rate over 0.3% can cause deliverability issues that take months to recover from. Buying lists is the fastest way to destroy the asset you're trying to build.
## Inputs
- Product description and target audience
- Activation threshold definition
- Current content/email assets (if any)
- Funnel metrics (if available)
## Outputs
Five markdown files:
1. **`activation-definition.md`** — Specific activation threshold
2. **`content-plan.md`** — Content topics by funnel stage + lead magnets
3. **`activation-emails.md`** — Triggered sequences per drop-off point
4. **`retention-emails.md`** — Value summaries, re-engagement, memory-anchored
5. **`revenue-emails.md`** — Referral, upsell, cart recovery, feature gates
## Key Principles
- **The list is the asset, not the blog.** Content without email capture wastes traffic. WHY: Traffic is ephemeral; email is compounding. Without a capture mechanism, every blog post leaves the audience one step removed from the relationship.
- **Activation defines the email target.** Without a threshold, sequences are generic welcomes. WHY: Activation is the handoff from "signup" to "customer." Every email either moves users toward activation or moves them away from churn.
- **Retention emails reinforce value, not sell.** Mint's weekly summary is valuable; a "we miss you" email is annoying. WHY: Users who love the product but forget it churn. Retention emails exist to remind, not convince.
- **Behavioral triggers beat schedule blasts.** Email when users do (or don't do) something, not on Tuesday morning. WHY: Relevance is the single biggest factor in open rates. Behavior-triggered emails are inherently relevant.
- **CEO email 30 minutes after signup.** One personal email opens communication more than 5 automated ones. WHY: Users expect automation. A human email is unexpected and memorable. Colin Nederkoorn's 17% reply rate proves this.
- **Never buy lists.** Deliverability is a multi-year reputation asset. WHY: One spam complaint surge can destroy deliverability for months. Organic list-building is slower but compounds; bought lists are fast and destructive.
## Examples
**Scenario: SaaS with high signup, low activation**
Trigger: "We get 500 signups per month to our project management tool but only 50 actually create a project. Help."
Process: (1) Activation threshold: "create first project with 2+ tasks." (2) Content plan: 2 articles/month on project management best practices → lead magnets with templates → activation follow-through. (3) Activation sequence: Day 0 CEO email, Day 1 "create your first project in 2 minutes" video, Day 3 "5 project templates to copy", Day 5 "need help? Book a 10min call". (4) Retention: weekly team summary emails. (5) Revenue: upsell to paid when team hits 10 users.
Output: Full lifecycle plan with drop-off-triggered emails.
**Scenario: Blog with no email capture**
Trigger: "Our blog gets 50k visits/month but we only have 200 email subscribers. Ratio is terrible."
Process: (1) Diagnosis: no lead magnets. 50k visits × 2% capture = 1,000 new subs/month achievable. (2) Lead magnet plan: 3 downloadable resources (checklist, template, mini-guide) placed on the top 5 articles. (3) Activation emails for new subscribers: welcome sequence with value, not pitches. (4) Retention: weekly newsletter with curated industry content. (5) Revenue: after 4 weeks of nurture, introduce product.
Output: Content-to-email capture strategy with specific lead magnets and follow-through.
**Scenario: Retention problem for infrequent-use product**
Trigger: "Our expense tracking app has good ratings but users sign up and then forget about it. How do we fix retention?"
Process: (1) Retention is the core problem — infrequent-use product. (2) Weekly value summary: "Here's what you spent this week." Even users who haven't used the app for 2 weeks get reminded of the value. (3) Memory-anchored: "A month ago you saved $47 by noticing your subscription charges." (4) Re-engagement: "You have 3 new transactions to categorize." (5) CEO email on signup + milestone emails ("You've tracked $1,000 in expenses!").
Output: Retention-first email plan that reinforces product value without sales pressure.
## References
- For the 4-stage lifecycle mapping with examples, see [references/lifecycle-stages.md](references/lifecycle-stages.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select content/email via Bullseye
- `clawhub install bookforge-seo-channel-strategy` — SEO and content are tightly coupled
- `clawhub install bookforge-viral-growth-loop-design` — Referral emails are part of viral loops
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/lifecycle-stages.md
# The 4-Stage Customer Lifecycle
From Chapter 14 of *Traction*. Every customer passes through these stages, and each stage has distinct email patterns.
## Stage 1: Acquisition
**Goal:** Get people onto the email list.
**Mechanism:** Content + lead magnets + list-building tactics.
Tactics:
- Gated content (email required for download)
- Free course delivered over email
- Newsletter advertising on other newsletters
- Exit-intent popup with lead magnet
- Co-marketing with complementary brands
**What this stage IS NOT:** buying email lists, cold outreach without opt-in.
## Stage 2: Activation
**Goal:** Get users to the first valuable action in your product.
**Mechanism:** Triggered emails at each drop-off point on the path to activation.
The Colin Nederkoorn (Customer.io) process:
1. Define the ideal user journey from signup to activation
2. Identify every step where users drop off
3. Create an automated email for each drop-off
4. Start with a CEO personal email 30 min after signup (17% reply rate)
Example activation thresholds:
- **Twitter:** follow 5 people
- **Dropbox:** upload 1 file
- **Facebook:** 7 friends in 10 days
- **Slack:** 2,000 team messages sent
- **Airbnb:** first booking
## Stage 3: Retention
**Goal:** Keep users engaged over time so they don't churn.
**Mechanism:** Value-reinforcing emails that remind users why they signed up.
Patterns that work:
- **Weekly/monthly value summaries** (Mint's financial report, BillGuard's credit monitoring)
- **Mention/activity notifications** (Twitter "someone mentioned you", Stack Overflow "new answer")
- **Usage reports** (Patrick McKenzie's "you are so awesome" emails with user's own stats)
- **Memory-anchored emails** (photo site anniversaries, "A year ago today...")
These are NOT sales emails. They're value-delivery emails that happen to drive retention.
## Stage 4: Revenue
**Goal:** Convert free users to paid, trial users to subscribers, one-time buyers to repeat customers, and existing customers to higher tiers.
**Mechanism:** Behavior-triggered sequences when users are at natural conversion moments.
Patterns:
- **Upsell sequences** (WP Engine's 7-email sequence from free speed test to paid hosting)
- **Cart abandonment retargeting** (for e-commerce and pricing page abandonment)
- **Feature-gate emails** (freemium products prompting upgrade when user hits the free tier ceiling)
- **Referral emails** (Dropbox storage for referrals — revenue via new acquisition)
- **Win-back campaigns** (churned users with a special offer)
## Cross-Stage Principle: Behavior, Not Schedule
Every stage's emails should be triggered by user behavior, not by calendar schedules. "Email users on Tuesday at 9am" is always worse than "email users when they do X or don't do Y".
Why: relevance is the dominant factor in email performance. Behavior-triggered emails are inherently relevant. Scheduled blasts are inherently irrelevant for some percentage of the audience.
## Exception: Newsletters
Newsletters are the one email type where scheduled sends make sense — because the email itself IS the value, not a prompt to take an action. Mint's weekly financial summary is a newsletter in structure even though it's retention in function.
## Source
Chapter 14 ("Email Marketing") of *Traction* by Gabriel Weinberg and Justin Mares.
生成上市公司可比公司分析报告。当用户请求分析某家公司的竞争对手、可比公司、同业对比、竞品分析、对标公司分析时使用此技能。
--- name: "comparable-company-analysis" author: "Yilin" description: "生成上市公司可比公司分析报告。当用户请求分析某家公司的竞争对手、可比公司、同业对比、竞品分析、对标公司分析时使用此技能。" --- # 可比公司分析 - 上市公司同业对标分析框架 针对目标上市公司,从业务剖析、可比公司筛选、对比分析、财务数据对比四个维度输出结构化可比公司分析报告。 ## 输入参数 | 字段 | 是否必填 | 说明 | | ---- | ---- | ---- | | 目标公司 | 必填 | 如"中际旭创"、"宁德时代"、"药明康德" | | 分析维度 | 选填 | 如"全面分析"、"仅财务对比"、"仅业务对标",不填则默认全面分析 | --- ## 数据获取流程 ### Step 1: 业务剖析数据获取 ```python 1. 调用 yiyanxuangu MCP 获取目标公司基本信息: get_stock_basic_info(stock_code="目标公司代码") 获取公司所属行业、概念板块、主营业务描述 2. 调用 yiyanxuangu MCP 获取目标公司财务数据: get_stock_financial_data(stock_code="目标公司代码") 获取最新一期营收、净利润、毛利率、各业务线收入(如有) 3. 调用 万行-news API(太一数据/万行数据 MCP web_search),关键词组合: - "[目标公司] 业务 产品 收入 拆分" (近6个月) — 获取业务结构 - "[目标公司] 核心竞争力 护城河" (近1年) — 获取竞争力分析 - "[目标公司] 研报 深度 分析" (近3个月) — 获取券商研报观点 ``` ### Step 2: 可比公司筛选 ```python 1. 调用 万行-news API,搜索可比公司: - "[目标公司] 竞争对手 可比公司" (近1年) - "[目标公司] 同业 竞品 对标" (近1年) - "[目标公司所在行业] 龙头 上市公司 排名" (近6个月) 2. 调用 yiyanxuangu MCP 验证可比公司代码: get_stock_basic_info(stock_code="可比公司1代码,可比公司2代码,...") 确保所有可比公司为A股上市公司,获取最新行业分类 3. 筛选5-10家A股上市可比公司,按业务相关性排序 ``` ### Step 3: 可比公司对比分析 ```python 1. 调用 万行-news API,获取各可比公司业务进展: - "[可比公司名] 业务 进展 订单 产能 出货量" (近6个月) - "[可比公司名] 新产品 新技术 客户" (近3个月) 2. 调用 yiyanxuangu MCP 获取各可比公司财务数据: get_stock_financial_data(stock_code="可比公司1代码,可比公司2代码,...") 获取最新一期年度累计财务数据(营收、净利、毛利率、ROE等) ``` ### Step 4: 财务数据对比 ```python 1. 确保所有可比公司财务数据已获取,如有缺失则单独调用: get_stock_financial_data(stock_code="缺失公司代码") 2. 整理对比表格,包含: - 营收规模及增速 - 净利润及增速 - 毛利率、净利率 - ROE、ROA - 估值指标(PE、PB) ``` --- ## 报告模板 ```markdown # [目标公司名称] 可比公司分析报告 **目标公司**:[公司全称]([股票代码]) **所属行业**:[申万/中信行业分类] **报告类型**:可比公司分析(买方视角) **生成时间**:[YYYY-MM-DD HH:MM] --- ## 一、业务剖析 [用一句话概括目标公司核心竞争力,如"XX公司是国内XX领域龙头,凭借XX技术/渠道/规模优势,在XX细分市场占据XX%份额。"] **[业务板块1名称]**:[首句加粗提炼重点。包含业务当前状态(营收、占比)、核心驱动力、竞争力、市场前景等,引用数据或事实支撑。论述自然流畅,避免模板化起手式。] **[业务板块2名称]**:[同上结构,区分成熟业务与新兴业务,但不显式标注"成熟/新兴"。] **[业务板块3名称]**:[如有,同上。] --- ## 二、可比公司筛选 **[业务分类1]可比公司**:[首句加粗提炼重点。以直接竞争者为主,可辅以战略相似者。列出该业务线的主要A股上市竞争对手,简述竞争格局。] **[业务分类2]可比公司**:[同上。以细分赛道对标者为主,关注产业链环节、技术及客户验证情况。] ### 可比公司表格 | 分类 | 公司名称 | 公司代码 | 可比业务 | 相关业务进展(需带业务数据或事件佐证,禁止列举财务数据) | | ---- | -------- | -------- | -------- | -------------------------------------------------------- | | [如"光模块"] | [公司A] | [代码] | [细分业务] | [如"其800G光模块2025年出货量达XX万只,已切入北美头部云厂商供应链"] | | [如"光模块"] | [公司B] | [代码] | [细分业务] | [如"1.6T光模块已完成客户验证,预计2026年Q2开始批量交付"] | | [如"光芯片"] | [公司C] | [代码] | [细分业务] | [如"EML光芯片月产能突破XX万只,良率提升至XX%"] | | ... | ... | ... | ... | ... | > 注:相关业务进展必须包含具体业务数据(如出货量、产能、市占率)或事件(如产品发布、客户签约、技术突破),禁止填写财务数据(如营收、利润、毛利率)。 --- ## 三、可比公司对比分析 ### 可比公司对比表格 | 公司名称 | 维度对比 | | -------- | -------- | | **[公司A]** | **业务对标**:<br>• [可比点1,如"双方在800G光模块市场占有率均位于国内前三,2025年合计份额超60%。"]<br>• [可比点2,如"均布局1.6T下一代产品,预计2026年进入量产阶段。"]<br>**竞争焦点**:<br>• [关键点1,如"目标公司在硅光技术路线上领先,而该公司在传统EML方案上成本控制更优。"] | | **[公司B]** | **业务对标**:<br>• [可比点1]<br>• [可比点2]<br>**竞争焦点**:<br>• [关键点1] | | **[公司C]** | **业务对标**:<br>• [可比点1]<br>• [可比点2]<br>**竞争焦点**:<br>• [关键点1] | | ... | ... | --- ## 四、财务数据对比 ### 可比公司财务对比表格 | 公司名称 | 公司代码 | 营收(亿元) | 营收增速 | 净利润(亿元) | 净利增速 | 毛利率 | 净利率 | ROE | PE(TTM) | PB | | -------- | -------- | ------------ | -------- | -------------- | -------- | ------ | ------ | --- | --------- | -- | | **[目标公司]** | [代码] | [数值] | [X%] | [数值] | [X%] | [X%] | [X%] | [X%] | [X] | [X] | | [公司A] | [代码] | [数值] | [X%] | [数值] | [X%] | [X%] | [X%] | [X%] | [X] | [X] | | [公司B] | [代码] | [数值] | [X%] | [数值] | [X%] | [X%] | [X%] | [X%] | [X] | [X] | | [公司C] | [代码] | [数值] | [X%] | [数值] | [X%] | [X%] | [X%] | [X%] | [X] | [X] | | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | > 注:财务数据为最新一期年度累计数据(如2025年年报或2026年Q3)。PE/PB为最新交易日数据。 ### 财务对比分析 **规模对比**:[分析营收/净利润规模梯队,目标公司所处位置] **盈利能力对比**:[分析毛利率、净利率、ROE差异及原因] **成长性对比**:[分析营收/净利增速差异,谁更具成长弹性] **估值对比**:[分析PE/PB估值分位,目标公司相对可比公司是溢价还是折价,是否合理] --- ## 五、综合结论 | 维度 | 结论 | | ---- | ---- | | **行业地位** | [目标公司在行业中的竞争地位:龙头/追赶者/细分冠军] | | **核心优势** | [1-2个核心竞争优势] | | **主要短板** | [1-2个相对可比公司的短板] | | **估值判断** | [相对可比公司,当前估值是否合理/高估/低估] | | **投资启示** | [从可比公司视角看,目标公司的投资价值与风险] | --- **数据来源**:yiyanxuangu MCP、万行新闻(太一数据/万行数据) **免责声明**:本报告仅供投研参考,不构成投资建议。市场有风险,投资需谨慎。 ``` --- ## 输出要求 ### 文件命名 `[目标公司简称]_可比公司分析_[YYYYMMDD].md` 示例:`中际旭创_可比公司分析_20260419.md` ### 产物回传 生成文件后,调用 `deliver_attachments` 工具回传给用户。 --- ## 内容质量要求 1. **业务剖析精准**:一句话概括核心竞争力必须准确、简洁;业务分析需区分成熟与新兴业务但不显式标注 2. **数据驱动**:所有论点必须有数据或事实支撑,禁止空泛表述 3. **可比公司数量**:必须包含5-10家A股上市可比公司,禁止过少或过多 4. **业务进展具体化**:可比公司表格中的"相关业务进展"必须包含具体业务数据(出货量、产能、市占率)或事件(产品发布、客户签约),**严禁填写财务数据** 5. **对标理由充分**:对比表格中每个可比公司需列出2-3个形成可比关系的核心理由 6. **竞争焦点明确**:需简述1-2个双方在可比领域的关键优劣势对比 7. **财务数据完整**:必须查询表格中每一家公司的财务数据,禁止遗漏;数据缺失需再次调用工具获取 8. **论述自然流畅**:严禁使用"核心竞争力在于"、"主要体现在"等模板化起手式 9. **客观专业**:使用投研术语,避免"优秀""卓越"等营销词汇 10. **结构完整**:必须包含业务剖析、可比公司筛选、对比分析、财务数据对比、综合结论五大模块 ## 踩坑经验 - yiyanxuangu MCP 财务数据:get_stock_financial_data 返回的是完整财务报表,需从中提取关键指标(营收、净利、毛利率、ROE等) - 业务拆分数据:yiyanxuangu MCP 可能不直接提供业务拆分,需通过 web_search 搜索研报获取 - 可比公司筛选:直接搜索"[公司名] 可比公司"可能返回券商研报中的可比公司列表,需验证这些公司是否仍为A股上市 - 财务数据时效性:需明确查询的是"最新一期年度累计"数据(如当前为2026年4月,应查询2025年年报或2026年Q1数据) - 估值数据:PE/PB需通过 web_search 获取最新交易日数据,yiyanxuangu MCP 可能不直接提供实时估值
Upload Excel, CSV, or PDF financial statements for AI-generated detailed business analysis, including revenue, costs, profitability, cash flow, and anomaly a...
# Financial Report AI (ai-financial-report)
> Upload Excel/CSV/PDF financial statements → AI auto-generates structured business analysis reports (revenue structure / cost anomalies / profitability / cash flow / balance sheet / KPI achievement / anomaly alerts).
## Tiered Features
| Feature | FREE | PRO |
|---------|:----:|:---:|
| Analyses/month | 3 | Unlimited |
| Input formats | CSV, Excel | CSV, Excel, PDF |
| Analysis dimensions | 3 basic | All 7 |
| Charts | ❌ | ✅ |
| Industry comparison | ❌ | ✅ |
| **Price** | **Free** | **$0.01 USDT/use** |
> Upgrade to PRO: [https://skillpay.me/ai-financial-report](https://skillpay.me/ai-financial-report)
---
## Architecture
```
User uploads file
↓
index.js (entry, routes to handlers)
↓
src/handlers/
├── skill_invoke.js ← core analysis engine dispatcher
├── file_upload.js ← file upload handler
└── message_handler.js ← text chat handler
↓
src/services/
├── billing.js ← SkillPay token validation + 5-min cache
├── file_parser.py ← Excel/CSV/PDF parsing
└── report_generator.py ← AI analysis + Markdown rendering
```
## Quick Start
### Upload a File (Recommended)
Upload your Excel/CSV/PDF financial file directly — AI automatically completes the full analysis.
**Supported formats**: `.csv`, `.xlsx`, `.xls`, `.pdf`
### Configure AI API Key
This skill does **not** include an AI model. Users configure their own API key.
Supported AI models (any one):
| Model | Provider | Get API Key |
|-------|----------|-------------|
| GPT-4o | OpenAI | platform.openai.com |
| Claude 3.5 | Anthropic | console.anthropic.com |
| DeepSeek V3 | DeepSeek | platform.deepseek.com |
| Qwen | Alibaba Cloud | bailian.console.aliyun.com |
| MiniMax | MiniMax | platform.minimax.chat |
> **No binding, no recommendation, no restriction** on specific models — user chooses freely.
---
## Output Example
```markdown
# Financial Report Analysis
**Company**: XX Tech Co.
**Period**: Q1 2024
**Tier**: PRO
---
## 1. Revenue Structure
| Item | Value |
|------|-------|
| Total Revenue | 3,800 (10K CNY) |
| YoY Change | +15.3% |
| QoQ Change | +8.2% |
**Structure**: Core business 82%, other business 18%
---
## 7. Anomaly Alerts
| Dimension | Severity | Description | Value |
|-----------|----------|-------------|-------|
| Cost | 🔴 HIGH | Admin expense ratio abnormally high | 18.5% (avg: 12%) |
| Cash Flow | 🟠 MEDIUM | Operating cash flow YoY declined | -12.3% |
```
---
## Data Format
| Format | Notes |
|--------|-------|
| CSV | UTF-8, first row = header |
| Excel (.xlsx) | Multi-sheet, reads first sheet by default |
| PDF | Text must be copyable (no scanned images) |
**Column guidelines**: Use clear dimension names (revenue, cost, profit, etc.). Avoid excessive merged cells.
---
## Privacy
- **No data upload**: All files processed locally, never sent to third-party servers
- **No file storage**: Temporary files deleted immediately after analysis
- **API calls**: Only uses user-configured AI API, data processed locally
- **Token validation**: Only verifies plan eligibility, no financial data stored
---
## Error Handling
| Error | Resolution |
|-------|-----------|
| "Unsupported format" | Use CSV, Excel (.xlsx/.xls), or PDF with copyable text |
| AI analysis failed | Check API key validity and balance; try another model |
| Report data inaccurate | AI analysis is for reference only; verify against source files |
---
## Tech Stack
- **Parsing**: Python 3 + pandas + openpyxl + pdfplumber
- **AI Interface**: OpenAI-compatible REST API
- **Runtime**: Node.js (OpenClaw Agent)
---
## Billing
- **Endpoint**: `POST https://skillpay.me/api/v1/billing/charge`
- **Header**: `X-API-Key: {api_key}`
- **Body**: `{"user_id": "...", "skill_id": "ai-financial-report", "amount": 0}`
- **Response**: `{"success": true, "balance": ...}`
- **Fallback**: Network error → FREE tier (usage not blocked)
- **Cache**: Validation result cached locally (SHA256 hash), TTL 5 minutes
## Env Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENCLAW_SKILL_DIR` | Skill root (for cache) | `__dirname/..` |
| `SKILL_BILLING_API_KEY` | Builder API Key (from SkillPay) | — |
| `SKILL_BILLING_SKILL_ID` | Skill Slug | `ai-financial-report` |
> For builder setup: visit [https://skillpay.me](https://skillpay.me)
FILE:requirements.txt
openpyxl>=3.1.0
pandas>=2.0.0
numpy>=1.24.0
matplotlib>=3.7.0
plotly>=5.18.0
pdfplumber>=0.10.0
tabulate>=0.9.0
kaleido>=0.2.1
FILE:index.js
#!/usr/bin/env node
/**
* Skill: Financial Report AI (ai-financial-report)
* Entry point - OpenClaw-compatible skill
*
* Tier System (per-use billing):
* FREE - 3 analyses/month, basic 3 dimensions
* PRO - $0.01 USDT/use, all 7 dimensions + charts
*
* Core Features:
* 1. Revenue structure analysis
* 2. Cost anomaly detection
* 3. Profitability analysis
* 4. Cash flow analysis
* 5. Balance sheet analysis
* 6. KPI achievement
* 7. Anomaly alerts
*/
const path = require('path');
const fs = require('fs');
// Resolve skill root
const SKILL_ROOT = __dirname;
// Load .env if present
const envPath = path.join(SKILL_ROOT, '.env');
if (fs.existsSync(envPath)) {
fs.readFileSync(envPath, 'utf8')
.split('\n')
.forEach(line => {
const idx = line.indexOf('=');
if (idx < 0 || line.startsWith('#')) return;
const k = line.slice(0, idx).trim();
const v = line.slice(idx + 1).trim();
if (k && !process.env[k]) process.env[k] = v;
});
}
// Lazy-load handlers to avoid circular deps
function getHandler(name) {
try {
return require(`./src/handlers/name`);
} catch (_) {
return null;
}
}
const skillInvoke = getHandler('skill_invoke');
const fileUpload = getHandler('file_upload');
const messageHandler = getHandler('message');
const skill = {
id: 'ai-financial-report',
name: 'Financial Report AI',
description: 'Upload Excel/CSV/PDF financial statements → AI auto-generates structured business analysis reports (revenue structure / cost anomalies / profitability / cash flow / balance sheet / KPI achievement / anomaly alerts). Per-use billing at $0.01 USDT.',
version: '1.0.0',
author: 'YK Global',
async invoke(params, context) {
if (!skillInvoke) {
return { success: false, error: 'skill_invoke handler not found' };
}
return skillInvoke.handleSkillInvoke(params, context);
},
configSchema: {
type: 'object',
properties: {
apiKey: {
type: 'string',
title: 'AI API Key',
description: 'User-configured AI model API Key (no binding/recommendation/restriction on model choice)',
},
defaultModel: {
type: 'string',
title: 'Default Model',
default: 'gpt-4o',
description: 'Default AI model to use',
},
chartTheme: {
type: 'string',
title: 'Chart Theme',
default: 'light',
enum: ['light', 'dark'],
},
},
required: [],
},
skillRoot: SKILL_ROOT,
handlers: {
async 'skill.invoke'(params, context) {
return this.invoke(params, context);
},
async 'file.upload'(params, context) {
if (!fileUpload) return { success: false, error: 'file_upload handler not found' };
return fileUpload.handleFileUpload(params, context);
},
async 'message.create'(params, context) {
if (!messageHandler) return { success: false, error: 'message_handler not found' };
return messageHandler.handleMessage(params, context);
},
},
};
module.exports = skill;
module.exports.default = skill;
FILE:README.md
# Financial Report AI
> Upload Excel/CSV/PDF financial statements → AI auto-generates structured business analysis reports.
**Supported formats**: CSV / Excel (.xlsx/.xls) / PDF
**Analysis dimensions**: Revenue structure · Cost anomalies · Profitability · Cash flow · Balance sheet · KPI achievement · Anomaly alerts
---
## Features
| Analysis Dimension | Description |
|-------------------|-------------|
| Revenue Structure | Total revenue, YoY/QoQ, business line breakdown |
| Cost Anomaly Detection | Cost breakdown, anomaly flagging |
| Profitability | Gross margin, net margin, profit trends |
| Cash Flow | Operating/investing/financing cash flow |
| Balance Sheet | Asset structure, debt ratio, solvency |
| KPI Achievement | Budget vs actual comparison |
| Anomaly Alerts | 🔴 HIGH / 🟠 MEDIUM / 🟡 LOW severity alerts |
---
## Tier Comparison
| | **FREE** | **PRO** |
|---|:---:|:---:|
| Analyses/month | 3 | Unlimited |
| Input formats | CSV, Excel | CSV, Excel, PDF |
| Analysis dimensions | 3 basic | All 7 |
| Charts | ❌ | ✅ |
| Industry comparison | ❌ | ✅ |
| **Price** | **Free** | **$0.01 USDT/use** |
---
## Quick Start
### Upload a File (Recommended)
Upload your Excel/CSV/PDF financial file directly — AI automatically completes the full analysis.
### Configure AI API Key
This skill does **not** include an AI model. Users configure their own API key.
Supported AI models (any one):
| Model | Provider |
|-------|----------|
| GPT-4o | OpenAI |
| Claude 3.5 | Anthropic |
| DeepSeek V3 | DeepSeek |
| Qwen | Alibaba Cloud |
| MiniMax | MiniMax |
> No binding, no recommendation, no restriction on model choice.
---
## Privacy
- No data upload: All files processed locally
- No file storage: Temporary files deleted immediately after analysis
- API calls: Only uses user-configured AI API
- Token validation: Only verifies plan eligibility
---
## Tech Stack
- **Parsing**: Python 3 + pandas + openpyxl + pdfplumber
- **AI Interface**: OpenAI-compatible REST API
- **Runtime**: Node.js (OpenClaw Agent)
---
> Get PRO: [https://skillpay.me/ai-financial-report](https://skillpay.me/ai-financial-report)
FILE:package.json
{
"name": "ai-financial-report",
"version": "1.0.0",
"description": "Financial Report AI - Upload Excel/CSV/PDF, AI auto-generates structured business analysis reports",
"main": "index.js",
"scripts": {
"analyze": "python3 src/services/report_generator.py",
"parse": "python3 src/services/file_parser.py"
},
"keywords": ["financial", "report", "ai", "excel", "csv", "pdf", "analysis"],
"author": "YK Global",
"license": "MIT"
}
FILE:src/services/file_parser.py
#!/usr/bin/env python3
"""
File Parser for Financial Report AI
Supports: CSV, XLSX, XLS, PDF
Extracts structured tabular data from uploaded financial statements.
"""
import sys
import json
import os
import traceback
from pathlib import Path
def parse_csv(filepath):
"""Parse CSV file into a list of row dicts."""
import pandas as pd
df = pd.read_csv(filepath, dtype=str, keep_default_na=False)
df = df.fillna("")
return {
"headers": list(df.columns),
"rows": df.values.tolist(),
"shape": list(df.shape),
"raw_sample": df.head(20).to_dict(orient="records"),
}
def parse_excel(filepath):
"""Parse Excel file - auto-detect sheet, return all sheets."""
import pandas as pd
xl = pd.ExcelFile(filepath)
sheets = {}
for sheet_name in xl.sheet_names:
df = pd.read_excel(filepath, sheet_name=sheet_name, dtype=str, header=None, keep_default_na=False)
df = df.fillna("")
sheets[sheet_name] = {
"headers": [str(h) for h in df.iloc[0].tolist()],
"rows": df.iloc[1:].values.tolist(),
"shape": [df.shape[0] - 1, df.shape[1]],
"raw_sample": df.head(20).to_dict(orient="records"),
}
return {
"sheets": sheets,
"sheet_names": xl.sheet_names,
}
def parse_pdf(filepath):
"""Parse PDF financial statements using pdfplumber."""
import pdfplumber
import pandas as pd
tables = []
all_text = []
try:
with pdfplumber.open(filepath) as pdf:
for i, page in enumerate(pdf.pages):
text = page.extract_text() or ""
all_text.append({"page": i + 1, "text": text})
# Try to extract tables
page_tables = page.extract_tables()
for j, table in enumerate(page_tables):
if table:
headers = table[0] if table else []
rows = table[1:] if len(table) > 1 else []
tables.append({
"page": i + 1,
"table_index": j,
"headers": [str(h or "").strip() for h in headers],
"rows": [[str(cell or "").strip() for cell in row] for row in rows],
"shape": [len(rows), len(headers)],
})
except Exception as e:
return {"error": str(e), "tables": [], "text": []}
return {
"tables": tables,
"text": all_text,
"num_pages": len(all_text),
}
def parse_file(filepath, file_ext=None):
"""Main dispatcher - auto-detect format from extension or content."""
if file_ext is None:
file_ext = Path(filepath).suffix.lower()
if file_ext in [".csv"]:
return {"format": "csv", **parse_csv(filepath)}
elif file_ext in [".xlsx", ".xls"]:
return {"format": "excel", **parse_excel(filepath)}
elif file_ext in [".pdf"]:
return {"format": "pdf", **parse_pdf(filepath)}
else:
return {"error": f"Unsupported format: {file_ext}"}
if __name__ == "__main__":
if len(sys.argv) < 2:
print(json.dumps({"error": "Usage: python file_parser.py <filepath>"}))
sys.exit(1)
filepath = sys.argv[1]
if not os.path.exists(filepath):
print(json.dumps({"error": f"File not found: {filepath}"}))
sys.exit(1)
try:
result = parse_file(filepath)
print(json.dumps(result, ensure_ascii=False, default=str))
except Exception as e:
print(json.dumps({"error": str(e), "trace": traceback.format_exc()}, ensure_ascii=False))
sys.exit(1)
FILE:src/services/billing.js
/**
* Billing Service
* Validates tokens via SkillPay billing API
* Caches results for 5 minutes to reduce API calls
*
* Fallback: on network error → FREE tier (do not block usage)
*/
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// --- Config ---
const BILLING_URL = 'https://skillpay.me/api/v1/billing';
const API_KEY = process.env.SKILL_BILLING_API_KEY || '';
const SKILL_ID = process.env.SKILL_BILLING_SKILL_ID || 'ai-financial-report';
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const DEV_MODE = !API_KEY;
const CACHE_DIR = '/tmp/ai-financial-report-cache';
// Ensure cache directory exists
try {
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
} catch (_) {}
// --- Cache helpers ---
function cacheKey(userId) {
return crypto.createHash('sha256').update(userId || 'anon').digest('hex') + '.json';
}
function readCache(userId) {
try {
const file = path.join(CACHE_DIR, cacheKey(userId));
if (!fs.existsSync(file)) return null;
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (Date.now() - data.ts > CACHE_TTL_MS) {
fs.unlinkSync(file);
return null;
}
return data;
} catch (_) {
return null;
}
}
function writeCache(userId, result) {
try {
const file = path.join(CACHE_DIR, cacheKey(userId));
fs.writeFileSync(file, JSON.stringify({ ...result, ts: Date.now() }), 'utf8');
} catch (_) {}
}
// --- Billing / token validation ---
async function validateToken(apiKey, userId = '') {
// Dev mode: no API key configured
if (DEV_MODE) {
return { valid: true, plan: 'PRO', balance: 999.0, reason: 'dev_mode' };
}
if (!apiKey || apiKey.trim() === '') {
return { valid: false, plan: 'FREE', reason: 'no_api_key' };
}
// Check cache first
const cacheKeyVal = apiKey + '|' + (userId || 'anon');
const cached = readCache(cacheKeyVal);
if (cached) return cached;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(`BILLING_URL/charge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
user_id: userId || apiKey,
skill_id: SKILL_ID,
amount: 0,
}),
signal: controller.signal,
});
clearTimeout(timeout);
let data;
try {
data = await response.json();
} catch (_) {
data = {};
}
if (response.ok && data.success) {
const result = {
valid: true,
plan: 'PRO',
balance: data.balance || 0,
};
writeCache(cacheKeyVal, result);
return result;
} else {
return { valid: false, plan: 'FREE', reason: data.error || 'charge_failed' };
}
} catch (err) {
// Network error / timeout → degrade to FREE tier, do not block
console.error('[Billing] Validation failed, degrading to FREE:', err.message);
return { valid: false, plan: 'FREE', reason: 'network_error' };
}
}
// --- Plan limits ---
const PLAN_LIMITS = {
FREE: { monthly: 3, formats: ['csv', 'xlsx'], dimensions: 3, charts: 0 },
PRO: { monthly: Infinity, formats: ['csv', 'xlsx', 'pdf'], dimensions: 99, charts: 99 },
};
function getPlanLimits(plan) {
return PLAN_LIMITS[plan] || PLAN_LIMITS.FREE;
}
module.exports = { validateToken, getPlanLimits, DEV_MODE };
FILE:src/services/report_generator.py
#!/usr/bin/env python3
"""
Financial Report AI Analysis Engine
Generates structured AI-powered financial analysis reports.
All output labels are in English for ClawHub compliance.
"""
import sys
import json
import re
import os
import traceback
from pathlib import Path
from typing import Optional, List, Dict, Any
from datetime import datetime
# ─────────────────────────────────────────────────────────────────────────────
# Number parsing helpers
# ─────────────────────────────────────────────────────────────────────────────
def parse_number(val) -> Optional[float]:
"""Parse a value to float, handling currency/comma formats."""
if val is None or val == "":
return None
s = str(val).strip()
s = re.sub(r'[¥$,\uff04\uffe5\s]', '', s)
if s.startswith('(') and s.endswith(')'):
s = '-' + s[1:-1]
try:
return float(s)
except ValueError:
return None
def pct_change(current, previous) -> Optional[float]:
"""Calculate percentage change."""
curr = parse_number(current)
prev = parse_number(previous)
if curr is None or prev is None or prev == 0:
return None
return round((curr - prev) / abs(prev) * 100, 2)
def detect_column_type(headers, rows) -> Dict[str, str]:
"""Auto-detect column types by header keywords."""
type_map = {}
patterns = {
"revenue": ["revenue", "sales", "income", "收入", "营业收入", "销售额"],
"gross_profit": ["gross", "gross_profit", "毛利", "毛利润"],
"net_profit": ["net", "net_profit", "净利润", "净利", "纯利"],
"total_cost": ["cost", "total_cost", "成本", "营业成本"],
"operating_cost": ["operating_cost", "运营成本", "经营成本"],
"admin_cost": ["admin", "管理费用", "管理费"],
"rd_cost": ["rd", "research", "研发费用", "研发"],
"sales_cost": ["selling", "marketing", "销售费用", "销售"],
"financial_cost": ["financial", "财务费用", "财务"],
"cashflow_operating": ["operating_cashflow", "cashflow_operating", "经营活动现金流", "经营现金流"],
"cashflow_investing": ["investing_cashflow", "投资活动现金流", "投资现金流"],
"cashflow_financing": ["financing_cashflow", "筹资活动现金流", "筹资现金流"],
"total_assets": ["total_assets", "assets", "总资产", "资产总计"],
"total_liabilities": ["total_liabilities", "liabilities", "总负债", "负债合计"],
"equity": ["equity", "shareholders_equity", "所有者权益", "净资产"],
"current_assets": ["current_assets", "流动资产"],
"current_liabilities": ["current_liabilities", "流动负债"],
"fixed_assets": ["fixed_assets", "固定资产"],
}
for col_idx, header in enumerate(headers):
h = str(header).lower()
for col_type, keywords in patterns.items():
if any(kw in h for kw in keywords):
type_map[col_idx] = col_type
break
return type_map
# ─────────────────────────────────────────────────────────────────────────────
# AI Prompt Template (English for broad AI model compatibility)
# ─────────────────────────────────────────────────────────────────────────────
ANALYSIS_PROMPT_TEMPLATE = """You are a professional financial analyst. Based on the following financial statement data, generate a structured business analysis report.
Data source:
{file_info}
Data content ({sheet_name}):
Headers: {headers}
Data rows (first 20):
{rows}
---
Please analyze across 7 dimensions. Only analyze dimensions where data is available. If a dimension cannot be identified from the data, state "Not detected in data":
## 1. Revenue Structure Analysis
- Total revenue amount
- YoY / QoQ change (if available)
- Revenue breakdown by business/product line (if identifiable)
- Revenue trend assessment
## 2. Cost Anomaly Detection
- Total cost and cost-to-revenue ratio
- Cost breakdown (operating/admin/sales/R&D/financial expenses)
- Anomaly flagging (compared to industry benchmarks, flag red if threshold exceeded)
## 3. Profitability Analysis
- Gross margin, net margin
- Profit YoY / QoQ change
- Profit quality assessment
## 4. Cash Flow Analysis
- Operating cash flow net amount
- Investing / financing cash flow (if available)
- Cash flow health assessment
## 5. Balance Sheet Analysis
- Asset structure (current / non-current assets)
- Liability structure (current / non-current liabilities)
- Debt-to-asset ratio
- Solvency risk assessment
## 6. KPI Achievement Analysis
- Compare key metrics against "budget" / "target" columns (if present)
- Calculate achievement rate
## 7. Anomaly Alerts
- List all anomalies: margin collapse, excessive debt ratio, negative operating cash flow, revenue decline, etc.
- Severity: 🔴 HIGH / 🟠 MEDIUM / 🟡 LOW
---
Output format (strictly follow this JSON format, no other content):
```json
{{
"revenue": {{
"total": "amount (10K CNY)",
"yoy_change": "YoY %",
"qoq_change": "QoQ %",
"breakdown": "business line breakdown description",
"summary": "revenue structure assessment"
}},
"cost": {{
"total": "total cost",
"ratio": "% of revenue",
"cost_breakdown": "cost breakdown by item",
"anomalies": ["anomaly list"],
"summary": "cost analysis assessment"
}},
"profit": {{
"gross_margin": "gross margin %",
"net_margin": "net margin %",
"yoy_change": "profit YoY %",
"summary": "profitability assessment"
}},
"cashflow": {{
"operating": "operating cash flow net",
"investing": "investing cash flow",
"financing": "financing cash flow",
"summary": "cash flow health assessment"
}},
"balance_sheet": {{
"total_assets": "total assets",
"total_liabilities": "total liabilities",
"equity": "net assets",
"debt_ratio": "debt-to-asset ratio %",
"current_ratio": "current ratio",
"summary": "balance sheet assessment"
}},
"kpi": {{
"achieved": ["identified KPIs and achievement status"],
"missing": "Budget/target data not detected"
}},
"anomalies": [
{{"dimension": "dimension", "severity": "🔴/🟠/🟡", "description": "description", "value": "value", "suggestion": "suggestion"}}
],
"summary": "Overall business assessment (within 100 characters)"
}}
```
"""
def build_prompt(file_info: str, headers: List[str], rows: List[List], sheet_name: str = "Main Data") -> str:
rows_str = "\n".join([str(row) for row in rows[:20]])
return ANALYSIS_PROMPT_TEMPLATE.format(
file_info=file_info,
sheet_name=sheet_name,
headers=headers,
rows=rows_str,
)
def call_ai_analysis(prompt: str, api_key: str, model: str = "gpt-4o") -> Dict[str, Any]:
"""Call AI API for financial analysis. Returns parsed JSON or raises."""
import urllib.request
import urllib.error
if "deepseek" in model.lower():
base_url = "https://api.deepseek.com/v1"
elif "claude" in model.lower() or "anthropic" in model.lower():
base_url = "https://api.anthropic.com/v1"
elif "qwen" in model.lower():
base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
elif "minimax" in model.lower():
base_url = "https://api.minimax.chat/v1"
else:
base_url = "https://api.openai.com/v1"
url = f"{base_url}/chat/completions"
headers_map = {
"openai": {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
"deepseek": {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
"anthropic": {"x-api-key": api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true"},
"qwen": {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
"minimax": {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
}
is_anthropic = "anthropic" in base_url
if is_anthropic:
payload_dict = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 4000}
payload = json.dumps(payload_dict)
headers = headers_map["anthropic"]
else:
payload = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 4000,
})
headers = headers_map.get(
next((k for k in headers_map if k in base_url), "openai"),
headers_map["openai"]
)
req = urllib.request.Request(url, data=payload.encode("utf-8"), headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read().decode("utf-8"))
if is_anthropic:
content = result["content"][0]["text"]
else:
content = result["choices"][0]["message"]["content"]
json_match = re.search(r'```(?:json)?\s*(.*?)```', content, re.DOTALL)
if json_match:
content = json_match.group(1)
return json.loads(content)
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8") if e.fp else ""
raise Exception(f"AI API HTTP {e.code}: {err_body[:500]}")
except Exception as e:
raise Exception(f"AI API call failed: {str(e)}")
# ─────────────────────────────────────────────────────────────────────────────
# Report Builder (Fallback when AI is not available)
# ─────────────────────────────────────────────────────────────────────────────
def build_fallback_report(headers: List[str], rows: List[List]) -> Dict[str, Any]:
"""Build a basic structured report from parsed data without AI."""
col_types = detect_column_type(headers, rows)
return {
"revenue": {"total": "N/A", "yoy_change": "N/A", "qoq_change": "N/A", "breakdown": "AI analysis required", "summary": "Data parsed. Configure AI API Key for complete analysis."},
"cost": {"total": "N/A", "ratio": "N/A", "cost_breakdown": "AI analysis required", "anomalies": [], "summary": "Data parsed. Configure AI API Key for complete analysis."},
"profit": {"gross_margin": "N/A", "net_margin": "N/A", "yoy_change": "N/A", "summary": "Data parsed. Configure AI API Key for complete analysis."},
"cashflow": {"operating": "N/A", "investing": "N/A", "financing": "N/A", "summary": "Data parsed. Configure AI API Key for complete analysis."},
"balance_sheet": {"total_assets": "N/A", "total_liabilities": "N/A", "equity": "N/A", "debt_ratio": "N/A", "current_ratio": "N/A", "summary": "Data parsed. Configure AI API Key for complete analysis."},
"kpi": {"achieved": [], "missing": "Budget/target data not detected"},
"anomalies": [],
"summary": "Data parsed. Configure AI API Key to generate complete business analysis report."
}
# ─────────────────────────────────────────────────────────────────────────────
# Markdown Report Renderer (English labels)
# ─────────────────────────────────────────────────────────────────────────────
def render_markdown_report(report_data: Dict, tier: str, company: str = "", period: str = "") -> str:
"""Render a structured Markdown report from analysis JSON. All labels in English."""
lines = [
f"# Financial Report Analysis",
f"",
f"**Company**: {company or 'Not provided'}",
f"**Period**: {period or 'Not provided'}",
f"**Tier**: {tier}",
f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"",
f"---",
f"",
]
r = report_data
# Revenue
rev = r.get("revenue", {})
lines += [
f"## 1. Revenue Structure Analysis",
f"",
f"| Item | Value |",
f"|------|-------|",
f"| Total Revenue | {rev.get('total', 'N/A')} |",
f"| YoY Change | {rev.get('yoy_change', 'N/A')} |",
f"| QoQ Change | {rev.get('qoq_change', 'N/A')} |",
f"",
f"**Breakdown**: {rev.get('breakdown', 'N/A')}",
f"",
f"**Assessment**: {rev.get('summary', '')}",
f"",
]
# Cost
cost = r.get("cost", {})
lines += [
f"## 2. Cost Anomaly Detection",
f"",
f"| Item | Value |",
f"|------|-------|",
f"| Total Cost | {cost.get('total', 'N/A')} |",
f"| Cost-to-Revenue Ratio | {cost.get('ratio', 'N/A')} |",
f"",
f"**Cost Structure**: {cost.get('cost_breakdown', 'N/A')}",
]
anomalies_cost = cost.get("anomalies", [])
if anomalies_cost:
lines += ["", "**Anomaly Flags**: "]
for a in anomalies_cost:
lines.append(f"- {a}")
lines += ["", f"**Assessment**: {cost.get('summary', '')}", ""]
# Profit
prof = r.get("profit", {})
lines += [
f"## 3. Profitability Analysis",
f"",
f"| Metric | Value |",
f"|--------|-------|",
f"| Gross Margin | {prof.get('gross_margin', 'N/A')} |",
f"| Net Margin | {prof.get('net_margin', 'N/A')} |",
f"| Profit YoY | {prof.get('yoy_change', 'N/A')} |",
f"",
f"**Assessment**: {prof.get('summary', '')}",
f"",
]
# Cashflow
cf = r.get("cashflow", {})
lines += [
f"## 4. Cash Flow Analysis",
f"",
f"| Item | Value |",
f"|------|-------|",
f"| Operating Cash Flow | {cf.get('operating', 'N/A')} |",
f"| Investing Cash Flow | {cf.get('investing', 'N/A')} |",
f"| Financing Cash Flow | {cf.get('financing', 'N/A')} |",
f"",
f"**Assessment**: {cf.get('summary', '')}",
f"",
]
# Balance Sheet
bs = r.get("balance_sheet", {})
lines += [
f"## 5. Balance Sheet Analysis",
f"",
f"| Item | Value |",
f"|------|-------|",
f"| Total Assets | {bs.get('total_assets', 'N/A')} |",
f"| Total Liabilities | {bs.get('total_liabilities', 'N/A')} |",
f"| Net Assets | {bs.get('equity', 'N/A')} |",
f"| Debt-to-Asset Ratio | {bs.get('debt_ratio', 'N/A')} |",
f"| Current Ratio | {bs.get('current_ratio', 'N/A')} |",
f"",
f"**Assessment**: {bs.get('summary', '')}",
f"",
]
# KPI
kpi = r.get("kpi", {})
lines += [
f"## 6. KPI Achievement Analysis",
]
achieved = kpi.get("achieved", [])
if achieved:
lines += ["", "| KPI Metric | Achievement |", "|------|------|"]
for item in achieved:
if isinstance(item, dict):
lines.append(f"| {item.get('name', 'N/A')} | {item.get('status', 'N/A')} |")
else:
lines.append(f"| {item} | N/A |")
else:
lines += ["", f"No KPI / budget data detected ({kpi.get('missing', 'N/A')})"]
lines += [""]
# Anomalies
all_anomalies = r.get("anomalies", [])
if all_anomalies:
lines += [f"## 7. Anomaly Alerts", ""]
lines += ["", "| Dimension | Severity | Description | Value | Suggestion |", "|------|------|------|------|------|"]
for a in all_anomalies:
lines.append(f"| {a.get('dimension','')} | {a.get('severity','')} | {a.get('description','')} | {a.get('value','')} | {a.get('suggestion','')} |")
lines += [""]
# Summary
lines += [
f"## Overall Business Assessment",
f"",
f"{r.get('summary', 'N/A')}",
f"",
f"---",
f"",
f"> This report is auto-generated by AI. Data is for reference only. Please verify against original financial statements.",
]
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────────────
# Main Entry Point
# ─────────────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print(json.dumps({"error": "Usage: report_generator.py <input_json_file>"}))
sys.exit(1)
input_file = sys.argv[1]
if not os.path.exists(input_file):
print(json.dumps({"error": f"Input file not found: {input_file}"}))
sys.exit(1)
with open(input_file, "r", encoding="utf-8") as f:
params = json.load(f)
file_path = params.get("file_path", "")
api_key = params.get("api_key", "")
model = params.get("model", "gpt-4o")
tier = params.get("tier", "FREE")
company = params.get("company_name", "")
period = params.get("period", "")
parsed_data = params.get("parsed_data", {})
# Get sheet data
if "sheets" in parsed_data:
sheets = parsed_data["sheets"]
sheet_name = list(sheets.keys())[0] if sheets else "Main Data"
sheet_data = sheets[sheet_name]
else:
sheet_name = parsed_data.get("format", "Data")
sheet_data = {
"headers": parsed_data.get("headers", []),
"rows": parsed_data.get("rows", []),
}
headers = sheet_data.get("headers", [])
rows = sheet_data.get("rows", [])
file_ext = Path(file_path).suffix.lower()
file_info = f"File: {file_path} ({file_ext})"
# Build prompt
prompt = build_prompt(file_info, headers, rows, sheet_name)
# Try AI analysis
ai_result = None
error_msg = None
if api_key and api_key.strip():
try:
ai_result = call_ai_analysis(prompt, api_key, model)
except Exception as e:
error_msg = str(e)
ai_result = None
# Fallback if no AI or AI failed
if ai_result is None:
ai_result = build_fallback_report(headers, rows)
if error_msg:
ai_result["_warning"] = f"AI analysis unavailable: {error_msg}. Basic report generated."
# Render Markdown
markdown_report = render_markdown_report(ai_result, tier, company, period)
result = {
"success": True,
"tier": tier,
"analysis": ai_result,
"markdown": markdown_report,
"file_info": {
"path": file_path,
"ext": file_ext,
"sheet": sheet_name,
"rows_count": len(rows),
"cols_count": len(headers),
}
}
print(json.dumps(result, ensure_ascii=False, default=str))
if __name__ == "__main__":
main()
FILE:src/handlers/message_handler.js
/**
* Message Handler
* Handles interactive chat messages for financial report analysis
* Supports: text queries about uploaded reports, file upload instructions
*/
const path = require('path');
const { handleSkillInvoke } = require('./skill_invoke');
/**
* Handle incoming message
* @param {Object} message - { text, userId, sessionId, apiKey, model }
* @returns {Object} - response
*/
async function handleMessage(message) {
const { text = '', userId = '', sessionId = '', apiKey = '', model = '' } = message;
if (!text || text.match(/^(help|usage|how to|guide)/i)) {
return getHelpMessage();
}
if (text.match(/^(plan|price|tier|cost|version|套餐|价格)/i)) {
return getPlanInfoMessage();
}
if (text.match(/^(status|balance|余额)/i)) {
return {
success: true,
message: 'Please provide your API Key so I can check your plan status.\n\nOr simply upload a financial file to start analysis.',
};
}
return getHelpMessage();
}
function getHelpMessage() {
return {
success: true,
message: `Financial Report AI
Upload your Excel/CSV/PDF financial statements → AI auto-generates structured business analysis reports.
Supported analysis dimensions:
1. Revenue structure analysis
2. Cost anomaly detection
3. Profitability analysis
4. Cash flow analysis
5. Balance sheet analysis
6. KPI achievement
7. Anomaly alerts
Steps:
1. Upload financial file (Excel/CSV/PDF)
2. Wait for AI automatic parsing
3. Receive complete analysis report
Tier:
• FREE: 3 analyses/month, 3 basic dimensions
• PRO: $0.01 USDT/use, all 7 dimensions + charts
> Get PRO: https://skillpay.me/ai-financial-report`,
};
}
function getPlanInfoMessage() {
return {
success: true,
message: `Tier Comparison
| Feature | FREE | PRO |
|---------|------|-----|
| Analyses/month | 3 | Unlimited |
| Input formats | CSV, Excel | CSV, Excel, PDF |
| Analysis dimensions | 3 basic | All 7 |
| Charts | No | Yes |
| Industry comparison | No | Yes |
| Price | Free | $0.01 USDT/use |
> Get PRO: https://skillpay.me/ai-financial-report`,
};
}
module.exports = { handleMessage };
FILE:src/handlers/file_upload.js
/**
* File Upload Handler
* Handles file upload events from OpenClaw
* Accepts: CSV, XLSX, XLS, PDF
*/
const path = require('path');
const fs = require('fs');
const { handleSkillInvoke } = require('./skill_invoke');
const ALLOWED_EXTENSIONS = ['.csv', '.xlsx', '.xls', '.pdf'];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
function validateFile(ext, size) {
const errors = [];
if (!ALLOWED_EXTENSIONS.includes(ext.toLowerCase())) {
errors.push(`Unsupported format: ext. Supported: CSV, Excel (.xlsx/.xls), PDF`);
}
if (size > MAX_FILE_SIZE) {
errors.push(`File too large: (size / 1024 / 1024).toFixed(1)MB, max 50MB`);
}
return errors;
}
/**
* Handle file upload
* @param {Object} event - { fileName, fileSize, fileContent (base64), mimeType, apiKey, model }
* @returns {Object} - analysis result
*/
async function handleFileUpload(event) {
const {
fileName = 'report.xlsx',
fileSize = 0,
fileContent = '',
mimeType = '',
apiKey = '',
model = '',
companyName = '',
period = '',
userId = '',
} = event;
const ext = path.extname(fileName).toLowerCase();
// Validate
const errors = validateFile(ext, fileSize);
if (errors.length > 0) {
return {
success: false,
errors,
message: errors.join('; '),
};
}
// Route to skill invoke
const result = await handleSkillInvoke({
apiKey,
fileContent,
fileName,
fileExt: ext,
model,
companyName,
period,
userId,
});
return {
success: true,
...result,
};
}
module.exports = { handleFileUpload };
FILE:src/handlers/skill_invoke.js
/**
* Skill Invoke Handler - Main entry point for financial report analysis
* Handles: skill.invoke events from OpenClaw
*/
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const { validateToken, getPlanLimits } = require('../services/billing');
const SKILL_ROOT = path.resolve(__dirname, '..', '..');
// Plan tier → analysis dimensions available
const TIER_DIMENSIONS = {
FREE: 3,
PRO: 99,
};
function sanitizeJsonSafe(obj) {
if (obj && typeof obj === 'object') {
const clean = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null) {
clean[k] = v;
} else if (Array.isArray(v)) {
clean[k] = v.map(sanitizeJsonSafe);
} else if (typeof v === 'object') {
clean[k] = sanitizeJsonSafe(v);
}
}
return clean;
}
return obj;
}
function runPython(scriptPath, args) {
return new Promise((resolve, reject) => {
const proc = spawn('python3', [scriptPath, ...args], {
cwd: SKILL_ROOT,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
timeout: 120000,
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', d => { stdout += d.toString(); });
proc.stderr.on('data', d => { stderr += d.toString(); });
proc.on('close', code => {
if (code !== 0) {
reject(new Error(`Python exited code: stderr || stdout`));
} else {
resolve(stdout);
}
});
proc.on('error', err => reject(err));
});
}
async function parseFile(filePath) {
const scriptPath = path.join(SKILL_ROOT, 'src', 'services', 'file_parser.py');
const output = await runPython(scriptPath, [filePath]);
return JSON.parse(output.trim());
}
async function generateReport(inputJsonPath) {
const scriptPath = path.join(SKILL_ROOT, 'src', 'services', 'report_generator.py');
const output = await runPython(scriptPath, [inputJsonPath]);
return JSON.parse(output.trim());
}
/**
* Main skill invoke handler
* @param {Object} args - { apiKey, fileContent (base64), fileName, fileExt, model, tier, companyName, period, userId }
* @returns {Object} - { markdown, analysis, fileInfo }
*/
async function handleSkillInvoke(args) {
const {
apiKey = '',
fileContent = '',
fileName = 'report.xlsx',
fileExt = '',
model = '',
companyName = '',
period = '',
userId = '',
} = args;
// 1. Billing / token validation
const validation = await validateToken(apiKey, userId);
const tier = validation.valid ? 'PRO' : 'FREE';
const limits = getPlanLimits(tier);
// 2. Save uploaded file to temp
const tmpDir = '/tmp/ai-financial-report';
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
const decoded = Buffer.from(fileContent, 'base64');
const savePath = path.join(tmpDir, `frp_Date.now()_fileName`);
fs.writeFileSync(savePath, decoded);
// 3. Parse file
let parsedData;
try {
parsedData = await parseFile(savePath);
} catch (err) {
try { fs.unlinkSync(savePath); } catch (_) {}
throw new Error(`File parsing failed: err.message`);
}
// 4. Build input for report generator
const inputJson = {
file_path: savePath,
api_key: apiKey,
model: model || 'gpt-4o',
tier,
company_name: companyName || '',
period: period || '',
parsed_data: sanitizeJsonSafe(parsedData),
};
const inputJsonPath = path.join(tmpDir, `input_Date.now().json`);
fs.writeFileSync(inputJsonPath, JSON.stringify(inputJson, null, 2), 'utf8');
// 5. Generate report
let reportResult;
try {
reportResult = await generateReport(inputJsonPath);
} catch (err) {
try { fs.unlinkSync(inputJsonPath); } catch (_) {}
try { fs.unlinkSync(savePath); } catch (_) {}
throw new Error(`Report generation failed: err.message`);
} finally {
try { fs.unlinkSync(inputJsonPath); } catch (_) {}
try { fs.unlinkSync(savePath); } catch (_) {}
}
return {
markdown: reportResult.markdown,
analysis: sanitizeJsonSafe(reportResult.analysis || {}),
file_info: reportResult.file_info || {},
tier,
limits,
validation_result: {
valid: validation.valid,
plan: validation.plan,
reason: validation.reason,
},
};
}
module.exports = { handleSkillInvoke };
Use when the user mentions Element plus drops, NFT creation, NFT collection launch, mint setup, sale stages, prereveal media, preview media, or publishing/pa...
---
name: element-drop
description: Use when the user mentions Element plus drops, NFT creation, NFT collection launch, mint setup, sale stages, prereveal media, preview media, or publishing/pausing/resuming a drop. Also use when they want to create an NFT on Element, create an Element collection/drop, configure mint price/supply/payment token/stages, preview current drop settings/design, update drop media or description, publish a configured drop, or provide chainName, slug, contractAddress, paymentToken, or a local image path for drop work. Read-only preview/list actions may run immediately; every state-changing flow must show an execution preview first, clearly distinguish preview from real execution, then wait for confirmation.
requires: ["node", "jq"]
metadata: {"openclaw":{"requires":{"bins":["node","npm"],"env":["ELEMENT_WALLET_PRIVATE_KEY"]},"primaryEnv":"ELEMENT_WALLET_PRIVATE_KEY"}}
---
# Element Drop
Use this skill for Element Drop lifecycle work: create a drop, preview current configuration, update settings or design, publish a configured drop, resume a paused drop, or pause a live drop.
This skill is not for marketplace trading, collection analytics, wallet portfolio inspection, or generic Element protocol research.
Run the bundled lifecycle commands under `scripts/`; do not default to ad-hoc API calls.
## Runtime Setup
Required environment variable:
- `ELEMENT_WALLET_PRIVATE_KEY`
Example:
```bash
export ELEMENT_WALLET_PRIVATE_KEY="your_wallet_private_key_here"
```
The private key must only be provided through `ELEMENT_WALLET_PRIVATE_KEY`; do not paste it into chat, JSON payloads, files for this skill, or command arguments. The scripts validate it as a `0x`-prefixed 32-byte hex key, use it only for local message and transaction signing, and redact the configured key from known error output. The raw private key is never sent over the network. The scripts may upload media, call Element APIs, and broadcast signed transactions after confirmation, so use a dedicated low-risk wallet.
Only upload user-provided NFT media images. Do not upload secrets, keys, dotfiles, shell configs, environment files, source archives, or arbitrary local documents. The bundled scripts validate uploads as regular local image files and reject symlinks, unsupported extensions, oversized files, and files whose bytes do not match the declared image type.
## Routing Contract
First classify the task, then read the matching reference before you answer with field guidance, collect parameters, or run commands:
- `Create`: new Element NFT collection/drop, stopping before publish
- `List`: show current drops and their `name` / `slug`
- `Preview`: inspect existing settings, design, prereveal media, or public availability
- `Update`: change settings, prereveal image, design fields, design media, or stages
- `Publish`: publish, resume a paused drop, or pause a live drop
- `List chains`: inspect supported chains or payment tokens
- Create: [references/create.md](./references/create.md)
- List: [references/list.md](./references/list.md)
- Preview: [references/preview.md](./references/preview.md)
- Update: [references/update.md](./references/update.md)
- Publish: [references/publish.md](./references/publish.md)
Do not guess supported chains, supported payment tokens, lifecycle defaults, or field constraints from memory. If the user is doing a relevant operation and asks or implies uncertainty about those values, read the matching reference first, then use the read-only command when live data is needed.
Do not use this skill for buying, selling, bidding, canceling, floor price, volume, rankings, activity history, inventory, or portfolio queries.
If the user asks about field meaning or code behavior in the context of Element Drop lifecycle work, this skill may be used for explanation, but do not run lifecycle commands unless the user is actually performing a lifecycle action.
## Confirmation Rules
Read-only commands run immediately:
- `list-user-drops`
- `preview-drop`
- `list-chains`
State-changing commands require preview first:
- `create-drop`
- `update-drop`
- `publish-drop`
For any state-changing command:
1. Gather the minimum required parameters for the lifecycle stage.
2. Run preview mode first.
3. Show a concise execution preview using business language.
4. Wait for explicit positive confirmation.
5. Execute only after confirmation.
If execution includes an onchain transaction, also state that continuing will send a real blockchain transaction, identify the user-visible action that sends it, and make clear that preview mode does not broadcast.
Every preview, execution confirmation, and failure report must repeat the selected chain. For create/deploy flows, also repeat the contract `symbol`. Never silently switch chains after an RPC or network problem; if the requested chain fails, report that chain and ask for a new explicit user instruction before using another chain.
## User-Facing Parameters
General:
- Use `chainName`; do not ask users for chain IDs.
- Use `slug` as the main identifier for existing drops.
- Resolve backend identifiers internally.
- Do not ask for hidden backend IDs when `slug` is enough.
- Local media paths must be absolute.
- Local media paths must point to real image files (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`); never use paths to credentials or system files.
- `paymentToken` accepts token name, symbol, or address.
Settings:
- `edition`: `limited` or `open`
- `maxSupply`: required when switching to limited edition
- `dropBeginTime`: mint start time
- `paymentToken`: sale currency
- `stages`: full mint stage array replacement when provided
- `preReveal`: pre-reveal image path
Design:
- `previewMedia`: design preview image path array
- `name`
- `description` / `desc`
- `website`: must be a complete URL
- `twitter`, `instagram`, `discord`, `telegram`, `medium`: users may provide suffix or full link; send suffix to backend
- `bannerFilePath`
- `dropFeaturedImage`
Do not expose backend-only identifiers, status flags, or compatibility storage names in user-facing summaries. Use business language such as chain, slug, live/paused/draft, prereveal image, preview images, and availability.
## Lifecycle Rules
Create:
- Required: `chainName`, `name`, `symbol`, `preReveal`.
- When a create request starts, proactively state these required fields, ask for any that are missing, and then state the defaults that will be applied if the user does not override them.
- Optional overrides include settings, stages, design media, design copy, website/social links, and payment token.
- Default mint start time is the next day.
- Default edition is limited edition with total supply `999`.
- Default preview image is the same image as `preReveal`.
- Default stage is one public stage with sale price `0`, duration `3 days`, phase supply equal to the collection supply, max `1` mint per wallet, no interval, and public mode.
- Create deploys and configures the drop but does not publish by default.
- After create succeeds, tell the user the drop is configured but not live yet, then guide them to preview publishing the same `chainName` + `slug` before confirmed publish execution.
Update:
- Required: `chainName`, `slug`.
- Patch semantics: only explicitly provided fields change; omitted fields keep remote values.
- If `stages` is provided, it replaces the full stage array.
- Stage IDs are not user-controlled.
- Updating settings or prereveal on a live drop requires republish and therefore an onchain confirmation.
- Updating a paused drop, design-only fields, design media, or collection profile fields does not force republish.
Publish:
- Required: `chainName`, `slug`.
- Covers first publish, resuming a paused drop, and pausing a live drop.
- Default target is live.
- If the user asks to keep it paused or pause a live drop, use the paused publish target.
- Publish requires an existing configured prereveal image. The publish flow prepares that image for the live drop configuration automatically; if readiness times out, tell the user to re-upload the prereveal image and retry.
## Web-Only Features
Element Drop supports these features, but this skill does not currently configure them:
- Whitelist stage configuration
- Design detail sections
For those, use the web editor and replace `your-slug` with the actual drop slug:
- [https://element.market/collections/your-slug/edit/drop](https://element.market/collections/your-slug/edit/drop)
## Reporting Contract
For previews, clearly label the response as preview-only and say no changes were applied.
For execution, summarize only the user-visible result.
Include the relevant fields for the lifecycle stage:
- Create: chain, symbol, slug, contract address, collection URL, drop URL, edit URL, settings summary, design summary, recommended next publish action
- List: chain, wallet, drop names, slugs, returned count
- Preview: chain, slug, availability state, mint start time, edition, supply, payment token, stages, design/prereveal media status, links
- Update: chain, slug, fields changed, whether republish was needed, updated settings/design summaries, links
- Publish: chain, slug, transaction hash if sent, final availability state, settings summary, links
Use these business labels in user-facing output:
- `draft`, `live`, `paused` for availability
- `limited edition`, `open edition` for edition
- `prereveal image` for prereveal media
- `preview images` for design preview media
- `sale currency` for payment token
## Failure Reporting
Do not say only "failed".
State the failed lifecycle stage, the relevant error or response summary, and the actionable recovery.
If required parameters are missing, state exactly which user-facing parameters are missing.
For upload, API, or onchain failures, identify the segment in business language.
Always repeat the selected chain in the failure message. For create/deploy failures, repeat the symbol too.
## Absolute Do-Nots
- Do not request or echo the private key.
- Do not execute state-changing commands before confirmation.
- Do not interpret "create" as "create and publish" by default.
- Do not overwrite unspecified fields with defaults during `update-drop`.
- Do not show internal script choreography or backend-only identifiers in normal user-facing output.
FILE:references/publish.md
# Publish Stage Reference
## When to Use
- The drop has already been created and configured, and is now ready to publish
- The drop is currently paused and should be switched back to live state
- The drop is already live and should be switched into paused state
## Minimum Required Parameters
- `chainName`
- A user-facing chain name
- Examples: `base`, `arbitrum`, `bsc`
- The script resolves it internally to the corresponding chain configuration
- `slug`
- The user-facing Element drop or collection slug
- The script resolves it internally to the identifiers required by the backend
## Preconditions
- The drop has already been created
- Settings and design have already been configured
- A valid prereveal asset has already been configured on the drop
- Publish uses the configured prereveal image and resolves the publish-ready prereveal URI automatically
- If prereveal readiness times out, ask the user to re-upload the prereveal image and retry
- If the user needs to change the sale currency first, use `update-drop` with `paymentToken` before publishing
- `publish-drop` can be used for first publish, for resuming a paused drop, and for pausing an already live drop
## Optional Behavior
- By default, publish makes the drop live
- If the user explicitly asks to keep the drop paused, the agent uses the internal paused publish mode
- A paused drop can be resumed by running `publish-drop` without paused mode
- An already live drop can be paused by running `publish-drop` in paused mode
## Execution Preview
First enter the installed skill's `scripts` directory:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts publish-drop --preview /tmp/element-drop-publish.json
```
The preview should be treated as a transaction preview for the publish step:
- It should confirm a prereveal image is configured and can become publish-ready
- It should warn that confirmed execution sends a real onchain publish transaction
- It should show the transaction summary when available
- It should make clear that preview mode does not broadcast the transaction
## Execute
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts publish-drop /tmp/element-drop-publish.json
```
## Example Payload
```json
{
"chainName": "base",
"slug": "ffffff-510bce"
}
```
## Output Focus
- `slug`
- resolved `contractAddress`
- publish transaction hash
- whether platform synchronization completed
- final availability state
- final `dropBeginTime`
- final `edition`
- final `maxSupply`
- final resolved `paymentToken`
- final full stage summary
- final `Drop URL`
- final `Collection URL`
- final `Edit URL`
FILE:references/list.md
# List Stage Reference
## When to Use
- Show the user's current Element Drop list on a supported chain
- Look up existing drop `name` and `slug` values before preview, update, or publish
- Confirm which collections on a chain are actual Element Drops
## Minimum Required Parameters
- `chainName`
- A user-facing chain name
- Examples: `base`, `arbitrum`, `bsc`
- The script resolves it internally to the corresponding chain configuration
## Common Optional Parameters
- `walletAddress`
- Optional override
- If omitted, the script derives the address from `ELEMENT_WALLET_PRIVATE_KEY`
- `first`
- Forward pagination size
- `after`
- Forward pagination cursor
- `last`
- Backward pagination size
- `before`
- Backward pagination cursor
## Run
First enter the installed skill's `scripts` directory:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts list-user-drops /tmp/element-drop-list.json
```
## Example Payload
```json
{
"chainName": "base"
}
```
## Output Focus
- `drops[].name`
- `drops[].slug`
- `drops[].isVerified`
- `pageInfo`
FILE:references/create.md
# Create Stage Reference
## When to Use
- Create a new Element Drop
- Deploy the contract and complete the initial media, settings, and design setup
- Explicitly stop before publish
## Minimum Required Parameters
When the user starts a create flow, proactively collect these fields first. If any are missing, ask for the missing required fields and state the defaults below before preparing the preview.
- `chainName`
- A user-facing chain name
- Example values: `base`, `bsc`, `arbitrum`
- Do not guess whether a chain is supported
- If the user is unsure, run `list-chains` before continuing
- The script resolves it internally to the corresponding chain configuration
- `name`
- Collection name shown as the contract name onchain
- Usually the project or collection name users will recognize
- This value cannot be changed after the contract is deployed
- `symbol`
- Short onchain identifier for the contract
- Usually an uppercase shorthand, such as `AZUKI` or `BAYC`
- This value cannot be changed after the contract is deployed
- `preReveal`
- Pre-reveal image path
- Must be a local absolute path
- The workflow uploads this prereveal asset during create
- The workflow also uses this image as the default initial design preview media unless explicit design preview assets are provided
If the user is unsure which chains are currently supported, run:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts list-chains
```
## Create Defaults
If the user does not override these fields, create uses:
- Mint start time: next day
- Edition: limited edition
- Total supply: `999`
- Sale currency: native chain currency
- Stage set: one public stage
- Default stage name: `Public`
- Default stage sale price: `0`
- Default stage duration: `3 days`
- Default phase supply: same as the collection supply
- Default max mints per wallet: `1`
- Default mint interval: `0`
- Default stage mode: public
- Default banner: prereveal image
- Default preview image: same image as `preReveal`, unless `previewMedia` is provided
Do not describe these as facts if you have not read this reference in the current operation.
## Common Optional Parameters
- `previewMedia`
- Optional preview image path or path array
- Must use local absolute path(s)
- If omitted, defaults to the same image as `preReveal`
- The workflow uploads these files and stores them as the preview images
- `bannerFilePath`
- Optional design banner image path
- If omitted, the prereveal image is used as the initial banner
- `description`
- Drop description
- `desc`
- Alias for `description`; accepted for user convenience and normalized before sending to the backend
- `website`
- Collection website URL; must be a complete URL
- `twitter`
- Twitter/X suffix or full link; normalized to suffix before backend write
- `instagram`
- Instagram suffix or full link; normalized to suffix before backend write
- `discord`
- Discord invite suffix or full link; normalized to suffix before backend write
- `telegram`
- Telegram suffix or full link; normalized to suffix before backend write
- `medium`
- Medium suffix or full link; normalized to suffix before backend write
- `dropFeaturedImage`
- Optional featured image URL for the drop design payload
- `paymentToken`
- Sale currency
- Defaults to native chain currency
- Accepts token `name`, `symbol`, or token address
- Do not guess the supported token list; use `list-chains` when the user asks what is supported or provides an ambiguous token
- `edition`
- `limited` = limited edition
- `open` = open edition
- Defaults to `limited`
- Open edition uses the platform default supply behavior automatically
- `maxSupply`
- Total supply cap for the collection
- Defaults to `999` for limited edition
- `dropBeginTime`
- Mint start time
- Unix timestamp in seconds
- If omitted, defaults to the next day
- `stages`
- Stage array
- If provided, it defines the full mint stage set
- If omitted, the default public stage above is used
## Stage Rules
Each stage may include:
- `stageName`
- `price`
- `duration`
- `maxSupplyAtThisStage`
- `maxMintedPerWallet`
- `interval`
- `stageMode`
Notes:
- `stageID` is never user-controlled
- If the user does not provide stages, do not ask for stage fields unless they want custom mint phases
- The script always assigns stage IDs in order:
- first stage `256`
- second stage `257`
- third stage `258`
- and so on
- Element stages also support whitelist mode on web
- This skill does not support configuring whitelist mode
- Design detail sections are web-only
- If any web-only feature is needed, edit it in the web UI instead, replacing `your-slug` with the actual drop slug:
- [https://element.market/collections/your-slug/edit/drop](https://element.market/collections/your-slug/edit/drop)
## Execution Preview
First enter the installed skill's `scripts` directory:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts create-drop --preview /tmp/element-drop-create.json
```
The preview should be treated as a transaction preview for the deployment step:
- It should repeat the selected chain and symbol
- It should warn that confirmed execution deploys a real onchain contract
- It should show the transaction summary when available
- It should make clear that preview mode does not broadcast anything
## Execute
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts create-drop /tmp/element-drop-create.json
```
## Output Focus
- `slug`
- selected chain
- `symbol`
- resolved `contractAddress`
- `Drop URL`
- `Collection URL`
- `Edit URL`
- settings summary
- design summary
- next step: preview publishing this same chain and slug, then publish only after confirmation
## Example Payload
```json
{
"chainName": "base",
"name": "ffffff",
"symbol": "F",
"paymentToken": "ETH",
"preReveal": "/absolute/path/to/image.png"
}
```
## Recovery Note
If contract deployment succeeds but later setup fails or times out, do not immediately rerun `create-drop`: that may deploy another contract. Preview the new slug, then use `update-drop` to complete missing settings, prereveal, design, or stage configuration.
If deployment or setup fails, repeat the selected chain and symbol in the error summary. Do not switch to another chain unless the user explicitly asks for that chain.
FILE:references/preview.md
# Preview Stage Reference
## When to Use
- Inspect the current drop settings
- Inspect the current design
- Inspect current prereveal media status
## Minimum Required Parameters
- `chainName`
- A user-facing chain name
- Examples: `base`, `arbitrum`, `bsc`
- The script resolves it internally to the corresponding chain configuration
- `slug`
- The user-facing Element drop or collection slug
- The script resolves it internally to the matching `contractAddress`
## Run
First enter the installed skill's `scripts` directory:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts preview-drop /tmp/element-drop-preview.json
```
## Example Payload
```json
{
"chainName": "base",
"slug": "ffffff-510bce"
}
```
## Output Focus
- `slug`
- resolved `contractAddress`
- availability state: draft, live, or paused
- `dropBeginTime`
- `edition`
- `maxSupply`
- resolved `paymentToken`
- full stage summary for each stage
- whether design exists
- design preview images
- prereveal image status
FILE:references/update.md
# Update Stage Reference
## When to Use
- Update settings on an existing drop
- Update design copy or media
- Adjust mint stages
## Minimum Required Parameters
- `chainName`
- A user-facing chain name
- Examples: `base`, `arbitrum`, `bsc`
- The script resolves it internally to the corresponding chain configuration
- `slug`
- The user-facing Element drop or collection slug
- The script resolves it internally to `contractAddress`
## Patch Semantics
- The script fetches current remote `settings` and `design` first
- It only overwrites fields the user explicitly provides
- Any omitted field keeps its current remote value
- If the current drop is live and the patch changes `settings` or `preReveal`, `update-drop` does not stop at offchain modification
- In that case it forcibly continues into the publish flow after the modification step
- If the current drop is already paused, the same patch stays offchain and does not force republish
- If the patch only changes `design`, design assets, or collection profile fields, no republish is triggered
- The settings- or prereveal-changing case becomes an onchain-affecting flow and must be confirmed as such
## Common Patchable Fields
### Settings
- `preReveal`
- Pre-reveal media absolute path
- `edition`
- `limited` = limited edition
- `open` = open edition
- Switching to open edition uses the platform default supply behavior automatically
- Switching to limited edition requires an explicit `maxSupply`
- `maxSupply`
- Supply cap for limited edition
- `dropBeginTime`
- `paymentToken`
- Accepts token `name`, `symbol`, or token address
- `stages`
### Design
- `previewMedia`
- Design preview image path array
- The workflow uploads each file, then stores the resulting URLs as design preview images
- `name`
- `description`
- `website`
- Must be a complete URL
- `twitter`
- Accepts either suffix or full link; backend payload uses suffix only
- `instagram`
- Accepts either suffix or full link; backend payload uses suffix only
- `discord`
- Accepts either suffix or full link; backend payload uses suffix only
- `telegram`
- Accepts either suffix or full link; backend payload uses suffix only
- `medium`
- Accepts either suffix or full link; backend payload uses suffix only
- `dropFeaturedImage`
### Design Assets
- `bannerFilePath`
- Banner image absolute path
Notes:
- `previewMedia` is the user-facing input field for design preview images
- If `preReveal` is uploaded and `previewMedia` is not explicitly provided, the workflow also uses the uploaded prereveal image as the initial design preview image
- Element stages also support whitelist mode on web
- This skill does not support configuring whitelist mode
- Design detail sections are web-only
- If any web-only feature is needed, edit it in the web UI instead, replacing `your-slug` with the actual drop slug:
- [https://element.market/collections/your-slug/edit/drop](https://element.market/collections/your-slug/edit/drop)
## Stage Rules
- If `stages` is omitted, the current remote stage config is preserved
- If `stages` is provided, it replaces the full current stage array
- `stageID` is never user-controlled
- The script always rewrites stage IDs to `256 + index`
## Execution Preview
First enter the installed skill's `scripts` directory:
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts update-drop --preview /tmp/element-drop-update.json
```
Current preview behavior:
- show the current values that matter for the requested patch
- show the merged result that would be applied
- show whether the update remains offchain or requires republish
- if republish is required, include the onchain transaction preview
## Execute
```bash
cd <skill-directory>/scripts
npx tsx src/cli.ts update-drop /tmp/element-drop-update.json
```
## Output Focus
- `slug`
- resolved `contractAddress`
- fields actually updated
- settings summary, if settings were updated
- design summary, if design was updated
- whether republish was triggered
- `Drop URL`
- `Collection URL`
- `Edit URL`
## Example Payload
```json
{
"chainName": "base",
"slug": "ffffff-510bce",
"maxSupply": 999,
"paymentToken": "WETH",
"description": "updated description"
}
```
## Example Payload With Media Updates
```json
{
"chainName": "base",
"slug": "ffff",
"paymentToken": "0x4200000000000000000000000000000000000006",
"preReveal": "/absolute/path/to/image.png",
"bannerFilePath": "/absolute/path/to/banner.png",
"previewMedia": [
"/absolute/path/to/image.png"
],
"description": "updated description"
}
```
## Payment Token Guidance
- Use `list-chains` if the user needs the currently supported payment token list for a chain
- Prefer token address when there is any ambiguity between display names or symbols
- If `paymentToken` is omitted during `update-drop`, the current remote payment token setting is preserved
FILE:scripts/pnpm-lock.yaml
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
cuimp:
specifier: ^1.10.0
version: 1.10.0
ethers:
specifier: ^6.13.5
version: 6.16.0
zod:
specifier: ^3.24.1
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^24.9.1
version: 24.12.2
tsx:
specifier: ^4.20.6
version: 4.21.0
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/[email protected])([email protected])
packages:
'@adraffy/[email protected]':
resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
'@esbuild/[email protected]':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@isaacs/[email protected]':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@noble/[email protected]':
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
'@noble/[email protected]':
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
'@rollup/[email protected]':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
cpu: [arm64]
os: [android]
'@rollup/[email protected]':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
cpu: [arm64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
cpu: [x64]
os: [darwin]
'@rollup/[email protected]':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
cpu: [arm64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
cpu: [x64]
os: [freebsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
cpu: [x64]
os: [openbsd]
'@rollup/[email protected]':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
cpu: [arm64]
os: [openharmony]
'@rollup/[email protected]':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
cpu: [arm64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
cpu: [ia32]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
cpu: [x64]
os: [win32]
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/[email protected]':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/[email protected]':
resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
'@types/[email protected]':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@vitest/[email protected]':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/[email protected]':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/[email protected]':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/[email protected]':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/[email protected]':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
[email protected]:
resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
[email protected]:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
[email protected]:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-+cIbWHQ+ZQ7Nc3TJoCxqfDl06/ormSN5zV6mV0qcJVzTM2lHLTs/8ujgSJpsbzg6DNKNeHSX6g/HoTDWgCKF3w==}
engines: {node: '>=18.17'}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
[email protected]:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
[email protected]:
resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
[email protected]:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
[email protected]:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
[email protected]:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
[email protected]:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
[email protected]:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
[email protected]:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
[email protected]:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
[email protected]:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
[email protected]:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
[email protected]:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
[email protected]:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
[email protected]:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
[email protected]:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
[email protected]:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
[email protected]:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
[email protected]:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
[email protected]:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@adraffy/[email protected]': {}
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@isaacs/[email protected]':
dependencies:
minipass: 7.1.3
'@jridgewell/[email protected]': {}
'@noble/[email protected]':
dependencies:
'@noble/hashes': 1.3.2
'@noble/[email protected]': {}
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@rollup/[email protected]':
optional: true
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 6.19.8
'@types/[email protected]':
dependencies:
undici-types: 7.16.0
'@vitest/[email protected]':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/[email protected])([email protected])
'@vitest/[email protected]':
dependencies:
tinyrainbow: 2.0.0
'@vitest/[email protected]':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/[email protected]':
dependencies:
tinyspy: 4.0.4
'@vitest/[email protected]':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
tar: 7.4.3
[email protected]:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
[email protected]:
dependencies:
'@types/estree': 1.0.8
[email protected]:
dependencies:
'@adraffy/ens-normalize': 1.10.1
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.2
'@types/node': 22.7.5
aes-js: 4.0.0-beta.5
tslib: 2.7.0
ws: 8.17.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.4
[email protected]:
optional: true
[email protected]:
dependencies:
resolve-pkg-maps: 1.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]: {}
[email protected]:
dependencies:
minipass: 7.1.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1
'@rollup/rollup-android-arm64': 4.60.1
'@rollup/rollup-darwin-arm64': 4.60.1
'@rollup/rollup-darwin-x64': 4.60.1
'@rollup/rollup-freebsd-arm64': 4.60.1
'@rollup/rollup-freebsd-x64': 4.60.1
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
'@rollup/rollup-linux-arm64-gnu': 4.60.1
'@rollup/rollup-linux-arm64-musl': 4.60.1
'@rollup/rollup-linux-loong64-gnu': 4.60.1
'@rollup/rollup-linux-loong64-musl': 4.60.1
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
'@rollup/rollup-linux-ppc64-musl': 4.60.1
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
'@rollup/rollup-linux-riscv64-musl': 4.60.1
'@rollup/rollup-linux-s390x-gnu': 4.60.1
'@rollup/rollup-linux-x64-gnu': 4.60.1
'@rollup/rollup-linux-x64-musl': 4.60.1
'@rollup/rollup-openbsd-x64': 4.60.1
'@rollup/rollup-openharmony-arm64': 4.60.1
'@rollup/rollup-win32-arm64-msvc': 4.60.1
'@rollup/rollup-win32-ia32-msvc': 4.60.1
'@rollup/rollup-win32-x64-gnu': 4.60.1
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
js-tokens: 9.0.1
[email protected]:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
mkdirp: 3.0.1
yallist: 5.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.4
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
esbuild: 0.27.7
get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected](@types/[email protected])([email protected]):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/[email protected])([email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
[email protected](@types/[email protected])([email protected]):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0([email protected])
picomatch: 4.0.4
postcss: 8.5.10
rollup: 4.60.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
tsx: 4.21.0
[email protected](@types/[email protected])([email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4([email protected](@types/[email protected])([email protected]))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/[email protected])([email protected])
vite-node: 3.2.4(@types/[email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
[email protected]:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
FILE:scripts/package.json
{
"name": "element-drop-scripts",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"check": "pnpm run typecheck",
"test": "vitest run",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"cuimp": "^1.10.0",
"ethers": "^6.13.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^24.9.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
FILE:scripts/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"],
"types": ["node", "vitest/globals"],
"outDir": "dist"
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
}
FILE:scripts/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
restoreMocks: true
}
});
FILE:scripts/src/schema.ts
import { z } from "zod";
import type { CreateDropInput, CreateStageInput, DropInput, StageInput } from "./types";
import { DEFAULT_MAX_SUPPLY, resolveDropMode } from "./drop-mode";
const DEFAULT_STAGE_DURATION_SECONDS = 3 * 24 * 60 * 60;
const DEFAULT_STAGE_NAME = "Public";
const DEFAULT_STAGE_PRICE = "0";
const DEFAULT_STAGE_INTERVAL = 0;
const DEFAULT_STAGE_MAX_MINTED_PER_WALLET = 1;
const DEFAULT_STAGE_ID = 256;
const stageSchema = z.object({
stageID: z.number().int().nonnegative(),
stageName: z.string().min(1),
price: z.string().min(1),
duration: z.number().int().positive(),
maxSupplyAtThisStage: z.number().int().positive(),
maxMintedPerWallet: z.number().int().positive(),
interval: z.number().int().nonnegative(),
stageMode: z.union([z.literal(0), z.literal(1)])
});
const createStageSchema = z.object({
stageID: z.number().int().nonnegative().optional(),
stageName: z.string().min(1),
price: z.string().min(1),
duration: z.number().int().positive(),
maxSupplyAtThisStage: z.number().int().positive(),
maxMintedPerWallet: z.number().int().positive(),
interval: z.number().int().nonnegative(),
stageMode: z.union([z.literal(0), z.literal(1)])
});
const createDropInputSchema = z.object({
chainMId: z.number().int().positive(),
name: z.string().min(1),
symbol: z.string().min(1),
paymentToken: z.string().min(1).optional(),
preReveal: z.string().min(1).optional(),
previewMedia: z.array(z.string().min(1)).optional(),
description: z.string().optional(),
desc: z.string().optional(),
website: z.string().optional(),
twitter: z.string().optional(),
instagram: z.string().optional(),
discord: z.string().optional(),
telegram: z.string().optional(),
medium: z.string().optional(),
dropFeaturedImage: z.string().optional(),
edition: z.union([z.literal("limited"), z.literal("open")]).optional(),
dropType: z.union([z.literal(0), z.literal(1)]).optional(),
maxSupply: z.number().int().positive().optional(),
dropBeginTime: z.number().int().positive().optional(),
stages: z.array(createStageSchema).min(1).optional()
}).superRefine((input, ctx) => {
if (!input.preReveal) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "preReveal is required",
path: ["preReveal"]
});
}
});
export const dropInputSchema = z
.object({
chainMId: z.number().int().positive(),
name: z.string().min(1),
symbol: z.string().min(1),
paymentToken: z.string().min(1).optional(),
preReveal: z.string().min(1),
description: z.string(),
dropType: z.union([z.literal(0), z.literal(1)]),
maxSupply: z.number().int().positive(),
dropBeginTime: z.number().int().positive(),
stages: z.array(stageSchema).min(1)
})
.superRefine((input, ctx) => {
const stageSupply = input.stages.reduce((sum, stage) => sum + stage.maxSupplyAtThisStage, 0);
if (stageSupply > input.maxSupply) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "stages total maxSupply exceeds drop maxSupply"
});
}
});
function defaultDropBeginTime(): number {
return Math.floor(Date.now() / 1000) + 24 * 60 * 60;
}
function assignStageIds(stages: CreateStageInput[]): StageInput[] {
return stages.map((stage, index) => ({
...stage,
stageID: DEFAULT_STAGE_ID + index
}));
}
function buildDefaultStage(maxSupply: number): StageInput {
return {
stageID: DEFAULT_STAGE_ID,
stageName: DEFAULT_STAGE_NAME,
price: DEFAULT_STAGE_PRICE,
duration: DEFAULT_STAGE_DURATION_SECONDS,
maxSupplyAtThisStage: maxSupply,
maxMintedPerWallet: DEFAULT_STAGE_MAX_MINTED_PER_WALLET,
interval: DEFAULT_STAGE_INTERVAL,
stageMode: 0
};
}
export function normalizeCreateDropInput(input: CreateDropInput): DropInput {
const parsed = createDropInputSchema.parse(input);
const { dropType, maxSupply } = resolveDropMode({
requestedEdition: parsed.edition,
requestedDropType: parsed.dropType,
requestedMaxSupply: parsed.maxSupply,
fallbackMaxSupply: DEFAULT_MAX_SUPPLY
});
const normalized: DropInput = {
chainMId: parsed.chainMId,
name: parsed.name,
symbol: parsed.symbol,
paymentToken: parsed.paymentToken,
preReveal: parsed.preReveal ?? "",
description: parsed.description ?? parsed.desc ?? "",
dropType,
maxSupply,
dropBeginTime: parsed.dropBeginTime ?? defaultDropBeginTime(),
stages: parsed.stages ? assignStageIds(parsed.stages) : [buildDefaultStage(maxSupply)]
};
return dropInputSchema.parse(normalized);
}
export type DropInputSchema = z.infer<typeof dropInputSchema>;
FILE:scripts/src/drop-mode.ts
import type { DropEdition, DropType } from "./types";
export const DEFAULT_DROP_TYPE = 0 as const;
export const DEFAULT_MAX_SUPPLY = 999;
export const OPEN_EDITION_MAX_SUPPLY = 1_000_000_000;
export function deriveEditionFromMaxSupply(maxSupply: unknown): DropEdition | null {
if (typeof maxSupply !== "number" || !Number.isFinite(maxSupply)) {
return null;
}
return maxSupply >= OPEN_EDITION_MAX_SUPPLY ? "open" : "limited";
}
export function resolveMaxSupplyForEdition(input: {
requestedEdition?: DropEdition;
requestedMaxSupply?: number;
fallbackMaxSupply?: number;
}): number {
if (input.requestedEdition === "open") {
return OPEN_EDITION_MAX_SUPPLY;
}
if (input.requestedEdition === "limited" && input.requestedMaxSupply === undefined) {
throw new Error("limited edition requires an explicit maxSupply");
}
return input.requestedMaxSupply ?? input.fallbackMaxSupply ?? DEFAULT_MAX_SUPPLY;
}
export function resolveDropMode(input: {
requestedEdition?: DropEdition;
requestedDropType?: DropType;
requestedMaxSupply?: number;
fallbackDropType?: DropType;
fallbackMaxSupply?: number;
}): { dropType: DropType; maxSupply: number } {
return {
dropType: input.requestedDropType ?? input.fallbackDropType ?? DEFAULT_DROP_TYPE,
maxSupply: resolveMaxSupplyForEdition(input)
};
}
FILE:scripts/src/security/upload-guard.ts
import { lstat, readFile, realpath, stat } from "node:fs/promises";
import { basename, isAbsolute } from "node:path";
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
const ALLOWED_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
export function inferUploadExtension(filePath: string): string {
const name = basename(filePath);
const index = name.lastIndexOf(".");
return index >= 0 ? name.slice(index) : "";
}
export function inferUploadImageMimeType(filePath: string): string {
const extension = inferUploadExtension(filePath).toLowerCase();
switch (extension) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
default:
throw new Error(`Unsupported upload image extension: extension || "(none)"`);
}
}
function isSupportedImageHeader(extension: string, header: Buffer): boolean {
if (extension === ".png") {
return header.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (extension === ".jpg" || extension === ".jpeg") {
return header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff;
}
if (extension === ".gif") {
const signature = header.subarray(0, 6).toString("ascii");
return signature === "GIF87a" || signature === "GIF89a";
}
if (extension === ".webp") {
return header.subarray(0, 4).toString("ascii") === "RIFF" && header.subarray(8, 12).toString("ascii") === "WEBP";
}
return false;
}
export function assertSafeUploadFileName(fileName: string) {
if (fileName !== basename(fileName) || fileName.startsWith(".") || fileName.includes("\0")) {
throw new Error(`Unsafe upload file name: fileName`);
}
}
export async function validateLocalImageUpload(filePath: string): Promise<{ realPath: string; mimeType: string }> {
if (!isAbsolute(filePath)) {
throw new Error(`Upload path must be absolute: filePath`);
}
const linkStats = await lstat(filePath);
if (linkStats.isSymbolicLink()) {
throw new Error(`Upload path must not be a symbolic link: filePath`);
}
if (!linkStats.isFile()) {
throw new Error(`Upload path must be a regular image file: filePath`);
}
if (linkStats.size <= 0 || linkStats.size > MAX_UPLOAD_BYTES) {
throw new Error(`Upload image size must be between 1 byte and MAX_UPLOAD_BYTES bytes: filePath`);
}
const realPath = await realpath(filePath);
const realStats = await stat(realPath);
if (!realStats.isFile()) {
throw new Error(`Upload path must resolve to a regular image file: filePath`);
}
const extension = inferUploadExtension(realPath).toLowerCase();
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
throw new Error(`Unsupported upload image extension: extension || "(none)"`);
}
const header = await readFile(realPath, { flag: "r" }).then((buffer) => buffer.subarray(0, 12));
if (!isSupportedImageHeader(extension, header)) {
throw new Error(`Upload file does not match supported image signature: filePath`);
}
return {
realPath,
mimeType: inferUploadImageMimeType(realPath)
};
}
export async function readValidatedLocalImageUpload(filePath: string): Promise<{
bytes: ArrayBuffer;
fileName: string;
mimeType: string;
realPath: string;
}> {
const validated = await validateLocalImageUpload(filePath);
const bytes = await readFile(validated.realPath);
return {
...validated,
bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
fileName: basename(validated.realPath)
};
}
FILE:scripts/src/auth/jwt.ts
import { Wallet } from "ethers";
import type { ElementIdentity, ElementLoginInput } from "../types";
import { ELEMENT_CHAIN_METADATA_BY_CHAIN_MID } from "../network/chains";
export function getElementBlockChainByChainMId(chainMId: number): { chain: string; chainId: string } {
const chain = ELEMENT_CHAIN_METADATA_BY_CHAIN_MID[chainMId];
if (!chain) {
throw new Error(`Unsupported chainMId for Element login: chainMId`);
}
return {
chain: chain.chain,
chainId: `0xchain.chainId.toString(16)`
};
}
export interface BuildElementAuthorizationDeps {
getLoginNonce: (identity: ElementIdentity) => Promise<string>;
loginAuth: (input: ElementLoginInput) => Promise<string>;
}
export interface ElementAuthorizationResult {
authorization: string;
nonce: string;
message: string;
identity: ElementIdentity;
}
export async function deriveWalletAddress(input: { privateKey: string }): Promise<string> {
const wallet = new Wallet(input.privateKey);
return wallet.address;
}
export function getElementIdentityByChainMId(chainMId: number, walletAddress: string): ElementIdentity {
const blockChain = getElementBlockChainByChainMId(chainMId);
return {
address: walletAddress,
blockChain
};
}
export function buildElementLoginMessage(input: { walletAddress: string; nonce: string }): string {
return `Welcome to Element!\n \nClick "Sign" to sign in. No password needed!\n \nI accept the Element Terms of Service: \n https://element.market/tos\n \nWallet address:\ninput.walletAddress\n \nNonce:\ninput.nonce`;
}
export async function buildElementAuthorization(
deps: BuildElementAuthorizationDeps,
input: {
privateKey: string;
walletAddress?: string;
chainMId: number;
}
): Promise<ElementAuthorizationResult> {
const walletAddress =
(input.walletAddress ?? (await deriveWalletAddress({ privateKey: input.privateKey }))).toLowerCase();
const identity = getElementIdentityByChainMId(input.chainMId, walletAddress);
const nonce = await deps.getLoginNonce(identity);
const message = buildElementLoginMessage({ walletAddress, nonce });
const wallet = new Wallet(input.privateKey);
const signature = await wallet.signMessage(message);
const token = await deps.loginAuth({
identity,
message,
signature
});
return {
authorization: `Bearer token`,
nonce,
message,
identity
};
}
FILE:scripts/src/network/rpc.ts
import { getJson } from "../api/http";
export const RPC_CONF_API_URL = "https://api.element.market/v1/quote/rpcConfInfo";
export interface RpcConfInfo {
chainMId: number;
rpcUrl: string;
isWalletPriority: boolean;
}
interface RpcConfResponse {
code: number;
status: string;
data: RpcConfInfo[];
}
const STATIC_RPC_URLS: Record<number, string> = {
1: "https://api.zan.top/node/v1/eth/mainnet/6e96cfbcaff949bfbdaeb5fbc554ac7c"
};
let cachedRpcConfigs: Map<number, RpcConfInfo> | null = null;
let lastFetchTime = 0;
const CACHE_DURATION_MS = 5 * 60 * 1000;
export async function fetchRpcConfigs(): Promise<RpcConfInfo[]> {
const result = await getJson<RpcConfResponse>(RPC_CONF_API_URL);
if (result.code !== 0) {
throw new Error(`Failed to fetch rpc configs: result.status`);
}
return result.data;
}
export async function getCachedRpcConfigs(): Promise<Map<number, RpcConfInfo>> {
const now = Date.now();
if (cachedRpcConfigs && now - lastFetchTime < CACHE_DURATION_MS) {
return cachedRpcConfigs;
}
const configs = await fetchRpcConfigs();
const filtered = configs.filter((config) => !config.isWalletPriority);
cachedRpcConfigs = new Map(filtered.map((config) => [config.chainMId, config]));
Object.entries(STATIC_RPC_URLS).forEach(([chainMId, rpcUrl]) => {
const numericChainMId = Number(chainMId);
if (!cachedRpcConfigs?.has(numericChainMId)) {
cachedRpcConfigs?.set(numericChainMId, {
chainMId: numericChainMId,
rpcUrl,
isWalletPriority: false
});
}
});
lastFetchTime = now;
return cachedRpcConfigs;
}
export async function getRpcUrlForChainMId(chainMId: number): Promise<string> {
const configs = await getCachedRpcConfigs();
const rpcUrl = configs.get(chainMId)?.rpcUrl;
if (!rpcUrl) {
throw new Error(`Unsupported chainMId for rpc resolution: chainMId`);
}
return rpcUrl;
}
export function __resetRpcConfigCacheForTests(): void {
cachedRpcConfigs = null;
lastFetchTime = 0;
}
FILE:scripts/src/network/transaction.ts
import { Interface, JsonRpcProvider, Wallet } from "ethers";
import { getRequiredWalletPrivateKey } from "../env";
import type { EncodedTransaction, ExecutedTransactionResult } from "../types";
const TOKEN_FACTORY_INTERFACE = new Interface([
"event CreateToken(address indexed tokenAddress, uint8 tokenType, string name, string symbol, address implementation, address protocolRecipient, uint16 protocolPoint)"
]);
function getConfiguredMinPriorityFeePerGasWei(): bigint | undefined {
const configured = process.env.ELEMENT_MIN_PRIORITY_FEE_PER_GAS_WEI;
if (!configured) {
return undefined;
}
const parsed = BigInt(configured);
if (parsed <= 0n) {
throw new Error("ELEMENT_MIN_PRIORITY_FEE_PER_GAS_WEI must be greater than 0");
}
return parsed;
}
export function buildFeeOverrides(input: {
maxFeePerGas?: bigint | null;
maxPriorityFeePerGas?: bigint | null;
gasPrice?: bigint | null;
minPriorityFeePerGas?: bigint;
}) {
const minPriorityFeePerGas = input.minPriorityFeePerGas ?? getConfiguredMinPriorityFeePerGasWei();
if (input.maxFeePerGas !== null && input.maxFeePerGas !== undefined) {
const feeOverrides: { maxFeePerGas: bigint; maxPriorityFeePerGas?: bigint } = {
maxFeePerGas: input.maxFeePerGas
};
const priorityFee =
input.maxPriorityFeePerGas !== null && input.maxPriorityFeePerGas !== undefined
? minPriorityFeePerGas !== undefined && input.maxPriorityFeePerGas < minPriorityFeePerGas
? minPriorityFeePerGas
: input.maxPriorityFeePerGas
: minPriorityFeePerGas;
if (priorityFee !== undefined) {
feeOverrides.maxPriorityFeePerGas = priorityFee;
if (feeOverrides.maxFeePerGas < priorityFee) {
feeOverrides.maxFeePerGas = priorityFee;
}
}
return feeOverrides;
}
if (input.gasPrice !== null && input.gasPrice !== undefined) {
return {
gasPrice:
minPriorityFeePerGas !== undefined && input.gasPrice < minPriorityFeePerGas
? minPriorityFeePerGas
: input.gasPrice
};
}
return minPriorityFeePerGas === undefined
? {}
: {
maxPriorityFeePerGas: minPriorityFeePerGas,
maxFeePerGas: minPriorityFeePerGas
};
}
export function parseMinimumTipCapWei(error: unknown): bigint | null {
const message = error instanceof Error ? error.message : String(error);
const match = message.match(/minimum needed\s+(\d+)/i);
return match ? BigInt(match[1]) : null;
}
export async function sendEncodedTransaction(input: {
rpcUrl: string;
transaction: EncodedTransaction;
waitConfirmations?: number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}): Promise<ExecutedTransactionResult> {
input.logger?.("send transaction:start", {
rpcUrl: input.rpcUrl,
to: input.transaction.to,
value: input.transaction.value,
waitConfirmations: input.waitConfirmations ?? 1
});
const privateKey = getRequiredWalletPrivateKey();
const provider = new JsonRpcProvider(input.rpcUrl);
const wallet = new Wallet(privateKey, provider);
let minPriorityFeePerGas: bigint | undefined;
let response;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const feeData = await provider.getFeeData();
const feeOverrides = buildFeeOverrides({
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasPrice: feeData.gasPrice,
minPriorityFeePerGas
});
try {
response = await wallet.sendTransaction({
to: input.transaction.to,
value: input.transaction.value,
data: input.transaction.data,
...feeOverrides
});
break;
} catch (error) {
const minimumTipCapWei = parseMinimumTipCapWei(error);
if (attempt >= 2 || minimumTipCapWei === null) {
throw error;
}
minPriorityFeePerGas = minimumTipCapWei;
input.logger?.("send transaction:retry-min-tip-cap", {
minimumTipCapWei: minimumTipCapWei.toString()
});
}
}
if (!response) {
throw new Error("Transaction response missing after send attempt");
}
const receipt = await response.wait(input.waitConfirmations ?? 1);
if (!receipt) {
throw new Error(`Transaction receipt missing for response.hash`);
}
if (receipt.status !== 1) {
throw new Error(`Transaction failed: response.hash`);
}
let contractAddress: string | undefined;
for (const log of receipt.logs) {
try {
const parsed = TOKEN_FACTORY_INTERFACE.parseLog(log);
if (parsed?.name === "CreateToken") {
contractAddress = String(parsed.args.tokenAddress).toLowerCase();
break;
}
} catch {
// ignore unrelated logs
}
}
input.logger?.("send transaction:confirmed", {
hash: response.hash,
blockNumber: receipt.blockNumber,
contractAddress
});
return {
hash: response.hash,
contractAddress,
receipt: {
hash: receipt.hash,
blockNumber: receipt.blockNumber,
status: receipt.status,
gasUsed: receipt.gasUsed.toString()
}
};
}
FILE:scripts/src/network/chains.ts
import type { ChainListPaymentToken, ChainListWithGasItem, ElementApiResponse, GetChainsWithGasResponse } from "../types";
export interface ElementChainMetadata {
chainMId: number;
chain: string;
chainId: number;
chainName: string;
aliases: string[];
}
export const ELEMENT_CHAIN_METADATA_BY_CHAIN_MID: Record<number, ElementChainMetadata> = {
1: { chainMId: 1, chain: "eth", chainId: 1, chainName: "ethereum", aliases: ["ethereum", "mainnet", "eth"] },
101: { chainMId: 101, chain: "polygon", chainId: 137, chainName: "polygon", aliases: ["polygon", "matic"] },
201: { chainMId: 201, chain: "bsc", chainId: 56, chainName: "bsc", aliases: ["bsc", "bnb", "binance", "binance smart chain"] },
401: { chainMId: 401, chain: "avalanche", chainId: 43114, chainName: "avalanche", aliases: ["avalanche", "avax"] },
601: { chainMId: 601, chain: "arbitrum", chainId: 42161, chainName: "arbitrum", aliases: ["arbitrum", "arbitrum one"] },
701: { chainMId: 701, chain: "zksync", chainId: 324, chainName: "zksync", aliases: ["zksync", "zksync era"] },
901: { chainMId: 901, chain: "linea", chainId: 59144, chainName: "linea", aliases: ["linea"] },
1101: { chainMId: 1101, chain: "opbnb", chainId: 204, chainName: "opbnb", aliases: ["opbnb"] },
1201: { chainMId: 1201, chain: "base", chainId: 8453, chainName: "base", aliases: ["base"] },
1301: { chainMId: 1301, chain: "scroll", chainId: 534352, chainName: "scroll", aliases: ["scroll"] },
1401: { chainMId: 1401, chain: "manta_pacific", chainId: 169, chainName: "manta", aliases: ["manta", "manta pacific"] },
1501: { chainMId: 1501, chain: "optimism", chainId: 10, chainName: "optimism", aliases: ["optimism", "op"] },
1601: { chainMId: 1601, chain: "mantle", chainId: 5000, chainName: "mantle", aliases: ["mantle"] },
1701: { chainMId: 1701, chain: "zkfair", chainId: 42766, chainName: "zkfair", aliases: ["zkfair"] },
1801: { chainMId: 1801, chain: "blast", chainId: 81457, chainName: "blast", aliases: ["blast"] },
1901: { chainMId: 1901, chain: "merlin", chainId: 4200, chainName: "merlin", aliases: ["merlin", "merlin chain"] },
2001: { chainMId: 2001, chain: "mode", chainId: 34443, chainName: "mode", aliases: ["mode"] },
2101: { chainMId: 2101, chain: "cyber", chainId: 7560, chainName: "cyber", aliases: ["cyber", "cyberconnect"] },
2201: { chainMId: 2201, chain: "bob", chainId: 60808, chainName: "bob", aliases: ["bob"] },
2301: { chainMId: 2301, chain: "lightlink", chainId: 1890, chainName: "lightlink", aliases: ["lightlink"] },
2501: { chainMId: 2501, chain: "nanon", chainId: 2748, chainName: "nanon", aliases: ["nanon"] },
2601: { chainMId: 2601, chain: "bera", chainId: 80094, chainName: "berachain", aliases: ["bera", "berachain"] },
2701: { chainMId: 2701, chain: "zeta", chainId: 7000, chainName: "zetachain", aliases: ["zeta", "zetachain"] },
2801: { chainMId: 2801, chain: "nibiru", chainId: 6900, chainName: "nibiru", aliases: ["nibiru"] },
2901: { chainMId: 2901, chain: "abstract", chainId: 2741, chainName: "abstract", aliases: ["abstract"] },
3001: { chainMId: 3001, chain: "monad", chainId: 143, chainName: "monad", aliases: ["monad"] },
3101: { chainMId: 3101, chain: "bitlayer", chainId: 200901, chainName: "bitlayer", aliases: ["bitlayer"] },
3201: { chainMId: 3201, chain: "mantra", chainId: 5888, chainName: "mantra", aliases: ["mantra"] }
};
const CHAIN_MID_BY_ALIAS = new Map(
Object.values(ELEMENT_CHAIN_METADATA_BY_CHAIN_MID).flatMap((metadata) =>
metadata.aliases.map((alias) => [normalizeChainName(alias), metadata.chainMId] as const)
)
);
let cachedChainsWithGas: ChainListWithGasItem[] | null = null;
function normalizeChainName(input: string): string {
return input.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
export function getElementChainNameByChainMId(chainMId: number): string {
const metadata = ELEMENT_CHAIN_METADATA_BY_CHAIN_MID[chainMId];
if (!metadata) {
throw new Error(`Unsupported chainMId for Element chain resolution: chainMId`);
}
return metadata.chainName;
}
function sortSupportedChainNames(chainMIds: number[]): string[] {
return chainMIds
.map((chainMId) => ELEMENT_CHAIN_METADATA_BY_CHAIN_MID[chainMId]?.chainName)
.filter((name): name is string => Boolean(name))
.sort((a, b) => a.localeCompare(b));
}
export interface ResolveElementChainInput {
chainName?: string;
chainMId?: number;
}
export interface ResolveElementChainResult {
chainMId: number;
chainName: string;
paymentTokens: ChainListPaymentToken[];
currency: string;
}
function normalizePaymentTokenSelector(input: string): string {
return input.trim().toLowerCase();
}
export function resolvePaymentToken(input: {
paymentToken?: string;
availablePaymentTokens?: ChainListPaymentToken[];
}): ChainListPaymentToken | null {
if (!input.paymentToken) {
return null;
}
const availablePaymentTokens = input.availablePaymentTokens ?? [];
const selector = normalizePaymentTokenSelector(input.paymentToken);
return (
availablePaymentTokens.find((token) => {
return (
normalizePaymentTokenSelector(token.name) === selector ||
(token.symbol ? normalizePaymentTokenSelector(token.symbol) === selector : false) ||
normalizePaymentTokenSelector(token.TokenAddress) === selector
);
}) ?? null
);
}
function formatSupportedPaymentTokens(paymentTokens: ChainListPaymentToken[]): string {
return paymentTokens
.map((token) => {
const symbol = token.symbol ? `/token.symbol` : "";
return `token.namesymbol (token.TokenAddress)`;
})
.join(", ");
}
export function resolveMintingTypeFromPaymentToken(input: {
paymentToken?: string;
availablePaymentTokens?: ChainListPaymentToken[];
fallbackMintingType?: number;
}): number {
if (!input.paymentToken) {
return input.fallbackMintingType ?? 0;
}
const availablePaymentTokens = input.availablePaymentTokens ?? [];
const matchedToken = resolvePaymentToken({
paymentToken: input.paymentToken,
availablePaymentTokens
});
if (!matchedToken) {
throw new Error(
`Unsupported paymentToken: input.paymentToken. Supported payment tokens: formatSupportedPaymentTokens(
availablePaymentTokens
)`
);
}
return matchedToken.SerId << 4;
}
export function resolvePaymentTokenFromMintingType(input: {
mintingType?: number;
availablePaymentTokens?: ChainListPaymentToken[];
}): ChainListPaymentToken | null {
if (!Number.isFinite(input.mintingType) || (input.mintingType ?? 0) <= 0) {
return null;
}
const serId = Math.floor((input.mintingType as number) / 16);
if (serId <= 0) {
return null;
}
return (input.availablePaymentTokens ?? []).find((token) => token.SerId === serId) ?? null;
}
export async function resolveElementChain(
input: ResolveElementChainInput,
deps: {
getChainsWithGas: () => Promise<ElementApiResponse<GetChainsWithGasResponse>>;
}
): Promise<ResolveElementChainResult> {
if (!cachedChainsWithGas) {
const response = await deps.getChainsWithGas();
cachedChainsWithGas = response.data.chains;
}
const supportedChainIds = cachedChainsWithGas.map((item) => item.chainMId);
let chainMId = input.chainMId;
if (input.chainName) {
chainMId = CHAIN_MID_BY_ALIAS.get(normalizeChainName(input.chainName));
if (!chainMId) {
throw new Error(
`Unsupported chainName: input.chainName. Supported chains: sortSupportedChainNames(supportedChainIds).join(", ")`
);
}
}
if (!chainMId) {
throw new Error("chainName is required");
}
const supportedChain = cachedChainsWithGas.find((item) => item.chainMId === chainMId);
if (!supportedChain) {
throw new Error(
`Unsupported chain for Element Drop: getElementChainNameByChainMId(chainMId). Supported chains: sortSupportedChainNames(
supportedChainIds
).join(", ")`
);
}
return {
chainMId,
chainName: getElementChainNameByChainMId(chainMId),
paymentTokens: supportedChain.paymentTokens,
currency: supportedChain.currency
};
}
FILE:scripts/src/cli.ts
import { readFile } from "node:fs/promises";
import { stdin as input } from "node:process";
import { createElementApiClient } from "./api/element";
import { createElementGraphqlClient } from "./api/graphql";
import { buildElementAuthorization, deriveWalletAddress, getElementIdentityByChainMId } from "./auth/jwt";
import { normalizeCreateDropInput } from "./schema";
import { deriveEditionFromMaxSupply } from "./drop-mode";
import {
getElementChainNameByChainMId,
resolveElementChain,
resolvePaymentToken,
resolvePaymentTokenFromMintingType
} from "./network/chains";
import { getRpcUrlForChainMId } from "./network/rpc";
import { sendEncodedTransaction } from "./network/transaction";
import { createDropFlow } from "./workflow/create-drop";
import { previewDropFlow } from "./workflow/preview-drop";
import { configureDropFlow, previewConfigureDrop } from "./workflow/configure-drop";
import { buildDisplayDesignAfterUpdate } from "./workflow/design-payloads";
import { createTokenFlow } from "./workflow/create-token";
import { postCreateCollectionFlow } from "./workflow/post-create-collection";
import {
extractPreRevealAnimationUrl,
extractPreRevealImageUrl,
buildPostDropSettingsPayloadFromSettings,
buildSetProjectConfigPayload,
publishDropFlow,
resolvePublishPreRevealIPFS,
waitForPreRevealIPFS,
waitForPublishedSettings
} from "./workflow/publish-drop";
import { formatSocialLinkForDisplay, normalizeWebsiteUrl } from "./workflow/links";
import { uploadSingleAsset, uploadWithExistingAuthorization } from "./workflow/uploads";
import { verifyRefGraphqlExamples } from "./workflow/verify-ref-graphql";
import { waitForCollectionContract } from "./workflow/wait-collection-contract";
import { getRequiredWalletPrivateKey, redactKnownSecrets } from "./env";
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
type CliOperationContext = {
command?: string;
chainName?: unknown;
symbol?: unknown;
slug?: unknown;
contractAddress?: unknown;
};
let activeOperationContext: CliOperationContext = {};
function setActiveOperationContext(command: string, payload: Record<string, unknown>) {
activeOperationContext = {
command,
chainName: payload.chainName ?? null,
symbol: payload.symbol ?? null,
slug: payload.slug ?? null,
contractAddress: payload.contractAddress ?? null
};
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of input) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
const FORBIDDEN_SECRET_PAYLOAD_KEYS = new Set([
"privateKey",
"walletPrivateKey",
"ELEMENT_WALLET_PRIVATE_KEY",
"mnemonic",
"seedPhrase"
]);
function assertNoSecretPayloadKeys(value: unknown, path = "$") {
if (Array.isArray(value)) {
value.forEach((item, index) => assertNoSecretPayloadKeys(item, `path[index]`));
return;
}
if (typeof value !== "object" || value === null) {
return;
}
for (const [key, nestedValue] of Object.entries(value)) {
const nextPath = `path.key`;
if (FORBIDDEN_SECRET_PAYLOAD_KEYS.has(key)) {
throw new Error(
`Refusing secret input at nextPath; provide wallet private key only through ELEMENT_WALLET_PRIVATE_KEY`
);
}
assertNoSecretPayloadKeys(nestedValue, nextPath);
}
}
async function readJsonArg(path?: string) {
const payload = path ? JSON.parse(await readFile(path, "utf8")) : JSON.parse(await readStdin());
assertNoSecretPayloadKeys(payload);
return payload;
}
function getPayloadPath(args: string[]) {
return args.find((arg) => !arg.startsWith("--"));
}
function listDefinedKeys(input: Record<string, unknown>, excludedKeys: string[] = []) {
return Object.keys(input).filter((key) => input[key] !== undefined && !excludedKeys.includes(key));
}
function isElementDropCollection(sourceType: string | null | undefined): boolean {
return typeof sourceType === "string" && sourceType.includes("ELEMENT_DROP");
}
function summarizeEncodedTransaction(input: {
step: string;
description: string;
transaction: { to: string; value: string; data: string };
}) {
return {
step: input.step,
description: input.description,
to: input.transaction.to,
value: input.transaction.value,
dataPrefix: input.transaction.data.slice(0, 18),
dataLength: input.transaction.data.length
};
}
function summarizeSupportedPaymentToken(token: {
name: string;
symbol?: string;
TokenAddress: string;
SerId: number;
} | null) {
if (!token) {
return null;
}
return {
name: token.name,
symbol: token.symbol ?? null,
address: token.TokenAddress
};
}
function summarizeNativePaymentToken(currency: unknown) {
if (typeof currency !== "string" || currency.trim().length === 0) {
return null;
}
return {
name: currency,
symbol: currency,
address: ZERO_ADDRESS
};
}
function summarizeResolvedPaymentToken(input: {
mintingType?: unknown;
availablePaymentTokens?: unknown[];
currency?: unknown;
}) {
const mintingType = typeof input.mintingType === "number" ? input.mintingType : undefined;
const token = summarizeSupportedPaymentToken(
resolvePaymentTokenFromMintingType({
mintingType,
availablePaymentTokens: Array.isArray(input.availablePaymentTokens)
? (input.availablePaymentTokens as never[])
: []
}) as never
);
if (token) {
return token;
}
if (mintingType === undefined || mintingType <= 0) {
return summarizeNativePaymentToken(input.currency);
}
return null;
}
function summarizeEdition(maxSupply: unknown) {
const edition = deriveEditionFromMaxSupply(maxSupply);
if (edition === "limited") {
return { value: 0, label: "limited" };
}
if (edition === "open") {
return { value: 1, label: "open" };
}
return { value: null, label: null };
}
function summarizeTimestamp(timestamp: unknown) {
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
return null;
}
return {
unix: timestamp,
iso: new Date(timestamp * 1000).toISOString()
};
}
function summarizeStage(stage: Record<string, unknown>) {
return {
stageName: stage.stageName ?? null,
price: stage.price ?? null,
duration: stage.duration ?? null,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage ?? null,
maxMintedPerWallet: stage.maxMintedPerWallet ?? null,
interval: stage.interval ?? null,
stageMode: stage.stageMode ?? null,
enableCallFromContract:
typeof stage.enableCallFromContract === "boolean" ? stage.enableCallFromContract : null,
enableMintToOther:
typeof stage.enableMintToOther === "boolean" ? stage.enableMintToOther : null
};
}
function summarizeSettingsSnapshot(input: {
maxSupply?: unknown;
totalMint?: unknown;
dropBeginTime?: unknown;
dropType?: unknown;
mintingType?: unknown;
stages?: unknown;
availablePaymentTokens?: unknown[];
currency?: unknown;
}) {
const edition = summarizeEdition(input.maxSupply);
const paymentToken = summarizeResolvedPaymentToken({
mintingType: input.mintingType,
availablePaymentTokens: input.availablePaymentTokens,
currency: input.currency
});
return {
maxSupply: edition.label === "open" ? null : input.maxSupply ?? null,
totalMint: input.totalMint ?? null,
dropBeginTime: summarizeTimestamp(input.dropBeginTime),
edition,
paymentToken,
stageCount: Array.isArray(input.stages) ? input.stages.length : 0,
stages: Array.isArray(input.stages)
? input.stages.map((stage) => summarizeStage(stage as Record<string, unknown>))
: []
};
}
function summarizeDesignSnapshot(input: {
bannerURL?: unknown;
previewMediaExt?: unknown;
description?: unknown;
website?: unknown;
twitter?: unknown;
instagram?: unknown;
discord?: unknown;
telegram?: unknown;
medium?: unknown;
dropFeaturedImage?: unknown;
}) {
const previewMediaExt = Array.isArray(input.previewMediaExt) ? input.previewMediaExt : [];
return {
bannerURL: input.bannerURL ?? null,
previewMediaCount: previewMediaExt.length,
previewMediaExt: previewMediaExt.map((item) => ({
image_url:
typeof item === "object" && item !== null && "image_url" in item ? (item.image_url ?? null) : null,
animation_url:
typeof item === "object" && item !== null && "animation_url" in item ? (item.animation_url ?? null) : null
})),
description: input.description ?? null,
website: normalizeWebsiteUrl(typeof input.website === "string" ? input.website : undefined) || null,
twitter:
formatSocialLinkForDisplay("twitter", typeof input.twitter === "string" ? input.twitter : undefined) || null,
instagram:
formatSocialLinkForDisplay("instagram", typeof input.instagram === "string" ? input.instagram : undefined) ||
null,
discord:
formatSocialLinkForDisplay("discord", typeof input.discord === "string" ? input.discord : undefined) || null,
telegram:
formatSocialLinkForDisplay("telegram", typeof input.telegram === "string" ? input.telegram : undefined) ||
null,
medium:
formatSocialLinkForDisplay("medium", typeof input.medium === "string" ? input.medium : undefined) || null,
dropFeaturedImage: input.dropFeaturedImage ?? null
};
}
function shouldRepublishUpdatedDrop(input: {
published?: unknown;
isPaused?: unknown;
willUpdateSettings?: unknown;
willUpdatePreReveal?: unknown;
}) {
return (
input.published === 1 &&
input.isPaused === false &&
Boolean(input.willUpdateSettings || input.willUpdatePreReveal)
);
}
function summarizePublishState(input: { published?: unknown; isPaused?: unknown }) {
if (input.published !== 1) {
return {
status: "draft",
label: "draft"
};
}
if (input.isPaused === true) {
return {
status: "paused",
label: "paused"
};
}
return {
status: "live",
label: "live"
};
}
function summarizePublishTargetState(isPaused: unknown) {
return isPaused === true
? {
status: "paused",
label: "publish but keep paused"
}
: {
status: "live",
label: "publish live"
};
}
function buildSigningSafetyNote() {
return {
privateKeyHandling:
"The private key is used only for local signing in this script and is never sent over the network.",
reminder:
"Network requests may carry derived wallet address, auth token, or signed transactions, but not the raw private key."
};
}
function buildCreateDropDryRunPreview(
payload: Record<string, unknown>,
input: {
onchainPreview?: {
createToken?: { to: string; value: string; data: string };
walletAddress?: string;
};
} = {}
) {
const normalized = normalizeCreateDropInput(payload as never);
const paymentTokens = Array.isArray(payload.paymentTokens)
? payload.paymentTokens.map((token) =>
typeof token === "object" && token !== null
? {
name: "name" in token ? token.name : undefined,
address: "TokenAddress" in token ? token.TokenAddress : undefined
}
: token
)
: [];
const selectedPaymentTokenRaw =
typeof payload.paymentToken === "string"
? (resolvePaymentToken({
paymentToken: payload.paymentToken,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? (payload.paymentTokens as never[]) : []
}) as never)
: null;
const selectedPaymentToken =
summarizeSupportedPaymentToken(selectedPaymentTokenRaw) ?? summarizeNativePaymentToken(payload.currency);
return {
command: "create-drop",
previewOnly: true,
required: {
chainName: payload.chainName ?? null,
name: payload.name ?? null,
symbol: payload.symbol ?? null,
preReveal: payload.preReveal ?? null
},
settingsPreview: summarizeSettingsSnapshot({
maxSupply: normalized.maxSupply,
dropBeginTime: normalized.dropBeginTime,
dropType: normalized.dropType,
mintingType: selectedPaymentTokenRaw ? (selectedPaymentTokenRaw as { SerId: number }).SerId << 4 : null,
stages: normalized.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
designPreview: {
preRevealSource: payload.preReveal ?? null,
bannerFilePath: payload.bannerFilePath ?? null,
previewMediaSources:
Array.isArray(payload.previewMedia) && payload.previewMedia.length > 0
? payload.previewMedia
: Array.isArray(payload.previewFilePaths) && payload.previewFilePaths.length > 0
? payload.previewFilePaths
: [payload.preReveal ?? null].filter(Boolean),
description: normalized.description || null,
website: payload.website ?? null,
twitter: payload.twitter ?? null,
instagram: payload.instagram ?? null,
discord: payload.discord ?? null,
telegram: payload.telegram ?? null,
medium: payload.medium ?? null,
dropFeaturedImage: payload.dropFeaturedImage ?? null,
note: "Create flow uploads and sets prereveal, settings, and initial design before stopping."
},
resolvedChain: {
chainName: payload.chainName ?? null,
currency: payload.currency ?? null,
supportedPaymentTokens: paymentTokens
},
selectedPaymentToken,
onchainRisk: {
hasBlockchainTransaction: true,
executionRequiresExplicitConfirmation: true,
note: "This flow deploys a new contract onchain."
},
signingSafety: buildSigningSafetyNote(),
chainOperationNotice: {
walletAddress: input.onchainPreview?.walletAddress ?? null,
requiresSignature: true,
note: "The deployment transaction is prepared for preview only and is not broadcast."
},
transactionPreview: input.onchainPreview?.createToken
? [
summarizeEncodedTransaction({
step: "createToken",
description: "Deploy the drop contract",
transaction: input.onchainPreview.createToken
})
]
: [],
willUploadImages: true,
willSendTransactions: true,
willModifyAvailability: false
};
}
function buildUpdateDropDryRunPreview(payload: Record<string, unknown>) {
const patchKeys = listDefinedKeys(payload, [
"authorization",
"walletAddress",
"chainMId",
"contractAddress",
"slug",
"getSettingsPollIntervalMs",
"getSettingsTimeoutMs"
]);
const hasPreRevealPatch = Boolean(payload.preReveal);
const hasPreviewMediaPatch = Array.isArray(payload.previewMedia) && payload.previewMedia.length > 0;
const hasPreviewFilePathsPatch = Array.isArray(payload.previewFilePaths) && payload.previewFilePaths.length > 0;
const hasDesignPatch = Boolean(
hasPreviewMediaPatch ||
payload.bannerFilePath ||
hasPreviewFilePathsPatch ||
(payload.name ??
payload.description ??
payload.dropFeaturedImage)
);
const hasCollectionMetadataPatch = Boolean(
payload.website ??
payload.twitter ??
payload.instagram ??
payload.discord ??
payload.telegram ??
payload.medium
);
return {
command: "update-drop",
previewOnly: true,
target: {
chainName: payload.chainName ?? null,
slug: payload.slug ?? null
},
resolvedTarget: {
contractAddress: payload.contractAddress ?? null
},
resolvedChain: {
chainName: payload.chainName ?? null,
currency: payload.currency ?? null,
supportedPaymentTokens: Array.isArray(payload.paymentTokens)
? payload.paymentTokens.map((token) =>
typeof token === "object" && token !== null
? {
name: "name" in token ? token.name : undefined,
address: "TokenAddress" in token ? token.TokenAddress : undefined
}
: token
)
: []
},
patchKeys,
patchPreview: {
settings: {
preReveal: payload.preReveal ?? null,
dropType: payload.dropType ?? null,
maxSupply: payload.maxSupply ?? null,
dropBeginTime: summarizeTimestamp(payload.dropBeginTime),
paymentToken: payload.paymentToken ?? null,
stages:
Array.isArray(payload.stages) && payload.stages.length > 0
? payload.stages.map((stage) => summarizeStage(stage as Record<string, unknown>))
: []
},
design: {
previewMedia:
Array.isArray(payload.previewMedia) && payload.previewMedia.length > 0 ? payload.previewMedia : [],
bannerFilePath: payload.bannerFilePath ?? null,
previewFilePaths:
Array.isArray(payload.previewFilePaths) && payload.previewFilePaths.length > 0 ? payload.previewFilePaths : [],
name: payload.name ?? null,
description: payload.description ?? null,
website: payload.website ?? null,
twitter: payload.twitter ?? null,
instagram: payload.instagram ?? null,
discord: payload.discord ?? null,
telegram: payload.telegram ?? null,
medium: payload.medium ?? null,
dropFeaturedImage: payload.dropFeaturedImage ?? null
}
},
willReadCurrentSettings: true,
willReadCurrentDesign: hasDesignPatch,
willUpdatePreReveal: hasPreRevealPatch,
onchainRisk: {
hasBlockchainTransaction: false,
executionRequiresExplicitConfirmation: false,
note: "This flow updates remote settings, prereveal, design, or collection profile fields and does not send an onchain transaction by itself."
},
signingSafety: buildSigningSafetyNote(),
transactionPreview: [],
willUploadImages: Boolean(
payload.preReveal || hasPreviewMediaPatch || payload.bannerFilePath || hasPreviewFilePathsPatch
),
willUpdateSettings: true,
willUpdateDesign: hasDesignPatch,
willUpdateCollectionProfile: hasCollectionMetadataPatch,
willSendTransactions: false,
willModifyAvailability: false
};
}
function buildPublishDropDryRunPreview(
payload: Record<string, unknown>,
input: {
onchainPreview?: {
setProjectConfig?: { to: string; value: string; data: string };
walletAddress?: string;
setProjectConfigPayload?: { isPaused?: boolean };
settings?: {
maxSupply?: number;
totalMint?: number;
dropBeginTime?: number;
dropType?: number;
mintingType?: number;
stages?: unknown[];
isPaused?: boolean;
};
tempURL?: {
preReveal?: {
imageURL?: string;
animationURL?: string;
};
};
};
} = {}
) {
return {
command: "publish-drop",
previewOnly: true,
target: {
chainName: payload.chainName ?? null,
slug: payload.slug ?? null
},
resolvedTarget: {
contractAddress: payload.contractAddress ?? null
},
resolvedChain: {
chainName: payload.chainName ?? null,
currency: payload.currency ?? null,
supportedPaymentTokens: Array.isArray(payload.paymentTokens)
? payload.paymentTokens.map((token) =>
typeof token === "object" && token !== null
? {
name: "name" in token ? token.name : undefined,
address: "TokenAddress" in token ? token.TokenAddress : undefined
}
: token
)
: []
},
selectedPaymentToken: summarizeSupportedPaymentToken(
resolvePaymentTokenFromMintingType({
mintingType: input.onchainPreview?.settings?.mintingType,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? (payload.paymentTokens as never[]) : []
}) as never
) ?? summarizeNativePaymentToken(payload.currency),
onchainRisk: {
hasBlockchainTransaction: true,
executionRequiresExplicitConfirmation: true,
note: "This flow sends the publish transaction onchain and can make the drop live."
},
signingSafety: buildSigningSafetyNote(),
chainOperationNotice: {
walletAddress: input.onchainPreview?.walletAddress ?? null,
requiresSignature: true,
note:
input.onchainPreview?.setProjectConfigPayload?.isPaused === true
? "The publish transaction is prepared for preview only, will keep the drop paused, and is not broadcast."
: "The publish transaction is prepared for preview only and is not broadcast."
},
publishTarget: {
state: summarizePublishTargetState(
input.onchainPreview?.setProjectConfigPayload?.isPaused ?? payload.isPaused
)
},
preRevealCheck: {
imageURL: input.onchainPreview?.tempURL ? extractPreRevealImageUrl(input.onchainPreview.tempURL as never) : null,
animationURL:
input.onchainPreview?.tempURL ? extractPreRevealAnimationUrl(input.onchainPreview.tempURL as never) : null
},
publishSequence: [
"Read current prereveal media status and confirm prereveal OSS image exists",
"Trigger/check prereveal OSS to IPFS resolution",
"Poll preRevealIPFS until it becomes non-empty",
"Prepare postSettings payload from current settings",
"Encode setProjectConfig with resolved preRevealIPFS",
"Broadcast publish transaction only during confirmed execution",
"Alternate callback/updateProjectConfig and edit/settings polling until publish state is synchronized"
],
settingsPreview: input.onchainPreview?.settings
? summarizeSettingsSnapshot({
maxSupply: input.onchainPreview.settings.maxSupply,
totalMint: input.onchainPreview.settings.totalMint,
dropBeginTime: input.onchainPreview.settings.dropBeginTime,
dropType: input.onchainPreview.settings.dropType,
mintingType: input.onchainPreview.settings.mintingType,
stages: input.onchainPreview.settings.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
})
: null,
publishModeNote:
input.onchainPreview?.setProjectConfigPayload?.isPaused === true
? "This publish will leave the drop paused."
: "This publish will make the drop live.",
transactionPreview: input.onchainPreview?.setProjectConfig
? [
summarizeEncodedTransaction({
step: "setProjectConfig",
description: "Publish the drop onchain",
transaction: input.onchainPreview.setProjectConfig
})
]
: [],
willUploadImages: false,
willSendTransactions: true,
willModifyAvailability: true
};
}
function printHelp() {
console.log(`element-drop scripts
Lifecycle commands:
create-drop Create a new drop, upload prereveal and initial design, and stop before publish.
preview-drop Read-only preview of the current settings, design, and prereveal media.
update-drop Update only the fields the user explicitly provides.
publish-drop Publish an already configured drop.
list-chains Read-only list of Element-supported chains and payment tokens.
list-user-collections Read-only list of a user's collections on a supported chain.
list-user-drops Read-only list of a user's Element Drops on a supported chain.
get-contract-by-slug Read-only resolve of collection contract address by slug.
Global flags:
--preview Preview a state-changing lifecycle command without mutating anything.
Advanced utilities (not the default user-facing path):
create-token
post-create-collection
get-settings
post-settings
get-design
post-design
upload-prereveal
upload-design
wait-collection-contract
wait-prereveal-ipfs
verify-ref-graphql
Minimal lifecycle payloads:
create-drop
{
"chainName": "base",
"name": "My Drop",
"symbol": "F",
"preReveal": "/absolute/path/to/image.png"
}
preview-drop
{
"chainName": "base",
"slug": "ffffff-510bce"
}
update-drop
{
"chainName": "base",
"slug": "my-drop-510bce",
"paymentToken": "ETH",
"maxSupply": 999,
"previewMedia": [
"/absolute/path/to/preview-1.png"
]
}
publish-drop
{
"chainName": "base",
"slug": "my-drop-510bce"
}
list-user-drops
{
"chainName": "base"
}
`);
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
const commandArgs = args.slice(1);
const dryRun = commandArgs.includes("--dry-run") || commandArgs.includes("--preview");
const filePath = getPayloadPath(commandArgs);
const api = createElementApiClient();
const graphql = createElementGraphqlClient();
if (!command || command === "help" || command === "--help" || command === "-h") {
printHelp();
return;
}
async function resolvePayloadChain<T extends Record<string, unknown>>(payload: T): Promise<T & {
chainMId: number;
chainName: string;
paymentTokens: unknown[];
currency: string;
}> {
activeOperationContext = {
command,
chainName: payload.chainName ?? payload.chainMId ?? null,
symbol: payload.symbol ?? null,
slug: payload.slug ?? null,
contractAddress: payload.contractAddress ?? null
};
const resolved = await resolveElementChain(
{
chainName: typeof payload.chainName === "string" ? payload.chainName : undefined,
chainMId: typeof payload.chainMId === "number" ? payload.chainMId : undefined
},
{
getChainsWithGas: api.getChainsWithGas
}
);
return {
...payload,
chainMId: resolved.chainMId,
chainName: resolved.chainName,
paymentTokens: resolved.paymentTokens,
currency: resolved.currency
};
}
async function createAuthorization(chainMId: number) {
const privateKey = getRequiredWalletPrivateKey();
const walletAddress = (await deriveWalletAddress({ privateKey })).toLowerCase();
const auth = await buildElementAuthorization(
{
getLoginNonce: graphql.getLoginNonce,
loginAuth: graphql.loginAuth
},
{
privateKey,
walletAddress,
chainMId
}
);
return {
authorization: auth.authorization,
walletAddress,
nonce: auth.nonce,
loginMessage: auth.message,
identity: auth.identity
};
}
async function getWalletAddressFromEnv() {
const privateKey = getRequiredWalletPrivateKey();
return (await deriveWalletAddress({ privateKey })).toLowerCase();
}
async function buildCreateDropOnchainPreview(payload: {
chainMId: number;
name: string;
symbol: string;
}) {
const auth = await createAuthorization(payload.chainMId);
const encoded = await api.postCreateToken(
auth.authorization,
{
chainMId: payload.chainMId,
name: payload.name,
symbol: payload.symbol
},
auth.walletAddress
);
return {
walletAddress: auth.walletAddress,
createToken: encoded.data
};
}
async function buildPublishDropOnchainPreview(payload: {
authorization: string;
walletAddress: string;
chainMId: number;
contractAddress: string;
dropID: number;
isPaused?: boolean;
preRevealIPFSPollIntervalMs?: number;
preRevealIPFSTimeoutMs?: number;
}) {
const settingsResponse = await api.getDropSettings(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress
},
payload.walletAddress
);
const preRevealResolution = await resolvePublishPreRevealIPFS(
{
authorization: payload.authorization,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
walletAddress: payload.walletAddress,
pollIntervalMs: payload.preRevealIPFSPollIntervalMs,
timeoutMs: payload.preRevealIPFSTimeoutMs
},
{
getTempURL: api.getTempURL,
getPreRevealIPFS: api.getPreRevealIPFS
}
);
const postSettingsPayload = buildPostDropSettingsPayloadFromSettings({
settings: settingsResponse.data,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress
});
const setProjectConfigPayload = buildSetProjectConfigPayload({
settings: settingsResponse.data,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
preRevealIPFS: preRevealResolution.preRevealIPFS.preRevealIPFS,
isPaused: payload.isPaused
});
const encoded = await api.postSetProjectConfig(
payload.authorization,
setProjectConfigPayload,
payload.walletAddress
);
return {
walletAddress: payload.walletAddress,
settings: settingsResponse.data,
tempURL: preRevealResolution.tempURL,
preRevealIPFS: preRevealResolution.preRevealIPFS,
postSettingsPayload,
setProjectConfigPayload,
setProjectConfig: encoded.data
};
}
async function resolveContractAddressBySlug(input: {
chainMId: number;
slug?: string;
contractAddress?: string;
}) {
if (!input.slug) {
if (!input.contractAddress) {
throw new Error("slug is required");
}
return {
slug: undefined,
contractAddress: input.contractAddress
};
}
const collection = await graphql.getCollectionDetailFromEditors(input.slug);
const expectedBlockChain = getElementIdentityByChainMId(input.chainMId, ZERO_ADDRESS).blockChain;
const contract =
collection.contracts.find(
(item) =>
item.blockChain.chain === expectedBlockChain.chain && item.blockChain.chainId === expectedBlockChain.chainId
) ?? null;
if (!contract) {
throw new Error(`No contract found for slug input.slug on chain getElementChainNameByChainMId(input.chainMId)`);
}
if (
input.contractAddress &&
input.contractAddress.toLowerCase() !== contract.address.toLowerCase()
) {
throw new Error(
`slug input.slug resolved to contractAddress contract.address, but payload provided input.contractAddress`
);
}
return {
slug: collection.slug,
contractAddress: contract.address
};
}
async function resolveDropLocator<T extends Record<string, unknown>>(
payload: T,
input: {
needDropID?: boolean;
walletAddress?: string;
} = {}
): Promise<T & { slug?: string; contractAddress: string; dropID?: number }> {
const resolvedContract = await resolveContractAddressBySlug({
chainMId: payload.chainMId as number,
slug: typeof payload.slug === "string" ? payload.slug : undefined,
contractAddress: typeof payload.contractAddress === "string" ? payload.contractAddress : undefined
});
let dropID =
typeof payload.dropID === "number" && Number.isFinite(payload.dropID) ? payload.dropID : undefined;
if (input.needDropID && dropID === undefined) {
const walletAddress = input.walletAddress ?? (await getWalletAddressFromEnv());
const settings = await api.getDropSettings(
{
chainMId: payload.chainMId as number,
contractAddress: resolvedContract.contractAddress
},
walletAddress
);
dropID = settings.data.dropID;
if (!(dropID > 0)) {
throw new Error(`dropID is not available yet for slug resolvedContract.slug ?? payload.slug ?? ""`.trim());
}
}
return {
...payload,
slug: resolvedContract.slug ?? (typeof payload.slug === "string" ? payload.slug : undefined),
contractAddress: resolvedContract.contractAddress,
...(dropID !== undefined ? { dropID } : {})
};
}
if (command === "preview") {
const payload = await readJsonArg(filePath);
console.log(JSON.stringify(payload, null, 2));
return;
}
if (command === "list-chains") {
const response = await api.getChainsWithGas();
const chains = response.data.chains.map((chain) => ({
chainName: (() => {
try {
return getElementChainNameByChainMId(chain.chainMId);
} catch {
return "unknown";
}
})(),
currency: chain.currency,
paymentTokens: chain.paymentTokens.map((token) => ({
name: token.name,
address: token.TokenAddress
}))
}));
console.log(JSON.stringify({ chains }, null, 2));
return;
}
if (command === "list-user-collections") {
const payload = await resolvePayloadChain(await readJsonArg(filePath));
const walletAddress = typeof payload.walletAddress === "string" && payload.walletAddress.length > 0
? payload.walletAddress.toLowerCase()
: await getWalletAddressFromEnv();
const identity = getElementIdentityByChainMId(payload.chainMId, walletAddress);
const result = await graphql.getUserCollectionList({
identity,
first: typeof payload.first === "number" ? payload.first : undefined,
after: typeof payload.after === "string" ? payload.after : undefined,
before: typeof payload.before === "string" ? payload.before : undefined,
last: typeof payload.last === "number" ? payload.last : undefined
});
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
walletAddress,
totalCollections: result.items.length
},
walletAddress,
chainName: payload.chainName,
collections: result.items.map((item) => ({
slug: item.slug,
name: item.name,
isVerified: item.isVerified,
imageUrl: item.imageUrl,
assetCount: item.stats.assetCount
})),
pageInfo: result.pageInfo
},
null,
2
)
);
return;
}
if (command === "list-user-drops") {
const payload = await resolvePayloadChain(await readJsonArg(filePath));
const walletAddress =
typeof payload.walletAddress === "string" && payload.walletAddress.length > 0
? payload.walletAddress.toLowerCase()
: await getWalletAddressFromEnv();
const identity = getElementIdentityByChainMId(payload.chainMId, walletAddress);
const result = await graphql.getUserCollectionList({
identity,
first: typeof payload.first === "number" ? payload.first : undefined,
after: typeof payload.after === "string" ? payload.after : undefined,
before: typeof payload.before === "string" ? payload.before : undefined,
last: typeof payload.last === "number" ? payload.last : undefined
});
const drops = result.items
.filter((item) => item.contracts.some((contract) => isElementDropCollection(contract.sourceType)))
.map((item) => ({
name: item.name,
slug: item.slug,
isVerified: item.isVerified,
imageUrl: item.imageUrl,
assetCount: item.stats.assetCount
}));
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
walletAddress,
totalDrops: drops.length
},
walletAddress,
chainName: payload.chainName,
drops,
pageInfo: result.pageInfo
},
null,
2
)
);
return;
}
if (command === "get-contract-by-slug") {
const payload = await readJsonArg(filePath);
const collection = await graphql.getCollectionDetailFromEditors(payload.slug);
const contract = collection.contracts[0] ?? null;
console.log(
JSON.stringify(
{
summary: {
slug: collection.slug,
name: collection.name,
contractAddress: contract?.address ?? null
},
slug: collection.slug,
name: collection.name,
contractAddress: contract?.address ?? null,
blockChain: contract?.blockChain ?? null,
sourceType: contract?.sourceType ?? null
},
null,
2
)
);
return;
}
if (command === "create-token") {
const payload = await resolvePayloadChain(await readJsonArg(filePath));
setActiveOperationContext(command, payload);
const result = await createTokenFlow(payload, {
deriveAddress: deriveWalletAddress,
resolveRpcUrl: getRpcUrlForChainMId,
createAuthorization: (input) =>
buildElementAuthorization(
{
getLoginNonce: graphql.getLoginNonce,
loginAuth: graphql.loginAuth
},
input
),
postCreateToken: api.postCreateToken,
sendTransaction: sendEncodedTransaction
});
const output: Record<string, unknown> = { ...result };
output.summary = {
chainName: payload.chainName,
symbol: payload.symbol,
contractAddress: result.transaction.contractAddress ?? payload.contractAddress ?? null,
transactionHash: result.transaction.hash
};
const contractAddress = result.transaction.contractAddress ?? payload.contractAddress;
if (contractAddress) {
output.postCreateCollection = await postCreateCollectionFlow(
{
chainMId: payload.chainMId,
contractAddress,
authorization: result.preflight.authorization,
imageFilePath: payload.preReveal,
pollIntervalMs: payload.pollIntervalMs,
timeoutMs: payload.timeoutMs
},
{
getCollectionContract: graphql.getCollectionContract,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors,
collectionEdit: graphql.collectionEdit
}
);
}
console.log(JSON.stringify(output, null, 2));
return;
}
if (command === "create-drop") {
const payload = await resolvePayloadChain(await readJsonArg(filePath));
setActiveOperationContext(command, payload);
if (dryRun) {
const onchainPreview = await buildCreateDropOnchainPreview({
chainMId: payload.chainMId,
name: String(payload.name),
symbol: String(payload.symbol)
});
console.log(JSON.stringify(buildCreateDropDryRunPreview(payload, { onchainPreview }), null, 2));
return;
}
const result = await createDropFlow(payload, {
createTokenFlow,
postCreateCollectionFlow,
uploadWithExistingAuthorization,
getDropSettings: api.getDropSettings,
postDropSettings: api.postDropSettings,
getOssSignSingle: api.getOssSignSingle,
postPreReveal: api.postPreReveal,
postDesign: api.postDesign,
uploadAsset: uploadSingleAsset,
createAuthorization,
deriveAddress: deriveWalletAddress,
resolveRpcUrl: getRpcUrlForChainMId,
createAuthorizationForToken: (input) =>
buildElementAuthorization(
{
getLoginNonce: graphql.getLoginNonce,
loginAuth: graphql.loginAuth
},
input
),
postCreateToken: api.postCreateToken,
sendTransaction: sendEncodedTransaction,
getCollectionContract: graphql.getCollectionContract,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors,
collectionEdit: graphql.collectionEdit
});
console.log(
JSON.stringify(
{
summary: result.summary,
settings: summarizeSettingsSnapshot({
maxSupply: result.initialSettingsPayload.maxSupply,
dropBeginTime: result.initialSettingsPayload.dropBeginTime,
dropType: result.initialSettingsPayload.dropType,
mintingType: result.initialSettingsPayload.mintingType,
stages: result.initialSettingsPayload.stagesUpdate,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
design: summarizeDesignSnapshot(result.designUpload.payload),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "configure-drop") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
if (dryRun) {
const auth =
payload.authorization && payload.walletAddress
? {
authorization: payload.authorization,
walletAddress: payload.walletAddress
}
: await createAuthorization(payload.chainMId);
const preview = await previewConfigureDrop(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
slug: payload.slug,
patch: payload
},
{
getDropSettings: api.getDropSettings,
getDropDesign: api.getDropDesign,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors
}
);
console.log(JSON.stringify(preview, null, 2));
return;
}
const auth =
payload.authorization && payload.walletAddress
? {
authorization: payload.authorization,
walletAddress: payload.walletAddress
}
: await createAuthorization(payload.chainMId);
const result = await configureDropFlow(
{
...payload,
authorization: auth.authorization,
walletAddress: auth.walletAddress
},
{
uploadWithExistingAuthorization,
getDropSettings: api.getDropSettings,
getDropDesign: api.getDropDesign,
postDropSettings: api.postDropSettings,
getOssSignSingle: api.getOssSignSingle,
postPreReveal: api.postPreReveal,
postDesign: api.postDesign,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors,
collectionEdit: graphql.collectionEdit,
uploadAsset: uploadSingleAsset
}
);
const displayDesignAfterConfigure = buildDisplayDesignAfterUpdate({
current: result.currentDesignResponse?.data ?? null,
designPayload: result.designPayload,
collectionEditPayload: result.collectionEditPayload
});
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: result.contractAddress,
slug: result.slug,
dropUrl: result.urls?.dropUrl ?? null,
collectionUrl: result.urls?.collectionUrl ?? null,
editUrl: result.urls?.editUrl ?? null,
preRevealUrl: result.preRevealUpload?.publicUrls[0] ?? null,
bannerUrl: result.designBannerUrl ?? null
},
settingsAfterUpdate: summarizeSettingsSnapshot({
maxSupply: result.settingsAfterConfigure.data.maxSupply,
totalMint: result.settingsAfterConfigure.data.totalMint,
dropBeginTime: result.settingsAfterConfigure.data.dropBeginTime,
dropType: result.settingsAfterConfigure.data.dropType,
mintingType: result.settingsAfterConfigure.data.mintingType,
stages: result.settingsAfterConfigure.data.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : []
}),
...(displayDesignAfterConfigure
? { designAfterUpdate: summarizeDesignSnapshot(displayDesignAfterConfigure) }
: {}),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "update-drop") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
if (dryRun) {
const auth =
payload.authorization && payload.walletAddress
? {
authorization: payload.authorization,
walletAddress: payload.walletAddress
}
: await createAuthorization(payload.chainMId);
const preview = await previewConfigureDrop(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
slug: payload.slug,
patch: payload
},
{
getDropSettings: api.getDropSettings,
getDropDesign: api.getDropDesign,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors
}
);
const currentPaymentToken = summarizeResolvedPaymentToken({
mintingType: preview.currentSettings.data?.mintingType,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
});
const nextPaymentToken = summarizeResolvedPaymentToken({
mintingType: preview.settingsPayload?.mintingType ?? preview.currentSettings.data?.mintingType,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
});
const requiresRepublish = shouldRepublishUpdatedDrop({
published: preview.currentSettings.data?.published,
isPaused: preview.currentSettings.data?.isPaused,
willUpdateSettings: preview.summary.willUpdateSettings,
willUpdatePreReveal: preview.summary.willUpdatePreReveal
});
const republishPreview =
requiresRepublish
? buildPublishDropDryRunPreview(payload, {
onchainPreview: await buildPublishDropOnchainPreview({
authorization: auth.authorization,
walletAddress: auth.walletAddress,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: preview.summary.dropID,
preRevealIPFSPollIntervalMs: payload.preRevealIPFSPollIntervalMs,
preRevealIPFSTimeoutMs: payload.preRevealIPFSTimeoutMs
})
})
: null;
console.log(
JSON.stringify(
requiresRepublish
? {
command: "update-drop",
previewOnly: true,
target: {
chainName: payload.chainName ?? null,
slug: payload.slug ?? null,
contractAddress: payload.contractAddress ?? null
},
current: {
publishState: summarizePublishState({
published: preview.currentSettings.data?.published,
isPaused: preview.currentSettings.data?.isPaused
}),
batch: preview.currentSettings.data?.batch ?? null,
settings: summarizeSettingsSnapshot({
maxSupply: preview.currentSettings.data?.maxSupply,
totalMint: preview.currentSettings.data?.totalMint,
dropBeginTime: preview.currentSettings.data?.dropBeginTime,
dropType: preview.currentSettings.data?.dropType,
mintingType: preview.currentSettings.data?.mintingType,
stages: preview.currentSettings.data?.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
...(preview.currentDesign?.data
? { design: summarizeDesignSnapshot(preview.currentDesign.data) }
: {})
},
currentPaymentToken,
nextPaymentToken,
...(preview.settingsPayload
? { settingsPreview: summarizeSettingsSnapshot({
maxSupply: preview.settingsPayload.maxSupply,
dropBeginTime: preview.settingsPayload.dropBeginTime,
dropType: preview.settingsPayload.dropType,
mintingType: preview.settingsPayload.mintingType,
stages: preview.settingsPayload.stagesUpdate,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}) }
: {}),
...(preview.designPayload ? { designPreview: summarizeDesignSnapshot(preview.designPayload) } : {}),
onchainRisk: {
hasBlockchainTransaction: true,
executionRequiresExplicitConfirmation: true,
note:
"This drop is already live, and the requested settings or prereveal update requires republish with an onchain transaction."
},
signingSafety: buildSigningSafetyNote(),
republishReason:
"The current drop is live and this patch changes settings or prereveal, so update-drop will continue into publish flow.",
...(republishPreview ? { republishPreview } : {})
}
: {
command: "update-drop",
previewOnly: true,
target: {
chainName: payload.chainName ?? null,
slug: payload.slug ?? null,
contractAddress: payload.contractAddress ?? null
},
current: {
publishState: summarizePublishState({
published: preview.currentSettings.data?.published,
isPaused: preview.currentSettings.data?.isPaused
}),
batch: preview.currentSettings.data?.batch ?? null,
settings: summarizeSettingsSnapshot({
maxSupply: preview.currentSettings.data?.maxSupply,
totalMint: preview.currentSettings.data?.totalMint,
dropBeginTime: preview.currentSettings.data?.dropBeginTime,
dropType: preview.currentSettings.data?.dropType,
mintingType: preview.currentSettings.data?.mintingType,
stages: preview.currentSettings.data?.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
...(preview.currentDesign?.data
? { design: summarizeDesignSnapshot(preview.currentDesign.data) }
: {})
},
currentPaymentToken,
nextPaymentToken,
...(preview.settingsPayload
? { settingsPreview: summarizeSettingsSnapshot({
maxSupply: preview.settingsPayload.maxSupply,
dropBeginTime: preview.settingsPayload.dropBeginTime,
dropType: preview.settingsPayload.dropType,
mintingType: preview.settingsPayload.mintingType,
stages: preview.settingsPayload.stagesUpdate,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}) }
: {}),
...(preview.designPayload ? { designPreview: summarizeDesignSnapshot(preview.designPayload) } : {}),
onchainRisk: {
hasBlockchainTransaction: false,
executionRequiresExplicitConfirmation: false,
note:
(preview.currentSettings.data?.published ?? 0) === 1
? preview.currentSettings.data?.isPaused === true
? "This drop is already paused, so update-drop applies the requested offchain changes without forcing republish."
: "This flow only changes design or collection profile fields, so it does not require republish or an onchain transaction."
: "This flow updates remote settings, prereveal, design, or collection profile fields and does not send an onchain transaction by itself."
},
signingSafety: buildSigningSafetyNote(),
transactionPreview: []
},
null,
2
)
);
return;
}
const auth =
payload.authorization && payload.walletAddress
? {
authorization: payload.authorization,
walletAddress: payload.walletAddress
}
: await createAuthorization(payload.chainMId);
const result = await configureDropFlow(
{
...payload,
authorization: auth.authorization,
walletAddress: auth.walletAddress
},
{
uploadWithExistingAuthorization,
getDropSettings: api.getDropSettings,
getDropDesign: api.getDropDesign,
postDropSettings: api.postDropSettings,
getOssSignSingle: api.getOssSignSingle,
postPreReveal: api.postPreReveal,
postDesign: api.postDesign,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors,
collectionEdit: graphql.collectionEdit,
uploadAsset: uploadSingleAsset
}
);
const republishResult =
shouldRepublishUpdatedDrop({
published: result.currentSettings.published,
isPaused: result.currentSettings.isPaused,
willUpdateSettings: Boolean(result.settingsPayload),
willUpdatePreReveal: Boolean(result.preRevealUpload)
})
? await publishDropFlow(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress,
rpcUrl: await getRpcUrlForChainMId(payload.chainMId),
chainMId: payload.chainMId,
contractAddress: result.contractAddress,
dropID: result.dropID,
getSettingsPollIntervalMs: payload.getSettingsPollIntervalMs,
getSettingsTimeoutMs: payload.getSettingsTimeoutMs,
preRevealIPFSPollIntervalMs: payload.preRevealIPFSPollIntervalMs,
preRevealIPFSTimeoutMs: payload.preRevealIPFSTimeoutMs
},
{
getDropSettings: api.getDropSettings,
getPreRevealIPFS: api.getPreRevealIPFS,
getTempURL: api.getTempURL,
postDropSettings: api.postDropSettings,
postSetProjectConfig: api.postSetProjectConfig,
sendTransaction: sendEncodedTransaction,
postCallbackUpdateProjectConfig: api.postCallbackUpdateProjectConfig
}
)
: null;
const displayDesignAfterUpdate = buildDisplayDesignAfterUpdate({
current: result.currentDesignResponse?.data ?? null,
designPayload: result.designPayload,
collectionEditPayload: result.collectionEditPayload
});
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: result.contractAddress,
slug: result.slug,
dropUrl: result.urls?.dropUrl ?? null,
collectionUrl: result.urls?.collectionUrl ?? null,
editUrl: result.urls?.editUrl ?? null,
preRevealUrl: result.preRevealUpload?.publicUrls[0] ?? null,
bannerUrl: result.designBannerUrl ?? null,
republished: Boolean(republishResult),
publishTransactionHash: republishResult?.setProjectConfigTransaction.hash ?? null
},
settingsAfterUpdate: summarizeSettingsSnapshot({
maxSupply: result.settingsAfterConfigure.data.maxSupply,
totalMint: result.settingsAfterConfigure.data.totalMint,
dropBeginTime: result.settingsAfterConfigure.data.dropBeginTime,
dropType: result.settingsAfterConfigure.data.dropType,
mintingType: result.settingsAfterConfigure.data.mintingType,
stages: result.settingsAfterConfigure.data.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
...(displayDesignAfterUpdate ? { designAfterUpdate: summarizeDesignSnapshot(displayDesignAfterUpdate) } : {}),
...(republishResult
? { publishResult: {
publishState: summarizePublishState({
published: republishResult.published.settings.published,
isPaused: republishResult.published.settings.isPaused
}),
publishTransactionHash: republishResult.setProjectConfigTransaction.hash,
settings: summarizeSettingsSnapshot({
maxSupply: republishResult.published.settings.maxSupply,
totalMint: republishResult.published.settings.totalMint,
dropBeginTime: republishResult.published.settings.dropBeginTime,
dropType: republishResult.published.settings.dropType,
mintingType: republishResult.published.settings.mintingType,
stages: republishResult.published.settings.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
})
} }
: {}),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "preview-drop") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
const auth =
payload.authorization && payload.walletAddress
? {
authorization: payload.authorization,
walletAddress: payload.walletAddress
}
: await createAuthorization(payload.chainMId);
const result = await previewDropFlow(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
slug: payload.slug,
page: payload.page,
pageSize: payload.pageSize
},
{
getDropSettings: api.getDropSettings,
getDropDesign: api.getDropDesign,
getTempURL: api.getTempURL
}
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
...result.summary,
paymentToken: summarizeResolvedPaymentToken({
mintingType: result.settings.data.mintingType,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
})
},
settings: summarizeSettingsSnapshot({
maxSupply: result.settings.data.maxSupply,
totalMint: result.settings.data.totalMint,
dropBeginTime: result.settings.data.dropBeginTime,
dropType: result.settings.data.dropType,
mintingType: result.settings.data.mintingType,
stages: result.settings.data.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
design: result.design ? summarizeDesignSnapshot(result.design.data) : null,
preRevealMedia: result.tempURL
? {
imageURL: extractPreRevealImageUrl(result.tempURL.data),
animationURL: extractPreRevealAnimationUrl(result.tempURL.data)
}
: null
},
null,
2
)
);
return;
}
if (command === "wait-collection-contract") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
const result = await waitForCollectionContract(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
pollIntervalMs: payload.pollIntervalMs,
timeoutMs: payload.timeoutMs
},
{
getCollectionContract: graphql.getCollectionContract
}
);
console.log(JSON.stringify(result, null, 2));
return;
}
if (command === "post-create-collection") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
const authorization =
payload.authorization ??
(
await createAuthorization(payload.chainMId)
).authorization;
const result = await postCreateCollectionFlow(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
authorization,
imageFilePath: payload.imageFilePath ?? payload.preReveal,
pollIntervalMs: payload.pollIntervalMs,
timeoutMs: payload.timeoutMs
},
{
getCollectionContract: graphql.getCollectionContract,
getMutateToken: graphql.getMutateToken,
getCollectionDetailFromEditors: graphql.getCollectionDetailFromEditors,
collectionEdit: graphql.collectionEdit
}
);
console.log(JSON.stringify({ authorization, ...result }, null, 2));
return;
}
if (command === "wait-prereveal-ipfs") {
const walletAddress = await getWalletAddressFromEnv();
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)), {
needDropID: true,
walletAddress
});
setActiveOperationContext(command, payload);
const result = await waitForPreRevealIPFS(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
walletAddress,
pollIntervalMs: payload.pollIntervalMs,
timeoutMs: payload.timeoutMs
},
{
getPreRevealIPFS: api.getPreRevealIPFS
}
);
console.log(JSON.stringify({ walletAddress, ...result }, null, 2));
return;
}
if (command === "wait-published-settings") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)), {
needDropID: true
});
setActiveOperationContext(command, payload);
const walletAddress = await getWalletAddressFromEnv();
const result = await waitForPublishedSettings(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
walletAddress,
expectedPublished: typeof payload.expectedPublished === "number" ? payload.expectedPublished : undefined,
pollIntervalMs: payload.pollIntervalMs,
timeoutMs: payload.timeoutMs
},
{
postCallbackUpdateProjectConfig: api.postCallbackUpdateProjectConfig,
getDropSettings: api.getDropSettings
}
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress
},
walletAddress,
publishState: summarizePublishState({
published: result.settings.published,
isPaused: result.settings.isPaused
}),
settings: summarizeSettingsSnapshot({
maxSupply: result.settings.maxSupply,
totalMint: result.settings.totalMint,
dropBeginTime: result.settings.dropBeginTime,
dropType: result.settings.dropType,
mintingType: result.settings.mintingType,
stages: result.settings.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
callbackResponse: result.callbackResponse,
attempts: result.attempts,
elapsedMs: result.elapsedMs
},
null,
2
)
);
return;
}
if (command === "publish-drop") {
const rawPayload = await readJsonArg(filePath);
const resolvedChainPayload = await resolvePayloadChain(rawPayload);
const auth = await createAuthorization(resolvedChainPayload.chainMId);
const payload = await resolveDropLocator(resolvedChainPayload, {
needDropID: true,
walletAddress: auth.walletAddress
});
setActiveOperationContext(command, payload);
if (dryRun) {
const onchainPreview = await buildPublishDropOnchainPreview({
authorization: auth.authorization,
walletAddress: auth.walletAddress,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
isPaused: typeof payload.isPaused === "boolean" ? payload.isPaused : undefined,
preRevealIPFSPollIntervalMs: payload.preRevealIPFSPollIntervalMs,
preRevealIPFSTimeoutMs: payload.preRevealIPFSTimeoutMs
});
console.log(JSON.stringify(buildPublishDropDryRunPreview(payload, { onchainPreview }), null, 2));
return;
}
const result = await publishDropFlow(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress,
rpcUrl: await getRpcUrlForChainMId(payload.chainMId),
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
isPaused: typeof payload.isPaused === "boolean" ? payload.isPaused : undefined,
getSettingsPollIntervalMs: payload.getSettingsPollIntervalMs,
getSettingsTimeoutMs: payload.getSettingsTimeoutMs,
preRevealIPFSPollIntervalMs: payload.preRevealIPFSPollIntervalMs,
preRevealIPFSTimeoutMs: payload.preRevealIPFSTimeoutMs
},
{
getDropSettings: api.getDropSettings,
getPreRevealIPFS: api.getPreRevealIPFS,
getTempURL: api.getTempURL,
postDropSettings: api.postDropSettings,
postSetProjectConfig: api.postSetProjectConfig,
sendTransaction: sendEncodedTransaction,
postCallbackUpdateProjectConfig: api.postCallbackUpdateProjectConfig
}
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
slug: payload.slug ?? null,
dropUrl: payload.slug ? `https://element.market/collections/payload.slug` : null,
collectionUrl: payload.slug ? `https://element.market/collections/payload.slug` : null,
editUrl: payload.slug ? `https://element.market/collections/payload.slug/edit/drop` : null,
publishState: summarizePublishState({
published: result.published.settings.published,
isPaused: result.published.settings.isPaused
}),
publishTransactionHash: result.setProjectConfigTransaction.hash,
callbackSucceeded: result.published.callbackResponse.code === 0
},
settingsAfterPublish: summarizeSettingsSnapshot({
maxSupply: result.published.settings.maxSupply,
totalMint: result.published.settings.totalMint,
dropBeginTime: result.published.settings.dropBeginTime,
dropType: result.published.settings.dropType,
mintingType: result.published.settings.mintingType,
stages: result.published.settings.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "get-settings") {
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)));
setActiveOperationContext(command, payload);
const walletAddress = await getWalletAddressFromEnv();
const result = await api.getDropSettings(
{
chainMId: payload.chainMId,
contractAddress: payload.contractAddress
},
walletAddress
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress
},
settings: summarizeSettingsSnapshot({
maxSupply: result.data.maxSupply,
totalMint: result.data.totalMint,
dropBeginTime: result.data.dropBeginTime,
dropType: result.data.dropType,
mintingType: result.data.mintingType,
stages: result.data.stages,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
})
},
null,
2
)
);
return;
}
if (command === "post-settings") {
const rawPayload = await readJsonArg(filePath);
const resolvedChainPayload = await resolvePayloadChain(rawPayload);
const auth = await createAuthorization(resolvedChainPayload.chainMId);
const payload = await resolveDropLocator(resolvedChainPayload, {
needDropID: true,
walletAddress: auth.walletAddress
});
setActiveOperationContext(command, payload);
const settingsPayload = {
dropID: payload.dropID ?? 0,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropType: payload.dropType,
maxSupply: payload.maxSupply,
fee: payload.fee ?? 0,
feeRecipient: payload.feeRecipient ?? "",
mintingType: payload.mintingType ?? 0,
dropBeginTime: payload.dropBeginTime,
stagesUpdate: payload.stagesUpdate ?? []
};
const result = await api.postDropSettings(
auth.authorization,
settingsPayload,
auth.walletAddress
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress: auth.walletAddress,
success: result.code === 0
},
requestedSettings: summarizeSettingsSnapshot({
maxSupply: settingsPayload.maxSupply,
dropBeginTime: settingsPayload.dropBeginTime,
dropType: settingsPayload.dropType,
mintingType: settingsPayload.mintingType,
stages: settingsPayload.stagesUpdate,
availablePaymentTokens: Array.isArray(payload.paymentTokens) ? payload.paymentTokens : [],
currency: payload.currency
}),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "get-design") {
const walletAddress = await getWalletAddressFromEnv();
const payload = await resolveDropLocator(await resolvePayloadChain(await readJsonArg(filePath)), {
needDropID: true,
walletAddress
});
setActiveOperationContext(command, payload);
const result = await api.getDropDesign(
{
dropID: payload.dropID,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress
},
walletAddress
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress,
hasDesign: Boolean(result.data)
},
design: summarizeDesignSnapshot(result.data)
},
null,
2
)
);
return;
}
if (command === "post-design") {
const rawPayload = await readJsonArg(filePath);
const resolvedChainPayload = await resolvePayloadChain(rawPayload);
const auth = await createAuthorization(resolvedChainPayload.chainMId);
const payload = await resolveDropLocator(resolvedChainPayload, {
needDropID: true,
walletAddress: auth.walletAddress
});
setActiveOperationContext(command, payload);
const designPayload = {
dropID: payload.dropID,
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropName: payload.dropName ?? "",
bannerURL: payload.bannerURL ?? "",
previewMediaExt: payload.previewMediaExt ?? [],
dropFeaturedImage: payload.dropFeaturedImage ?? "",
description: payload.description ?? "",
website: payload.website ?? "",
twitter: payload.twitter ?? "",
instagram: payload.instagram ?? "",
discord: payload.discord ?? "",
telegram: payload.telegram ?? "",
medium: payload.medium ?? "",
detailsUpdate: []
};
const result = await api.postDesign(
auth.authorization,
designPayload,
auth.walletAddress
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress: auth.walletAddress,
success: result.code === 0
},
requestedDesign: summarizeDesignSnapshot(designPayload),
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "upload-prereveal") {
const rawPayload = await readJsonArg(filePath);
const resolvedChainPayload = await resolvePayloadChain(rawPayload);
const auth = await createAuthorization(resolvedChainPayload.chainMId);
const payload = await resolveDropLocator(resolvedChainPayload);
setActiveOperationContext(command, payload);
const result = await uploadWithExistingAuthorization(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress
},
{
mode: "prereveal",
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropType: payload.dropType,
filePath: payload.filePath
},
{
getOssSignSingle: api.getOssSignSingle,
postPreReveal: api.postPreReveal,
uploadAsset: uploadSingleAsset
}
);
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress: auth.walletAddress,
uploaded: true,
publicUrl: result.publicUrls[0] ?? null
},
upload: {
sourcePath: result.uploads[0]?.sourcePath ?? null,
fileName: result.uploads[0]?.fileName ?? null,
objectKey: result.uploads[0]?.objectKey ?? null,
publicUrls: result.publicUrls
},
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "upload-design") {
const rawPayload = await readJsonArg(filePath);
const resolvedChainPayload = await resolvePayloadChain(rawPayload);
const auth = await createAuthorization(resolvedChainPayload.chainMId);
const payload = await resolveDropLocator(resolvedChainPayload, {
needDropID: true,
walletAddress: auth.walletAddress
});
setActiveOperationContext(command, payload);
const result = await uploadWithExistingAuthorization(
{
authorization: auth.authorization,
walletAddress: auth.walletAddress
},
{
mode: "design",
chainMId: payload.chainMId,
contractAddress: payload.contractAddress,
dropID: payload.dropID,
dropName: payload.dropName,
bannerFilePath: payload.bannerFilePath,
previewFilePaths: payload.previewFilePaths
},
{
getOssSignSingle: api.getOssSignSingle,
postPreReveal: api.postPreReveal,
uploadAsset: uploadSingleAsset
}
);
if (result.mode !== "design") {
throw new Error("upload-design expected design upload result");
}
const designResult = result;
console.log(
JSON.stringify(
{
summary: {
chainName: payload.chainName,
contractAddress: payload.contractAddress,
walletAddress: auth.walletAddress,
uploadedBanner: Boolean(designResult.payload.bannerURL),
previewMediaCount: designResult.payload.previewMediaExt.length
},
designUpload: {
bannerUrl: designResult.payload.bannerURL || null,
previewUrls: designResult.payload.previewMediaExt
.map((item) => item.image_url ?? "")
.filter(Boolean),
requestedDesign: summarizeDesignSnapshot(designResult.payload)
},
signingSafety: buildSigningSafetyNote()
},
null,
2
)
);
return;
}
if (command === "verify-ref-graphql") {
const result = await verifyRefGraphqlExamples();
console.log(JSON.stringify(result, null, 2));
return;
}
throw new Error(`unknown command: command`);
}
main().catch((error: unknown) => {
const message = redactKnownSecrets(error instanceof Error ? error.message : String(error));
console.error(
JSON.stringify(
{
error: message,
operationContext: activeOperationContext,
chainName: activeOperationContext.chainName ?? null,
symbol: activeOperationContext.symbol ?? null,
note:
"Verify chainName before retrying. A drop deployed on the wrong chain cannot be moved; create a new drop on the intended chain instead."
},
null,
2
)
);
process.exitCode = 1;
});
FILE:scripts/src/types.ts
export type DropType = 0 | 1;
export type DropEdition = "limited" | "open";
export type StageMode = 0 | 1;
export interface StageInput {
stageID: number;
stageName: string;
price: string;
duration: number;
maxSupplyAtThisStage: number;
maxMintedPerWallet: number;
interval: number;
stageMode: StageMode;
}
export interface CreateStageInput {
stageID?: number;
stageName: string;
price: string;
duration: number;
maxSupplyAtThisStage: number;
maxMintedPerWallet: number;
interval: number;
stageMode: StageMode;
}
export interface DropInput {
chainMId: number;
name: string;
symbol: string;
paymentToken?: string;
preReveal: string;
description: string;
dropType: DropType;
maxSupply: number;
dropBeginTime: number;
stages: StageInput[];
}
export interface CreateDropInput {
chainMId: number;
name: string;
symbol: string;
paymentToken?: string;
preReveal?: string;
previewMedia?: string[];
description?: string;
desc?: string;
website?: string;
twitter?: string;
instagram?: string;
discord?: string;
telegram?: string;
medium?: string;
dropFeaturedImage?: string;
edition?: DropEdition;
dropType?: DropType;
maxSupply?: number;
dropBeginTime?: number;
stages?: CreateStageInput[];
}
export interface CreateTokenRequest {
chainMId: number;
name: string;
symbol: string;
}
export interface ElementBlockChainIdentity {
chain: string;
chainId: string;
}
export interface ElementIdentity {
address: string;
blockChain: ElementBlockChainIdentity;
}
export interface ElementChainCollectionSummary {
id: string;
slug: string;
}
export interface ElementCollectionContractResponse {
chainCollection: ElementChainCollectionSummary | null;
}
export interface ElementCollectionSummary {
id: string;
name: string;
slug: string;
}
export interface ElementUserCollectionListItem extends ElementCollectionSummary {
imageUrl: string | null;
featuredImageUrl: string | null;
bannerImageUrl: string | null;
isVerified: boolean;
description: string | null;
stats: {
assetCount: number;
};
contracts: Array<{
sourceType: string | null;
}>;
}
export interface ElementCollectionContractInfo {
blockChain: ElementBlockChainIdentity;
address: string;
sourceType: string | null;
}
export interface ElementCollectionDetailFromEditors {
contracts: ElementCollectionContractInfo[];
id: string;
name: string;
slug: string;
description: string | null;
imageUrl: string | null;
bannerImageUrl: string | null;
featuredImageUrl: string | null;
externalUrl: string | null;
weiboUrl: string | null;
twitterUrl: string | null;
instagramUrl: string | null;
facebookUrl: string | null;
mediumUrl: string | null;
telegramUrl: string | null;
discordUrl: string | null;
categories: Array<{
id: string;
name: string;
slug: string;
}>;
paymentTokens: Array<{
id: string;
name: string;
address: string;
symbol: string;
chain: string;
chainId: string;
}>;
royalty: number;
royaltyAddress: string | null;
isVerified: boolean;
}
export interface ElementLoginInput {
identity: ElementIdentity;
message: string;
signature: string;
realm?: string;
source?: string;
}
export interface ElementCollectionEditInput {
collectionId: string;
token: string;
imageFilePath?: string;
name?: string;
slug?: string;
description?: string;
image?: unknown;
featuredImage?: unknown;
bannerImage?: unknown;
externalUrl?: string;
weiboUrl?: string;
twitterUrl?: string;
instagramUrl?: string;
facebookUrl?: string;
mediumUrl?: string;
telegramUrl?: string;
discordUrl?: string;
categories?: string[];
paymentTokens?: string[];
royalty?: number;
royaltyAddress?: string;
}
export interface EncodedTransaction {
from: string;
to: string;
value: string;
data: string;
}
export interface ElementApiResponse<T> {
code: number;
message: string;
data: T;
}
export interface OssSignSingleRequest {
chainMId: number;
contractAddress: string;
mediaType: "prereveal" | "design";
}
export interface OssSignedPostData {
accessid: string;
policy: string;
signature: string;
dir: string;
host: string;
expire?: string;
x_oss_credential?: string;
x_oss_date?: string;
security_token?: string;
callback?: string;
}
export interface PreRevealUploadPayload {
chainMId: number;
contractAddress: string;
dropType: DropType;
preRevealExt: {
image_url: string;
animation_url: string;
};
}
export interface DesignUploadPayload {
dropID: number;
chainMId?: number;
contractAddress?: string;
dropName: string;
bannerURL: string;
previewMediaExt: Array<{
image_url: string;
animation_url: string;
}>;
dropFeaturedImage: string;
description: string;
website: string;
twitter: string;
instagram: string;
discord: string;
telegram: string;
medium: string;
detailsUpdate: Array<{
detailID?: number;
template?: number;
title?: string;
description?: string;
imageURL?: string;
textAlign?: number | null;
faq?: unknown;
}>;
}
export interface CreateTokenPreflight {
walletAddress: string;
rpcUrl: string;
authorization: string;
nonce: string;
loginMessage: string;
identity: ElementIdentity;
}
export interface ExecutedTransactionReceipt {
hash: string;
blockNumber: number;
status: number;
gasUsed: string;
}
export interface ExecutedTransactionResult {
hash: string;
receipt: ExecutedTransactionReceipt;
contractAddress?: string;
}
export interface GetDropSettingsQuery {
chainMId: number;
contractAddress: string;
}
export interface GetDropSettingsStage {
stageID: number;
stageName: string;
price: string;
interval: number;
duration: number;
maxSupplyAtThisStage: number;
maxMintedPerWallet: number;
stageMode: number;
whiteListNum: number;
enableCallFromContract: boolean;
enableMintToOther: boolean;
address?: string;
}
export interface PaymentTokenInfo {
symbol?: string;
address?: string;
decimals?: number;
chain?: string;
chainId?: string;
logoUrl?: string;
}
export interface GetDropSettingsResponse {
dropID: number;
published: number;
maxSupply?: number;
feeRecipient?: string;
fee?: number;
dropBeginTime?: number;
isMinted: boolean;
dropType: DropType;
baseURI: string;
isStageQuotaSharedForWallet: boolean;
isPaused: boolean;
mintingType: number;
paymentToken?: PaymentTokenInfo;
paymentTokens: PaymentTokenInfo[];
mintFee: string;
totalMint: number;
batch: string;
batchTime: number;
stages: GetDropSettingsStage[];
}
export interface PostDropSettingsAllowList {
address: string;
buyCount: number;
}
export interface PostDropSettingsStageUpdate {
stageID: number;
stageName: string;
price: string;
interval: number;
duration: number;
maxMintedPerWallet: number;
maxSupplyAtThisStage: number;
stageMode: StageMode | null;
allowListsNew: PostDropSettingsAllowList[];
}
export interface PostDropSettingsRequest {
dropID: number;
chainMId: number;
contractAddress: string;
dropType: DropType;
maxSupply: number;
fee: number;
feeRecipient: string;
mintingType: number;
dropBeginTime: number;
stagesUpdate: PostDropSettingsStageUpdate[];
}
export interface GetDropDesignQuery {
dropID: number;
chainMId: number;
contractAddress: string;
}
export interface DropDesignDetail {
detailID: number;
template: number;
title: string;
description: string;
imageURL: string;
textAlign: number | null;
faq: unknown;
}
export interface GetDropDesignResponse {
dropID: number;
dropName: string;
bannerURL: string;
previewMedia: string[];
previewMediaExt: Array<{
image_url?: string;
animation_url?: string;
}>;
description: string;
website: string;
twitter: string;
instagram: string;
discord: string;
telegram: string;
medium: string;
dropFeaturedImage: string;
details: DropDesignDetail[];
}
export interface TempUrlMetaJson {
[key: string]: unknown;
}
export interface GetTempURLQuery {
chainMId: number;
contractAddress: string;
dropID: number;
page: number;
pageSize: number;
}
export interface GetTempURLResponse {
preReveal: {
image_url?: string;
animation_url?: string;
imageURL?: string;
animationURL?: string;
};
metaPublish: boolean;
metaDataIPFS: string;
metaJsons: TempUrlMetaJson[];
imgSum: number;
metaSum: number;
animationSum: number;
}
export interface GetPreRevealIPFSQuery {
chainMId: number;
contractAddress: string;
dropID: number;
}
export interface GetPreRevealIPFSResponse {
preRevealIPFS: string;
}
export interface CallbackUpdateProjectConfigRequest {
dropId: number;
chainMId: number;
contractAddress: string;
}
export interface SetProjectConfigStageInput {
stageID: number;
stageName?: string;
stageMode: number;
price: string;
address?: string;
duration: number;
maxSupplyAtThisStage: number;
maxMintedPerWallet: number;
interval: number;
enableCallFromContract?: boolean;
enableMintToOther?: boolean;
}
export interface SetProjectConfigRequest {
chainMId: number;
contractAddress: string;
nftAddress: string;
isPaused: boolean;
dropBeginTime: number;
maxSupply: string;
baseURI: string;
mintingType: number;
stages: SetProjectConfigStageInput[];
}
export interface ChainListPaymentToken {
ChainMId: number;
SerId: number;
TokenAddress: string;
Enable: boolean;
name: string;
symbol?: string;
icon: string;
decimal: number;
accuracy: number;
}
export interface ChainListWithGasItem {
chainMId: number;
currency: string;
tokenCount: number;
usdPrice: number;
paymentTokens: ChainListPaymentToken[];
}
export interface GetChainsWithGasResponse {
chains: ChainListWithGasItem[];
}
FILE:scripts/src/workflow/configure-drop.ts
import type { CreateStageInput, DropEdition, GetDropDesignResponse, GetDropSettingsStage, StageInput } from "../types";
import { resolveMintingTypeFromPaymentToken } from "../network/chains";
import { DEFAULT_MAX_SUPPLY, resolveDropMode } from "../drop-mode";
import { buildDropUrls, buildInitialSettingsPayload, createWorkflowLogger, runStage } from "./create-drop";
import {
buildCollectionEditPayload,
buildDisplayDesignAfterUpdate,
buildMergedDesignPayload,
hasCollectionMetadataOverride,
hasDesignOverride
} from "./design-payloads";
import { uploadWithExistingAuthorization } from "./uploads";
import type {
ChainListPaymentToken,
DesignUploadPayload,
ElementApiResponse,
ElementCollectionDetailFromEditors,
ElementCollectionEditInput,
ElementCollectionSummary,
GetDropSettingsResponse,
OssSignedPostData,
PostDropSettingsRequest,
PreRevealUploadPayload
} from "../types";
export interface ConfigureDropFlowInput {
chainMId: number;
authorization: string;
walletAddress: string;
contractAddress: string;
slug?: string;
name?: string;
symbol?: string;
paymentToken?: string;
paymentTokens?: ChainListPaymentToken[];
preReveal?: string;
previewMedia?: string[];
description?: string;
edition?: DropEdition;
dropType?: 0 | 1;
maxSupply?: number;
dropBeginTime?: number;
stages?: CreateStageInput[];
bannerFilePath?: string;
previewFilePaths?: string[];
website?: string;
twitter?: string;
instagram?: string;
discord?: string;
telegram?: string;
medium?: string;
dropFeaturedImage?: string;
}
export interface ConfigureDropFlowDeps {
uploadWithExistingAuthorization: typeof uploadWithExistingAuthorization;
getDropSettings: (
query: { chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropSettingsResponse>>;
getDropDesign: (
query: { dropID: number; chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropDesignResponse>>;
postDropSettings: (
authorization: string,
body: PostDropSettingsRequest,
walletAddress: string
) => Promise<ElementApiResponse<null>>;
getOssSignSingle: (
authorization: string,
query: { chainMId: number; contractAddress: string; mediaType: "prereveal" | "design" },
walletAddress?: string
) => Promise<ElementApiResponse<OssSignedPostData>>;
postPreReveal: (
authorization: string,
body: PreRevealUploadPayload,
walletAddress?: string
) => Promise<ElementApiResponse<null>>;
postDesign: (
authorization: string,
body: DesignUploadPayload,
walletAddress?: string
) => Promise<ElementApiResponse<null>>;
getMutateToken: (authorization?: string) => Promise<string>;
getCollectionDetailFromEditors: (
slug: string,
authorization?: string
) => Promise<ElementCollectionDetailFromEditors>;
collectionEdit: (
input: ElementCollectionEditInput,
authorization?: string
) => Promise<ElementCollectionSummary>;
uploadAsset: (input: {
filePath: string;
fileName: string;
oss: OssSignedPostData;
}) => Promise<{
sourcePath: string;
fileName: string;
objectKey: string;
publicUrl: string;
}>;
}
const DEFAULT_STAGE_DURATION_SECONDS = 3 * 24 * 60 * 60;
const DEFAULT_STAGE_NAME = "Public";
const DEFAULT_STAGE_PRICE = "0";
const DEFAULT_STAGE_INTERVAL = 0;
const DEFAULT_STAGE_MAX_MINTED_PER_WALLET = 1;
const DEFAULT_STAGE_ID = 256;
function hasSettingsOverride(input: ConfigureDropFlowInput): boolean {
return Boolean(
input.preReveal !== undefined ||
input.edition !== undefined ||
input.dropType !== undefined ||
input.maxSupply !== undefined ||
input.dropBeginTime !== undefined ||
input.paymentToken !== undefined ||
(input.stages !== undefined && input.stages.length >= 0)
);
}
function defaultDropBeginTime(): number {
return Math.floor(Date.now() / 1000) + 24 * 60 * 60;
}
function assignStageIds(stages: CreateStageInput[]): StageInput[] {
return stages.map((stage, index) => ({
...stage,
stageID: DEFAULT_STAGE_ID + index
}));
}
function buildDefaultStage(maxSupply: number): StageInput {
return {
stageID: DEFAULT_STAGE_ID,
stageName: DEFAULT_STAGE_NAME,
price: DEFAULT_STAGE_PRICE,
duration: DEFAULT_STAGE_DURATION_SECONDS,
maxSupplyAtThisStage: maxSupply,
maxMintedPerWallet: DEFAULT_STAGE_MAX_MINTED_PER_WALLET,
interval: DEFAULT_STAGE_INTERVAL,
stageMode: 0
};
}
export async function previewConfigureDrop(input: {
authorization: string;
walletAddress: string;
chainMId: number;
contractAddress: string;
slug?: string;
patch: Omit<ConfigureDropFlowInput, "authorization" | "walletAddress" | "chainMId" | "contractAddress" | "slug">;
}, deps: Pick<ConfigureDropFlowDeps, "getDropSettings" | "getDropDesign" | "getMutateToken" | "getCollectionDetailFromEditors">) {
const logger = createWorkflowLogger();
const mergedInput: ConfigureDropFlowInput = {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
authorization: input.authorization,
walletAddress: input.walletAddress,
slug: input.slug,
...input.patch
};
const currentSettingsResponse = await runStage(logger, "previewUpdate:getCurrentSettings", () =>
deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
)
);
const currentSettings = currentSettingsResponse.data;
const shouldUpdateSettings = hasSettingsOverride(mergedInput);
const settingsPayload = shouldUpdateSettings
? buildMergedSettingsPayload({
current: currentSettings,
patch: mergedInput,
contractAddress: input.contractAddress
})
: null;
const shouldUpdateDesign = hasDesignOverride(mergedInput);
const currentDesignResponse =
currentSettings.dropID > 0 && shouldUpdateDesign
? await runStage(logger, "previewUpdate:getCurrentDesign", () =>
deps.getDropDesign(
{
dropID: currentSettings.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
)
)
: null;
const currentDesign = currentDesignResponse?.data ?? null;
const designPayload =
shouldUpdateDesign
? buildMergedDesignPayload({
current: currentDesign,
patch: mergedInput,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: currentSettings.dropID,
uploadedBannerUrl: null,
uploadedPreviewUrls: undefined
})
: null;
const collectionEditPayload =
input.slug && hasCollectionMetadataOverride(mergedInput)
? buildCollectionEditPayload({
current: await runStage(logger, "previewUpdate:getCurrentCollectionDetail", () =>
deps.getCollectionDetailFromEditors(input.slug!, input.authorization)
),
patch: mergedInput,
token: await runStage(logger, "previewUpdate:getMutateToken", () => deps.getMutateToken(input.authorization))
})
: null;
return {
contractAddress: input.contractAddress,
chainMId: input.chainMId,
slug: input.slug ?? null,
urls: input.slug ? buildDropUrls(input.slug) : null,
currentSettings: currentSettingsResponse,
currentDesign: currentDesignResponse,
settingsPayload,
designPayload,
collectionEditPayload,
summary: {
dropID: currentSettings.dropID,
willReadCurrentSettings: true,
willReadCurrentDesign: shouldUpdateDesign,
willUpdateSettings: shouldUpdateSettings,
willUpdatePreReveal: Boolean(mergedInput.preReveal),
willUpdateDesign: shouldUpdateDesign,
willUpdateCollectionProfile: Boolean(collectionEditPayload),
willUploadImages: Boolean(
mergedInput.preReveal ??
mergedInput.bannerFilePath ??
(mergedInput.previewFilePaths && mergedInput.previewFilePaths.length > 0)
),
patchKeys: Object.keys(mergedInput).filter((key) => {
const value = mergedInput[key as keyof ConfigureDropFlowInput];
return value !== undefined && !["authorization", "walletAddress", "chainMId", "contractAddress", "slug"].includes(key);
}),
nextSettings: settingsPayload
? {
dropType: settingsPayload.dropType,
maxSupply: settingsPayload.maxSupply,
dropBeginTime: settingsPayload.dropBeginTime,
mintingType: settingsPayload.mintingType,
stageCount: settingsPayload.stagesUpdate.length
}
: null,
nextDesign: designPayload
? {
dropName: designPayload.dropName,
bannerURL: designPayload.bannerURL,
previewCount: designPayload.previewMediaExt.length,
description: designPayload.description,
website: designPayload.website,
twitter: designPayload.twitter,
instagram: designPayload.instagram,
discord: designPayload.discord,
telegram: designPayload.telegram,
medium: designPayload.medium
}
: null,
nextCollectionProfile: collectionEditPayload
? {
externalUrl: collectionEditPayload.externalUrl ?? null,
twitterUrl: collectionEditPayload.twitterUrl ?? null,
instagramUrl: collectionEditPayload.instagramUrl ?? null,
discordUrl: collectionEditPayload.discordUrl ?? null,
telegramUrl: collectionEditPayload.telegramUrl ?? null,
mediumUrl: collectionEditPayload.mediumUrl ?? null
}
: null
}
};
}
function mapCurrentStages(stages: GetDropSettingsStage[] | null | undefined): StageInput[] {
if (!stages || stages.length === 0) {
return [];
}
return stages.map((stage) => ({
stageID: stage.stageID,
stageName: stage.stageName,
price: stage.price,
duration: stage.duration,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage,
maxMintedPerWallet: stage.maxMintedPerWallet,
interval: stage.interval,
stageMode: (stage.stageMode === 1 ? 1 : 0) as 0 | 1
}));
}
function buildMergedSettingsPayload(input: {
current: GetDropSettingsResponse;
patch: ConfigureDropFlowInput;
contractAddress: string;
}): PostDropSettingsRequest {
const { dropType, maxSupply } = resolveDropMode({
requestedEdition: input.patch.edition,
requestedDropType: input.patch.dropType,
requestedMaxSupply: input.patch.maxSupply,
fallbackDropType: input.current.dropType ?? undefined,
fallbackMaxSupply: input.current.maxSupply ?? DEFAULT_MAX_SUPPLY
});
const stages =
input.patch.stages && input.patch.stages.length > 0
? assignStageIds(input.patch.stages)
: (() => {
const currentStages = mapCurrentStages(input.current.stages);
return currentStages.length > 0 ? currentStages : [buildDefaultStage(maxSupply)];
})();
return {
dropID: input.current.dropID ?? 0,
chainMId: input.patch.chainMId,
contractAddress: input.contractAddress,
dropType,
maxSupply,
fee: input.current.fee ?? 0,
feeRecipient: input.current.feeRecipient ?? "",
mintingType: resolveMintingTypeFromPaymentToken({
paymentToken: input.patch.paymentToken,
availablePaymentTokens: input.patch.paymentTokens,
fallbackMintingType: input.current.mintingType ?? 0
}),
dropBeginTime: input.patch.dropBeginTime ?? input.current.dropBeginTime ?? defaultDropBeginTime(),
stagesUpdate: stages.map((stage) => ({
stageID: stage.stageID,
stageName: stage.stageName,
price: stage.price,
interval: stage.interval,
duration: stage.duration,
maxMintedPerWallet: stage.maxMintedPerWallet,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage,
stageMode: stage.stageMode,
allowListsNew: []
}))
};
}
function hasCurrentBanner(current: GetDropDesignResponse | null): boolean {
return Boolean(current?.bannerURL?.trim());
}
function hasCurrentPreviewMedia(current: GetDropDesignResponse | null): boolean {
return Boolean(
current?.previewMediaExt?.some((item) => item.image_url?.trim() || item.animation_url?.trim()) ||
current?.previewMedia?.some((item) => {
if (typeof item === "string") {
return item.trim().length > 0;
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
return Boolean(
(typeof record.image_url === "string" && record.image_url.trim()) ||
(typeof record.imageURL === "string" && record.imageURL.trim()) ||
(typeof record.animation_url === "string" && record.animation_url.trim()) ||
(typeof record.animationURL === "string" && record.animationURL.trim())
);
}
return false;
})
);
}
export function resolveDesignAssetUrlsForUpdate(input: {
currentDesign: GetDropDesignResponse | null;
preRevealUrl?: string | null;
explicitBannerProvided: boolean;
explicitPreviewProvided: boolean;
uploadedBannerUrl?: string | null;
uploadedPreviewUrls?: string[];
}): { uploadedBannerUrl: string | null; uploadedPreviewUrls: string[] | undefined } {
const preRevealUrl = input.preRevealUrl?.trim();
const uploadedPreviewUrls =
input.uploadedPreviewUrls && input.uploadedPreviewUrls.length > 0
? input.uploadedPreviewUrls
: !input.explicitPreviewProvided && preRevealUrl && !hasCurrentPreviewMedia(input.currentDesign)
? [preRevealUrl]
: undefined;
return {
uploadedBannerUrl:
input.uploadedBannerUrl ??
(!input.explicitBannerProvided && preRevealUrl && !hasCurrentBanner(input.currentDesign) ? preRevealUrl : null),
uploadedPreviewUrls
};
}
export async function configureDropFlow(input: ConfigureDropFlowInput, deps: ConfigureDropFlowDeps) {
const logger = createWorkflowLogger();
const auth = {
authorization: input.authorization,
walletAddress: input.walletAddress
};
const preRevealPath = input.preReveal;
const currentSettingsResponse = await runStage(logger, "getCurrentSettings", () =>
deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
auth.walletAddress
)
);
const currentSettings = currentSettingsResponse.data;
const currentDropID = currentSettings.dropID ?? 0;
const shouldUpdateSettings = hasSettingsOverride(input);
const shouldUpdateDesign = hasDesignOverride(input);
const shouldUpdateCollectionMetadata = Boolean(input.slug && hasCollectionMetadataOverride(input));
const settingsPayload = shouldUpdateSettings
? buildMergedSettingsPayload({
current: currentSettings,
patch: input,
contractAddress: input.contractAddress
})
: null;
const settingsResponse = settingsPayload
? await runStage(logger, "postSettings", () =>
deps.postDropSettings(auth.authorization, settingsPayload, auth.walletAddress)
)
: null;
const settingsAfterConfigure =
settingsPayload
? await runStage(logger, "getSettingsAfterConfigure", () =>
deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
auth.walletAddress
)
)
: currentSettingsResponse;
const dropID = settingsAfterConfigure.data.dropID ?? currentDropID;
const preRevealUpload = preRevealPath
? await runStage(logger, "uploadPreReveal", () =>
deps.uploadWithExistingAuthorization(
auth,
{
mode: "prereveal",
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropType: resolveDropMode({
requestedEdition: input.edition,
requestedDropType: input.dropType,
requestedMaxSupply: input.maxSupply,
fallbackDropType: currentSettings.dropType ?? undefined,
fallbackMaxSupply: currentSettings.maxSupply ?? DEFAULT_MAX_SUPPLY
}).dropType,
filePath: preRevealPath
},
{
getOssSignSingle: deps.getOssSignSingle,
postPreReveal: deps.postPreReveal,
uploadAsset: deps.uploadAsset
}
)
)
: null;
if ((shouldUpdateDesign || input.bannerFilePath || (input.previewFilePaths && input.previewFilePaths.length > 0)) && !(dropID > 0)) {
throw new Error(`dropID is missing for design update on input.contractAddress`);
}
const currentDesignResponse =
dropID > 0 && shouldUpdateDesign
? await runStage(logger, "getCurrentDesign", () =>
deps.getDropDesign(
{
dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
auth.walletAddress
)
)
: null;
const currentDesign = currentDesignResponse?.data ?? null;
const assetDesignUpload =
input.bannerFilePath ||
(input.previewMedia && input.previewMedia.length > 0) ||
(input.previewFilePaths && input.previewFilePaths.length > 0)
? await runStage(logger, "uploadDesignAssets", () => {
const designBannerFilePath = input.bannerFilePath;
const designPreviewFilePaths = input.previewMedia?.length
? input.previewMedia
: input.previewFilePaths?.length
? input.previewFilePaths
: [];
if (!designBannerFilePath && designPreviewFilePaths.length === 0) {
throw new Error("design asset update requires at least one banner or preview source");
}
return deps.uploadWithExistingAuthorization(
auth,
{
mode: "design",
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID,
dropName: input.name ?? currentDesign?.dropName ?? "",
bannerFilePath: designBannerFilePath,
previewFilePaths: designPreviewFilePaths
},
{
getOssSignSingle: deps.getOssSignSingle,
postPreReveal: deps.postPreReveal,
uploadAsset: deps.uploadAsset
}
);
})
: null;
const designAssetUrls = resolveDesignAssetUrlsForUpdate({
currentDesign,
preRevealUrl: preRevealUpload?.publicUrls[0],
explicitBannerProvided: Boolean(input.bannerFilePath),
explicitPreviewProvided: Boolean(
(input.previewMedia && input.previewMedia.length > 0) ||
(input.previewFilePaths && input.previewFilePaths.length > 0)
),
uploadedBannerUrl: assetDesignUpload?.mode === "design" ? assetDesignUpload.payload.bannerURL : null,
uploadedPreviewUrls:
assetDesignUpload?.mode === "design"
? assetDesignUpload.payload.previewMediaExt.map((item) => item.image_url ?? "").filter(Boolean)
: undefined
});
const designPayload =
shouldUpdateDesign
? buildMergedDesignPayload({
current: currentDesign,
patch: input,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID,
uploadedBannerUrl: designAssetUrls.uploadedBannerUrl,
uploadedPreviewUrls: designAssetUrls.uploadedPreviewUrls
})
: null;
const designResponse =
designPayload
? await runStage(logger, "postDesign", () =>
deps.postDesign(auth.authorization, designPayload, auth.walletAddress)
)
: null;
const designBannerUrl = designPayload?.bannerURL ?? null;
const collectionEditPayload =
shouldUpdateCollectionMetadata
? buildCollectionEditPayload({
current: await runStage(logger, "getCurrentCollectionDetail", () =>
deps.getCollectionDetailFromEditors(input.slug!, auth.authorization)
),
patch: input,
token: await runStage(logger, "getMutateToken", () => deps.getMutateToken(auth.authorization))
})
: null;
const collectionEditResponse =
collectionEditPayload
? await runStage(logger, "collectionEdit", () =>
deps.collectionEdit(collectionEditPayload, auth.authorization)
)
: null;
const urls = input.slug ? buildDropUrls(input.slug) : null;
return {
currentSettings,
contractAddress: input.contractAddress,
currentSettingsResponse,
settingsPayload,
settingsResponse,
settingsAfterConfigure,
dropID,
preRevealUpload,
currentDesignResponse,
designPayload,
designResponse,
collectionEditPayload,
collectionEditResponse,
designUpload: assetDesignUpload,
designBannerUrl,
slug: input.slug ?? null,
urls
};
}
FILE:scripts/src/workflow/post-create-collection.ts
import { waitForCollectionContract } from "./wait-collection-contract";
import { buildCollectionEditPayload, type CollectionMetadataPatch } from "./design-payloads";
import type {
ElementChainCollectionSummary,
ElementCollectionDetailFromEditors,
ElementCollectionEditInput,
ElementCollectionSummary
} from "../types";
export interface PostCreateCollectionInput {
chainMId: number;
contractAddress: string;
authorization?: string;
imageFilePath?: string;
collectionMetadata?: CollectionMetadataPatch;
pollIntervalMs?: number;
timeoutMs?: number;
}
export interface PostCreateCollectionDeps {
getCollectionContract: (input: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
}) => Promise<ElementChainCollectionSummary | null>;
getMutateToken: (authorization?: string) => Promise<string>;
getCollectionDetailFromEditors: (
slug: string,
authorization?: string
) => Promise<ElementCollectionDetailFromEditors>;
collectionEdit: (input: ElementCollectionEditInput, authorization?: string) => Promise<ElementCollectionSummary>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
export async function postCreateCollectionFlow(
input: PostCreateCollectionInput,
deps: PostCreateCollectionDeps
) {
const collectionContract = await waitForCollectionContract(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
pollIntervalMs: input.pollIntervalMs,
timeoutMs: input.timeoutMs
},
{
getCollectionContract: deps.getCollectionContract,
sleep: deps.sleep,
now: deps.now,
logger: deps.logger
}
);
deps.logger?.("postCreateCollection:get mutate token", {
contractAddress: input.contractAddress
});
const mutateToken = await deps.getMutateToken(input.authorization);
deps.logger?.("postCreateCollection:get detail", {
slug: collectionContract.collection.slug
});
const collectionDetail = await deps.getCollectionDetailFromEditors(
collectionContract.collection.slug,
input.authorization
);
deps.logger?.("postCreateCollection:collection edit", {
collectionId: collectionContract.collection.id,
slug: collectionContract.collection.slug
});
const collectionEdit = await deps.collectionEdit(
buildCollectionEditPayload({
current: collectionDetail,
patch: input.collectionMetadata ?? {},
token: mutateToken,
imageFilePath: input.imageFilePath
}),
input.authorization
);
return {
collectionContract,
mutateToken,
collectionDetail,
collectionEdit
};
}
FILE:scripts/src/workflow/create-token.ts
import { buildElementAuthorization, deriveWalletAddress } from "../auth/jwt";
import { getRequiredWalletPrivateKey } from "../env";
import { getRpcUrlForChainMId } from "../network/rpc";
import { sendEncodedTransaction } from "../network/transaction";
import { normalizeCreateDropInput } from "../schema";
import type {
CreateDropInput,
CreateTokenPreflight,
ElementApiResponse,
EncodedTransaction,
ExecutedTransactionResult
} from "../types";
export interface CreateTokenFlowDeps {
deriveAddress: typeof deriveWalletAddress;
resolveRpcUrl: typeof getRpcUrlForChainMId;
createAuthorization: (input: {
privateKey: string;
walletAddress?: string;
chainMId: number;
}) => Promise<{
authorization: string;
nonce: string;
message: string;
identity: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
};
}>;
postCreateToken: (
authorization: string,
body: { chainMId: number; name: string; symbol: string },
walletAddress?: string
) => Promise<ElementApiResponse<EncodedTransaction>>;
sendTransaction: typeof sendEncodedTransaction;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
export async function createTokenFlow(input: CreateDropInput, deps: CreateTokenFlowDeps) {
const parsed = normalizeCreateDropInput(input);
const privateKey = getRequiredWalletPrivateKey();
const walletAddress = (await deps.deriveAddress({ privateKey })).toLowerCase();
const rpcUrl = await deps.resolveRpcUrl(parsed.chainMId);
const auth = await deps.createAuthorization({
privateKey,
walletAddress,
chainMId: parsed.chainMId
});
const response = await deps.postCreateToken(
auth.authorization,
{
chainMId: parsed.chainMId,
name: parsed.name,
symbol: parsed.symbol
},
walletAddress
);
if (response.code !== 0) {
throw new Error(`createToken failed: response.message`);
}
const transaction = await deps.sendTransaction({
rpcUrl,
transaction: response.data,
logger: deps.logger
});
return {
...response,
transaction: transaction satisfies ExecutedTransactionResult,
preflight: {
walletAddress,
rpcUrl,
authorization: auth.authorization,
nonce: auth.nonce,
loginMessage: auth.message,
identity: auth.identity
} satisfies CreateTokenPreflight
};
}
FILE:scripts/src/workflow/publish-drop.ts
import type {
CallbackUpdateProjectConfigRequest,
ElementApiResponse,
EncodedTransaction,
ExecutedTransactionResult,
GetDropSettingsResponse,
GetPreRevealIPFSResponse,
GetTempURLResponse,
PostDropSettingsRequest,
StageMode,
SetProjectConfigRequest
} from "../types";
export interface WaitForPreRevealIPFSInput {
chainMId: number;
contractAddress: string;
dropID: number;
walletAddress: string;
pollIntervalMs?: number;
timeoutMs?: number;
}
export interface WaitForPreRevealIPFSDeps {
getPreRevealIPFS: (
query: { chainMId: number; contractAddress: string; dropID: number },
walletAddress: string
) => Promise<ElementApiResponse<GetPreRevealIPFSResponse>>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
export interface WaitForPublishedInput {
chainMId: number;
contractAddress: string;
dropID: number;
walletAddress: string;
expectedPublished?: number;
expectedIsPaused?: boolean;
pollIntervalMs?: number;
timeoutMs?: number;
}
export interface WaitForPublishedDeps {
postCallbackUpdateProjectConfig: (
body: CallbackUpdateProjectConfigRequest
) => Promise<ElementApiResponse<null>>;
getDropSettings: (
query: { chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropSettingsResponse>>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
export interface ResolvePublishPreRevealInput {
authorization: string;
walletAddress: string;
chainMId: number;
contractAddress: string;
dropID: number;
pollIntervalMs?: number;
timeoutMs?: number;
}
export interface ResolvePublishPreRevealDeps {
getTempURL: (
authorization: string,
query: { chainMId: number; contractAddress: string; dropID: number; page: number; pageSize: number },
walletAddress: string
) => Promise<ElementApiResponse<GetTempURLResponse>>;
getPreRevealIPFS: (
query: { chainMId: number; contractAddress: string; dropID: number },
walletAddress: string
) => Promise<ElementApiResponse<GetPreRevealIPFSResponse>>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
export interface PublishDropFlowInput {
authorization: string;
walletAddress: string;
rpcUrl: string;
chainMId: number;
contractAddress: string;
dropID: number;
isPaused?: boolean;
getSettingsPollIntervalMs?: number;
getSettingsTimeoutMs?: number;
preRevealIPFSPollIntervalMs?: number;
preRevealIPFSTimeoutMs?: number;
}
export interface PublishDropFlowDeps {
getDropSettings: (
query: { chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropSettingsResponse>>;
getPreRevealIPFS: (
query: { chainMId: number; contractAddress: string; dropID: number },
walletAddress: string
) => Promise<ElementApiResponse<GetPreRevealIPFSResponse>>;
getTempURL: (
authorization: string,
query: { chainMId: number; contractAddress: string; dropID: number; page: number; pageSize: number },
walletAddress: string
) => Promise<ElementApiResponse<GetTempURLResponse>>;
postDropSettings: (
authorization: string,
body: PostDropSettingsRequest,
walletAddress: string
) => Promise<ElementApiResponse<null>>;
postSetProjectConfig: (
authorization: string,
body: SetProjectConfigRequest,
walletAddress: string
) => Promise<ElementApiResponse<EncodedTransaction>>;
sendTransaction: (input: {
rpcUrl: string;
transaction: EncodedTransaction;
waitConfirmations?: number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}) => Promise<ExecutedTransactionResult>;
postCallbackUpdateProjectConfig: (
body: CallbackUpdateProjectConfigRequest
) => Promise<ElementApiResponse<null>>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
async function defaultSleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export function extractPreRevealImageUrl(tempURL: GetTempURLResponse | null | undefined): string {
return tempURL?.preReveal?.image_url?.trim() ?? tempURL?.preReveal?.imageURL?.trim() ?? "";
}
export function extractPreRevealAnimationUrl(tempURL: GetTempURLResponse | null | undefined): string {
return tempURL?.preReveal?.animation_url?.trim() ?? tempURL?.preReveal?.animationURL?.trim() ?? "";
}
export function assertPreRevealImageUrlExists(input: {
contractAddress: string;
preRevealImageUrl: string;
}) {
if (!input.preRevealImageUrl) {
throw new Error(
`publish requires a configured prereveal image URL for input.contractAddress; ` +
`the current prereveal media resource is empty, so rerun upload/preReveal and retry publish`
);
}
}
export function buildPostDropSettingsPayloadFromSettings(input: {
settings: GetDropSettingsResponse;
chainMId: number;
contractAddress: string;
}): PostDropSettingsRequest {
return {
dropID: input.settings.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropType: input.settings.dropType,
maxSupply: input.settings.maxSupply ?? 0,
fee: input.settings.fee ?? 0,
feeRecipient: input.settings.feeRecipient ?? "",
mintingType: input.settings.mintingType,
dropBeginTime: input.settings.dropBeginTime ?? 0,
stagesUpdate: input.settings.stages.map((stage) => ({
stageID: stage.stageID,
stageName: stage.stageName,
price: stage.price,
interval: stage.interval,
duration: stage.duration,
maxMintedPerWallet: stage.maxMintedPerWallet,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage,
stageMode: stage.stageMode as StageMode | null,
allowListsNew: []
}))
};
}
export function buildSetProjectConfigPayload(input: {
settings: GetDropSettingsResponse;
chainMId: number;
contractAddress: string;
preRevealIPFS: string;
isPaused?: boolean;
}): SetProjectConfigRequest {
return {
isPaused: input.isPaused ?? false,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
nftAddress: input.contractAddress,
dropBeginTime: input.settings.dropBeginTime ?? 0,
maxSupply: String(input.settings.maxSupply ?? 0),
baseURI: input.preRevealIPFS,
mintingType: input.settings.mintingType,
stages: input.settings.stages.map((stage) => ({
stageID: stage.stageID,
stageName: stage.stageName,
stageMode: stage.stageMode,
price: stage.price,
address: stage.address ?? "0x0000000000000000000000000000000000000000",
duration: stage.duration,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage,
maxMintedPerWallet: stage.maxMintedPerWallet,
interval: stage.interval,
enableCallFromContract: stage.enableCallFromContract,
enableMintToOther: stage.enableMintToOther
}))
};
}
export async function waitForPreRevealIPFS(
input: WaitForPreRevealIPFSInput,
deps: WaitForPreRevealIPFSDeps
) {
const pollIntervalMs = input.pollIntervalMs ?? 5_000;
const timeoutMs = input.timeoutMs ?? 10 * 60 * 1000;
const sleep = deps.sleep ?? defaultSleep;
const now = deps.now ?? Date.now;
const startedAt = now();
let attempts = 0;
while (true) {
attempts += 1;
deps.logger?.("preRevealIPFS:poll", {
attempt: attempts,
dropID: input.dropID,
elapsedMs: now() - startedAt
});
const response = await deps.getPreRevealIPFS(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID
},
input.walletAddress
);
const preRevealIPFS = response.data?.preRevealIPFS ?? "";
if (preRevealIPFS) {
deps.logger?.("preRevealIPFS:resolved", {
attempt: attempts,
dropID: input.dropID,
preRevealIPFS,
elapsedMs: now() - startedAt
});
return {
preRevealIPFS,
attempts,
elapsedMs: now() - startedAt
};
}
if (now() - startedAt >= timeoutMs) {
throw new Error(
`preRevealIPFS timed out after attempts attempts (timeoutMsms) for drop input.dropID; ` +
`if prereveal OSS to IPFS promotion is stuck, run upload/preReveal again and retry publish`
);
}
await sleep(pollIntervalMs);
}
}
export async function resolvePublishPreRevealIPFS(
input: ResolvePublishPreRevealInput,
deps: ResolvePublishPreRevealDeps
) {
deps.logger?.("publish:prereveal:read-temp-url", {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID
});
const tempURLResponse = await deps.getTempURL(
input.authorization,
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID,
page: 1,
pageSize: 1
},
input.walletAddress
);
assertPreRevealImageUrlExists({
contractAddress: input.contractAddress,
preRevealImageUrl: extractPreRevealImageUrl(tempURLResponse.data)
});
deps.logger?.("publish:prereveal:trigger-and-poll-ipfs", {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID
});
const preRevealIPFS = await waitForPreRevealIPFS(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID,
walletAddress: input.walletAddress,
pollIntervalMs: input.pollIntervalMs,
timeoutMs: input.timeoutMs
},
{
getPreRevealIPFS: deps.getPreRevealIPFS,
sleep: deps.sleep,
now: deps.now,
logger: deps.logger
}
);
return {
tempURL: tempURLResponse.data,
preRevealIPFS
};
}
export async function waitForPublishedSettings(
input: WaitForPublishedInput,
deps: WaitForPublishedDeps
) {
const pollIntervalMs = input.pollIntervalMs ?? 5_000;
const timeoutMs = input.timeoutMs ?? 10 * 60 * 1000;
const expectedPublished = input.expectedPublished ?? 1;
const expectedIsPaused = input.expectedIsPaused;
const sleep = deps.sleep ?? defaultSleep;
const now = deps.now ?? Date.now;
const startedAt = now();
let attempts = 0;
while (true) {
attempts += 1;
deps.logger?.("publishedSettings:callback", {
attempt: attempts,
dropID: input.dropID,
contractAddress: input.contractAddress,
elapsedMs: now() - startedAt
});
const callbackResponse = await deps.postCallbackUpdateProjectConfig({
dropId: input.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress
});
deps.logger?.("publishedSettings:poll", {
attempt: attempts,
dropID: input.dropID,
contractAddress: input.contractAddress,
callbackCode: callbackResponse.code,
elapsedMs: now() - startedAt
});
const response = await deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
);
const publishedMatches = (response.data?.published ?? 0) === expectedPublished;
const pausedMatches =
expectedIsPaused === undefined ? true : (response.data?.isPaused ?? false) === expectedIsPaused;
if (publishedMatches && pausedMatches) {
const finalSettingsResponse = await deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
);
deps.logger?.("publishedSettings:resolved", {
attempt: attempts,
dropID: input.dropID,
contractAddress: input.contractAddress,
expectedPublished,
expectedIsPaused,
published: finalSettingsResponse.data?.published ?? 0,
isPaused: finalSettingsResponse.data?.isPaused ?? false,
elapsedMs: now() - startedAt
});
return {
settings: finalSettingsResponse.data,
callbackResponse,
attempts,
elapsedMs: now() - startedAt
};
}
if (now() - startedAt >= timeoutMs) {
throw new Error(
`published settings timed out after attempts attempts (timeoutMsms) for input.contractAddress; ` +
`expected published=expectedPublished` +
`` and isPaused=${expectedIsPaused`}, ` +
`got published=response.data?.published ?? 0 and isPaused=response.data?.isPaused ?? false`
);
}
await sleep(pollIntervalMs);
}
}
export async function publishDropFlow(
input: PublishDropFlowInput,
deps: PublishDropFlowDeps
) {
deps.logger?.("publish:start", {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID
});
const settingsBeforePublishResponse = await deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
);
const settingsBeforePublish = settingsBeforePublishResponse.data;
const preRevealResolution = await resolvePublishPreRevealIPFS(
{
authorization: input.authorization,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID,
walletAddress: input.walletAddress,
pollIntervalMs: input.preRevealIPFSPollIntervalMs,
timeoutMs: input.preRevealIPFSTimeoutMs
},
{
getTempURL: deps.getTempURL,
getPreRevealIPFS: deps.getPreRevealIPFS,
sleep: deps.sleep,
now: deps.now,
logger: deps.logger
}
);
const postSettingsPayload = buildPostDropSettingsPayloadFromSettings({
settings: settingsBeforePublish,
chainMId: input.chainMId,
contractAddress: input.contractAddress
});
const postSettingsResponse = await deps.postDropSettings(
input.authorization,
postSettingsPayload,
input.walletAddress
);
const setProjectConfigPayload = buildSetProjectConfigPayload({
settings: settingsBeforePublish,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
preRevealIPFS: preRevealResolution.preRevealIPFS.preRevealIPFS,
isPaused: input.isPaused
});
const setProjectConfigResponse = await deps.postSetProjectConfig(
input.authorization,
setProjectConfigPayload,
input.walletAddress
);
if (setProjectConfigResponse.code !== 0) {
throw new Error(`setProjectConfig failed: setProjectConfigResponse.message`);
}
const setProjectConfigTransaction = await deps.sendTransaction({
rpcUrl: input.rpcUrl,
transaction: setProjectConfigResponse.data,
logger: deps.logger
});
const callbackPayload = {
dropId: input.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress
};
const published = await waitForPublishedSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID,
walletAddress: input.walletAddress,
expectedPublished: 1,
expectedIsPaused: setProjectConfigPayload.isPaused,
pollIntervalMs: input.getSettingsPollIntervalMs,
timeoutMs: input.getSettingsTimeoutMs
},
{
postCallbackUpdateProjectConfig: deps.postCallbackUpdateProjectConfig,
getDropSettings: deps.getDropSettings,
sleep: deps.sleep,
now: deps.now,
logger: deps.logger
}
);
return {
settingsBeforePublish,
tempURLBeforePublish: preRevealResolution.tempURL,
preRevealIPFS: preRevealResolution.preRevealIPFS,
postSettingsPayload,
postSettingsResponse,
setProjectConfigPayload,
setProjectConfigResponse,
setProjectConfigTransaction,
callbackPayload,
published
};
}
FILE:scripts/src/workflow/uploads.ts
import { createCuimpHttp } from "cuimp";
import type { CuimpInstance } from "cuimp";
import { DEFAULT_OSS_UPLOAD_TIMEOUT_MS } from "../config";
import {
assertSafeUploadFileName,
inferUploadExtension,
validateLocalImageUpload
} from "../security/upload-guard";
import type {
DesignUploadPayload,
DropType,
ElementApiResponse,
OssSignedPostData,
PreRevealUploadPayload
} from "../types";
export interface UploadedAssetRef {
sourcePath: string;
fileName: string;
objectKey: string;
publicUrl: string;
}
export interface UploadAuthContext {
authorization: string;
walletAddress: string;
}
export interface PreRevealUploadInput {
mode: "prereveal";
chainMId: number;
contractAddress: string;
dropType: DropType;
filePath: string;
}
export interface DesignUploadInput {
mode: "design";
chainMId: number;
contractAddress: string;
dropID: number;
dropName: string;
bannerFilePath?: string;
previewFilePaths: string[];
}
export type UploadInput = PreRevealUploadInput | DesignUploadInput;
export interface UploadDeps {
getOssSignSingle: (
authorization: string,
query: { chainMId: number; contractAddress: string; mediaType: "prereveal" | "design" },
walletAddress?: string
) => Promise<ElementApiResponse<OssSignedPostData>>;
postPreReveal: (
authorization: string,
body: PreRevealUploadPayload,
walletAddress?: string
) => Promise<ElementApiResponse<null>>;
uploadAsset: (input: {
filePath: string;
fileName: string;
oss: OssSignedPostData;
}) => Promise<UploadedAssetRef>;
}
export interface CuimpUploadDeps {
createClient?: () => Pick<CuimpInstance, "request">;
}
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
reject(new Error(`label timed out after msms`));
}, ms);
promise.then(
(value) => {
clearTimeout(timeoutHandle);
resolve(value);
},
(error) => {
clearTimeout(timeoutHandle);
reject(error);
}
);
});
}
export function convertOssUrlToPublicUrl(url: string): string {
return url
.replace(
"https://element-master.oss-cn-hongkong.aliyuncs.com/creator-studio/",
"https://c.nfte.ai/creator-studio/"
)
.replace("https://ele-lpd.nfte.ai/", "https://c.nfte.ai/");
}
export function buildPreRevealUploadPayload(input: {
chainMId: number;
contractAddress: string;
dropType: DropType;
publicUrl: string;
}): PreRevealUploadPayload {
return {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropType: input.dropType,
preRevealExt: {
image_url: input.publicUrl,
animation_url: ""
}
};
}
export function buildDesignUploadPayload(input: {
chainMId: number;
contractAddress: string;
dropID: number;
dropName: string;
bannerUrl?: string;
previewUrls: string[];
}): DesignUploadPayload {
return {
dropID: input.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropName: input.dropName,
bannerURL: input.bannerUrl ?? "",
previewMediaExt: input.previewUrls.map((url) => ({
image_url: url,
animation_url: ""
})),
dropFeaturedImage: "",
description: "",
website: "",
twitter: "",
instagram: "",
discord: "",
telegram: "",
medium: "",
detailsUpdate: []
};
}
export async function uploadAssetGroupWithExistingAuthorization(input: {
auth: UploadAuthContext;
chainMId: number;
contractAddress: string;
mediaType: "prereveal" | "design";
files: Array<{
filePath: string;
fileName: string;
}>;
}, deps: Pick<UploadDeps, "getOssSignSingle" | "uploadAsset">): Promise<UploadedAssetRef[]> {
const uploads: UploadedAssetRef[] = [];
for (const file of input.files) {
const sign = await deps.getOssSignSingle(
input.auth.authorization,
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
mediaType: input.mediaType
},
input.auth.walletAddress
);
uploads.push(
await deps.uploadAsset({
filePath: file.filePath,
fileName: file.fileName,
oss: sign.data
})
);
}
return uploads;
}
export async function uploadSingleAsset(input: {
filePath: string;
fileName: string;
oss: OssSignedPostData;
}, deps: CuimpUploadDeps = {}): Promise<UploadedAssetRef> {
return uploadAssetWithCuimp(input, deps);
}
export async function uploadAssetWithCuimp(
input: {
filePath: string;
fileName: string;
oss: OssSignedPostData;
},
deps: CuimpUploadDeps = {}
): Promise<UploadedAssetRef> {
assertSafeUploadFileName(input.fileName);
const validated = await validateLocalImageUpload(input.filePath);
const objectKey = `input.oss.dir.replace(/\/$/, "")/input.fileName`;
const mimeType = validated.mimeType;
const extraCurlArgs = [
"-F",
`name=input.fileName`,
"-F",
`key=objectKey`,
"-F",
"success_action_status=200",
"-F",
`policy=input.oss.policy`
];
if (input.oss.x_oss_credential && input.oss.x_oss_date) {
extraCurlArgs.push("-F", `x-oss-signature=input.oss.signature`);
extraCurlArgs.push("-F", "x-oss-signature-version=OSS4-HMAC-SHA256");
extraCurlArgs.push("-F", `x-oss-credential=input.oss.x_oss_credential`);
extraCurlArgs.push("-F", `x-oss-date=input.oss.x_oss_date`);
if (input.oss.security_token) {
extraCurlArgs.push("-F", `x-oss-security-token=input.oss.security_token`);
}
if (input.oss.callback) {
extraCurlArgs.push("-F", `callback=input.oss.callback`);
}
} else {
extraCurlArgs.push("-F", `OSSAccessKeyId=input.oss.accessid`);
extraCurlArgs.push("-F", `signature=input.oss.signature`);
}
extraCurlArgs.push(
"-F",
`file=@validated.realPath;type=mimeType;filename=input.fileName`
);
const client =
(deps.createClient ??
(() => createCuimpHttp()))();
const response = await withTimeout(
client.request({
url: input.oss.host,
method: "POST",
headers: {
Accept: "*/*",
Referer: "https://element.market/",
Origin: "https://element.market"
},
extraCurlArgs
}),
DEFAULT_OSS_UPLOAD_TIMEOUT_MS,
`OSS upload request to input.oss.host`
);
const responseRecord = response as unknown as Record<string, unknown>;
const responseBody =
"data" in responseRecord
? responseRecord.data
: "body" in responseRecord
? responseRecord.body
: response;
if (response.status !== 200) {
console.error(
`[element-drop] external response "POST",
url: input.oss.host,
status: response.status,
body: responseBody)}`
);
throw new Error(
`OSS upload failed with status response.status: JSON.stringify(responseBody)`
);
}
const publicUrl = convertOssUrlToPublicUrl(`input.oss.host/objectKey`);
return {
sourcePath: validated.realPath,
fileName: input.fileName,
objectKey,
publicUrl
};
}
function inferExtension(filePath: string): string {
return inferUploadExtension(filePath);
}
export async function uploadWithExistingAuthorization(
auth: UploadAuthContext,
input: UploadInput,
deps: UploadDeps
) {
if (input.mode === "prereveal") {
const [asset] = await uploadAssetGroupWithExistingAuthorization(
{
auth,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
mediaType: "prereveal",
files: [
{
filePath: input.filePath,
fileName: `pre-revealinferExtension(input.filePath)`
}
]
},
deps
);
const payload = buildPreRevealUploadPayload({
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropType: input.dropType,
publicUrl: asset.publicUrl
});
const response = await deps.postPreReveal(auth.authorization, payload, auth.walletAddress);
return {
mode: "prereveal" as const,
uploads: [asset],
publicUrls: [asset.publicUrl],
payload,
response
};
}
const uploads = await uploadAssetGroupWithExistingAuthorization(
{
auth,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
mediaType: "design",
files: [
...(input.bannerFilePath
? [
{
filePath: input.bannerFilePath,
fileName: `bannerinferExtension(input.bannerFilePath)`
}
]
: []),
...input.previewFilePaths.map((filePath, index) => ({
filePath,
fileName: `preview-index + 1inferExtension(filePath)`
}))
]
},
deps
);
const bannerUpload = input.bannerFilePath ? uploads[0] : null;
const previewUploads = input.bannerFilePath ? uploads.slice(1) : uploads;
const payload = buildDesignUploadPayload({
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID: input.dropID,
dropName: input.dropName,
bannerUrl: bannerUpload?.publicUrl,
previewUrls: previewUploads.map((item) => item.publicUrl)
});
return {
mode: "design" as const,
uploads,
publicUrls: uploads.map((item) => item.publicUrl),
payload
};
}
FILE:scripts/src/workflow/design-payloads.ts
import type {
DesignUploadPayload,
ElementCollectionDetailFromEditors,
ElementCollectionEditInput,
GetDropDesignResponse
} from "../types";
import { normalizeSocialLinkSuffix, normalizeWebsiteUrl } from "./links";
export interface CollectionMetadataPatch {
description?: string;
website?: string;
twitter?: string;
instagram?: string;
discord?: string;
telegram?: string;
medium?: string;
}
export interface DesignPayloadPatch extends CollectionMetadataPatch {
name?: string;
preReveal?: string;
previewMedia?: string[];
bannerFilePath?: string;
previewFilePaths?: string[];
dropFeaturedImage?: string;
}
const EMPTY_DESIGN: GetDropDesignResponse = {
dropID: 0,
dropName: "",
bannerURL: "",
previewMedia: [],
previewMediaExt: [],
description: "",
website: "",
twitter: "",
instagram: "",
discord: "",
telegram: "",
medium: "",
dropFeaturedImage: "",
details: []
};
export function hasCollectionMetadataOverride(input: CollectionMetadataPatch): boolean {
return Boolean(
input.description !== undefined ||
input.website !== undefined ||
input.twitter !== undefined ||
input.instagram !== undefined ||
input.discord !== undefined ||
input.telegram !== undefined ||
input.medium !== undefined
);
}
export function hasDesignOverride(input: DesignPayloadPatch): boolean {
return Boolean(
input.preReveal !== undefined ||
input.previewMedia !== undefined ||
input.bannerFilePath ||
(input.previewFilePaths && input.previewFilePaths.length > 0) ||
input.name !== undefined ||
input.description !== undefined ||
input.dropFeaturedImage !== undefined
);
}
export function buildCollectionEditPayload(input: {
current: ElementCollectionDetailFromEditors;
patch: CollectionMetadataPatch;
token: string;
imageFilePath?: string;
}): ElementCollectionEditInput {
return {
collectionId: input.current.id,
token: input.token,
imageFilePath: input.imageFilePath,
image: input.imageFilePath ? null : undefined,
description: input.patch.description ?? input.current.description ?? undefined,
externalUrl: normalizeWebsiteUrl(input.patch.website ?? input.current.externalUrl) || undefined,
twitterUrl: normalizeSocialLinkSuffix("twitter", input.patch.twitter ?? input.current.twitterUrl) || undefined,
instagramUrl:
normalizeSocialLinkSuffix("instagram", input.patch.instagram ?? input.current.instagramUrl) || undefined,
discordUrl: normalizeSocialLinkSuffix("discord", input.patch.discord ?? input.current.discordUrl) || undefined,
telegramUrl:
normalizeSocialLinkSuffix("telegram", input.patch.telegram ?? input.current.telegramUrl) || undefined,
mediumUrl: normalizeSocialLinkSuffix("medium", input.patch.medium ?? input.current.mediumUrl) || undefined,
categories: input.current.categories.map((item) => item.id),
paymentTokens: input.current.paymentTokens.map((item) => item.id)
};
}
export function buildMergedDesignPayload(input: {
current: GetDropDesignResponse | null;
patch: DesignPayloadPatch;
chainMId: number;
contractAddress: string;
dropID: number;
uploadedBannerUrl?: string | null;
uploadedPreviewUrls?: string[];
}): DesignUploadPayload {
const current = input.current ?? EMPTY_DESIGN;
return {
dropID: input.dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropName: input.patch.name ?? current.dropName,
bannerURL: input.uploadedBannerUrl ?? current.bannerURL ?? "",
previewMediaExt: (() => {
if (input.uploadedPreviewUrls && input.uploadedPreviewUrls.length > 0) {
return input.uploadedPreviewUrls.map((url) => ({
image_url: url,
animation_url: ""
}));
}
return current.previewMediaExt.map((item) => ({
image_url: item.image_url ?? "",
animation_url: item.animation_url ?? ""
}));
})(),
dropFeaturedImage: input.patch.dropFeaturedImage ?? current.dropFeaturedImage ?? "",
description: input.patch.description ?? current.description ?? "",
website: normalizeWebsiteUrl(input.patch.website ?? current.website),
twitter: normalizeSocialLinkSuffix("twitter", input.patch.twitter ?? current.twitter),
instagram: normalizeSocialLinkSuffix("instagram", input.patch.instagram ?? current.instagram),
discord: normalizeSocialLinkSuffix("discord", input.patch.discord ?? current.discord),
telegram: normalizeSocialLinkSuffix("telegram", input.patch.telegram ?? current.telegram),
medium: normalizeSocialLinkSuffix("medium", input.patch.medium ?? current.medium),
detailsUpdate: current.details ?? []
};
}
export function buildDisplayDesignAfterUpdate(input: {
current: GetDropDesignResponse | null;
designPayload: DesignUploadPayload | null;
collectionEditPayload: ElementCollectionEditInput | null;
}) {
const hasAnyUpdate = Boolean(input.current || input.designPayload || input.collectionEditPayload);
if (!hasAnyUpdate) {
return null;
}
const current = input.current ?? EMPTY_DESIGN;
const design = input.designPayload;
const collection = input.collectionEditPayload;
return {
bannerURL: design?.bannerURL ?? current.bannerURL ?? "",
previewMediaExt: design?.previewMediaExt ?? current.previewMediaExt ?? [],
description: design?.description ?? current.description ?? "",
website: collection?.externalUrl ?? design?.website ?? current.website ?? "",
twitter: collection?.twitterUrl ?? design?.twitter ?? current.twitter ?? "",
instagram: collection?.instagramUrl ?? design?.instagram ?? current.instagram ?? "",
discord: collection?.discordUrl ?? design?.discord ?? current.discord ?? "",
telegram: collection?.telegramUrl ?? design?.telegram ?? current.telegram ?? "",
medium: collection?.mediumUrl ?? design?.medium ?? current.medium ?? "",
dropFeaturedImage: design?.dropFeaturedImage ?? current.dropFeaturedImage ?? ""
};
}
FILE:scripts/src/workflow/create-drop.ts
import { normalizeCreateDropInput } from "../schema";
import { resolveMintingTypeFromPaymentToken } from "../network/chains";
import { HttpRequestError } from "../api/http";
import { redactKnownSecrets } from "../env";
import { buildMergedDesignPayload } from "./design-payloads";
import { createTokenFlow } from "./create-token";
import { postCreateCollectionFlow } from "./post-create-collection";
import { uploadWithExistingAuthorization } from "./uploads";
import type {
CreateDropInput,
ElementApiResponse,
EncodedTransaction,
GetDropSettingsResponse,
OssSignedPostData,
ExecutedTransactionResult,
DesignUploadPayload,
PreRevealUploadPayload,
ElementCollectionDetailFromEditors,
ElementChainCollectionSummary,
ElementCollectionSummary,
ElementCollectionEditInput,
DropInput,
PostDropSettingsRequest,
ChainListPaymentToken
} from "../types";
export type WorkflowLogger = (message: string, meta?: Record<string, unknown>) => void;
export interface CreateDropFlowInput extends CreateDropInput {
chainName?: string;
paymentTokens?: ChainListPaymentToken[];
bannerFilePath?: string;
previewFilePaths?: string[];
pollIntervalMs?: number;
timeoutMs?: number;
preRevealIPFSPollIntervalMs?: number;
preRevealIPFSTimeoutMs?: number;
}
export function createWorkflowLogger(): WorkflowLogger {
return (message, meta = {}) => {
if (!message.endsWith(":failed")) {
return;
}
const timestamp = new Date().toISOString();
console.error(`[element-drop] timestamp message JSON.stringify(meta)`);
};
}
export function buildDropUrls(slug: string) {
return {
dropUrl: `https://element.market/drop/slug`,
collectionUrl: `https://element.market/collections/slug`,
editUrl: `https://element.market/collections/slug/edit/drop`
};
}
export function buildCreatedDropSummary(result: {
chainName?: string;
symbol?: string;
contractAddress: string;
dropID: number;
slug: string;
urls: {
dropUrl: string;
collectionUrl: string;
editUrl: string;
};
createToken: {
transaction: {
hash: string;
};
};
}) {
return {
chainName: result.chainName ?? null,
symbol: result.symbol ?? null,
contractAddress: result.contractAddress,
dropID: result.dropID,
slug: result.slug,
createTransactionHash: result.createToken.transaction.hash,
dropUrl: result.urls.dropUrl,
collectionUrl: result.urls.collectionUrl,
editUrl: result.urls.editUrl,
nextRecommendedAction:
result.chainName && result.slug
? {
action: "publish-drop",
description:
"The drop is configured but not live yet. Preview publish, then publish after explicit confirmation.",
payload: {
chainName: result.chainName,
slug: result.slug
}
}
: null
};
}
export async function runStage<T>(
logger: WorkflowLogger,
stage: string,
fn: () => Promise<T>
): Promise<T> {
try {
return await fn();
} catch (error) {
const message = redactKnownSecrets(error instanceof Error ? error.message : String(error));
const httpMeta =
error instanceof HttpRequestError
? {
method: error.method,
url: error.url,
status: error.status,
attempts: error.attempts,
maxAttempts: error.maxAttempts,
durationMs: error.durationMs,
timedOut: error.timedOut,
retriable: error.retriable
}
: undefined;
logger(`stage:failed`, {
error: message,
...(httpMeta ? { http: httpMeta } : {})
});
const suffix = httpMeta
? ` [httpMeta.method httpMeta.url; attempts=httpMeta.attempts/httpMeta.maxAttempts; status=httpMeta.status ?? "network"; timeout=httpMeta.timedOut; durationMs=httpMeta.durationMs]`
: "";
throw new Error(`stage failed: messagesuffix`);
}
}
export interface CreateDropFlowDeps {
createTokenFlow: typeof createTokenFlow;
postCreateCollectionFlow: typeof postCreateCollectionFlow;
uploadWithExistingAuthorization: typeof uploadWithExistingAuthorization;
getDropSettings: (
query: { chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropSettingsResponse>>;
postDropSettings: (
authorization: string,
body: PostDropSettingsRequest,
walletAddress: string
) => Promise<ElementApiResponse<null>>;
getOssSignSingle: (
authorization: string,
query: { chainMId: number; contractAddress: string; mediaType: "prereveal" | "design" },
walletAddress?: string
) => Promise<ElementApiResponse<OssSignedPostData>>;
postPreReveal: (
authorization: string,
body: PreRevealUploadPayload,
walletAddress?: string
) => Promise<ElementApiResponse<null>>;
postDesign: (
authorization: string,
body: DesignUploadPayload,
walletAddress?: string
) => Promise<ElementApiResponse<null>>;
uploadAsset: (input: {
filePath: string;
fileName: string;
oss: OssSignedPostData;
}) => Promise<{
sourcePath: string;
fileName: string;
objectKey: string;
publicUrl: string;
}>;
createAuthorization: (chainMId: number) => Promise<{
authorization: string;
walletAddress: string;
nonce: string;
loginMessage: string;
identity: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
};
}>;
deriveAddress: (input: { privateKey: string }) => Promise<string>;
resolveRpcUrl: (chainMId: number) => Promise<string>;
createAuthorizationForToken: (input: {
privateKey: string;
walletAddress?: string;
chainMId: number;
}) => Promise<{
authorization: string;
nonce: string;
message: string;
identity: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
};
}>;
postCreateToken: (
authorization: string,
body: { chainMId: number; name: string; symbol: string },
walletAddress?: string
) => Promise<ElementApiResponse<EncodedTransaction>>;
sendTransaction: (input: {
rpcUrl: string;
transaction: EncodedTransaction;
waitConfirmations?: number;
}) => Promise<ExecutedTransactionResult>;
getCollectionContract: (input: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
}) => Promise<ElementChainCollectionSummary | null>;
getMutateToken: (authorization?: string) => Promise<string>;
getCollectionDetailFromEditors: (
slug: string,
authorization?: string
) => Promise<ElementCollectionDetailFromEditors>;
collectionEdit: (input: ElementCollectionEditInput, authorization?: string) => Promise<ElementCollectionSummary>;
}
export function buildInitialSettingsPayload(input: {
normalized: Pick<DropInput, "chainMId" | "dropType" | "maxSupply" | "dropBeginTime" | "stages" | "paymentToken">;
contractAddress: string;
availablePaymentTokens?: ChainListPaymentToken[];
}): PostDropSettingsRequest {
return {
dropID: 0,
chainMId: input.normalized.chainMId,
contractAddress: input.contractAddress,
dropType: input.normalized.dropType,
maxSupply: input.normalized.maxSupply,
fee: 0,
feeRecipient: "",
mintingType: resolveMintingTypeFromPaymentToken({
paymentToken: input.normalized.paymentToken,
availablePaymentTokens: input.availablePaymentTokens
}),
dropBeginTime: input.normalized.dropBeginTime,
stagesUpdate: input.normalized.stages.map((stage) => ({
stageID: stage.stageID,
stageName: stage.stageName,
price: stage.price,
interval: stage.interval,
duration: stage.duration,
maxMintedPerWallet: stage.maxMintedPerWallet,
maxSupplyAtThisStage: stage.maxSupplyAtThisStage,
stageMode: stage.stageMode,
allowListsNew: []
}))
};
}
export async function createDropFlow(input: CreateDropFlowInput, deps: CreateDropFlowDeps) {
const logger = createWorkflowLogger();
const normalized = normalizeCreateDropInput(input);
const createToken = await runStage(logger, "createToken", () =>
deps.createTokenFlow(normalized, {
deriveAddress: deps.deriveAddress,
resolveRpcUrl: deps.resolveRpcUrl,
createAuthorization: deps.createAuthorizationForToken,
postCreateToken: deps.postCreateToken,
sendTransaction: deps.sendTransaction,
logger
})
);
const contractAddress = createToken.transaction.contractAddress;
if (!contractAddress) {
throw new Error("contractAddress missing from createToken transaction receipt");
}
const postCreateCollection = await runStage(logger, "postCreateCollection", () =>
deps.postCreateCollectionFlow(
{
chainMId: normalized.chainMId,
contractAddress,
authorization: createToken.preflight.authorization,
imageFilePath: normalized.preReveal,
collectionMetadata: {
description: normalized.description,
website: input.website,
twitter: input.twitter,
instagram: input.instagram,
discord: input.discord,
telegram: input.telegram,
medium: input.medium
},
pollIntervalMs: input.pollIntervalMs,
timeoutMs: input.timeoutMs
},
{
getCollectionContract: deps.getCollectionContract,
getMutateToken: deps.getMutateToken,
getCollectionDetailFromEditors: deps.getCollectionDetailFromEditors,
collectionEdit: deps.collectionEdit,
logger
}
)
);
const auth = {
authorization: createToken.preflight.authorization,
walletAddress: createToken.preflight.walletAddress
};
const initialSettingsPayload = buildInitialSettingsPayload({
normalized,
contractAddress,
availablePaymentTokens: input.paymentTokens
});
const initialSettings = await runStage(logger, "postInitialSettings", () =>
deps.postDropSettings(auth.authorization, initialSettingsPayload, auth.walletAddress)
);
const settingsAfterCreate = await runStage(logger, "getSettingsAfterCreate", () =>
deps.getDropSettings(
{
chainMId: normalized.chainMId,
contractAddress
},
auth.walletAddress
)
);
const dropID = settingsAfterCreate.data.dropID;
if (!(dropID > 0)) {
throw new Error(`getSettingsAfterCreate returned invalid dropID for contractAddress: dropID`);
}
const preRevealUpload = await runStage(logger, "uploadPreReveal", () =>
deps.uploadWithExistingAuthorization(
auth,
{
mode: "prereveal",
chainMId: normalized.chainMId,
contractAddress,
dropType: normalized.dropType,
filePath: normalized.preReveal
},
{
getOssSignSingle: deps.getOssSignSingle,
postPreReveal: deps.postPreReveal,
uploadAsset: deps.uploadAsset
}
)
);
const designBannerFilePath = input.bannerFilePath ?? normalized.preReveal;
const designPreviewFilePaths = input.previewMedia?.length
? input.previewMedia
: input.previewFilePaths?.length
? input.previewFilePaths
: [normalized.preReveal];
const designUpload = await runStage(logger, "uploadDesign", () =>
deps.uploadWithExistingAuthorization(
auth,
{
mode: "design",
chainMId: normalized.chainMId,
contractAddress,
dropID,
dropName: normalized.name,
bannerFilePath: designBannerFilePath,
previewFilePaths: designPreviewFilePaths
},
{
getOssSignSingle: deps.getOssSignSingle,
postPreReveal: deps.postPreReveal,
uploadAsset: deps.uploadAsset
}
)
);
if (designUpload.mode !== "design") {
throw new Error("uploadDesign returned a non-design payload");
}
const designPayload = buildMergedDesignPayload({
current: {
dropID,
dropName: designUpload.payload.dropName,
bannerURL: designUpload.payload.bannerURL,
previewMedia: [],
previewMediaExt: designUpload.payload.previewMediaExt,
description: "",
website: "",
twitter: "",
instagram: "",
discord: "",
telegram: "",
medium: "",
dropFeaturedImage: "",
details: []
},
patch: {
name: normalized.name,
description: normalized.description,
website: input.website,
twitter: input.twitter,
instagram: input.instagram,
discord: input.discord,
telegram: input.telegram,
medium: input.medium,
dropFeaturedImage: input.dropFeaturedImage
},
chainMId: normalized.chainMId,
contractAddress,
dropID
});
const designResponse = await runStage(logger, "postDesign", () =>
deps.postDesign(auth.authorization, designPayload, auth.walletAddress)
);
const designBannerUrl = designPayload.bannerURL;
const designPreviewCount = designPayload.previewMediaExt.length;
const slug = postCreateCollection.collectionContract.collection.slug;
const urls = buildDropUrls(slug);
return {
normalized,
contractAddress,
createToken,
postCreateCollection,
preRevealUpload,
initialSettingsPayload,
initialSettings,
settingsAfterCreate,
dropID,
slug,
urls,
designUpload: {
...designUpload,
payload: designPayload
},
designResponse,
designBannerUrl,
summary: buildCreatedDropSummary({
chainName: input.chainName,
symbol: normalized.symbol,
contractAddress,
dropID,
slug,
urls,
createToken
})
};
}
FILE:scripts/src/workflow/preview-drop.ts
import { buildDropUrls, createWorkflowLogger, runStage } from "./create-drop";
import type {
ElementApiResponse,
GetDropDesignResponse,
GetDropSettingsResponse,
GetTempURLResponse
} from "../types";
export interface PreviewDropFlowInput {
authorization: string;
walletAddress: string;
chainMId: number;
contractAddress: string;
slug?: string;
page?: number;
pageSize?: number;
}
export interface PreviewDropFlowDeps {
getDropSettings: (
query: { chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropSettingsResponse>>;
getDropDesign: (
query: { dropID: number; chainMId: number; contractAddress: string },
walletAddress: string
) => Promise<ElementApiResponse<GetDropDesignResponse>>;
getTempURL: (
authorization: string,
query: { chainMId: number; contractAddress: string; dropID: number; page: number; pageSize: number },
walletAddress: string
) => Promise<ElementApiResponse<GetTempURLResponse>>;
}
function summarizePublishState(input: { published?: unknown; isPaused?: unknown }) {
if (input.published !== 1) {
return {
status: "draft",
label: "draft"
};
}
if (input.isPaused === true) {
return {
status: "paused",
label: "paused"
};
}
return {
status: "live",
label: "live"
};
}
export async function previewDropFlow(input: PreviewDropFlowInput, deps: PreviewDropFlowDeps) {
const logger = createWorkflowLogger();
const settings = await runStage(logger, "preview:getSettings", () =>
deps.getDropSettings(
{
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
)
);
const dropID = settings.data.dropID;
const design =
dropID > 0
? await runStage(logger, "preview:getDesign", () =>
deps.getDropDesign(
{
dropID,
chainMId: input.chainMId,
contractAddress: input.contractAddress
},
input.walletAddress
)
)
: null;
const tempURL =
dropID > 0
? await runStage(logger, "preview:getTempURL", () =>
deps.getTempURL(
input.authorization,
{
chainMId: input.chainMId,
contractAddress: input.contractAddress,
dropID,
page: input.page ?? 1,
pageSize: input.pageSize ?? 20
},
input.walletAddress
)
)
: null;
return {
chainMId: input.chainMId,
contractAddress: input.contractAddress,
slug: input.slug ?? null,
urls: input.slug ? buildDropUrls(input.slug) : null,
settings,
design,
tempURL,
summary: {
slug: input.slug ?? null,
...(input.slug ? buildDropUrls(input.slug) : { dropUrl: null, collectionUrl: null, editUrl: null }),
publishState: summarizePublishState({
published: settings.data.published,
isPaused: settings.data.isPaused
}),
batch: settings.data.batch,
stageCount: settings.data.stages?.length ?? 0,
hasDesign: Boolean(design?.data),
hasPreRevealMedia: Boolean(
tempURL?.data?.preReveal?.image_url?.trim() ?? tempURL?.data?.preReveal?.imageURL?.trim()
)
}
};
}
FILE:scripts/src/workflow/links.ts
const SOCIAL_LINK_PREFIXES = {
twitter: ["https://x.com/", "https://twitter.com/"],
instagram: ["https://www.instagram.com/", "https://instagram.com/"],
discord: ["https://discord.gg/"],
telegram: ["https://t.me/"],
medium: ["https://www.medium.com/@", "https://medium.com/@", "https://www.medium.com/", "https://medium.com/"]
} as const;
export type SocialLinkField = keyof typeof SOCIAL_LINK_PREFIXES;
const SOCIAL_LINK_DISPLAY_PREFIX = {
twitter: "https://x.com/",
instagram: "https://www.instagram.com/",
discord: "https://discord.gg/",
telegram: "https://t.me/",
medium: "https://www.medium.com/@"
} as const;
function stripKnownPrefix(value: string, prefixes: readonly string[]) {
for (const prefix of prefixes) {
if (value.startsWith(prefix)) {
return value.slice(prefix.length);
}
}
return value;
}
function trimSlashes(value: string) {
return value.replace(/^\/+/, "").replace(/\/+$/, "");
}
export function normalizeWebsiteUrl(value: string | null | undefined) {
return typeof value === "string" ? value.trim() : "";
}
export function normalizeSocialLinkSuffix(field: SocialLinkField, value: string | null | undefined) {
if (typeof value !== "string") {
return "";
}
let normalized = value.trim();
if (!normalized) {
return "";
}
normalized = stripKnownPrefix(normalized, SOCIAL_LINK_PREFIXES[field]);
if (field === "medium" && normalized.startsWith("@")) {
normalized = normalized.slice(1);
}
return trimSlashes(normalized);
}
export function formatSocialLinkForDisplay(field: SocialLinkField, value: string | null | undefined) {
const suffix = normalizeSocialLinkSuffix(field, value);
if (!suffix) {
return "";
}
return `SOCIAL_LINK_DISPLAY_PREFIX[field]suffix`;
}
FILE:scripts/src/workflow/verify-ref-graphql.ts
import { buildElementAuthorization, deriveWalletAddress } from "../auth/jwt";
import { getRequiredWalletPrivateKey } from "../env";
import { createElementGraphqlClient } from "../api/graphql";
export async function verifyRefGraphqlExamples() {
const privateKey = getRequiredWalletPrivateKey();
const walletAddress = (await deriveWalletAddress({ privateKey })).toLowerCase();
const graphql = createElementGraphqlClient();
const authorization = (
await buildElementAuthorization(
{
getLoginNonce: graphql.getLoginNonce,
loginAuth: graphql.loginAuth
},
{
privateKey,
walletAddress,
chainMId: 1201
}
)
).authorization;
const results: Record<string, unknown> = {};
results.getNonce = await graphql.getLoginNonce({
address: "0x00000007699893e07f12d7d35ac7e4534c31613e",
blockChain: {
chain: "eth",
chainId: "0x1"
}
});
results.collectionContract = await graphql.getCollectionContract({
address: "0x7122ad8c3f90fd23cb89d3ffa5ce7feac8d64c6a",
blockChain: {
chain: "base",
chainId: "0x2105"
}
});
results.getMutateToken = await graphql.getMutateToken(authorization);
results.collectionDetailFromEditors = await graphql.getCollectionDetailFromEditors(
"1122-5",
authorization
);
try {
results.collectionEdit = await graphql.collectionEdit(
{
collectionId: "18829680",
token: String(results.getMutateToken),
image: null
},
authorization
);
} catch (error) {
results.collectionEdit = {
ok: false,
error: error instanceof Error ? error.message : String(error)
};
}
return {
authorization,
walletAddress,
results
};
}
FILE:scripts/src/workflow/wait-collection-contract.ts
import { getElementBlockChainByChainMId } from "../auth/jwt";
import type { ElementChainCollectionSummary } from "../types";
export interface WaitForCollectionContractInput {
chainMId: number;
contractAddress: string;
pollIntervalMs?: number;
timeoutMs?: number;
}
export interface WaitForCollectionContractDeps {
getCollectionContract: (input: {
address: string;
blockChain: {
chain: string;
chainId: string;
};
}) => Promise<ElementChainCollectionSummary | null>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
logger?: (message: string, meta?: Record<string, unknown>) => void;
}
async function defaultSleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitForCollectionContract(
input: WaitForCollectionContractInput,
deps: WaitForCollectionContractDeps
) {
const pollIntervalMs = input.pollIntervalMs ?? 5_000;
const timeoutMs = input.timeoutMs ?? 10 * 60 * 1000;
const sleep = deps.sleep ?? defaultSleep;
const now = deps.now ?? Date.now;
const blockChain = getElementBlockChainByChainMId(input.chainMId);
const startedAt = now();
let attempts = 0;
while (true) {
attempts += 1;
deps.logger?.("collectionContract:poll", {
attempt: attempts,
contractAddress: input.contractAddress,
elapsedMs: now() - startedAt
});
const collection = await deps.getCollectionContract({
address: input.contractAddress,
blockChain
});
if (collection) {
deps.logger?.("collectionContract:resolved", {
attempt: attempts,
contractAddress: input.contractAddress,
slug: collection.slug,
collectionId: collection.id,
elapsedMs: now() - startedAt
});
return {
contractAddress: input.contractAddress,
blockChain,
attempts,
elapsedMs: now() - startedAt,
collection
};
}
if (now() - startedAt >= timeoutMs) {
throw new Error(
`CollectionContract timed out after attempts attempts (timeoutMsms) for input.contractAddress`
);
}
await sleep(pollIntervalMs);
}
}
FILE:scripts/src/api/http.ts
import { DEFAULT_NETWORK_TIMEOUT_MS } from "../config";
function summarizeForLog(value: unknown): unknown {
if (typeof value === "string") {
return value.length > 4000 ? `value.slice(0, 4000)...<truncated>` : value;
}
if (Array.isArray(value)) {
return value.map((item) => summarizeForLog(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, summarizeForLog(nested)])
);
}
return value;
}
const DEFAULT_HTTP_TIMEOUT_MS = DEFAULT_NETWORK_TIMEOUT_MS;
const DEFAULT_HTTP_MAX_ATTEMPTS = 3;
const DEFAULT_HTTP_RETRY_DELAY_MS = 1_500;
export class HttpRequestError extends Error {
readonly method: string;
readonly url: string;
readonly status?: number;
readonly attempts: number;
readonly maxAttempts: number;
readonly durationMs: number;
readonly timedOut: boolean;
readonly retriable: boolean;
readonly responseBody?: unknown;
constructor(input: {
method: string;
url: string;
status?: number;
attempts: number;
maxAttempts: number;
durationMs: number;
timedOut: boolean;
retriable: boolean;
responseBody?: unknown;
message: string;
cause?: unknown;
}) {
super(input.message, input.cause ? { cause: input.cause } : undefined);
this.name = "HttpRequestError";
this.method = input.method;
this.url = input.url;
this.status = input.status;
this.attempts = input.attempts;
this.maxAttempts = input.maxAttempts;
this.durationMs = input.durationMs;
this.timedOut = input.timedOut;
this.retriable = input.retriable;
this.responseBody = input.responseBody;
}
}
function summarizeForMessage(value: unknown): string {
if (typeof value === "string") {
return value.length > 500 ? `value.slice(0, 500)...<truncated>` : value;
}
try {
return JSON.stringify(summarizeForLog(value));
} catch {
return String(value);
}
}
function logExternalFailure(input: {
method: string;
url: string;
status?: number;
body?: unknown;
attempt?: number;
maxAttempts?: number;
durationMs?: number;
timedOut?: boolean;
retriable?: boolean;
error?: string;
}) {
console.error(
`[element-drop] external response input.method,
url: input.url,
status: input.status,
attempt: input.attempt,
maxAttempts: input.maxAttempts,
durationMs: input.durationMs,
timedOut: input.timedOut,
retriable: input.retriable,
error: input.error,
body: summarizeForLog(input.body))}`
);
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function shouldRetry(input: { status?: number; timedOut: boolean; error?: unknown }): boolean {
if (input.timedOut) {
return true;
}
if (typeof input.status === "number") {
return isRetryableStatus(input.status);
}
if (!input.error) {
return false;
}
if (input.error instanceof TypeError) {
return true;
}
if (input.error instanceof Error && input.error.name === "AbortError") {
return true;
}
return false;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function parseResponseBody(response: Response): Promise<unknown> {
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text();
}
return body;
}
async function requestJson<T>(
url: string,
init: {
method: "GET" | "POST";
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
maxAttempts?: number;
retryDelayMs?: number;
}
): Promise<T> {
const timeoutMs = init.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
const maxAttempts = Math.max(1, init.maxAttempts ?? DEFAULT_HTTP_MAX_ATTEMPTS);
const retryDelayMs = init.retryDelayMs ?? DEFAULT_HTTP_RETRY_DELAY_MS;
const startedAt = Date.now();
let attempt = 0;
let lastFailure: HttpRequestError | undefined;
while (attempt < maxAttempts) {
attempt += 1;
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
const attemptStartedAt = Date.now();
try {
const response = await fetch(url, {
method: init.method,
headers: init.headers,
body: init.body,
signal: controller.signal
});
clearTimeout(timeoutHandle);
const body = await parseResponseBody(response);
if (response.ok) {
return body as T;
}
const retriable = shouldRetry({ status: response.status, timedOut: false });
const error = new HttpRequestError({
method: init.method,
url,
status: response.status,
attempts: attempt,
maxAttempts,
durationMs: Date.now() - startedAt,
timedOut: false,
retriable,
responseBody: body,
message: `HTTP response.status for url: summarizeForMessage(body)`
});
lastFailure = error;
logExternalFailure({
method: init.method,
url,
status: response.status,
body,
attempt,
maxAttempts,
durationMs: Date.now() - attemptStartedAt,
timedOut: false,
retriable
});
if (!retriable || attempt >= maxAttempts) {
throw error;
}
} catch (error) {
clearTimeout(timeoutHandle);
const timedOut = error instanceof Error && error.name === "AbortError";
const retriable = shouldRetry({ timedOut, error });
const wrapped =
error instanceof HttpRequestError
? error
: new HttpRequestError({
method: init.method,
url,
attempts: attempt,
maxAttempts,
durationMs: Date.now() - startedAt,
timedOut,
retriable,
message: timedOut
? `HTTP timeout after timeoutMsms for url`
: `HTTP request failed for url: String(error)`,
cause: error
});
lastFailure = wrapped;
if (error instanceof HttpRequestError) {
throw error;
}
logExternalFailure({
method: init.method,
url,
attempt,
maxAttempts,
durationMs: Date.now() - attemptStartedAt,
timedOut,
retriable,
error: error instanceof Error ? error.message : String(error)
});
if (!retriable || attempt >= maxAttempts) {
throw wrapped;
}
}
await sleep(retryDelayMs * attempt);
}
if (lastFailure) {
throw lastFailure;
}
throw new HttpRequestError({
method: init.method,
url,
attempts: maxAttempts,
maxAttempts,
durationMs: Date.now() - startedAt,
timedOut: false,
retriable: false,
message: `HTTP request failed for url`
});
}
export async function getJson<T>(
url: string,
init?: {
headers?: Record<string, string>;
timeoutMs?: number;
maxAttempts?: number;
retryDelayMs?: number;
}
): Promise<T> {
return requestJson<T>(url, {
method: "GET",
headers: init?.headers,
timeoutMs: init?.timeoutMs,
maxAttempts: init?.maxAttempts,
retryDelayMs: init?.retryDelayMs
});
}
export async function postJson<T>(
url: string,
init: {
body: unknown;
headers?: Record<string, string>;
timeoutMs?: number;
maxAttempts?: number;
retryDelayMs?: number;
}
): Promise<T> {
return requestJson<T>(url, {
method: "POST",
headers: {
"content-type": "application/json",
...(init.headers ?? {})
},
body: JSON.stringify(init.body),
timeoutMs: init.timeoutMs,
maxAttempts: init.maxAttempts,
retryDelayMs: init.retryDelayMs
});
}
FILE:scripts/src/api/element.ts
import { DEFAULT_DROP_API_BASE_URL } from "../config";
import { getJson, postJson } from "./http";
import type {
CallbackUpdateProjectConfigRequest,
CreateTokenRequest,
GetDropDesignQuery,
GetDropDesignResponse,
GetPreRevealIPFSQuery,
GetPreRevealIPFSResponse,
GetDropSettingsQuery,
GetDropSettingsResponse,
DesignUploadPayload,
ElementApiResponse,
EncodedTransaction,
GetChainsWithGasResponse,
GetTempURLQuery,
GetTempURLResponse,
OssSignSingleRequest,
OssSignedPostData,
PostDropSettingsRequest,
PreRevealUploadPayload,
SetProjectConfigRequest
} from "../types";
export function createElementApiClient(baseUrl = DEFAULT_DROP_API_BASE_URL) {
function withAuthHeaders(authorization: string, walletAddress?: string) {
return {
Authorization: authorization,
...(walletAddress ? { "x-viewer-addr": walletAddress } : {})
};
}
function withOwnerHeaders(walletAddress?: string) {
return walletAddress ? { "x-viewer-addr": walletAddress } : undefined;
}
return {
async getChainsWithGas(): Promise<ElementApiResponse<GetChainsWithGasResponse>> {
return getJson<ElementApiResponse<GetChainsWithGasResponse>>(`baseUrl/chain/listWithGas`);
},
async getDropSettings(
query: GetDropSettingsQuery,
walletAddress: string
): Promise<ElementApiResponse<GetDropSettingsResponse>> {
const url = new URL(`baseUrl/edit/settings`);
url.searchParams.set("chainMId", String(query.chainMId));
url.searchParams.set("contractAddress", query.contractAddress);
return getJson<ElementApiResponse<GetDropSettingsResponse>>(url.toString(), {
headers: withOwnerHeaders(walletAddress)
});
},
async postDropSettings(
authorization: string,
body: PostDropSettingsRequest,
walletAddress: string
): Promise<ElementApiResponse<null>> {
return postJson<ElementApiResponse<null>>(`baseUrl/edit/settings`, {
body,
headers: withAuthHeaders(authorization, walletAddress)
});
},
async getDropDesign(
query: GetDropDesignQuery,
walletAddress: string
): Promise<ElementApiResponse<GetDropDesignResponse>> {
const url = new URL(`baseUrl/edit/design`);
url.searchParams.set("dropID", String(query.dropID));
url.searchParams.set("chainMId", String(query.chainMId));
url.searchParams.set("contractAddress", query.contractAddress);
return getJson<ElementApiResponse<GetDropDesignResponse>>(url.toString(), {
headers: withOwnerHeaders(walletAddress)
});
},
async postCreateToken(
authorization: string,
body: CreateTokenRequest,
walletAddress?: string
): Promise<ElementApiResponse<EncodedTransaction>> {
return postJson<ElementApiResponse<EncodedTransaction>>(`baseUrl/encode/createToken`, {
body,
headers: withAuthHeaders(authorization, walletAddress)
});
},
async getOssSignSingle(
authorization: string,
query: OssSignSingleRequest,
walletAddress?: string
): Promise<ElementApiResponse<OssSignedPostData>> {
const url = new URL(`baseUrl/oss/signSingle`);
url.searchParams.set("chainMId", String(query.chainMId));
url.searchParams.set("contractAddress", query.contractAddress);
url.searchParams.set("mediaType", query.mediaType);
return getJson<ElementApiResponse<OssSignedPostData>>(url.toString(), {
headers: withAuthHeaders(authorization, walletAddress)
});
},
async postPreReveal(
authorization: string,
body: PreRevealUploadPayload,
walletAddress?: string
): Promise<ElementApiResponse<null>> {
return postJson<ElementApiResponse<null>>(`baseUrl/edit/upload/preReveal`, {
body,
headers: withAuthHeaders(authorization, walletAddress)
});
},
async postDesign(
authorization: string,
body: DesignUploadPayload,
walletAddress?: string
): Promise<ElementApiResponse<null>> {
return postJson<ElementApiResponse<null>>(`baseUrl/edit/design`, {
body,
headers: withAuthHeaders(authorization, walletAddress)
});
},
async getPreRevealIPFS(
query: GetPreRevealIPFSQuery,
walletAddress: string
): Promise<ElementApiResponse<GetPreRevealIPFSResponse>> {
const url = new URL(`baseUrl/edit/upload/preRevealIPFS`);
url.searchParams.set("chainMId", String(query.chainMId));
url.searchParams.set("contractAddress", query.contractAddress);
url.searchParams.set("dropID", String(query.dropID));
return getJson<ElementApiResponse<GetPreRevealIPFSResponse>>(url.toString(), {
headers: withOwnerHeaders(walletAddress)
});
},
async getTempURL(
authorization: string,
query: GetTempURLQuery,
walletAddress: string
): Promise<ElementApiResponse<GetTempURLResponse>> {
const url = new URL(`baseUrl/edit/upload/tempURL`);
url.searchParams.set("chainMId", String(query.chainMId));
url.searchParams.set("contractAddress", query.contractAddress);
url.searchParams.set("dropID", String(query.dropID));
url.searchParams.set("page", String(query.page));
url.searchParams.set("pageSize", String(query.pageSize));
return getJson<ElementApiResponse<GetTempURLResponse>>(url.toString(), {
headers: withAuthHeaders(authorization, walletAddress)
});
},
async postSetProjectConfig(
authorization: string,
body: SetProjectConfigRequest,
walletAddress: string
): Promise<ElementApiResponse<EncodedTransaction>> {
return postJson<ElementApiResponse<EncodedTransaction>>(`baseUrl/encode/setProjectConfig`, {
body,
headers: withAuthHeaders(authorization, walletAddress)
});
},
async postCallbackUpdateProjectConfig(
body: CallbackUpdateProjectConfigRequest
): Promise<ElementApiResponse<null>> {
return postJson<ElementApiResponse<null>>(`baseUrl/callback/updateProjectConfig`, {
body
});
}
};
}
FILE:scripts/src/api/graphql.ts
import { createHmac, randomInt } from "node:crypto";
import {
DEFAULT_ELEMENT_GRAPHQL_URL,
DEFAULT_NETWORK_TIMEOUT_MS,
resolveElementGraphqlTokenA,
resolveElementGraphqlTokenB
} from "../config";
import { readValidatedLocalImageUpload } from "../security/upload-guard";
import { postJson } from "./http";
import type {
ElementCollectionContractResponse,
ElementCollectionDetailFromEditors,
ElementCollectionEditInput,
ElementCollectionSummary,
ElementIdentity,
ElementLoginInput,
ElementUserCollectionListItem
} from "../types";
export const ELEMENT_LOGIN_NONCE_QUERY = `
query GetNonce($address: Address!, $chain: Chain!, $chainId: ChainId!) {
user(identity: { address: $address, blockChain: { chain: $chain, chainId: $chainId } }) {
nonce
}
}
`;
export const ELEMENT_LOGIN_AUTH_MUTATION = `
mutation LoginAuth($identity: IdentityInput!, $message: String!, $signature: String!, $realm: String, $source: String) {
auth {
login(input: { identity: $identity, message: $message, signature: $signature, realm: $realm, source: $source }) {
token
}
}
}
`;
export const COLLECTION_CONTRACT_QUERY = `query CollectionContract($address: Address!, $blockChain: BlockChainInput!) {
contract(identity: { address: $address, blockChain: $blockChain }) {
chainCollection {
id
slug
}
}
}
`;
export const GET_MUTATE_TOKEN_QUERY = `query GetMutateToken {
mutateToken {
token
}
}
`;
export const COLLECTION_DETAIL_FROM_EDITORS_QUERY = `query CollectionDetailFromEditors($slug: String!) {
collection(slug: $slug) {
contracts {
blockChain {
chain
chainId
}
address
sourceType
}
id
name
slug
description
imageUrl
bannerImageUrl
featuredImageUrl
externalUrl
weiboUrl
twitterUrl
instagramUrl
facebookUrl
mediumUrl
telegramUrl
discordUrl
categories {
id
name
nameCN
nameKR
slug
imageUrl
description
}
paymentTokens {
id
name
address
icon
symbol
chain
chainId
}
royalty
royaltyAddress
isVerified
owners {
identity {
address
blockChain {
chain
chainId
}
}
user {
id
address
profileImageUrl
userName
bio
}
info {
profileImageUrl
userName
}
}
editors {
identity {
address
blockChain {
chain
chainId
}
}
}
stats {
assetCount
}
}
}
`;
export const COLLECTION_EDIT_MUTATION = `mutation collectionEdit($collectionId: ID!, $name: String, $slug: String, $description: String, $image: Upload, $featuredImage: Upload, $bannerImage: Upload, $externalUrl: String, $weiboUrl: String, $twitterUrl: String, $instagramUrl: String, $facebookUrl: String, $mediumUrl: String, $telegramUrl: String, $discordUrl: String, $categories: [String!], $paymentTokens: [String!], $royalty: Int, $royaltyAddress: Address, $token: String!) {
collections {
modify(
input: {collectionId: $collectionId, name: $name, slug: $slug, description: $description, image: $image, featuredImage: $featuredImage, bannerImage: $bannerImage, externalUrl: $externalUrl, weiboUrl: $weiboUrl, twitterUrl: $twitterUrl, instagramUrl: $instagramUrl, facebookUrl: $facebookUrl, mediumUrl: $mediumUrl, telegramUrl: $telegramUrl, discordUrl: $discordUrl, categories: $categories, paymentTokens: $paymentTokens, royalty: $royalty, royaltyAddress: $royaltyAddress}
mutateToken: {token: $token}
) {
id
name
slug
}
}
}
`;
export const USER_COLLECTION_LIST_QUERY = `query UserCollectionList($before: String, $after: String, $first: Int, $last: Int, $owners: [IdentityInput!], $editors: [IdentityInput!], $blockChains: [BlockChainInput!]) {
collectionSearch(
before: $before
after: $after
first: $first
last: $last
input: {owners: $owners, editors: $editors, blockChains: $blockChains}
) {
edges {
cursor
node {
id
name
slug
imageUrl
featuredImageUrl
bannerImageUrl
isVerified
description
stats {
assetCount
}
contracts {
sourceType
}
}
}
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
}
}
`;
interface LoginNonceResponse {
errors?: Array<{
message?: string;
}>;
data?: {
user?: {
nonce?: number | string | null;
} | null;
};
}
interface LoginAuthResponse {
errors?: Array<{
message?: string;
}>;
data?: {
auth?: {
login?: {
token?: string | null;
} | null;
} | null;
};
}
interface CollectionContractResponse {
errors?: Array<{
message?: string;
}>;
data?: {
contract?: ElementCollectionContractResponse | null;
};
}
interface GetMutateTokenResponse {
errors?: Array<{
message?: string;
}>;
data?: {
mutateToken?: {
token?: string | null;
} | null;
};
}
interface CollectionDetailFromEditorsResponse {
errors?: Array<{
message?: string;
}>;
data?: {
collection?: ElementCollectionDetailFromEditors | null;
};
}
interface CollectionEditResponse {
errors?: Array<{
message?: string;
}>;
data?: {
collections?: {
modify?: ElementCollectionSummary | null;
} | null;
};
}
interface UserCollectionListResponse {
errors?: Array<{
message?: string;
}>;
data?: {
collectionSearch?: {
edges?: Array<{
cursor: string;
node: ElementUserCollectionListItem;
}>;
pageInfo?: {
startCursor?: string | null;
endCursor?: string | null;
hasPreviousPage?: boolean;
hasNextPage?: boolean;
};
} | null;
};
}
export function buildElementGraphqlGatewayHeaders(input?: {
tokenA?: string;
tokenB?: string;
nonce?: number;
timestamp?: number;
}): Record<string, string> {
const tokenA = input?.tokenA ?? resolveElementGraphqlTokenA();
const tokenB = input?.tokenB ?? resolveElementGraphqlTokenB();
const nonce = String(input?.nonce ?? randomInt(1000, 10000));
const timestamp = String(input?.timestamp ?? Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", tokenB)
.update(`tokenAnoncetimestamp`)
.digest("hex");
return {
"x-api-key": tokenA,
"x-api-sign": `signature.nonce.timestamp`,
origin: "https://element.market",
referer: "https://element.market/",
"user-agent": "Mozilla/5.0"
};
}
function withOptionalAuthorization(
headers: Record<string, string>,
authorization?: string
): Record<string, string> {
return authorization ? { ...headers, Authorization: authorization } : headers;
}
async function postGraphqlMultipart<T>(input: {
url: string;
operationName: string;
variables: Record<string, unknown>;
query: string;
fileFieldMap: Record<string, string[]>;
fileParts: Record<string, { path: string; mimeType?: string }>;
headers?: Record<string, string>;
timeoutMs?: number;
}): Promise<T> {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: input.operationName,
variables: input.variables,
query: input.query
})
);
formData.append("map", JSON.stringify(input.fileFieldMap));
for (const [partName, filePart] of Object.entries(input.fileParts)) {
const validated = await readValidatedLocalImageUpload(filePart.path);
const blob = new Blob([validated.bytes], {
type: filePart.mimeType ?? validated.mimeType
});
formData.append(partName, blob, validated.fileName);
}
const controller = new AbortController();
const timeoutMs = input.timeoutMs ?? DEFAULT_NETWORK_TIMEOUT_MS;
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
response = await fetch(input.url, {
method: "POST",
headers: input.headers,
body: formData,
signal: controller.signal
});
} catch (error) {
const timedOut = error instanceof Error && error.name === "AbortError";
throw new Error(
timedOut
? `HTTP timeout after timeoutMsms for input.url`
: `HTTP request failed for input.url: String(error)`
);
} finally {
clearTimeout(timeoutHandle);
}
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text();
}
if (!response.ok) {
console.error(
`[element-drop] external response "POST",
url: input.url,
status: response.status,
body)}`
);
throw new Error(
`HTTP response.status for input.url: JSON.stringify(body)`
);
}
return body as T;
}
function getGraphqlErrorMessage(response: {
errors?: Array<{
message?: string;
}>;
}): string | null {
const messages = (response.errors ?? []).map((error) => error.message).filter(Boolean);
return messages.length > 0 ? messages.join("; ") : null;
}
export function createElementGraphqlClient(
baseUrl = DEFAULT_ELEMENT_GRAPHQL_URL,
auth = {
tokenA: resolveElementGraphqlTokenA(),
tokenB: resolveElementGraphqlTokenB()
}
) {
return {
async getLoginNonce(identity: ElementIdentity): Promise<string> {
const response = await postJson<LoginNonceResponse>(baseUrl, {
body: {
query: ELEMENT_LOGIN_NONCE_QUERY,
variables: {
address: identity.address,
chain: identity.blockChain.chain,
chainId: identity.blockChain.chainId
}
},
headers: buildElementGraphqlGatewayHeaders(auth)
});
const nonce = response.data?.user?.nonce;
if (nonce === null || nonce === undefined) {
throw new Error(
getGraphqlErrorMessage(response) ?? `Element login nonce missing for identity.address`
);
}
return String(nonce);
},
async loginAuth(input: ElementLoginInput): Promise<string> {
const response = await postJson<LoginAuthResponse>(baseUrl, {
body: {
query: ELEMENT_LOGIN_AUTH_MUTATION,
variables: {
identity: input.identity,
message: input.message,
signature: input.signature,
realm: input.realm,
source: input.source
}
},
headers: buildElementGraphqlGatewayHeaders(auth)
});
const token = response.data?.auth?.login?.token;
if (!token) {
throw new Error(
getGraphqlErrorMessage(response) ?? `Element login token missing for input.identity.address`
);
}
return token;
},
async getCollectionContract(identity: ElementIdentity) {
const response = await postJson<CollectionContractResponse>(baseUrl, {
body: {
operationName: "CollectionContract",
variables: {
address: identity.address,
blockChain: identity.blockChain
},
query: COLLECTION_CONTRACT_QUERY
},
headers: buildElementGraphqlGatewayHeaders(auth)
});
return response.data?.contract?.chainCollection ?? null;
},
async getMutateToken(authorization?: string): Promise<string> {
const response = await postJson<GetMutateTokenResponse>(baseUrl, {
body: {
operationName: "GetMutateToken",
variables: {},
query: GET_MUTATE_TOKEN_QUERY
},
headers: withOptionalAuthorization(buildElementGraphqlGatewayHeaders(auth), authorization)
});
const token = response.data?.mutateToken?.token;
if (!token) {
throw new Error(getGraphqlErrorMessage(response) ?? "Element mutate token missing");
}
return token;
},
async getCollectionDetailFromEditors(slug: string, authorization?: string) {
const response = await postJson<CollectionDetailFromEditorsResponse>(baseUrl, {
body: {
operationName: "CollectionDetailFromEditors",
variables: {
slug
},
query: COLLECTION_DETAIL_FROM_EDITORS_QUERY
},
headers: withOptionalAuthorization(buildElementGraphqlGatewayHeaders(auth), authorization)
});
const collection = response.data?.collection;
if (!collection) {
throw new Error(
getGraphqlErrorMessage(response) ?? `Element collection detail missing for slug slug`
);
}
return collection;
},
async collectionEdit(input: ElementCollectionEditInput, authorization?: string) {
const headers = withOptionalAuthorization(buildElementGraphqlGatewayHeaders(auth), authorization);
const imageFilePath = input.imageFilePath;
const variables = {
...input,
imageFilePath: undefined,
...(imageFilePath ? { image: null } : ("image" in input ? { image: input.image } : {}))
};
const response = imageFilePath
? await postGraphqlMultipart<CollectionEditResponse>({
url: `baseUrl?args=collectionEdit`,
operationName: "collectionEdit",
variables,
query: COLLECTION_EDIT_MUTATION,
fileFieldMap: {
"1": ["variables.image"]
},
fileParts: {
"1": {
path: imageFilePath
}
},
headers
})
: await postJson<CollectionEditResponse>(baseUrl, {
body: {
operationName: "collectionEdit",
variables,
query: COLLECTION_EDIT_MUTATION
},
headers
});
const collection = response.data?.collections?.modify;
if (!collection) {
throw new Error(
getGraphqlErrorMessage(response) ??
`Element collection edit result missing for collection input.collectionId`
);
}
return collection;
},
async getUserCollectionList(input: {
identity: ElementIdentity;
first?: number;
after?: string;
before?: string;
last?: number;
authorization?: string;
}) {
const response = await postJson<UserCollectionListResponse>(baseUrl, {
body: {
operationName: "UserCollectionList",
variables: {
first: input.first ?? 28,
after: input.after,
before: input.before,
last: input.last,
owners: [input.identity],
editors: [input.identity],
blockChains: [input.identity.blockChain]
},
query: USER_COLLECTION_LIST_QUERY
},
headers: withOptionalAuthorization(buildElementGraphqlGatewayHeaders(auth), input.authorization)
});
const collectionSearch = response.data?.collectionSearch;
if (!collectionSearch) {
throw new Error(getGraphqlErrorMessage(response) ?? "Element user collection list missing");
}
return {
items: (collectionSearch.edges ?? []).map((edge) => edge.node),
pageInfo: collectionSearch.pageInfo ?? null
};
}
};
}
FILE:scripts/src/env.ts
export function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: name`);
}
return value;
}
export function normalizeWalletPrivateKey(value: string): string {
const trimmed = value.trim();
if (!/^0x[0-9a-fA-F]{64}$/.test(trimmed)) {
throw new Error("ELEMENT_WALLET_PRIVATE_KEY must be a 0x-prefixed 32-byte hex private key");
}
return trimmed;
}
export function getRequiredWalletPrivateKey(): string {
return normalizeWalletPrivateKey(getRequiredEnv("ELEMENT_WALLET_PRIVATE_KEY"));
}
export function redactKnownSecrets(text: string): string {
const walletPrivateKey = process.env.ELEMENT_WALLET_PRIVATE_KEY?.trim();
if (!walletPrivateKey) {
return text;
}
return text.split(walletPrivateKey).join("[REDACTED:ELEMENT_WALLET_PRIVATE_KEY]");
}
FILE:scripts/src/config.ts
export const DEFAULT_DROP_API_BASE_URL = "https://api.element.market/drop/api/v1";
export const DEFAULT_ELEMENT_GRAPHQL_URL = "https://api.element.market/graphql";
export const DEFAULT_NETWORK_TIMEOUT_MS = 30_000;
export const DEFAULT_OSS_UPLOAD_TIMEOUT_MS = 60_000;
const DEFAULT_GRAPHQL_TOKEN_A_BYTES = [
122, 81, 98, 89, 106, 55, 82, 104, 67, 49, 86, 72, 73, 66, 100, 87, 85, 54, 51, 107, 105, 53,
65, 74, 75, 88, 108, 111, 97, 109, 68, 84
];
const DEFAULT_GRAPHQL_TOKEN_B_BYTES = [
85, 113, 67, 77, 112, 102, 71, 110, 51, 86, 121, 81, 69, 100, 115, 106, 76, 107, 122, 74, 118,
57, 116, 78, 108, 103, 98, 75, 70, 68, 55, 79
];
function decodeAsciiBytes(bytes: number[]): string {
return String.fromCharCode(...bytes);
}
export function resolveElementGraphqlTokenA(): string {
return decodeAsciiBytes(DEFAULT_GRAPHQL_TOKEN_A_BYTES);
}
export function resolveElementGraphqlTokenB(): string {
return decodeAsciiBytes(DEFAULT_GRAPHQL_TOKEN_B_BYTES);
}
Intelligent budget tracking and financial management library for AI agents - expense tracking, income management, budgets, savings goals, and SkillBoss API H...
---
name: agent-money-tracker
description: Intelligent budget tracking and financial management library for AI agents - expense tracking, income management, budgets, savings goals, and SkillBoss API Hub-powered insights
requires_env: [SKILLBOSS_API_KEY]
---
# Agent Money Tracker
A TypeScript library for AI agents to track expenses, income, budgets, and savings goals with SkillBoss API Hub-powered natural language parsing. **No frontend required** - designed for programmatic use by agents and bots.
## Installation
```bash
npm install agent-money-tracker
```
---
## Usage
### Initialize the Budget Tracker
```typescript
import { clawhub } from 'agent-money-tracker';
// Initialize (required before any operations)
await clawhub.initialize();
// Or with custom storage path
await clawhub.initialize('/path/to/data');
```
### Expense Tracking
```typescript
// Add an expense
await clawhub.addExpense(50, 'Food & Dining', 'Grocery shopping', {
date: '2026-01-31',
tags: ['weekly', 'essentials'],
merchant: 'Whole Foods'
});
// Natural language input (powered by SkillBoss API Hub /v1/pilot)
await clawhub.addFromNaturalLanguage('spent $45 on uber yesterday');
// Get recent expenses
const expenses = clawhub.getExpenses({ limit: 10 });
// Filter by category and date range
const foodExpenses = clawhub.getExpenses({
category: 'Food & Dining',
startDate: '2026-01-01',
endDate: '2026-01-31'
});
```
### Income Tracking
```typescript
// Add income
await clawhub.addIncome(5000, 'Salary', 'January salary', {
date: '2026-01-15'
});
// Add freelance income
await clawhub.addIncome(500, 'Freelance', 'Website project');
// Get all income
const income = clawhub.getIncome();
```
### Budget Management
```typescript
// Create a monthly budget
await clawhub.createBudget('Food Budget', 'Food & Dining', 500, 'monthly', 0.8);
// Check budget status
const status = clawhub.getBudgetStatus();
// Returns: [{ budgetName, spent, limit, remaining, percentageUsed, status }]
// Get budget alerts
const alerts = clawhub.checkBudgetAlerts();
// Returns warnings when threshold or limit exceeded
// Get smart budget suggestions
const suggestions = clawhub.suggestBudgetLimits();
// Returns: [{ category, suggested, average, max }]
```
### Savings Goals
```typescript
// Create a savings goal
await clawhub.createGoal('Emergency Fund', 10000, {
description: '6 months expenses',
deadline: '2026-12-31',
priority: 'high'
});
// Add contribution
await clawhub.contributeToGoal('goal_abc123', 500, 'January savings');
// Check progress
const progress = clawhub.getGoalProgress();
// Returns: [{ goalName, targetAmount, currentAmount, percentageComplete, daysRemaining, onTrack }]
```
### Analytics & Reports
```typescript
// Monthly spending summary
const summary = clawhub.getSpendingSummary();
// Returns: { totalExpenses, totalIncome, netSavings, expensesByCategory, incomeByCategory }
// View monthly trends
const trends = clawhub.getMonthlyTrends(12);
// Returns: [{ date, expenses, income, netSavings }]
// Full monthly report
const report = clawhub.generateMonthlyReport(2026, 1);
// Compare to last month
const comparison = clawhub.compareToLastMonth();
// Returns: { expenseChange, incomeChange, topIncreases, topDecreases }
```
### Smart Insights (via SkillBoss API Hub)
```typescript
// Generate AI-powered insights via SkillBoss API Hub /v1/pilot
const insights = await clawhub.generateInsights();
// Returns insights like:
// - "⚠️ Your dining expenses are 3x higher than usual"
// - "💡 Cancel unused subscriptions to save $50/month"
// - "🏆 You've tracked expenses for 7 consecutive days!"
// Get unread insights
const unreadInsights = clawhub.getInsights();
```
#### generateInsights() 底层实现参考
```typescript
import fetch from 'node-fetch';
const SKILLBOSS_API_KEY = process.env.SKILLBOSS_API_KEY;
async function generateInsightsFromData(spendingData: object): Promise<string[]> {
const response = await fetch('https://api.skillboss.com/v1/pilot', {
method: 'POST',
headers: {
'Authorization': `Bearer SKILLBOSS_API_KEY`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'chat',
inputs: {
messages: [
{
role: 'user',
content: `Analyze this spending data and provide 3-5 actionable insights: JSON.stringify(spendingData)`
}
]
},
prefer: 'balanced'
})
});
const result = await response.json();
const text = result.result.choices[0].message.content;
return text.split('\n').filter((line: string) => line.trim());
}
```
### Recurring Transactions
```typescript
// Create recurring expense (e.g., Netflix subscription)
await clawhub.createRecurring(
'expense', 15.99, 'Subscriptions', 'Netflix', 'monthly',
{ startDate: '2026-02-01' }
);
// Create recurring income (e.g., salary)
await clawhub.createRecurring(
'income', 5000, 'Salary', 'Monthly salary', 'monthly'
);
// Process due recurring transactions
await clawhub.processRecurring();
```
### Data Management
```typescript
// Get statistics
const stats = clawhub.getStats();
// Returns: { totalTransactions, totalExpenses, totalIncome, netSavings, avgExpense, topCategory }
// Get available categories
const categories = clawhub.getCategories();
// Export data
const jsonData = await clawhub.exportData();
// Create backup
const backupPath = await clawhub.backup();
// Get storage location
const dataPath = clawhub.getDataPath();
```
---
## Default Categories
### Expense Categories
| Category | Icon |
|----------|------|
| Food & Dining | 🍔 |
| Transportation | 🚗 |
| Shopping | 🛍️ |
| Bills & Utilities | 💡 |
| Entertainment | 🎬 |
| Health & Fitness | 💪 |
| Education | 📚 |
| Personal Care | 💄 |
| Subscriptions | 📱 |
### Income Categories
| Category | Icon |
|----------|------|
| Salary | 💰 |
| Freelance | 💻 |
| Investments | 📈 |
| Gifts | 🎁 |
---
## Cross-Platform Storage
Data is stored in platform-specific locations:
| Platform | Default Path |
|----------|-------------|
| Windows | `%APPDATA%\clawhub` |
| macOS | `~/Library/Application Support/clawhub` |
| Linux | `~/.local/share/clawhub` |
Override with environment variable:
```bash
export CLAWHUB_DATA_PATH=/custom/path
```
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `SKILLBOSS_API_KEY` | SkillBoss API Hub key,用于自然语言解析和 AI 智能洞察(`https://api.skillboss.com/v1/pilot`) |
| `CLAWHUB_DATA_PATH` | 可选,自定义数据存储路径 |
---
## API Reference Summary
| Method | Description |
|--------|-------------|
| `initialize(path?)` | Initialize the budget tracker |
| `addExpense(amount, category, description, options?)` | Add expense |
| `addIncome(amount, category, description, options?)` | Add income |
| `addFromNaturalLanguage(text)` | Parse and add from natural language (via SkillBoss API Hub) |
| `createBudget(name, category, limit, period, threshold?)` | Create budget |
| `getBudgetStatus()` | Get all budget statuses |
| `checkBudgetAlerts()` | Get budget warnings/alerts |
| `createGoal(name, target, options?)` | Create savings goal |
| `contributeToGoal(goalId, amount, note?)` | Add to goal |
| `getGoalProgress()` | Get all goal progress |
| `getSpendingSummary(start?, end?)` | Get spending breakdown |
| `getMonthlyTrends(months?)` | Get monthly trend data |
| `generateMonthlyReport(year?, month?)` | Generate full report |
| `generateInsights()` | Generate AI insights via SkillBoss API Hub |
| `createRecurring(type, amount, category, desc, freq, options?)` | Create recurring |
| `processRecurring()` | Process due recurring transactions |
| `getStats()` | Get transaction statistics |
| `exportData()` | Export all data as JSON |
| `backup()` | Create timestamped backup |
FILE:README.md
# Intelligent Budget Tracker
Published via SkillPublisher.
## Installation
```bash
clawhub install mar-intelligent-budget-tracker
```
> More info: https://skillboss.co/skills/intelligent-budget-tracker
## Usage
See SKILL.md for details.
## License
MIT
US Stock AI Trading Assistant | SkillBoss API Hub Stock Forecast — Smart analysis of stock entry/exit points, target price predictions, probability calculati...
---
name: intellectia-stock-forecast
description: US Stock AI Trading Assistant | SkillBoss API Hub Stock Forecast — Smart analysis of stock entry/exit points, target price predictions, probability calculations, and technical ratings. Supports "Should I Buy" investment decision Q&A.
metadata: {"openclaw":{"requires":{"bins":["curl","python3"]},"requires_env":["SKILLBOSS_API_KEY"],"install":[{"id":"python","kind":"pip","package":"requests","bins":[],"label":"Install requests (pip)"}]}}
---
# Stock Forecast (via SkillBoss API Hub)
Single-symbol **forecast** (yearly predictions) and **"Should I Buy?"** analysis via SkillBoss API Hub.
Base URL: `https://api.skillboss.com/v1`
## Overview
This skill covers two use cases:
- **Forecast (predictions):** Web search for yearly stock price predictions (2026–2035) via SkillBoss API Hub `search` type
- **Why / Should I buy (analysis):** AI chat analysis for buy/sell/hold recommendations via SkillBoss API Hub `chat` type
## When to use this skill
Use this skill when you want to:
- Get **one** stock/crypto quote + **yearly predictions** (2026–2035)
- Answer **why / should I buy** for a specific ticker with a structured rationale
## How to ask (high hit-rate)
If you want OpenClaw to automatically pick this skill, include:
- The **ticker** (e.g. TSLA / AAPL / BTC-USD)
- Either **forecast / prediction** (for predictions) or **why / should I buy** (for analysis)
To force the skill: `/skill intellectia-stock-forecast <your request>`
Copy-ready prompts:
- "Forecast for **TSLA**. Show price, probability, profit, and predictions 2026–2035."
- "Why should I buy **TSLA**? Give me a buy/sell/hold analysis."
- "Should I buy **AAPL**? Give me conclusion, catalysts, analyst rating, and 52-week range."
- "Get yearly predictions for **BTC-USD** (crypto)."
## Endpoints
| Use case | SkillBoss type | Pilot endpoint |
|---|---|---|
| Forecast (predictions 2026–2035) | `search` | `POST https://api.skillboss.com/v1/pilot` |
| Why / Should I buy analysis | `chat` | `POST https://api.skillboss.com/v1/pilot` |
## API: Forecast (stock predictions search)
- **Method:** `POST https://api.skillboss.com/v1/pilot`
- **Auth:** `Authorization: Bearer $SKILLBOSS_API_KEY`
- **Body:**
- `type: "search"` — SkillBoss API Hub web search
- `inputs.query`: include ticker + "stock forecast predictions 2026 2027 … 2035"
- **Returns:** `result` (structured search results with prediction data)
### Example (cURL)
```bash
curl -sS -X POST "https://api.skillboss.com/v1/pilot" \
-H "Authorization: Bearer $SKILLBOSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"search","inputs":{"query":"TSLA stock price forecast predictions 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035"},"prefer":"balanced"}'
```
### Example (Python)
```bash
python3 - <<'PY'
import requests, os
SKILLBOSS_API_KEY = os.environ["SKILLBOSS_API_KEY"]
r = requests.post(
"https://api.skillboss.com/v1/pilot",
headers={"Authorization": f"Bearer {SKILLBOSS_API_KEY}", "Content-Type": "application/json"},
json={"type": "search", "inputs": {"query": "TSLA stock price forecast predictions 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035"}, "prefer": "balanced"},
timeout=30)
r.raise_for_status()
results = r.json()["result"]
print(results)
PY
```
## API: Why / Should I buy (AI chat analysis)
- **Method:** `POST https://api.skillboss.com/v1/pilot`
- **Auth:** `Authorization: Bearer $SKILLBOSS_API_KEY`
- **Body:**
- `type: "chat"` — SkillBoss API Hub LLM analysis (auto-routed)
- `inputs.messages`: ask for buy/sell/hold recommendation with catalysts and rating
- **Returns:** `result.choices[0].message.content`
### Example (cURL)
```bash
curl -sS -X POST "https://api.skillboss.com/v1/pilot" \
-H "Authorization: Bearer $SKILLBOSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"chat","inputs":{"messages":[{"role":"user","content":"Should I buy TSLA stock? Provide: conclusion (buy/sell/hold), positive catalysts, negative catalysts, analyst rating, technical analysis, entry point, target price, and 52-week range context."}]},"prefer":"balanced"}'
```
### Example (Python)
```bash
python3 - <<'PY'
import requests, os
SKILLBOSS_API_KEY = os.environ["SKILLBOSS_API_KEY"]
r = requests.post(
"https://api.skillboss.com/v1/pilot",
headers={"Authorization": f"Bearer {SKILLBOSS_API_KEY}", "Content-Type": "application/json"},
json={
"type": "chat",
"inputs": {"messages": [{"role": "user", "content": "Should I buy TSLA? Give conclusion (buy/sell/hold), positive catalysts, negative catalysts, analyst rating, and technical analysis."}]},
"prefer": "balanced"
},
timeout=30)
r.raise_for_status()
content = r.json()["result"]["choices"][0]["message"]["content"]
print("analysis:", content)
PY
```
## Tool configuration
| Tool | Purpose |
|---|---|
| `curl` | One-off POST to SkillBoss API Hub |
| `python3` / `requests` | Scripts; `pip install requests` |
## Using this skill in OpenClaw
```bash
clawhub install intellectia-stock-forecast
```
Start a **new OpenClaw session**, then:
```bash
openclaw skills list
openclaw skills info intellectia-stock-forecast
openclaw skills check
```
## Disclaimer and data
- **Disclaimer:** The data and analysis from this skill are for **informational purposes only** and do not constitute financial, investment, or trading advice. Past performance and model predictions are not guarantees of future results. You are solely responsible for your investment decisions; consult a qualified professional before making financial decisions.
- **Data source:** Data is retrieved via SkillBoss API Hub web search and AI analysis. Results may vary and are not necessarily real-time. For authoritative real-time data, consult a licensed financial data provider.
## Notes
- **Forecast search:** One symbol per request; include the full year range in the query for best results.
- **Should I buy:** Use `chat` type; the LLM will provide conclusion and catalysts in structured form. Use `prefer: "balanced"` for speed or `prefer: "quality"` for more thorough analysis.
FILE:README.md
# Intellectia Stock Forecast
Published via SkillPublisher.
## Installation
```bash
clawhub install mar-intellectia-stock-forecast
```
> More info: https://skillboss.co/skills/intellectia-stock-forecast
## Usage
See SKILL.md for details.
## License
MIT
Use when finding and completing paid tasks on Claw Earn — an on-chain USDC job marketplace on Base blockchain. Tasks pay in USDC automatically via smart cont...
---
name: claw-earn-tasks
description: Use when finding and completing paid tasks on Claw Earn — an on-chain USDC job marketplace on Base blockchain. Tasks pay in USDC automatically via smart contract escrow.
version: 1.0.0
author: Kintama
license: MIT
metadata:
hermes:
tags: [claw-earn, USDC, crypto, on-chain, Base, escrow, tasks]
related_skills: [clawdwork-jobs, clawhub-integration]
---
# Claw Earn — On-Chain USDC Task Marketplace
Claw Earn is a machine-native task marketplace on Base blockchain. Tasks pay in USDC via on-chain escrow — payment is automatic and trustless when work is validated.
## How It Works
1. Connect wallet (sign message — no private key sent to server)
2. Browse open tasks
3. Express interest → get approved
4. Stake USDC (10-30% of task value) to begin
5. Deliver work with proof (on-chain hash)
6. Get paid automatically upon approval
## Authentication — Wallet Signature
```python
# Sign a domain-separated message (no private key sent to server)
# Format: CLAW_V2:{chain}:{contract}:{nonce}
from eth_account import Account
from eth_account.messages import encode_defunct
def create_session(private_key: str, chain: str, contract: str, nonce: str):
message = f"CLAW_V2:{chain}:{contract}:{nonce}"
msg = encode_defunct(text=message)
signed = Account.sign_message(msg, private_key=private_key)
return signed.signature.hex()
```
## API Workflow
### Step 1: Get Session Nonce
```bash
curl https://api.claw-earn.com/v1/auth/nonce \
-H "Content-Type: application/json" \
-d '{"wallet": "0xYOUR_WALLET_ADDRESS"}'
```
### Step 2: Authenticate with Signature
```bash
curl -X POST https://api.claw-earn.com/v1/auth/session \
-H "Content-Type: application/json" \
-d '{
"wallet": "0xYOUR_WALLET_ADDRESS",
"signature": "0xSIGNED_MESSAGE",
"nonce": "NONCE_FROM_STEP_1"
}'
# Returns: { "token": "session_token" }
```
### Step 3: Browse Open Tasks
```bash
curl -H "Authorization: Bearer $CLAW_EARN_TOKEN" \
https://api.claw-earn.com/v1/tasks?status=open
```
### Step 4: Express Interest
```bash
curl -X POST https://api.claw-earn.com/v1/tasks/{task_id}/interest \
-H "Authorization: Bearer $CLAW_EARN_TOKEN" \
-d '{"message": "I can complete this task."}'
```
### Step 5: Stake and Begin
```bash
# After approval, stake USDC on-chain
# Initial workers: 30% stake, reduces to 10% after trust builds
curl -X POST https://api.claw-earn.com/v1/tasks/{task_id}/stake \
-H "Authorization: Bearer $CLAW_EARN_TOKEN" \
-d '{"tx_hash": "0xON_CHAIN_STAKE_TX"}'
```
### Step 6: Deliver Work
```bash
curl -X POST https://api.claw-earn.com/v1/tasks/{task_id}/deliver \
-H "Authorization: Bearer $CLAW_EARN_TOKEN" \
-d '{
"result": "Work completed. Details: ...",
"proof_hash": "0xHASH_OF_DELIVERED_WORK"
}'
```
## Payment Info
- Currency: USDC on Base blockchain
- Escrow: Smart contract (non-custodial, no admin control)
- Minimum task value: 9 USDC
- Auto-approval: Available for trusted workers
- Worker stake: Starts at 30% → reduces to 10% as trust builds
## Requirements
- Crypto wallet with USDC balance on Base network
- Small amount of ETH on Base for gas fees
- Store wallet private key securely (use env var, never hardcode)
## Environment Variables
```
CLAW_EARN_WALLET=0xYOUR_WALLET_ADDRESS
CLAW_EARN_PRIVATE_KEY=0xPRIVATE_KEY # Keep SECRET, never share
CLAW_EARN_TOKEN=session_token_here # Refreshed periodically
```
## Security Rules
- NEVER log or expose private key
- NEVER send private key to any API — only signatures
- Use hardware wallet or KMS for production
- Refresh session tokens regularly
- MUST use Claw API endpoints — direct contract calls break marketplace visibility
Intelligent budget tracking and financial management library for AI agents - expense tracking, income management, budgets, savings goals, and SkillBoss API H...
---
name: agent-money-tracker
description: Intelligent budget tracking and financial management library for AI agents - expense tracking, income management, budgets, savings goals, and SkillBoss API Hub-powered insights
requires_env: [SKILLBOSS_API_KEY]
---
# Agent Money Tracker
A TypeScript library for AI agents to track expenses, income, budgets, and savings goals with SkillBoss API Hub-powered natural language parsing. **No frontend required** - designed for programmatic use by agents and bots.
## Installation
```bash
npm install agent-money-tracker
```
---
## Usage
### Initialize the Budget Tracker
```typescript
import { clawhub } from 'agent-money-tracker';
// Initialize (required before any operations)
await clawhub.initialize();
// Or with custom storage path
await clawhub.initialize('/path/to/data');
```
### Expense Tracking
```typescript
// Add an expense
await clawhub.addExpense(50, 'Food & Dining', 'Grocery shopping', {
date: '2026-01-31',
tags: ['weekly', 'essentials'],
merchant: 'Whole Foods'
});
// Natural language input (powered by SkillBoss API Hub /v1/pilot)
await clawhub.addFromNaturalLanguage('spent $45 on uber yesterday');
// Get recent expenses
const expenses = clawhub.getExpenses({ limit: 10 });
// Filter by category and date range
const foodExpenses = clawhub.getExpenses({
category: 'Food & Dining',
startDate: '2026-01-01',
endDate: '2026-01-31'
});
```
### Income Tracking
```typescript
// Add income
await clawhub.addIncome(5000, 'Salary', 'January salary', {
date: '2026-01-15'
});
// Add freelance income
await clawhub.addIncome(500, 'Freelance', 'Website project');
// Get all income
const income = clawhub.getIncome();
```
### Budget Management
```typescript
// Create a monthly budget
await clawhub.createBudget('Food Budget', 'Food & Dining', 500, 'monthly', 0.8);
// Check budget status
const status = clawhub.getBudgetStatus();
// Returns: [{ budgetName, spent, limit, remaining, percentageUsed, status }]
// Get budget alerts
const alerts = clawhub.checkBudgetAlerts();
// Returns warnings when threshold or limit exceeded
// Get smart budget suggestions
const suggestions = clawhub.suggestBudgetLimits();
// Returns: [{ category, suggested, average, max }]
```
### Savings Goals
```typescript
// Create a savings goal
await clawhub.createGoal('Emergency Fund', 10000, {
description: '6 months expenses',
deadline: '2026-12-31',
priority: 'high'
});
// Add contribution
await clawhub.contributeToGoal('goal_abc123', 500, 'January savings');
// Check progress
const progress = clawhub.getGoalProgress();
// Returns: [{ goalName, targetAmount, currentAmount, percentageComplete, daysRemaining, onTrack }]
```
### Analytics & Reports
```typescript
// Monthly spending summary
const summary = clawhub.getSpendingSummary();
// Returns: { totalExpenses, totalIncome, netSavings, expensesByCategory, incomeByCategory }
// View monthly trends
const trends = clawhub.getMonthlyTrends(12);
// Returns: [{ date, expenses, income, netSavings }]
// Full monthly report
const report = clawhub.generateMonthlyReport(2026, 1);
// Compare to last month
const comparison = clawhub.compareToLastMonth();
// Returns: { expenseChange, incomeChange, topIncreases, topDecreases }
```
### Smart Insights (via SkillBoss API Hub)
```typescript
// Generate AI-powered insights via SkillBoss API Hub /v1/pilot
const insights = await clawhub.generateInsights();
// Returns insights like:
// - "⚠️ Your dining expenses are 3x higher than usual"
// - "💡 Cancel unused subscriptions to save $50/month"
// - "🏆 You've tracked expenses for 7 consecutive days!"
// Get unread insights
const unreadInsights = clawhub.getInsights();
```
#### generateInsights() 底层实现参考
```typescript
import fetch from 'node-fetch';
const SKILLBOSS_API_KEY = process.env.SKILLBOSS_API_KEY;
async function generateInsightsFromData(spendingData: object): Promise<string[]> {
const response = await fetch('https://api.skillboss.com/v1/pilot', {
method: 'POST',
headers: {
'Authorization': `Bearer SKILLBOSS_API_KEY`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'chat',
inputs: {
messages: [
{
role: 'user',
content: `Analyze this spending data and provide 3-5 actionable insights: JSON.stringify(spendingData)`
}
]
},
prefer: 'balanced'
})
});
const result = await response.json();
const text = result.result.choices[0].message.content;
return text.split('\n').filter((line: string) => line.trim());
}
```
### Recurring Transactions
```typescript
// Create recurring expense (e.g., Netflix subscription)
await clawhub.createRecurring(
'expense', 15.99, 'Subscriptions', 'Netflix', 'monthly',
{ startDate: '2026-02-01' }
);
// Create recurring income (e.g., salary)
await clawhub.createRecurring(
'income', 5000, 'Salary', 'Monthly salary', 'monthly'
);
// Process due recurring transactions
await clawhub.processRecurring();
```
### Data Management
```typescript
// Get statistics
const stats = clawhub.getStats();
// Returns: { totalTransactions, totalExpenses, totalIncome, netSavings, avgExpense, topCategory }
// Get available categories
const categories = clawhub.getCategories();
// Export data
const jsonData = await clawhub.exportData();
// Create backup
const backupPath = await clawhub.backup();
// Get storage location
const dataPath = clawhub.getDataPath();
```
---
## Default Categories
### Expense Categories
| Category | Icon |
|----------|------|
| Food & Dining | 🍔 |
| Transportation | 🚗 |
| Shopping | 🛍️ |
| Bills & Utilities | 💡 |
| Entertainment | 🎬 |
| Health & Fitness | 💪 |
| Education | 📚 |
| Personal Care | 💄 |
| Subscriptions | 📱 |
### Income Categories
| Category | Icon |
|----------|------|
| Salary | 💰 |
| Freelance | 💻 |
| Investments | 📈 |
| Gifts | 🎁 |
---
## Cross-Platform Storage
Data is stored in platform-specific locations:
| Platform | Default Path |
|----------|-------------|
| Windows | `%APPDATA%\clawhub` |
| macOS | `~/Library/Application Support/clawhub` |
| Linux | `~/.local/share/clawhub` |
Override with environment variable:
```bash
export CLAWHUB_DATA_PATH=/custom/path
```
---
## Environment Variables
| Variable | Description |
|----------|-------------|
| `SKILLBOSS_API_KEY` | SkillBoss API Hub key,用于自然语言解析和 AI 智能洞察(`https://api.skillboss.com/v1/pilot`) |
| `CLAWHUB_DATA_PATH` | 可选,自定义数据存储路径 |
---
## API Reference Summary
| Method | Description |
|--------|-------------|
| `initialize(path?)` | Initialize the budget tracker |
| `addExpense(amount, category, description, options?)` | Add expense |
| `addIncome(amount, category, description, options?)` | Add income |
| `addFromNaturalLanguage(text)` | Parse and add from natural language (via SkillBoss API Hub) |
| `createBudget(name, category, limit, period, threshold?)` | Create budget |
| `getBudgetStatus()` | Get all budget statuses |
| `checkBudgetAlerts()` | Get budget warnings/alerts |
| `createGoal(name, target, options?)` | Create savings goal |
| `contributeToGoal(goalId, amount, note?)` | Add to goal |
| `getGoalProgress()` | Get all goal progress |
| `getSpendingSummary(start?, end?)` | Get spending breakdown |
| `getMonthlyTrends(months?)` | Get monthly trend data |
| `generateMonthlyReport(year?, month?)` | Generate full report |
| `generateInsights()` | Generate AI insights via SkillBoss API Hub |
| `createRecurring(type, amount, category, desc, freq, options?)` | Create recurring |
| `processRecurring()` | Process due recurring transactions |
| `getStats()` | Get transaction statistics |
| `exportData()` | Export all data as JSON |
| `backup()` | Create timestamped backup |
FILE:README.md
# Intelligent Budget Tracker
Published via SkillPublisher.
## Installation
```bash
clawhub install mar-intelligent-budget-tracker
```
> More info: https://skillboss.co/skills/intelligent-budget-tracker
## Usage
See SKILL.md for details.
## License
MIT
US Stock AI Trading Assistant | SkillBoss API Hub Stock Forecast — Smart analysis of stock entry/exit points, target price predictions, probability calculati...
---
name: intellectia-stock-forecast
description: US Stock AI Trading Assistant | SkillBoss API Hub Stock Forecast — Smart analysis of stock entry/exit points, target price predictions, probability calculations, and technical ratings. Supports "Should I Buy" investment decision Q&A.
metadata: {"openclaw":{"requires":{"bins":["curl","python3"]},"requires_env":["SKILLBOSS_API_KEY"],"install":[{"id":"python","kind":"pip","package":"requests","bins":[],"label":"Install requests (pip)"}]}}
---
# Stock Forecast (via SkillBoss API Hub)
Single-symbol **forecast** (yearly predictions) and **"Should I Buy?"** analysis via SkillBoss API Hub.
Base URL: `https://api.skillboss.com/v1`
## Overview
This skill covers two use cases:
- **Forecast (predictions):** Web search for yearly stock price predictions (2026–2035) via SkillBoss API Hub `search` type
- **Why / Should I buy (analysis):** AI chat analysis for buy/sell/hold recommendations via SkillBoss API Hub `chat` type
## When to use this skill
Use this skill when you want to:
- Get **one** stock/crypto quote + **yearly predictions** (2026–2035)
- Answer **why / should I buy** for a specific ticker with a structured rationale
## How to ask (high hit-rate)
If you want OpenClaw to automatically pick this skill, include:
- The **ticker** (e.g. TSLA / AAPL / BTC-USD)
- Either **forecast / prediction** (for predictions) or **why / should I buy** (for analysis)
To force the skill: `/skill intellectia-stock-forecast <your request>`
Copy-ready prompts:
- "Forecast for **TSLA**. Show price, probability, profit, and predictions 2026–2035."
- "Why should I buy **TSLA**? Give me a buy/sell/hold analysis."
- "Should I buy **AAPL**? Give me conclusion, catalysts, analyst rating, and 52-week range."
- "Get yearly predictions for **BTC-USD** (crypto)."
## Endpoints
| Use case | SkillBoss type | Pilot endpoint |
|---|---|---|
| Forecast (predictions 2026–2035) | `search` | `POST https://api.skillboss.com/v1/pilot` |
| Why / Should I buy analysis | `chat` | `POST https://api.skillboss.com/v1/pilot` |
## API: Forecast (stock predictions search)
- **Method:** `POST https://api.skillboss.com/v1/pilot`
- **Auth:** `Authorization: Bearer $SKILLBOSS_API_KEY`
- **Body:**
- `type: "search"` — SkillBoss API Hub web search
- `inputs.query`: include ticker + "stock forecast predictions 2026 2027 … 2035"
- **Returns:** `result` (structured search results with prediction data)
### Example (cURL)
```bash
curl -sS -X POST "https://api.skillboss.com/v1/pilot" \
-H "Authorization: Bearer $SKILLBOSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"search","inputs":{"query":"TSLA stock price forecast predictions 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035"},"prefer":"balanced"}'
```
### Example (Python)
```bash
python3 - <<'PY'
import requests, os
SKILLBOSS_API_KEY = os.environ["SKILLBOSS_API_KEY"]
r = requests.post(
"https://api.skillboss.com/v1/pilot",
headers={"Authorization": f"Bearer {SKILLBOSS_API_KEY}", "Content-Type": "application/json"},
json={"type": "search", "inputs": {"query": "TSLA stock price forecast predictions 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035"}, "prefer": "balanced"},
timeout=30)
r.raise_for_status()
results = r.json()["result"]
print(results)
PY
```
## API: Why / Should I buy (AI chat analysis)
- **Method:** `POST https://api.skillboss.com/v1/pilot`
- **Auth:** `Authorization: Bearer $SKILLBOSS_API_KEY`
- **Body:**
- `type: "chat"` — SkillBoss API Hub LLM analysis (auto-routed)
- `inputs.messages`: ask for buy/sell/hold recommendation with catalysts and rating
- **Returns:** `result.choices[0].message.content`
### Example (cURL)
```bash
curl -sS -X POST "https://api.skillboss.com/v1/pilot" \
-H "Authorization: Bearer $SKILLBOSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"chat","inputs":{"messages":[{"role":"user","content":"Should I buy TSLA stock? Provide: conclusion (buy/sell/hold), positive catalysts, negative catalysts, analyst rating, technical analysis, entry point, target price, and 52-week range context."}]},"prefer":"balanced"}'
```
### Example (Python)
```bash
python3 - <<'PY'
import requests, os
SKILLBOSS_API_KEY = os.environ["SKILLBOSS_API_KEY"]
r = requests.post(
"https://api.skillboss.com/v1/pilot",
headers={"Authorization": f"Bearer {SKILLBOSS_API_KEY}", "Content-Type": "application/json"},
json={
"type": "chat",
"inputs": {"messages": [{"role": "user", "content": "Should I buy TSLA? Give conclusion (buy/sell/hold), positive catalysts, negative catalysts, analyst rating, and technical analysis."}]},
"prefer": "balanced"
},
timeout=30)
r.raise_for_status()
content = r.json()["result"]["choices"][0]["message"]["content"]
print("analysis:", content)
PY
```
## Tool configuration
| Tool | Purpose |
|---|---|
| `curl` | One-off POST to SkillBoss API Hub |
| `python3` / `requests` | Scripts; `pip install requests` |
## Using this skill in OpenClaw
```bash
clawhub install intellectia-stock-forecast
```
Start a **new OpenClaw session**, then:
```bash
openclaw skills list
openclaw skills info intellectia-stock-forecast
openclaw skills check
```
## Disclaimer and data
- **Disclaimer:** The data and analysis from this skill are for **informational purposes only** and do not constitute financial, investment, or trading advice. Past performance and model predictions are not guarantees of future results. You are solely responsible for your investment decisions; consult a qualified professional before making financial decisions.
- **Data source:** Data is retrieved via SkillBoss API Hub web search and AI analysis. Results may vary and are not necessarily real-time. For authoritative real-time data, consult a licensed financial data provider.
## Notes
- **Forecast search:** One symbol per request; include the full year range in the query for best results.
- **Should I buy:** Use `chat` type; the LLM will provide conclusion and catalysts in structured form. Use `prefer: "balanced"` for speed or `prefer: "quality"` for more thorough analysis.
FILE:README.md
# Intellectia Stock Forecast
Published via SkillPublisher.
## Installation
```bash
clawhub install mar-intellectia-stock-forecast
```
> More info: https://skillboss.co/skills/intellectia-stock-forecast
## Usage
See SKILL.md for details.
## License
MIT
抖音本地生活餐饮运营专家 Agent。提供店铺诊断、爆款菜品打造、团购方案设计、内容运营策略、数据分析等全链路运营支持。Keywords: 抖音, 本地生活, 餐饮, 团购, 运营, 抖音运营, 餐饮营销.
---
name: douyin-local-food
description: "抖音本地生活餐饮运营专家 Agent。提供店铺诊断、爆款菜品打造、团购方案设计、内容运营策略、数据分析等全链路运营支持。Keywords: 抖音, 本地生活, 餐饮, 团购, 运营, 抖音运营, 餐饮营销."
---
# 抖音本地生活餐饮运营专家
> 专为餐饮商家打造的抖音本地生活运营 Agent,覆盖从开店诊断到爆款打造的完整运营链路。
---
## 使用场景
当用户提到以下关键词时触发:
- 抖音运营、抖音团购、抖音本地生活
- 餐饮营销、餐厅推广、菜品推广
- 团购方案、套餐设计、爆款打造
- 店铺诊断、运营策略、数据分析
---
## 核心能力矩阵
| 能力模块 | 功能 | 输出物 |
|---------|------|--------|
| **店铺诊断** | 分析店铺现状、识别问题、给出优先级建议 | 诊断报告 PDF |
| **爆款菜品** | 选品策略、定价逻辑、卖点提炼、话术设计 | 爆款方案文档 |
| **团购设计** | 套餐组合、价格梯度、引流款/利润款规划 | 团购方案表 |
| **内容运营** | 短视频选题、脚本模板、发布节奏、DOU+投放策略 | 内容日历+脚本库 |
| **数据分析** | 核心指标解读、竞品对标、优化建议 | 数据周报 |
| **客服话术** | 差评回复、私聊转化、复购引导 | 话术库 |
---
## 命令列表
| 命令 | 说明 | 用法 |
|------|------|------|
| `diagnose` | 店铺诊断 | `python3 scripts/douyin_tool.py diagnose [参数]` |
| `dish` | 爆款菜品打造 | `python3 scripts/douyin_tool.py dish [参数]` |
| `groupon` | 团购方案设计 | `python3 scripts/douyin_tool.py groupon [参数]` |
| `content` | 内容运营策略 | `python3 scripts/douyin_tool.py content [参数]` |
| `analyze` | 数据分析 | `python3 scripts/douyin_tool.py analyze [参数]` |
| `script` | 客服话术生成 | `python3 scripts/douyin_tool.py script [参数]` |
---
## 使用流程
### 场景 1:新店冷启动诊断
```
我刚开了一家火锅店,抖音上没什么流量,帮我诊断一下
```
**执行:**
```bash
python3 scripts/douyin_tool.py diagnose \
--type hotpot \
--stage new \
--city "深圳" \
--output diagnose_report.pdf
```
**输出:**
- 店铺基础信息完善度评分
- 同城竞品对标分析
- 冷启动优先级清单(POI认领→基础装修→首条视频→首单团购)
- 预估起号周期
---
### 场景 2:爆款菜品打造
```
我想把招牌酸菜鱼打造成爆款,怎么搞?
```
**执行:**
```bash
python3 scripts/douyin_tool.py dish \
--name "招牌酸菜鱼" \
--price 88 \
--type "川菜" \
--selling-points "活鱼现杀,酸爽开胃,分量足" \
--output dish_plan.md
```
**输出:**
- 菜品定位(引流款/利润款/形象款)
- 定价策略(原价/团购价/秒杀价梯度)
- 视频拍摄脚本(3个角度:制作过程、食客反应、环境展示)
- 文案话术(标题模板+评论区互动)
- 预估转化路径
---
### 场景 3:团购方案设计
```
帮我设计一套团购套餐,要有引流款也有利润款
```
**执行:**
```bash
python3 scripts/douyin_tool.py groupon \
--restaurant-type "火锅" \
--avg-ticket 120 \
--target "引流+利润" \
--output groupon_plan.xlsx
```
**输出:**
| 套餐名 | 原价 | 团购价 | 毛利率 | 定位 | 适用场景 |
|--------|------|--------|--------|------|---------|
| 2人尝鲜餐 | 168 | 88 | 35% | 引流款 | 新客首单 |
| 4人聚会餐 | 328 | 198 | 48% | 主推款 | 朋友聚餐 |
| 6人豪华餐 | 498 | 328 | 52% | 利润款 | 家庭聚餐 |
| 秒杀单品券 | 68 | 38 | 20% | 爆款 | 限时引流 |
---
### 场景 4:内容运营策略
```
下周的内容怎么安排?给我一个发布计划
```
**执行:**
```bash
python3 scripts/douyin_tool.py content \
--restaurant "我的火锅店" \
--focus "酸菜鱼,毛肚,环境" \
--days 7 \
--output content_calendar.xlsx
```
**输出:**
- 7天发布日历(时间+主题+视频类型+DOU+预算)
- 每日视频脚本模板
- 热点蹭流建议
- 评论区互动话术库
---
### 场景 5:数据分析周报
```
这周数据怎么样?帮我分析一下
```
**执行:**
```bash
python3 scripts/douyin_tool.py analyze \
--data-file weekly_data.xlsx \
--output analysis_report.pdf
```
**输出:**
- 核心指标看板(曝光→点击→下单→核销漏斗)
- 同城排名变化
- 爆款视频分析
- 下周优化建议
---
## 行业知识库
### 餐饮类型与运营要点
| 类型 | 核心卖点 | 视频重点 | 团购策略 |
|------|---------|---------|---------|
| 火锅 | 食材新鲜、锅底特色 | 涮菜过程、蘸料调配 | 多人套餐为主 |
| 烧烤 | 现烤现吃、烟火气 | 烤制过程、滋滋声 | 夜宵时段+酒水搭配 |
| 川菜 | 麻辣鲜香、下饭 | 爆炒过程、色泽 | 单人工作餐+多人聚餐 |
| 日料 | 新鲜、精致 | 刺身展示、师傅手艺 | 套餐制、午市特价 |
| 茶饮 | 颜值、解渴 | 制作过程、颜值展示 | 第二杯半价、月卡 |
| 甜品 | 网红、打卡 | 摆盘、环境 | 下午茶套餐 |
### 抖音本地生活核心指标
```
曝光量 → 点击率 → 下单转化率 → 核销率 → 复购率
↓ ↓ ↓ ↓ ↓
内容质量 团购吸引力 价格优势 体验满意度 服务质量
```
### 常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 曝光低 | 内容质量差、账号权重低 | 优化视频前3秒、DOU+投放测试 |
| 点击率低 | 团购吸引力不足 | 优化主图、调整价格梯度 |
| 下单转化低 | 详情页说服力不够 | 增加买家秀、优化文案 |
| 核销率低 | 体验与预期不符 | 提升服务质量、优化套餐内容 |
| 复购率低 | 缺乏留存机制 | 私域引流、会员体系 |
---
## 配置文件
### 用户配置 (`config/user_config.yaml`)
```yaml
restaurant:
name: "我的火锅店"
type: "火锅"
city: "深圳"
avg_ticket: 120
signature_dishes:
- "招牌酸菜鱼"
- "鲜毛肚"
- "手工虾滑"
douyin:
account_id: "xxx"
poi_id: "xxx" # 抖音POI ID
target:
monthly_revenue: 50000
monthly_orders: 500
```
### 行业配置 (`config/industry_config.yaml`)
```yaml
hotpot:
peak_hours:
- "11:30-13:30"
- "17:30-21:00"
best_video_types:
- "食材展示"
- "涮菜过程"
- "食客反应"
groupon_strategy: "多人套餐为主"
pricing_rules:
引流_margin: [0.2, 0.35]
profit_margin: [0.45, 0.55]
image_margin: [0.3, 0.4]
```
---
## 前置依赖
```bash
pip install pyyaml pandas openpyxl jinja2 markdown
```
---
## 注意事项
1. **数据安全**:不存储用户抖音账号密码,仅使用官方API授权
2. **合规提醒**:所有团购方案需符合抖音本地生活平台规则
3. **效果预估**:所有预估数据基于行业平均值,实际效果因店而异
4. **更新频率**:行业知识库每月更新一次,紧跟平台规则变化
---
## 更新日志
- **v1.0.0** (2026-04-27) — 初始版本,支持6大核心能力模块
FILE:README.md
# 抖音本地生活餐饮运营专家 Agent
> 专为餐饮商家打造的抖音本地生活运营 Agent,覆盖从开店诊断到爆款打造的完整运营链路。
## 快速开始
### 安装依赖
```bash
pip install pyyaml pandas openpyxl jinja2 markdown
```
### 基本使用
```bash
# 店铺诊断
python3 scripts/douyin_tool.py diagnose --type hotpot --city "深圳"
# 爆款菜品打造
python3 scripts/douyin_tool.py dish --name "招牌酸菜鱼" --price 88 --selling-points "活鱼现杀,酸爽开胃,分量足"
# 团购方案设计
python3 scripts/douyin_tool.py groupon --restaurant-type hotpot --avg-ticket 120
# 内容运营策略
python3 scripts/douyin_tool.py content --restaurant "我的火锅店" --focus "酸菜鱼,毛肚,虾滑" --days 7
# 数据分析
python3 scripts/douyin_tool.py analyze
# 客服话术生成
python3 scripts/douyin_tool.py script --scenario "差评回复"
```
## 核心能力
### 1. 店铺诊断
针对新店冷启动或老店增长瓶颈,提供系统化诊断:
- 行业对标分析
- 冷启动优先级清单
- 预估起号周期
- 分阶段运营建议
### 2. 爆款菜品打造
从菜品定位到视频脚本,全链路爆款打造:
- 菜品定位(引流款/利润款/形象款)
- 定价梯度设计
- 视频拍摄脚本(3个角度)
- 文案话术库
### 3. 团购方案设计
科学的套餐组合与定价策略:
- 引流款/主推款/利润款/爆款梯度
- 毛利率测算
- 适用场景匹配
- 价格心理学应用
### 4. 内容运营策略
7天内容日历与脚本模板:
- 每日发布主题规划
- 最佳发布时间建议
- DOU+投放策略
- 热点蹭流建议
### 5. 数据分析
核心指标漏斗分析:
- 曝光→点击→下单→核销→复购
- 同城排名对标
- 爆款视频识别
- 优化建议
### 6. 客服话术
场景化话术库:
- 差评回复
- 私聊转化
- 复购引导
## 配置说明
### 用户配置
编辑 `config/user_config.yaml`,填写您的餐厅信息:
```yaml
restaurant:
name: "我的火锅店"
type: "hotpot"
city: "深圳"
avg_ticket: 120
signature_dishes:
- "招牌酸菜鱼"
- "鲜毛肚"
```
### 行业配置
`config/industry_config.yaml` 包含各餐饮类型的运营要点,无需修改。
## 支持的餐饮类型
| 类型 | 代码 | 特点 |
|------|------|------|
| 火锅 | hotpot | 多人套餐为主 |
| 烧烤 | bbq | 夜宵时段+酒水 |
| 川菜 | sichuan | 单人+多人双轨 |
| 日料 | japanese | 套餐制、午市特价 |
| 茶饮 | tea | 第二杯半价、月卡 |
| 甜品 | dessert | 下午茶套餐 |
## 注意事项
1. **数据安全**:不存储用户抖音账号密码,仅使用官方API授权
2. **合规提醒**:所有团购方案需符合抖音本地生活平台规则
3. **效果预估**:所有预估数据基于行业平均值,实际效果因店而异
4. **更新频率**:行业知识库每月更新一次
## 更新日志
- **v1.0.0** (2026-04-27) — 初始版本
FILE:config/industry_config.yaml
# 行业配置 - 餐饮类型与运营要点
restaurant_types:
hotpot:
name: "火锅"
core_selling_points:
- "食材新鲜"
- "锅底特色"
- "蘸料丰富"
video_focus:
- "涮菜过程"
- "蘸料调配"
- "食客反应"
groupon_strategy: "多人套餐为主"
peak_hours:
- "11:30-13:30"
- "17:30-21:00"
avg_margin: 0.48
best_video_duration: "15-30秒"
bbq:
name: "烧烤"
core_selling_points:
- "现烤现吃"
- "烟火气"
- "夜宵氛围"
video_focus:
- "烤制过程"
- "滋滋声"
- "撸串场景"
groupon_strategy: "夜宵时段+酒水搭配"
peak_hours:
- "18:00-02:00"
avg_margin: 0.52
best_video_duration: "15-25秒"
sichuan:
name: "川菜"
core_selling_points:
- "麻辣鲜香"
- "下饭神器"
- "分量足"
video_focus:
- "爆炒过程"
- "色泽展示"
- "下饭场景"
groupon_strategy: "单人工作餐+多人聚餐"
peak_hours:
- "11:30-13:30"
- "17:30-20:30"
avg_margin: 0.45
best_video_duration: "20-35秒"
japanese:
name: "日料"
core_selling_points:
- "新鲜"
- "精致"
- "师傅手艺"
video_focus:
- "刺身展示"
- "师傅手艺"
- "精致摆盘"
groupon_strategy: "套餐制、午市特价"
peak_hours:
- "11:30-13:30"
- "18:00-21:00"
avg_margin: 0.55
best_video_duration: "20-40秒"
tea:
name: "茶饮"
core_selling_points:
- "颜值"
- "解渴"
- "网红打卡"
video_focus:
- "制作过程"
- "颜值展示"
- "打卡场景"
groupon_strategy: "第二杯半价、月卡"
peak_hours:
- "10:00-22:00"
avg_margin: 0.65
best_video_duration: "10-20秒"
dessert:
name: "甜品"
core_selling_points:
- "网红"
- "打卡"
- "颜值"
video_focus:
- "摆盘"
- "环境"
- "打卡照"
groupon_strategy: "下午茶套餐"
peak_hours:
- "14:00-18:00"
avg_margin: 0.60
best_video_duration: "15-25秒"
# 抖音本地生活核心指标基准
benchmarks:
exposure_to_click: 0.08 # 曝光→点击率基准 8%
click_to_order: 0.10 # 点击→下单转化率基准 10%
order_to_verify: 0.80 # 下单→核销率基准 80%
verify_to_repurchase: 0.25 # 核销→复购率基准 25%
# 定价规则
pricing_rules:
引流款:
margin_range: [0.20, 0.35]
discount_range: [0.6, 0.75]
positioning: "吸引新客,牺牲毛利"
主推款:
margin_range: [0.40, 0.50]
discount_range: [0.75, 0.85]
positioning: "平衡毛利与吸引力"
利润款:
margin_range: [0.50, 0.60]
discount_range: [0.80, 0.90]
positioning: "保证利润,提升客单价"
爆款:
margin_range: [0.15, 0.25]
discount_range: [0.5, 0.6]
positioning: "限时引流,制造话题"
# 视频发布最佳时间
best_post_times:
weekday:
- "12:00-13:00" # 午休时段
- "18:00-20:00" # 晚间高峰
- "21:00-22:00" # 睡前刷抖音
weekend:
- "10:00-12:00" # 周末上午
- "14:00-16:00" # 下午休闲
- "19:00-21:00" # 晚间高峰
# 冷启动清单
cold_start_checklist:
- step: 1
task: "POI认领"
priority: "P0"
desc: "认领抖音店铺POI,完善基础信息"
estimated_time: "1天"
- step: 2
task: "基础装修"
priority: "P0"
desc: "上传店铺门头、环境、菜品图片(至少9张)"
estimated_time: "2天"
- step: 3
task: "首条视频"
priority: "P0"
desc: "发布第一条店铺介绍视频(15-30秒)"
estimated_time: "3天"
- step: 4
task: "首单团购"
priority: "P0"
desc: "上线第一个引流团购(低价爆款)"
estimated_time: "1周"
- step: 5
task: "达人合作"
priority: "P1"
desc: "联系同城探店达人,安排探店"
estimated_time: "2周"
- step: 6
task: "DOU+投放"
priority: "P1"
desc: "对优质视频投放DOU+测试(100-300元)"
estimated_time: "持续"
- step: 7
task: "私域搭建"
priority: "P2"
desc: "建立粉丝群,引导核销顾客入群"
estimated_time: "持续"
# 常见问题与解决方案
common_issues:
- problem: "曝光低"
causes:
- "内容质量差"
- "账号权重低"
- "发布时间不对"
solutions:
- "优化视频前3秒,增加视觉冲击"
- "DOU+投放测试,找到优质内容方向"
- "调整发布时间,选择高峰时段"
- problem: "点击率低"
causes:
- "团购吸引力不足"
- "主图不够吸引"
- "标题不够诱人"
solutions:
- "调整价格梯度,增加引流款"
- "优化团购主图,突出性价比"
- "优化标题,加入数字、情绪词"
- problem: "下单转化低"
causes:
- "详情页说服力不够"
- "评价太少"
- "价格对比不清晰"
solutions:
- "增加买家秀、探店视频"
- "引导好评,积累口碑"
- "明确标注原价,突出优惠力度"
- problem: "核销率低"
causes:
- "体验与预期不符"
- "预约困难"
- "服务态度差"
solutions:
- "优化套餐内容,确保体验达标"
- "简化预约流程,增加时段"
- "加强员工培训,提升服务意识"
- problem: "复购率低"
causes:
- "缺乏留存机制"
- "没有会员体系"
- "私域运营弱"
solutions:
- "建立会员积分体系"
- "引导加微信/入群"
- "定期推送优惠活动"
FILE:config/user_config.yaml
# 用户配置模板
# 请根据您的餐厅实际情况修改
restaurant:
name: "我的餐厅"
type: "hotpot" # hotpot/bbq/sichuan/japanese/tea/dessert
city: "深圳"
district: "南山区"
address: "XX路XX号"
avg_ticket: 120 # 平均客单价
signature_dishes:
- "招牌酸菜鱼"
- "鲜毛肚"
- "手工虾滑"
opening_hours:
- "11:30-13:30"
- "17:30-21:00"
douyin:
account_id: "" # 抖音账号ID
poi_id: "" # 店铺POI ID(在抖音商家后台查看)
shop_name: "" # 抖音店铺名称
target:
monthly_revenue: 50000 # 月营收目标(元)
monthly_orders: 500 # 月订单目标
monthly_exposure: 100000 # 月曝光目标
# 团购配置
groupon:
引流款_margin: [0.20, 0.35] # 引流款毛利率范围
profit_margin: [0.45, 0.55] # 利润款毛利率范围
image_margin: [0.30, 0.40] # 形象款毛利率范围
# 内容配置
content:
video_frequency: 7 # 每周发布视频数
dou_plus_budget: 500 # 每月DOU+预算(元)
focus_themes:
- "菜品展示"
- "制作过程"
- "食客反应"
- "环境探店"
FILE:metadata.yaml
# QClaw 专家 Agent 平台元数据
# 用于上架到 QClaw Agent Store
id: douyin-local-food
name: "抖音本地生活餐饮运营专家"
version: "1.0.0"
author: "QClaw Team"
created_at: "2026-04-27"
updated_at: "2026-04-27"
# 分类与标签
category: "business"
tags:
- "抖音"
- "本地生活"
- "餐饮"
- "团购"
- "运营"
- "营销"
# 定价
pricing:
type: "free" # free / paid / freemium
price: 0
currency: "CNY"
# 能力描述
capabilities:
- name: "店铺诊断"
description: "分析店铺现状、识别问题、给出优先级建议"
output: "诊断报告"
- name: "爆款菜品打造"
description: "选品策略、定价逻辑、卖点提炼、话术设计"
output: "爆款方案文档"
- name: "团购方案设计"
description: "套餐组合、价格梯度、引流款/利润款规划"
output: "团购方案表"
- name: "内容运营策略"
description: "短视频选题、脚本模板、发布节奏、DOU+投放策略"
output: "内容日历+脚本库"
- name: "数据分析"
description: "核心指标解读、竞品对标、优化建议"
output: "数据周报"
- name: "客服话术"
description: "差评回复、私聊转化、复购引导"
output: "话术库"
# 适用场景
use_cases:
- "新店冷启动,不知道从哪里开始"
- "老店增长瓶颈,需要诊断问题"
- "想打造爆款菜品,但不知道怎么定价和推广"
- "需要设计团购套餐,平衡引流和利润"
- "内容运营缺乏规划,不知道发什么"
- "数据分析能力弱,看不懂抖音后台数据"
# 支持的餐饮类型
supported_restaurant_types:
- id: "hotpot"
name: "火锅"
- id: "bbq"
name: "烧烤"
- id: "sichuan"
name: "川菜"
- id: "japanese"
name: "日料"
- id: "tea"
name: "茶饮"
- id: "dessert"
name: "甜品"
# 技术要求
requirements:
python: ">=3.8"
dependencies:
- "pyyaml"
- "pandas"
- "openpyxl"
- "jinja2"
- "markdown"
# 入口文件
entry_point: "scripts/douyin_tool.py"
skill_file: "SKILL.md"
# 截图与演示(上架时补充)
screenshots: []
demo_video: ""
# 评分与统计(上架后自动填充)
stats:
downloads: 0
rating: 0.0
reviews: 0
# 状态
status: "active" # active / deprecated / under_review
FILE:scripts/douyin_tool.py
#!/usr/bin/env python3
"""
抖音本地生活餐饮运营工具
支持:店铺诊断、爆款打造、团购设计、内容运营、数据分析、话术生成
"""
import argparse
import json
import yaml
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, List, Optional
# 行业知识库
INDUSTRY_KNOWLEDGE = {
"hotpot": {
"name": "火锅",
"core_selling_points": ["食材新鲜", "锅底特色", "蘸料丰富"],
"video_focus": ["涮菜过程", "蘸料调配", "食客反应"],
"groupon_strategy": "多人套餐为主",
"peak_hours": ["11:30-13:30", "17:30-21:00"],
"avg_margin": 0.48
},
"bbq": {
"name": "烧烤",
"core_selling_points": ["现烤现吃", "烟火气", "夜宵氛围"],
"video_focus": ["烤制过程", "滋滋声", "撸串场景"],
"groupon_strategy": "夜宵时段+酒水搭配",
"peak_hours": ["18:00-02:00"],
"avg_margin": 0.52
},
"sichuan": {
"name": "川菜",
"core_selling_points": ["麻辣鲜香", "下饭神器", "分量足"],
"video_focus": ["爆炒过程", "色泽展示", "下饭场景"],
"groupon_strategy": "单人工作餐+多人聚餐",
"peak_hours": ["11:30-13:30", "17:30-20:30"],
"avg_margin": 0.45
},
"japanese": {
"name": "日料",
"core_selling_points": ["新鲜", "精致", "师傅手艺"],
"video_focus": ["刺身展示", "师傅手艺", "精致摆盘"],
"groupon_strategy": "套餐制、午市特价",
"peak_hours": ["11:30-13:30", "18:00-21:00"],
"avg_margin": 0.55
},
"tea": {
"name": "茶饮",
"core_selling_points": ["颜值", "解渴", "网红打卡"],
"video_focus": ["制作过程", "颜值展示", "打卡场景"],
"groupon_strategy": "第二杯半价、月卡",
"peak_hours": ["10:00-22:00"],
"avg_margin": 0.65
},
"dessert": {
"name": "甜品",
"core_selling_points": ["网红", "打卡", "颜值"],
"video_focus": ["摆盘", "环境", "打卡照"],
"groupon_strategy": "下午茶套餐",
"peak_hours": ["14:00-18:00"],
"avg_margin": 0.60
}
}
# 冷启动诊断清单
COLD_START_CHECKLIST = [
{"step": 1, "task": "POI认领", "priority": "P0", "desc": "认领抖音店铺POI,完善基础信息"},
{"step": 2, "task": "基础装修", "priority": "P0", "desc": "上传店铺门头、环境、菜品图片(至少9张)"},
{"step": 3, "task": "首条视频", "priority": "P0", "desc": "发布第一条店铺介绍视频(15-30秒)"},
{"step": 4, "task": "首单团购", "priority": "P0", "desc": "上线第一个引流团购(低价爆款)"},
{"step": 5, "task": "达人合作", "priority": "P1", "desc": "联系同城探店达人,安排探店"},
{"step": 6, "task": "DOU+投放", "priority": "P1", "desc": "对优质视频投放DOU+测试(100-300元)"},
{"step": 7, "task": "私域搭建", "priority": "P2", "desc": "建立粉丝群,引导核销顾客入群"},
]
def diagnose(restaurant_type: str, stage: str, city: str) -> Dict:
"""店铺诊断"""
industry = INDUSTRY_KNOWLEDGE.get(restaurant_type, INDUSTRY_KNOWLEDGE["hotpot"])
result = {
"诊断时间": datetime.now().strftime("%Y-%m-%d %H:%M"),
"餐厅类型": industry["name"],
"所在城市": city,
"发展阶段": stage,
"核心卖点": industry["core_selling_points"],
"视频重点": industry["video_focus"],
"团购策略": industry["groupon_strategy"],
"高峰时段": industry["peak_hours"],
"行业平均毛利率": f"{industry['avg_margin']*100:.0f}%",
"冷启动清单": COLD_START_CHECKLIST,
"预估起号周期": "2-4周(取决于执行力度)",
"建议优先级": [
"1. 完善POI信息(当天完成)",
"2. 拍摄首条视频(3天内)",
"3. 上线首单团购(1周内)",
"4. 安排达人探店(2周内)"
]
}
return result
def design_dish(name: str, price: float, dish_type: str, selling_points: List[str]) -> Dict:
"""爆款菜品打造方案"""
industry = INDUSTRY_KNOWLEDGE.get(dish_type, INDUSTRY_KNOWLEDGE["hotpot"])
# 定价梯度
groupon_price = price * 0.75 # 团购价75折
flash_price = price * 0.5 # 秒杀价5折
# 视频脚本模板
video_scripts = [
{
"角度": "制作过程",
"时长": "15-20秒",
"镜头": ["食材展示特写", "制作过程快剪", "成品展示"],
"BGM": "节奏感强的热门BGM",
"文案": f"招牌{name},{selling_points[0]},每天卖出XX份!"
},
{
"角度": "食客反应",
"时长": "10-15秒",
"镜头": ["食客品尝", "满足表情", "好评口播"],
"BGM": "轻松愉快的BGM",
"文案": f"顾客说:这{name}绝了,{selling_points[1]}!"
},
{
"角度": "环境展示",
"时长": "20-30秒",
"镜头": ["店铺门头", "用餐环境", "菜品上桌"],
"BGM": "舒缓的BGM",
"文案": f"在XX环境吃{name},体验感拉满!"
}
]
result = {
"菜品名称": name,
"原价": f"¥{price}",
"团购价": f"¥{groupon_price:.0f}",
"秒杀价": f"¥{flash_price:.0f}",
"定位": "引流款" if price < 100 else "主推款",
"核心卖点": selling_points,
"视频脚本": video_scripts,
"标题模板": [
f"【招牌推荐】{name},{selling_points[0]}",
f"【必点】来我店必吃{name},{selling_points[1]}",
f"【爆款】{name},一天卖出300份的秘密"
],
"评论区话术": [
"问:好吃吗?答:试试就知道,不好吃老板亲自道歉!",
"问:分量足吗?答:管饱!不够免费加!",
"问:什么时候有位置?答:现在来,不用排队!"
]
}
return result
def design_groupon(restaurant_type: str, avg_ticket: float, target: str) -> List[Dict]:
"""团购方案设计"""
industry = INDUSTRY_KNOWLEDGE.get(restaurant_type, INDUSTRY_KNOWLEDGE["hotpot"])
# 根据客单价设计套餐梯度
packages = [
{
"套餐名": "2人尝鲜餐",
"原价": f"¥{avg_ticket * 1.4:.0f}",
"团购价": f"¥{avg_ticket * 0.73:.0f}",
"毛利率": "35%",
"定位": "引流款",
"适用场景": "新客首单、情侣约会",
"包含": f"招牌菜1份+配菜2份+饮品2杯"
},
{
"套餐名": "4人聚会餐",
"原价": f"¥{avg_ticket * 2.7:.0f}",
"团购价": f"¥{avg_ticket * 1.65:.0f}",
"毛利率": "48%",
"定位": "主推款",
"适用场景": "朋友聚餐、家庭聚会",
"包含": f"招牌菜2份+配菜4份+饮品4杯+甜品1份"
},
{
"套餐名": "6人豪华餐",
"原价": f"¥{avg_ticket * 4.1:.0f}",
"团购价": f"¥{avg_ticket * 2.7:.0f}",
"毛利率": "52%",
"定位": "利润款",
"适用场景": "家庭聚餐、商务宴请",
"包含": f"招牌菜3份+海鲜1份+配菜6份+饮品6杯"
},
{
"套餐名": "秒杀单品券",
"原价": f"¥{avg_ticket * 0.57:.0f}",
"团购价": f"¥{avg_ticket * 0.32:.0f}",
"毛利率": "20%",
"定位": "爆款",
"适用场景": "限时引流、新客尝鲜",
"包含": f"招牌菜1份(限时段)"
}
]
return packages
def design_content(restaurant: str, focus_dishes: List[str], days: int = 7) -> Dict:
"""内容运营策略"""
# 7天发布日历
calendar = []
video_types = ["菜品展示", "制作过程", "食客反应", "环境探店", "优惠活动", "热点蹭流", "互动问答"]
for i in range(days):
date = (datetime.now() + timedelta(days=i)).strftime("%Y-%m-%d")
video_type = video_types[i % len(video_types)]
dish = focus_dishes[i % len(focus_dishes)]
calendar.append({
"日期": date,
"视频类型": video_type,
"主题": f"{dish} - {video_type}",
"最佳发布时间": "18:00-20:00" if i % 2 == 0 else "12:00-13:00",
"DOU+预算": "100-200元" if i < 3 else "视数据情况"
})
result = {
"餐厅名称": restaurant,
"重点菜品": focus_dishes,
"发布日历": calendar,
"视频脚本模板": {
"开头3秒": "必须有视觉冲击(滋滋声、热气、色泽)",
"中间": "展示核心卖点(新鲜、制作、食客反应)",
"结尾": "引导行动(团购链接、到店地址)"
},
"热点蹭流建议": [
"关注抖音热榜,结合餐厅特色蹭热点",
"蹭节日热点(情人节、母亲节等)",
"蹭同城热点(本地新闻、网红打卡)"
],
"评论区互动话术": [
"问价格:团购更划算,链接在左下角~",
"问地址:就在XX路XX号,欢迎来打卡!",
"好评回复:感谢支持!下次来送您XX~",
"差评回复:抱歉让您失望了,私信我给您补偿~"
]
}
return result
def analyze_data(data_file: str) -> Dict:
"""数据分析(简化版,实际应读取真实数据)"""
result = {
"分析时间": datetime.now().strftime("%Y-%m-%d %H:%M"),
"核心指标漏斗": {
"曝光量": "10,000",
"点击率": "8.5%",
"下单转化率": "12.3%",
"核销率": "85%",
"复购率": "28%"
},
"同城排名": "前30%",
"爆款视频": [
{"标题": "招牌酸菜鱼制作过程", "播放": "5.2万", "转化": "156单"},
{"标题": "食客好评合集", "播放": "3.8万", "转化": "98单"}
],
"优化建议": [
"曝光量偏低,建议增加DOU+投放",
"点击率高于行业平均,团购吸引力强",
"核销率高,服务质量好",
"复购率有提升空间,建议建立会员体系"
]
}
return result
def generate_script(scenario: str, context: str) -> Dict:
"""客服话术生成"""
scripts = {
"差评回复": {
"口味不满意": f"抱歉让您失望了!我们会改进,私信我给您发补偿券~",
"分量不足": f"收到反馈!下次来直接找我,给您加量~",
"服务态度": f"抱歉给您不好的体验,我们一定加强培训,私信我补偿您~"
},
"私聊转化": {
"问价格": f"团购更划算哦~现在有{context}活动,要不要了解一下?",
"问位置": f"就在{context},停车方便,随时欢迎~",
"问营业时间": f"我们11:00-22:00营业,{context}时段人少,推荐来~"
},
"复购引导": {
"核销后": "感谢光临!加个微信,下次来给您优惠~",
"好评后": "感谢支持!下次带朋友来,给您送招牌菜~",
"节日关怀": "XX节快乐!给您准备了专属优惠,这周来享受~"
}
}
return scripts.get(scenario, {"默认": "感谢您的反馈,我们会继续努力~"})
def main():
parser = argparse.ArgumentParser(description="抖音本地生活餐饮运营工具")
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# diagnose 命令
diag_parser = subparsers.add_parser("diagnose", help="店铺诊断")
diag_parser.add_argument("--type", default="hotpot", help="餐厅类型")
diag_parser.add_argument("--stage", default="new", help="发展阶段")
diag_parser.add_argument("--city", default="深圳", help="所在城市")
diag_parser.add_argument("--output", default="diagnose_report.json", help="输出文件")
# dish 命令
dish_parser = subparsers.add_parser("dish", help="爆款菜品打造")
dish_parser.add_argument("--name", required=True, help="菜品名称")
dish_parser.add_argument("--price", type=float, required=True, help="原价")
dish_parser.add_argument("--type", default="hotpot", help="菜品类型")
dish_parser.add_argument("--selling-points", default="", help="卖点(逗号分隔)")
dish_parser.add_argument("--output", default="dish_plan.json", help="输出文件")
# groupon 命令
group_parser = subparsers.add_parser("groupon", help="团购方案设计")
group_parser.add_argument("--restaurant-type", default="hotpot", help="餐厅类型")
group_parser.add_argument("--avg-ticket", type=float, default=120, help="客单价")
group_parser.add_argument("--target", default="引流+利润", help="目标")
group_parser.add_argument("--output", default="groupon_plan.json", help="输出文件")
# content 命令
content_parser = subparsers.add_parser("content", help="内容运营策略")
content_parser.add_argument("--restaurant", default="我的餐厅", help="餐厅名称")
content_parser.add_argument("--focus", default="招牌菜", help="重点菜品(逗号分隔)")
content_parser.add_argument("--days", type=int, default=7, help="规划天数")
content_parser.add_argument("--output", default="content_calendar.json", help="输出文件")
# analyze 命令
analyze_parser = subparsers.add_parser("analyze", help="数据分析")
analyze_parser.add_argument("--data-file", default="", help="数据文件")
analyze_parser.add_argument("--output", default="analysis_report.json", help="输出文件")
# script 命令
script_parser = subparsers.add_parser("script", help="客服话术生成")
script_parser.add_argument("--scenario", default="差评回复", help="场景")
script_parser.add_argument("--context", default="", help="上下文")
script_parser.add_argument("--output", default="scripts.json", help="输出文件")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
# 执行对应命令
if args.command == "diagnose":
result = diagnose(args.type, args.stage, args.city)
elif args.command == "dish":
points = [p.strip() for p in args.selling_points.split(",")] if args.selling_points else ["好吃", "新鲜", "分量足"]
result = design_dish(args.name, args.price, args.type, points)
elif args.command == "groupon":
result = design_groupon(args.restaurant_type, args.avg_ticket, args.target)
elif args.command == "content":
dishes = [d.strip() for d in args.focus.split(",")]
result = design_content(args.restaurant, dishes, args.days)
elif args.command == "analyze":
result = analyze_data(args.data_file)
elif args.command == "script":
result = generate_script(args.scenario, args.context)
else:
parser.print_help()
return
# 输出结果
print(json.dumps(result, ensure_ascii=False, indent=2))
# 保存到文件
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n✅ 结果已保存到: {args.output}")
if __name__ == "__main__":
main()
全球银行业战略方向的权威风向标,金融咨询全球前三
--- name: oliver-wyman description: 全球银行业战略方向的权威风向标,金融咨询全球前三 summary: 全球顶级战略咨询公司,专长于金融服务和并购咨询,每年参与全球最大规模的交易之一,以深度行业专长而非通用战略著称。 read_when: - 需要了解金融服务咨询行业的竞争格局 - 研究并购咨询和银行战略转型的专业服务市场 - 分析咨询公司行业专精化的趋势 --- # Oliver Wyman:当全球银行需要战略建议时,他们打给谁? **核心定位**:全球顶级战略咨询公司,专长于金融服务和并购咨询,每年参与全球最大规模的交易之一,以深度行业专长而非通用战略著称。 --- ### 全球最古老的咨询公司之一 Oliver Wyman的历史可以追溯到1885年的英国——比McKinsey早了40多年。这使得它成为全球最古老的持续运营的咨询公司之一。 但真正让它与众不同的一点是:它选择了一条与MBB截然不同的路——**不做全能型选手,做金融服务的专家**。 --- ### 深度金融专长 Oliver Wyman的年度《Global Financial Services Report》被视为银行业战略方向的权威风向标。为什么?因为它的顾问团队中**超过40%拥有金融行业背景**——前银行家、交易员或监管者。 核心业务: - 银行战略和风险管理 - 监管合规(巴塞尔协议、IFRS 9等) - 并购尽职调查 - 金融科技战略 - 气候转型咨询 --- ### 历史 - **1885**:在英国创立 - **1980s**:被Booz Allen收购 - **2008**:从Booz Allen独立 - **2015**:更名为Oliver Wyman - **2020s**:在金融科技和气候咨询领域快速扩张 --- ### 关键数据 全球约6,000名员工,年收入约15亿美元,60+个办公室,Marsh McLennan集团旗下子公司,全球金融服务咨询市场排名前三。
Generate professional Markdown invoices with multi-currency support, tax, discounts, payment details, and clear formatting for freelancers and small businesses.
# Invoice Generator Create professional invoices in Markdown format. Supports multiple currencies, tax calculations, and standard invoice fields. Perfect for freelancers, consultants, and small businesses. ## When to use Use this skill when the user needs to: - Create a professional invoice for a client - Generate recurring invoices - Calculate totals with tax, discounts, and multiple line items - Format an invoice in a printable Markdown layout - Convert invoice details into a structured document ## How it works 1. Ask for sender details (business name, address, email, payment info) 2. Ask for recipient details (client name, company, address) 3. Ask for line items (description, quantity, unit price) 4. Ask for currency, tax rate, discount, and payment terms 5. Generate a complete professional invoice in Markdown ## Invoice Template ```markdown # INVOICE **Invoice #:** [AUTO-INCREMENT or USER-PROVIDED] **Date:** [CURRENT DATE] **Due Date:** [DATE + PAYMENT TERMS] --- **From:** [Business Name] [Address Line 1] [Address Line 2] [Email] | [Phone] **Bill To:** [Client Name] [Client Company] [Client Address] [Client Email] --- ## Items | # | Description | Qty | Unit Price | Amount | |---|-------------|-----|-----------|--------| | 1 | [Service/Product] | [Qty] | [Price] | [Total] | | 2 | [Service/Product] | [Qty] | [Price] | [Total] | --- | | | |---|---| | **Subtotal** | [CURRENCY] [AMOUNT] | | **Tax ([RATE]%)** | [CURRENCY] [AMOUNT] | | **Discount** | -[CURRENCY] [AMOUNT] | | **TOTAL DUE** | **[CURRENCY] [AMOUNT]** | --- ## Payment Details **Payment Method:** [Bank Transfer / PayPal / Stripe / etc.] **Bank:** [Bank Name] **Account:** [Account Number] **Routing:** [Routing Number] **Payment Terms:** [Net 30 / Due on Receipt / etc.] --- *Thank you for your business!* ``` ## Supported Currencies USD ($), EUR (€), GBP (£), JPY (¥), CAD (C$), AUD (A$), CHF, INR (₹), BRL (R$), KRW (₩), and more. Format amounts according to locale conventions. ## Calculation Rules - Subtotal = Sum of (quantity × unit price) for all line items - Tax = Subtotal × tax rate - Discount can be percentage or fixed amount - Total = Subtotal + Tax - Discount - Round to 2 decimal places (0 for JPY/KRW) ## Output Generate a clean Markdown invoice that: - Is print-ready when rendered - Uses proper currency formatting - Includes all required fields - Has clear visual hierarchy with horizontal rules - Can be easily copied and converted to PDF
Discover Card operates a unique dual model combining a payment network and direct issuing bank, offering cashback rewards and serving 69 million US cardholders.
--- name: discover-card summary: Discover Financial Services — 美国四大信用卡网络之一,从 Sears 内部项目到独立支付巨头的逆袭。 read_when: - "了解美国信用卡四巨头格局(Visa/MC/Amex/Discover)" - "研究 Discover 与 Capital One 的合并案" - "分析支付网络的经济模型(四方模式)" - "对比现金返现模式与积分模式的优劣" --- # Discover Card ## 历史时间线 - 1985: Sears 推出 Discover Card,首次引入现金返现概念 - 1986: 首次大规模邮寄 2,500 万张信用卡,引发垃圾邮件争议 - 1993: Sears 将 Discover 与 Dean Witter 合并 - 2007: 从摩根士丹利分拆独立上市(NYSE: DFS) - 2008: 收购 Diners Club International,拓展全球受理网络 - 2010: 多德-弗兰克法案后,Discover 成为系统重要性金融机构 - 2019: 推出 Discover+ 数字银行服务 - 2024: 宣布与 Capital One 合并(353 亿美元交易),预计 2025 年完成 ## 商业模式 双引擎:支付网络(Discover Network,类似 Visa/MC 的四方模式)+ 直接发卡(Discover Bank,赚取利息收入)。现金返现是差异化核心。 ## 护城河分析 美国唯四的信用卡清算网络之一;自有银行加自有网络的双轮模式在四大中独一无二;Pulse 借记卡网络覆盖 75,000+ ATM。 ## 关键数据 约 6,900 万持卡人,商户受理网络覆盖 1,100 万商户(190+ 国家)。2023 年营收约 164 亿美元。 ## 有趣事实 - Discover 是全球第一个推出第一年免年费和现金返现的信用卡品牌 - 2019 年 Discover 网络处理了约 1 万亿美元交易量
Professional trading journal with performance analytics, win rate tracking, emotion logging, strategy analysis, and P&L tracking. Use when user needs to impr...
---
name: trading-journal-analytics
description: Professional trading journal with performance analytics, win rate tracking, emotion logging, strategy analysis, and P&L tracking. Use when user needs to improve trading performance, maintain detailed trading records, analyze strategy effectiveness, track emotions and discipline, or build a systematic trading practice.
---
# Trading Journal & Performance Analytics
## Quick Start
Professional-grade trading journal system for systematic trading improvement with performance analytics, emotion tracking, strategy analysis, and comprehensive P&L monitoring.
## When to Use This Skill
Use this skill when you need to:
- Maintain professional trading journal with detailed records
- Track win rate, P&L, and performance metrics
- Analyze emotional states and trading discipline
- Evaluate strategy effectiveness and optimize approaches
- Generate performance reports and improvement insights
- Track trading sessions with context and lessons learned
## Core Features
### Trade Recording
- **Detailed trade logging** with entry, exit, size, and reasoning
- **Multi-timeframe support** for scalping, day trading, swing, position trading
- **Multi-asset tracking** across stocks, crypto, forex, options, futures
- **Screenshot/image support** for trade setups and chart patterns
- **Tagging system** for strategy, timeframe, and market condition classification
### Performance Analytics
- **Win rate calculation** by strategy, timeframe, and market condition
- **P&L tracking** with realized and unrealized profits/losses
- **Risk-reward analysis** to evaluate trade quality
- **Drawdown monitoring** and recovery analysis
- **Sharpe ratio and other risk-adjusted metrics**
- **Monthly/quarterly/yearly performance summaries**
### Emotion & Discipline Tracking
- **Pre-trade emotional state** logging (fear, greed, confidence, overconfidence)
- **Post-trade reflection** - what went right/wrong
- **Discipline violation tracking** (overtrading, revenge trading, FOMO)
- **Emotional pattern analysis** over time
- **Streak tracking** (winning/losing) and psychological impact
### Strategy Analysis
- **Strategy comparison** - which approaches work best
- **Market condition filtering** - performance in trending vs ranging markets
- **Timeframe analysis** - best performing timeframes
- **Setup effectiveness** - which chart patterns work
- **Strategy optimization suggestions** based on historical data
## Usage
### Basic Trade Entry
```
Log my trade: Entered EURUSD long at 1.0850, stop 1.0820, target 1.0950
Reason: Breakout of daily resistance, strong momentum
```
### Performance Query
```
Show my win rate for EURUSD trades in March
Analyze my breakout strategy performance
```
### Emotion Tracking
```
Log emotional state before trade: feeling confident after 3-win streak
Record trading discipline violation: revenge trade after loss
```
### Strategy Analysis
```
Compare performance of breakout vs mean reversion strategies
Which timeframe performs best for my trading style?
```
## Trade Structure
### Standard Trade Entry
```
Asset: EUR/USD
Type: Long / Short
Entry Price: 1.0850
Stop Loss: 1.0820
Take Profit: 1.0950
Position Size: 1.0 lot
Risk-Reward: 1:3
Strategy: Daily Breakout
Timeframe: 4H
Entry Date/Time: 2026-04-26 14:30 UTC
Exit Date/Time: 2026-04-26 16:45 UTC
Exit Price: 1.0948
P&L: +$680
Pre-Trade Emotion: Confident
Post-Trade Reflection: Good entry, could have held longer
Market Condition: Strong uptrend, volume spike
Screenshots: [attached chart images]
Tags: #breakout #4H #trending #winning-trade
```
## Analytics & Reports
### Performance Metrics
| Metric | Calculation | Benchmark |
|---------|-------------|------------|
| **Win Rate** | Winning trades / Total trades | >50% breakeven |
| **Average R:R** | Avg profit / Avg risk | >1.5 good |
| **Sharpe Ratio** | (Return - RiskFree) / StdDev | >1.0 good |
| **Max Drawdown** | Peak to trough % | <20% good |
| **Win/Loss Ratio** | Avg win / Avg loss | >2.0 good |
| **Profit Factor** | Gross profit / Gross loss | >1.5 good |
### Performance Reports
```
Monthly Performance Report - April 2026
Total Trades: 47
Winning Trades: 28 (59.6%)
Losing Trades: 19 (40.4%)
Total P&L: +$4,230
Average Win: +$234
Average Loss: -$89
Win/Loss Ratio: 2.63
Best Strategy: 4H Breakout (68% win rate)
Best Timeframe: 4H
Biggest Win: +$680
Biggest Loss: -$340
Max Drawdown: -8.2%
Emotional Pattern: Overconfident after 3+ win streaks
Discipline Score: 7.2/10 (improvement needed)
Top Improvements:
1. Reduce position size after 3+ win streaks (overconfidence)
2. Better stop placement on mean reversion trades
3. Avoid revenge trading after losses
```
## Emotion & Psychology
### Emotional States to Track
- **Confidence** - normal trading state
- **Overconfidence** - after winning streaks, dangerous
- **Fear** - missing opportunities, hesitation
- **Greed** - overleveraging, chasing trades
- **Revenge** - trying to recover losses
- **FOMO** - fear of missing out
- **Anxiety** - after losses, poor decisions
### Discipline Violations
- **Overtrading** - too many trades, no edge
- **Revenge Trading** - trying to recover losses
- **FOMO Entries** - chasing moves without setup
- **No Stop Loss** - or moved stop for hope
- **Overleveraging** - risk too high for account
- **Trading When Distracted** - not focused
### Psychology Insights
```
Emotional Pattern Analysis:
Overconfidence Pattern:
- Occurs after: 3+ consecutive wins
- Next trade win rate: 34% (down from 59%)
- Recommendation: Reduce position size by 30%
Revenge Trading Pattern:
- Occurs after: Losses >$200
- Next trade P&L: -$120 average
- Recommendation: Take 1-hour break after loss
FOMO Entries:
- Trigger: Strong moves without setup
- Win rate: 27%
- Recommendation: Stick to your setups, ignore noise
```
## Strategy Analysis
### Strategy Comparison
```
Strategy Performance Comparison:
Breakout Strategy:
- Trades: 18 | Win Rate: 68% | Avg R:R: 2.1
- Best in: Trending markets | Worst in: Ranging
- Improvement: Add volume confirmation
Mean Reversion Strategy:
- Trades: 14 | Win Rate: 52% | Avg R:R: 1.3
- Best in: Ranging markets | Worst in: Breakouts
- Improvement: Better stop placement
Momentum Strategy:
- Trades: 11 | Win Rate: 45% | Avg R:R: 0.9
- Best in: Strong trends | Worst in: Chop
- Improvement: Avoid overtrading, increase R:R
```
## Export & Integration
### Export Formats
- **CSV export** for spreadsheet analysis
- **JSON export** for custom dashboards
- **PDF reports** for professional documentation
- **Calendar integration** for trade reminders
- **TradingView sync** for chart annotations
### Cloud Storage
- **Automatic backup** to cloud storage
- **Cross-device sync** for mobile access
- **Version history** and trade record integrity
- **Collaborative features** for trading teams
## Advanced Features
### Pattern Recognition
- **Winning streak analysis** - what leads to wins
- **Losing streak patterns** - what causes drawdowns
- **Market condition correlation** - performance by environment
- **Time-of-day analysis** - best trading hours
- **Day-of-week patterns** - which days perform best
### Risk Management
- **Position sizing calculator** - based on account and risk
- **Portfolio correlation** - if trading multiple assets
- **Risk-of-ruin calculation** - position sizing safety
- **Monte Carlo simulation** - expected outcomes
### Goal Setting & Tracking
- **Monthly P&L targets** with progress tracking
- **Win rate goals** with achievement tracking
- **Drawdown limits** and violation alerts
- **Discipline score improvement** over time
## Setup & Configuration
### Initial Configuration
```
Set trading account size and base risk per trade (1-2%)
Configure preferred timeframes and strategies
Set up emotion tracking preferences
Configure performance goal thresholds
```
### Journal Templates
- **Pre-trade checklist** - confirm setup before entry
- **Post-trade reflection** - what did I learn
- **Weekly review** - strategy and discipline assessment
- **Monthly goals** - target setting and progress
## Quality Improvements
### Journal Quality Checklist
- [ ] Every trade has clear reason and setup
- [ ] Entry, exit, stop, target all recorded
- [ ] P&L calculated accurately
- [ ] Emotional state logged for each trade
- [ ] Strategy and timeframe tagged
- [ ] Post-trade reflection completed
- [ ] Screenshots/notes for complex setups
### Journal Maintenance
- **Daily journal review** - what did I learn today?
- **Weekly strategy assessment** - what's working/not working?
- **Monthly performance review** - am I improving on key metrics?
- **Quarterly plan** - what to focus on next quarter?
## Getting Started
### 1. Initial Setup
```
Configure my trading journal with account size and risk parameters
Set up preferred strategies and timeframes
```
### 2. First Trade Entry
```
Log my first trade with all required fields
Document reasoning and emotional state
```
### 3. Performance Tracking
```
Generate my first performance report
Identify areas for improvement
```
### 4. Continuous Improvement
```
Review journal weekly for patterns
Adjust strategies based on analytics
Work on discipline based on insights
```
## Getting Help
For advanced features and trading psychology:
- [Trading Psychology Guide](references/trading-psychology.md)
- [Strategy Development Framework](references/strategy-development.md)
- [Risk Management Best Practices](references/risk-management.md)
- [Performance Optimization](references/performance-coaching.md)
FILE:clawhub.json
{
"name": "trading-journal-analytics",
"displayName": "Trading Journal & Performance Analytics",
"version": "1.0.0",
"description": "Professional trading journal with performance analytics, win rate tracking, emotion logging, strategy analysis, and comprehensive P&L monitoring for systematic trading improvement.",
"author": "ahmed181283",
"license": "MIT",
"pricing": {
"model": "subscription",
"price": 19900,
"interval": "monthly"
},
"tags": ["trading", "journal", "performance", "analytics", "win-rate", "p-l", "emotion-tracking", "strategy-analysis"],
"minOpenClawVersion": "1.0.0",
"category": "finance"
}
Professional DeFi yield farming optimizer for tracking and optimizing yields across multiple chains and protocols. Use when user needs to discover high-yield...
---
name: defi-yield-optimizer
description: Professional DeFi yield farming optimizer for tracking and optimizing yields across multiple chains and protocols. Use when user needs to discover high-yield DeFi opportunities, track portfolio performance, calculate ROI, and manage yield farming strategies across Ethereum, Polygon, BSC, Arbitrum, Optimism, and other EVM chains.
---
# DeFi Yield Farming Optimizer
## Quick Start
Professional-grade DeFi yield farming tool for discovering, tracking, and optimizing yields across multiple chains and protocols with automated ROI calculations, risk assessment, and portfolio management.
## When to Use This Skill
Use this skill when you need to:
- Discover high-yield DeFi opportunities across chains
- Track yield farming portfolio performance in real-time
- Calculate accurate ROI and APY metrics
- Compare protocols and identify best yields
- Manage yield farming strategies with risk assessment
- Monitor impermanent loss exposure
- Optimize gas fees and transaction timing
## Core Features
### Yield Discovery
- **Multi-chain scanning** across Ethereum, Polygon, BSC, Arbitrum, Optimism, Avalanche, and more
- **Protocol coverage** including Aave, Compound, Curve, Yearn, Lido, Uniswap, PancakeSwap, and 100+ others
- **Real-time yield updates** with live APY/APR data
- **Risk categorization** by protocol, chain, and liquidity depth
- **Impermanent loss calculation** for liquidity pools
### Portfolio Tracking
- **Multi-position portfolio management** across chains and protocols
- **Real-time value tracking** with current prices and yields
- **Performance analytics** with historical returns and growth charts
- **Gas optimization** suggestions for optimal transaction timing
- **Yield history** with daily, weekly, monthly metrics
### Risk Management
- **Protocol risk scoring** based on TVL, age, audits, and governance
- **Chain risk assessment** for network congestion and bridge issues
- **Smart contract analysis** for audit status and exploit history
- **Liquidity depth evaluation** for entry/exit capacity
- **Impermanent loss tracking** and warnings
### Analytics & Reporting
- **Daily yield reports** with portfolio performance
- **ROI calculations** including gas fees, rewards, and compounding
- **Yield optimization suggestions** based on market conditions
- **Historical performance** tracking and trend analysis
- **Export capabilities** to CSV, JSON, or spreadsheet formats
## Usage
### Basic Discovery
```
Find best DeFi yields for stablecoins on Ethereum mainnet
```
### Portfolio Management
```
Track my yield farming portfolio across all chains
Generate performance report for last 30 days
```
### Risk Analysis
```
Analyze risk for Aave USDC pool on Ethereum
Calculate impermanent loss exposure for my Curve positions
```
### Yield Comparison
```
Compare yields for ETH staking across all available protocols
Find best yield for my risk tolerance and investment amount
```
## Supported Chains
- **Ethereum** - Mainnet with full DeFi ecosystem
- **Polygon** - Low gas fees, high yields
- **Binance Smart Chain (BSC)** - Asian markets, BEP20 tokens
- **Arbitrum** - Ethereum L2, low costs
- **Optimism** - Ethereum L2, major protocols
- **Avalanche** - Fast finality, unique protocols
- **Fantom** - Ultra-low gas fees
- **Solana** - High speed, different yield mechanics
- **L2 Networks** - zkSync, StarkNet, Polygon zkEVM
## Supported Protocols
### Lending & Borrowing
- Aave, Compound, dYdX, Benqi, Fortress, Fulcru, Venus
### Yield Aggregators
- Yearn, Beefy Finance, AutoFarm, Harvest Finance
### DEX & AMM
- Uniswap, Curve, PancakeSwap, SushiSwap, QuickSwap, TraderJoe
### Liquidity Staking
- Convex, Staked AAVE, SushiSwap LP, Curve LP, PancakeSwap LP
### Bridges & Cross-Chain
- AnySwap, Hop Protocol, Synapse, Multichain, Stargate
## Setup & Prerequisites
### Account Requirements
- Crypto wallet (MetaMask, Trust Wallet, Rabby, or hardware wallet)
- Small ETH/BSC/POL gas reserve for transactions
- Connection to yield data APIs (free tier available)
### Initial Setup
```
Connect to my DeFi portfolio and scan current positions
Set risk tolerance preferences and minimum yield thresholds
Configure chain access for each network I use
```
## Advanced Features
### Yield Optimization
- **Auto-compound detection** for protocols offering auto-compound
- **Yield farming alerts** when better opportunities emerge
- **Gas timing optimization** suggesting optimal transaction windows
- **Portfolio rebalancing** recommendations based on yield changes
### Security Features
- **Protocol audit tracking** - shows last audit date and findings
- **Smart contract monitoring** for exploit alerts and warnings
- **TVL threshold alerts** when liquidity drops significantly
- **Governance proposal tracking** for protocol changes
### Portfolio Analytics
- **Win/loss tracking** by protocol, chain, and timeframe
- **Gas cost analysis** to calculate net ROI
- **Yield stability scoring** to identify volatile vs stable yields
- **Diversification analysis** showing portfolio concentration risk
## Output Formats
### Daily Yield Report
```
Portfolio Value: $45,231.23
Daily Change: +$342.45 (+0.76%)
Best Yield: Curve 3CRV LP @ 12.4% APY
Worst Yield: Venus USDC @ 3.1% APY
Gas Fees Today: $23.45
```
### Risk Assessment
```
Protocol Risk Score: 3.2/10 (Moderate)
TVL Coverage: $1.2B (High liquidity)
Audit Status: Passed (Last audit: 14 days ago)
Impermanent Loss Risk: Low (0.8% historical)
Chain Risk: Low (Ethereum mainnet)
```
## Risk Management
### Yield vs Risk Categories
| Risk Level | APY Range | Protocols | Liquidity |
|------------|-------------|------------|-------------|
| **Conservative** | 2-8% | Aave, Compound, Lido | $10B+ TVL |
| **Moderate** | 8-20% | Yearn, Curve, Uniswap LP | $1-10B TVL |
| **Aggressive** | 20-50% | New protocols, new chains | $100M-1B TVL |
| **DeFi Native** | 50-200% | New liquidity, farming | <$100M TVL |
### Security Best Practices
- **Never invest more than you can lose** - DeFi is high risk
- **Verify protocol audits** before investing
- **Check TVL depth** - ensure you can exit position
- **Monitor gas fees** - high gas kills profits
- **Track impermanent loss** - understand LP risks
- **Diversify across protocols** - concentration risk
## Getting Started
### 1. Connect Wallet
```
Connect my DeFi wallet for yield discovery
```
### 2. Discover Opportunities
```
Show me best DeFi yields for my investment amount
Filter by risk tolerance and preferred chains
```
### 3. Analyze & Compare
```
Compare Aave vs Compound yields for stablecoins
Calculate expected ROI including gas fees
```
### 4. Track Portfolio
```
Track my current yield farming positions
Generate performance report and optimization suggestions
```
## Troubleshooting
### Low Yield Discovery
1. Verify wallet is connected correctly
2. Check that chains are accessible
3. Ensure sufficient gas for queries
4. Try different timeframes (current yields vs 7-day average)
### Gas Fee Issues
1. Use L2 chains for lower costs
2. Time transactions during low gas periods
3. Check gas price trackers for optimal windows
4. Consider transaction batching
### TVL Concerns
1. Never invest in pools under $100K TVL
2. Check liquidity depth for your position size
3. Look for rug pull indicators in new protocols
4. Verify protocol age and team reputation
## Pricing & Features
### Premium Tiers (via ClawHub subscription)
| Feature | Basic | Professional | Enterprise |
|---------|--------|-------------|-------------|
| Chains Supported | 3 chains | All 8+ chains | Custom chain support |
| Protocols Tracked | 50+ | 100+ | Custom protocols |
| Real-time Updates | 15-minute delay | 1-minute updates | Live data |
| Risk Scoring | Basic | Advanced | Custom risk models |
| Portfolio Size | $10K max | $100K max | Unlimited |
| Alerts | Email | Email + SMS | Custom webhooks |
| Support | Community | Priority | Dedicated |
## Data Sources & APIs
- **DeFi Llama API** - Comprehensive yield data
- **Defi Pulse** - TVL and protocol metrics
- **Etherscan & Explorers** - Gas prices and transaction data
- **Protocol APIs** - Direct protocol yield rates
- **Chain APIs** - Network status and fees
## Quality & Reliability
- **Multi-source validation** - cross-check yield data
- **Risk-adjusted returns** - account for protocol risk
- **Real-time error handling** - handle API failures gracefully
- **Portfolio reconciliation** - verify on-chain positions
- **Historical accuracy** - track yield performance over time
## Getting Help
For advanced features and customization:
- [DeFi Yield Strategy Guide](references/yield-strategies.md)
- [Risk Assessment Framework](references/risk-scoring.md)
- [Chain Gas Optimization](references/gas-optimization.md)
- [Portfolio Rebalancing](references/rebalancing.md)
FILE:clawhub.json
{
"name": "defi-yield-optimizer",
"displayName": "DeFi Yield Farming Optimizer",
"version": "1.0.0",
"description": "Professional DeFi yield farming optimizer with multi-chain tracking, ROI calculations, risk assessment, and portfolio management across Ethereum, Polygon, BSC, Arbitrum, Optimism, and more.",
"author": "ahmed181283",
"license": "MIT",
"pricing": {
"model": "subscription",
"price": 14900,
"interval": "monthly"
},
"tags": ["defi", "yield-farming", "crypto", "ethereum", "polygon", "bsc", "arbitrum", "optimism", "portfolio", "roi"],
"minOpenClawVersion": "1.0.0",
"category": "financial"
}
Sell CrawlHub API keys for Twitter/X crawling via ETH payment. Use when a user wants to buy, access, or get pricing for CrawlHub API access. Handles wallet s...
---
name: crawlhub-reseller
description: Sell CrawlHub API keys for Twitter/X crawling via ETH payment. Use when a user wants to buy, access, or get pricing for CrawlHub API access. Handles wallet signature verification and payment verification on Ethereum. Works with other agents via A2A protocol.
---
# CrawlHub Reseller Skill
Sell CrawlHub API keys for Twitter/X crawling — fully automated with ETH payment verification.
## Overview
This skill wraps the CrawlHub Reseller Agent which:
- Verifies wallet ownership via Ethereum signature
- Verifies ETH payment on-chain via Etherscan
- Delivers API key + full API documentation
- Runs as standalone service on port 3000
## How It Works
```
Customer Agent → Signature + TX Hash → Reseller Agent → API Key + Docs
```
**Flow:**
1. Customer signs message with wallet (proves ownership)
2. Customer sends TX hash (proves payment)
3. Reseller verifies both on-chain
4. If valid → API key delivered with CrawlHub API docs
## Payment
- **Price:** 0.010 ETH per 24 hours
- **Payment wallet:** 0x19c4455Bf8C5D8662B434e1985cd31B8947A7C39
- **Verification:** Etherscan API check
## Customer Request Format
```json
{
"customerWallet": "0x742d35Cc6634C0532925a3b844Bc9e7595f5bA12",
"signature": "0x1234abcd...",
"message": "Request API Key for CrawlHub\nWallet: 0x742d35Cc6634C0532925a3b844Bc9e7595f5bA12\nNonce: abc12345",
"txHash": "0xabc123def456..."
}
```
## Reseller Agent API
**Endpoints:**
- `POST /json-rpc` — A2A JSON-RPC endpoint (tasks/send, agentcard/get)
- `GET /agent-card` — Agent capabilities and endpoints
- `GET /health` — Health check
**Start Reseller:**
```bash
cd /root/.openclaw/workspace/reseller-agent
node dist/server.js
```
## CrawlHub API Docs
Full API documentation in `references/crawlhub-api.md`.
**Base URL:** `https://api.thecrawlhub.com/api/v1`
**Auth:**
- `POST /auth/login` → JWT token
- Use `Authorization: Bearer {token}` + `X-API-KEY: {key}`
**Endpoints:**
- `GET /profile/user/by-screen-name?screen_name={username}`
- `GET /profile/tweets/by-screen-name?screen_name={username}`
- `GET /timeline/search?query={query}&mode=top`
- `GET /tweet/by-id?tweet_id={id}`
## Verification Results
On successful sale, notify via:
1. Update `/tmp/reseller-events.json` with event
2. Check `/root/.openclaw/workspace/reseller-agent/notifications.json`
## Error Codes
- `Signature verification failed` — Wallet signature doesn't match
- `Insufficient payment` — Less than 0.010 ETH sent
- `Payment sent to wrong address` — TX wasn't to our wallet
- `No API keys available` — Pool exhausted
FILE:README.md
# CrawlHub Reseller Skill
A monetizable OpenClaw skill for selling CrawlHub API access via ETH payment.
## Structure
```
crawlhub-reseller-skill/
├── SKILL.md # Main skill file
├── scripts/
│ ├── request-api-key.sh # CLI tool for customers
│ └── sign-request.sh # Message signing helper
└── references/
└── crawlhub-api.json # Full API documentation
```
## For Customers
### To get an API Key:
1. **Generate message to sign:**
```bash
./scripts/sign-request.sh YOUR_WALLET_ADDRESS
```
2. **Sign the message** with your Ethereum wallet (Metamask, Rabby, etc.)
3. **Send 0.010 ETH** to: `0x19c4455Bf8C5D8662B434e1985cd31B8947A7C39`
4. **Get TX hash** from your wallet transaction
5. **Request API key** via Reseller Agent (contact your admin)
## For Admins
### Start Reseller Agent:
```bash
cd /root/.openclaw/workspace/reseller-agent
node dist/server.js
```
### Check Status:
```bash
curl http://localhost:3000/health
curl http://localhost:3000/agent-card
```
### Pricing
- 0.010 ETH = 24 hours access
- Unlimited API calls during validity
## Payment Verification
1. **Signature verification** — `ethers.verifyMessage()` confirms wallet ownership
2. **TX verification** — Etherscan API confirms payment to our wallet
Both must pass before API key is delivered.
## Publishing to ClawHub
1. Create GitHub repo with skill files
2. Login to clawhub.ai with GitHub
3. Publish skill via "Publish Skill" button
4. Set pricing (optional) or free with ETH payment
FILE:scripts/request-api-key.sh
#!/bin/bash
# CrawlHub Reseller Client - Request API Key
# Usage: ./request-api-key.sh <wallet> <signature> <message> <txHash>
WALLET="-"
SIGNATURE="-"
MESSAGE="-"
TX_HASH="-"
if [ -z "$WALLET" ] || [ -z "$SIGNATURE" ] || [ -z "$MESSAGE" ] || [ -z "$TX_HASH" ]; then
echo "Usage: request-api-key.sh <wallet> <signature> <message> <txHash>"
echo "Example: ./request-api-key.sh 0x742d... 0x1234... \"Request API Key...\" 0xabc..."
exit 1
fi
curl -s -X POST http://localhost:3000/json-rpc \
-H "Content-Type: application/json" \
-d "{
\"jsonrpc\": \"2.0\",
\"id\": 1,
\"method\": \"tasks/send\",
\"params\": {
\"message\": {
\"role\": \"user\",
\"parts\": [{
\"type\": \"text\",
\"text\": JSON.stringify({
\"customerWallet\": \"$WALLET\",
\"signature\": \"$SIGNATURE\",
\"message\": \"$MESSAGE\",
\"txHash\": \"$TX_HASH\"
})
}]
}
}
}" | python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d.get('result', {})
status = result.get('status', {})
if status.get('state') == 'completed':
artifacts = result.get('artifacts', [])
for a in artifacts:
for p in a.get('parts', []):
if p.get('type') == 'data':
data = p.get('data', {})
print('SUCCESS: API Key =', data.get('apiKey'))
print('Expires:', data.get('expiresAt'))
else:
msg = status.get('message', {})
for p in msg.get('parts', []):
print('ERROR:', p.get('text', 'Unknown error'))
"
FILE:scripts/sign-request.sh
#!/bin/bash
# Generate a signed message for CrawlHub API request
# Requires: an Ethereum wallet with private key (e.g., Metamask)
WALLET="-"
NONCE="-$(date +%s)"
if [ -z "$WALLET" ]; then
echo "Usage: sign-request.sh <wallet_address> [nonce]"
echo "Generates the message to sign for API key request"
exit 1
fi
MESSAGE="Request API Key for CrawlHub
Wallet: $WALLET
Nonce: $NONCE"
echo "=== MESSAGE TO SIGN ==="
echo "$MESSAGE"
echo "======================="
echo ""
echo "Sign this message with your Ethereum wallet (e.g., Metamask)"
echo "Then use the signature + message when requesting API key"
FILE:references/crawlhub-api.json
{
"openapi": "3.0.0",
"info": {
"title": "CrawlHub API",
"description": "X (Twitter) Data Crawling API - Access tweets, profiles, followers, timelines and more.\n\n## Authentication\n1. Login to get JWT: `POST /auth/login`\n2. Use JWT as Bearer token + your API Key in X-API-KEY header\n\n## Base URL\n```\nhttps://api.thecrawlhub.com/api/v1\n```\n\n## Pricing\n- **0.015 ETH per 24 hours** - Unlimited crawling for Platform X during the validity period\n- Payment to: `0x19c4455Bf8C5D8662B434e1985cd31B8947A7C39`",
"version": "1.0.0",
"contact": {
"name": "CrawlHub Support"
}
},
"servers": [
{
"url": "https://api.thecrawlhub.com/api/v1",
"description": "Production"
}
],
"paths": {
"/auth/login": {
"post": {
"summary": "Login to get JWT token",
"tags": ["Authentication"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {"type": "string", "example": "[email protected]"},
"password": {"type": "string", "example": "yourpassword"}
}
}
}
}
},
"responses": {
"200": {
"description": "Login successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"token": {"type": "string", "description": "JWT Bearer token"},
"refresh_token": {"type": "string"}
}
}
}
}
}
}
}
}
}
},
"/auth/refresh": {
"post": {
"summary": "Refresh JWT token",
"tags": ["Authentication"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refresh_token"],
"properties": {
"refresh_token": {"type": "string"}
}
}
}
}
},
"responses": {
"200": {
"description": "Token refreshed"
}
}
}
},
"/execution/endpoints/{endpointId}/execute": {
"post": {
"summary": "Execute any endpoint",
"tags": ["Execution"],
"parameters": [
{
"name": "endpointId",
"in": "path",
"required": true,
"schema": {"type": "integer"},
"description": "Endpoint ID (see Endpoints section)"
}
],
"headers": {
"Authorization": {
"type": "string",
"description": "Bearer {JWT from login}",
"required": true
},
"X-API-KEY": {
"type": "string",
"description": "Your API key",
"required": true
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"params": {
"type": "object",
"description": "Endpoint-specific parameters"
}
}
}
}
}
},
"responses": {
"200": {"description": "Execution successful"},
"401": {"description": "Authentication error"}
}
}
},
"/profile/user/by-id": {
"get": {
"summary": "Get User Profile by ID",
"tags": ["Profile"],
"parameters": [
{"name": "user_id", "in": "query", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "User profile data"}
}
}
},
"/profile/user/by-screen-name": {
"get": {
"summary": "Get User Profile by Screen Name",
"tags": ["Profile"],
"parameters": [
{"name": "screen_name", "in": "query", "required": true, "schema": {"type": "string"}, "example": "elonmusk"}
],
"responses": {
"200": {"description": "User profile data"}
}
}
},
"/profile/tweets/by-id": {
"get": {
"summary": "Get Profile Tweets by User ID",
"tags": ["Profile"],
"parameters": [
{"name": "user_id", "in": "query", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Tweets from user"}
}
}
},
"/profile/tweets/by-screen-name": {
"get": {
"summary": "Get Profile Tweets by Screen Name",
"tags": ["Profile"],
"parameters": [
{"name": "screen_name", "in": "query", "required": true, "schema": {"type": "string"}, "example": "elonmusk"}
],
"responses": {
"200": {"description": "Tweets from user"}
}
}
},
"/timeline/search": {
"get": {
"summary": "Search Timeline",
"tags": ["Timeline"],
"parameters": [
{"name": "query", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Search query"},
{"name": "mode", "in": "query", "schema": {"type": "string", "enum": ["latest", "top", "people", "media", "lists"]}, "description": "Search mode"},
{"name": "since", "in": "query", "schema": {"type": "string"}, "description": "Start date (YYYY-MM-DD)"},
{"name": "until", "in": "query", "schema": {"type": "string"}, "description": "End date (YYYY-MM-DD)"}
],
"responses": {
"200": {"description": "Search results"}
}
}
},
"/timeline/by-id": {
"get": {
"summary": "Get Timeline by User ID (with Followers/Following support)",
"tags": ["Timeline"],
"parameters": [
{"name": "user_id", "in": "query", "required": true, "schema": {"type": "string"}},
{"name": "initial_page", "in": "query", "schema": {"type": "string", "enum": ["Followers", "Following"]}, "description": "For crawling followers/following lists"},
{"name": "cursor", "in": "query", "schema": {"type": "string"}, "description": "Pagination cursor"},
{"name": "context", "in": "query", "schema": {"type": "integer"}, "description": "Request context (max 100)"}
],
"responses": {
"200": {"description": "Timeline data with pagination"}
}
}
},
"/timeline/by-screen-name": {
"get": {
"summary": "Get Timeline by Screen Name (with Followers/Following support)",
"tags": ["Timeline"],
"parameters": [
{"name": "screen_name", "in": "query", "required": true, "schema": {"type": "string"}},
{"name": "initial_page", "in": "query", "schema": {"type": "string", "enum": ["Followers", "Following"]}},
{"name": "cursor", "in": "query", "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Timeline data"}
}
}
},
"/timeline/conversation": {
"get": {
"summary": "Get Timeline Conversation",
"tags": ["Timeline"],
"parameters": [
{"name": "tweet_id", "in": "query", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Conversation thread"}
}
}
},
"/tweet/by-id": {
"get": {
"summary": "Get Tweet by ID",
"tags": ["Tweet"],
"parameters": [
{"name": "tweet_id", "in": "query", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Tweet data"}
}
}
},
"/scraper/platforms": {
"get": {
"summary": "Get All Platforms",
"tags": ["Admin"],
"responses": {
"200": {"description": "List of all platforms"}
}
}
},
"/scraper/platforms/{platformId}": {
"get": {
"summary": "Get Platform Details",
"tags": ["Admin"],
"parameters": [
{"name": "platformId", "in": "path", "required": true, "schema": {"type": "integer"}}
],
"responses": {
"200": {"description": "Platform info with modules and endpoints"}
}
}
}
},
"tags": [
{"name": "Authentication", "description": "Login and token management"},
{"name": "Profile", "description": "User profile endpoints"},
{"name": "Timeline", "description": "Timeline and follower crawling"},
{"name": "Tweet", "description": "Single tweet retrieval"},
{"name": "Execution", "description": "Execute any endpoint"},
{"name": "Admin", "description": "Platform and endpoint management"}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
},
"apiKey": {
"type": "apiKey",
"in": "header",
"name": "X-API-KEY"
}
}
}
}