
Agent Media
- 22 installs
- 77 repo stars
- Updated August 2, 2026
- gitroomhq/agent-media
Produce UGC videos, product-acting UGC, and SaaS review videos from the terminal with the agent-media CLI, including actor and voiceover controls.
About
A CLI for producing complete UGC videos, product-acting UGC, and SaaS review videos from the terminal using the agent-media tool. A developer uses it to generate talking-head UGC content with lip-synced actors and voiceovers.
- Requires an --actor slug for talking-head lip sync
- Actor list command with randomized actor selection for variety
Agent Media by the numbers
- 22 all-time installs (skills.sh)
- Ranked #996 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gitroomhq/agent-media --skill agent-mediaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 22 |
|---|---|
| repo stars | ★ 77 |
| Last updated | August 2, 2026 |
| Repository | gitroomhq/agent-media ↗ |
What it does
Produce UGC videos, product-acting UGC, and SaaS review videos from the terminal with the agent-media CLI, including actor and voiceover controls.
Files
npm release: https://www.npmjs.com/package/agent-media-cli agent-media cli github: https://github.com/gitroomhq/agent-media official website: https://agent-media.ai
agent-media — AI UGC Video Production & Media Generation
Produce complete UGC videos, Product Acting UGC, and SaaS review videos from the terminal using the agent-media CLI.
---
MANDATORY RULES — READ BEFORE EVERY COMMAND
You MUST follow ALL of these rules. Violating any rule produces a broken, unwatchable video.
Rule 1: ALWAYS use --actor — PICK A RANDOM ONE
Every UGC video MUST include --actor <slug>. Without an actor, there is no talking head and no lip sync — the video will just be a static image with voiceover.
If the user hasn't specified an actor: 1. Run agent-media actor list to get the full list of available actors 2. Pick a random actor from the list — do NOT always use the same one (e.g., don't always default to sofia or naomi) 3. Tell the user which actor you picked and suggest they can browse with agent-media actor list or pick a specific one with --actor <slug>
NEVER hardcode a default actor. Every video should feel different — variety in actors is key to quality UGC content.
Rule 2: ALWAYS count words — 2.5 words per second
Natural speech is 2.5 words/second. Scripts MUST match the target duration exactly — too many words sounds robotic, too few words creates awkward pauses and silence:
- 5s video → 10–12 words (not fewer!)
- 10s video → 22–25 words (not fewer!)
- 15s video → 33–37 words (not fewer!)
CRITICAL: Count the words before submitting. If the script is too short, ADD more content. If too long, CUT words. A 15-word script on a 10s video = 5 seconds of dead silence. A 50-word script on a 10s video = rushed robotic speech. Both are broken.
Rule 3: SaaS reviews MUST have screenshots
For any SaaS/product review video, you MUST provide 1-3 product screenshots via --broll --broll-images. Without screenshots, the video has no product context — viewers see only a talking head with no evidence of the product.
--broll-images accepts both HTTP/HTTPS URLs and local file paths (local files are auto-uploaded). Images are semantically matched to the most relevant broll scene based on filename — so use descriptive filenames! Examples:
--broll-images ./dashboard.png,./calendar-view.png(local files — descriptive names!)--broll-images https://example.com/pricing-page.png,https://example.com/editor.png(URLs)- Mix of both works too
If the user provides a product URL but no screenshots, visit the site yourself and extract image URLs from the page.
Rule 4: SaaS reviews MUST have the product name
Ask the user: "What SaaS product should I review?" Do NOT proceed without it. The product name must appear in the script so it shows up in subtitles.
Rule 5: Always use --sync
Always append --sync to wait for the result and get the output URL.
Rule 6: Name screenshot files descriptively
Broll images are semantically matched to scenes by filename. Use descriptive names so the AI assigns the right image to the right scene:
- GOOD:
dashboard.png,calendar-view.png,post-editor.png - BAD:
screenshot1.png,IMG_0042.png,image.png
When saving screenshots for --broll-images, rename them to describe what they show.
---
Prerequisites
The agent-media CLI must be installed and authenticated:
npm install -g agent-media-cli
agent-media loginVerify with agent-media whoami. If not logged in, run agent-media login and follow the OTP flow.
UGC Pipeline (Flagship Feature)
The UGC pipeline turns a script into a complete video with AI talking heads, B-roll, voiceover, and animated subtitles — one command.
Flow
Script → Scene splitting → TTS voiceover → AI talking heads + B-roll → Crossfade assembly → Animated subtitles → Background music → End screen CTA
Basic UGC
# ALWAYS include --actor for talking heads + lip sync
agent-media ugc "Ever wonder why some videos go viral? Here's the secret..." --actor sofia --sync
# From file
agent-media ugc ./script.txt --actor naomi --sync
# AI-generated script from a product description
agent-media ugc -g "A fitness tracker that monitors sleep quality" --actor marcus --syncUGC with B-roll
# With B-roll cutaway scenes mixed in
agent-media ugc "Your script here..." --actor marcus --broll --sync
# With product screenshots as B-roll (REQUIRED for SaaS reviews)
agent-media ugc "Your script here..." --actor sofia --broll --broll-images https://example.com/screenshot1.png,https://example.com/screenshot2.png --syncUGC Flags
| Flag | Description | Example |
|---|---|---|
--actor <slug> | Library actor for talking heads | --actor sofia |
--persona <slug> | Custom persona (cloned voice + face) | --persona brand-voice |
--face-url <url> | Direct face photo URL or local file | --face-url ./photo.png |
--voice <name> | TTS voice | --voice nova |
--tone <name> | Voice tone: energetic, calm, confident, dramatic | --tone energetic |
--style <name> | Subtitle style (17 options — pick random!) | --style tiktok |
-d, --duration <s> | Target duration: 5, 10, or 15 seconds | --duration 10 |
--aspect <ratio> | Aspect ratio: 9:16, 16:9, 1:1 | --aspect 16:9 |
--music <genre> | Background music: chill, energetic, corporate, dramatic, upbeat | --music chill |
--cta <text> | End screen call-to-action text | --cta "Follow for more" |
--broll | Enable B-roll cutaway scenes | --broll |
--broll-images <urls> | Comma-separated screenshot/image URLs for B-roll | --broll-images url1,url2 |
--template <slug> | Script template (see below) | --template saas-review |
-g, --generate-script <prompt> | AI-generate script from description | -g "yoga mat product" |
--product-url <url> | Product URL for script generation context | --product-url https://... |
-s, --sync | Wait for completion and print output URL | --sync |
PIP Mode (Picture-in-Picture)
PIP mode creates a full-frame talking head with rotating B-roll image overlays in the lower portion. The actor speaks to camera while relevant visuals slide in and out below. Subtitles appear just above the overlay.
# Basic PIP video — actor speaks to camera with auto-generated B-roll overlays
agent-media ugc "Stop scrolling. If you struggle to grow on social media, consistency beats perfection every time." \
--actor adaeze --pip --duration 15 --style hormozi --sync
# PIP with specific style
agent-media ugc "Three things I wish I knew before starting my business..." \
--actor sofia --pip --duration 10 --style tiktok --syncPIP Options:
| Flag | Values | Default | Description |
|---|---|---|---|
--pip-position <pos> | bottom-center, bottom-left, bottom-right | bottom-center | B-roll overlay position |
--pip-size <size> | small, medium, large | medium | B-roll overlay size (40%, 55%, 70% of width) |
--pip-animation <anim> | slide-up, slide-left, slide-right, fade, scale | slide-up | Overlay entrance/exit animation |
--pip-style <style> | none, rounded, shadow | none | Overlay frame style |
# PIP with bottom-right overlay, large size, slide-left animation
agent-media ugc "Your script here..." \
--actor adaeze --pip --pip-position bottom-right --pip-size large --pip-animation slide-left --sync
# PIP with rounded overlay and scale animation
agent-media ugc "Your script here..." \
--actor sofia --pip --pip-style rounded --pip-animation scale --duration 10 --syncPIP Rules:
--actoris required (PIP needs a talking head)- Max duration is 15 seconds (longer videos are split into ≤10s clips with seamless continuity)
- B-roll overlays are auto-generated from the script — no
--broll-imagesneeded - Script word count rules apply: 2.5 words/sec (15s = ~37 words max)
- Do NOT combine
--pipwith--broll— they are separate modes
Script Templates
| Template | Structure | Best For |
|---|---|---|
monologue | Hook → Body → CTA | Direct-to-camera talking |
testimonial | Problem → Solution → Result → CTA | Customer stories |
product-review | Intro → Experience → Verdict → CTA | Product reviews |
problem-solution | Hook → Pain → Solution → CTA | Before/after pain points |
saas-review | Hook → Walkthrough → Opinion → CTA | SaaS/app reviews |
before-after | Hook → Before → After → CTA | Transformations |
listicle | Hook → Tip 1 → Tip 2 → Tip 3 + CTA | Tips and lists |
product-demo | Intro → Demo → Recap → CTA | Product walkthroughs |
Rule 7: ALWAYS use --style — PICK A RANDOM ONE
Every UGC video MUST include --style <name>. Without a style, you get the same hormozi subtitle every time — boring and repetitive.
If the user hasn't specified a style: 1. Pick a random style from the list below — do NOT always default to hormozi 2. Tell the user which style you picked
NEVER hardcode a default style. Variety in styles makes each video feel unique.
Subtitle Styles (17 styles)
Popular
| Style | Look | Best For |
|---|---|---|
hormozi | Bold white, yellow karaoke highlight | Business/marketing content |
tiktok | Bold white, orange-red karaoke | TikTok-style UGC |
minimal | Light, fade in/out | Professional, subtle |
clean | White text on dark box | Clean readability |
Bold & Energetic
| Style | Look | Best For |
|---|---|---|
bold | Cyan neon outline, karaoke | High energy |
impact | Huge text, 2 words, max punch | Short punchy clips |
fire | Red-orange karaoke, dark red outline | Hype / excitement |
pop | Yellow text, 2 words at a time | Attention-grabbing |
spotlight | Gold highlight, deep shadow | Premium / luxury |
Aesthetic & Soft
| Style | Look | Best For |
|---|---|---|
aesthetic | Subtle, lowercase, airy | Lifestyle / beauty |
pastel | Soft pink tones | Feminine / soft content |
glow | Purple-pink glow outline | Night / party vibes |
Colorful
| Style | Look | Best For |
|---|---|---|
neon | Green neon text | Tech / gaming |
electric | Cyan text + magenta highlight | Bold creative |
gradient | Blue-to-coral karaoke | Modern / trendy |
karaoke | Green word-by-word | Karaoke-style |
boxed | White bold on solid black box | Maximum contrast |
SaaS Review Videos
Generate a complete SaaS review video. ALL FOUR are required — do NOT skip any: 1. Product name in the script (so it appears in subtitles) 2. `--actor` (for talking head + lip sync) 3. `--broll --broll-images` with 1-3 product screenshot URLs 4. Script word count matching duration (2.5 words/sec)
Step-by-Step Flow (FOLLOW THIS EXACTLY)
1. Get product name — ask user if not provided. STOP if missing. 2. Get 1-3 screenshot URLs — ask user, or if they give a URL, visit the site and extract <img> URLs showing the product dashboard/UI. STOP if no screenshots. 3. Pick an actor — ask user or pick a RANDOM one from agent-media actor list. Never always use the same one. 4. Write the script — MUST be 22-25 words for 10s or 33-37 words for 15s. Too few words = awkward pauses. Too many = robotic. Mention the product name 2-3 times. Count the words before submitting. 5. Run the command with ALL required flags:
# CORRECT — descriptive filenames so images match the right scenes
agent-media ugc "Postiz is the best social media tool I've used. Postiz schedules across twenty-five platforms with AI. Try Postiz today." \
--actor naomi --duration 10 --style hormozi --broll \
--broll-images ./postiz-dashboard.png,./postiz-calendar.png --sync
# CORRECT — URLs with descriptive paths work too
agent-media ugc "Postiz is the best social media tool I've used. Postiz schedules across twenty-five platforms with AI. Try Postiz today." \
--actor naomi --duration 10 --style hormozi --broll \
--broll-images "https://postiz.com/dashboard-screenshot.png,https://postiz.com/scheduling-view.png" --sync
# WRONG — no actor, no screenshots, script too long
agent-media ugc "Here's how to schedule a post in Postiz step by step..." --syncReview Flags
| Flag | Description | Example |
|---|---|---|
--actor <slug> | AI actor (required) | --actor naomi |
--broll | Enable B-roll scenes (required for reviews) | --broll |
--broll-images <paths> | Screenshot URLs or local files (required, 1-3) | --broll-images ./img1.png,./img2.png |
--duration <s> | 5, 10, or 15 seconds | --duration 10 |
--style <name> | Subtitle style | --style hormozi |
--cta <text> | End screen text | --cta "Try it free" |
-s, --sync | Wait for result (always use) | --sync |
Product Acting UGC
Generate creator-style product-in-hand UGC from a product image, actor, scenario template, and short spoken script. Use this when the user wants an actor to hold, react to, smell, present, or demonstrate a physical product.
Requirements: 1. Product image URL — must be public HTTPS, or upload a local product image first and use the returned URL. 2. Actor — --actor <slug> is required. Run agent-media actor list if the user has not chosen one. 3. Script or product context — provide either --script for exact words or --about so the API can generate a short script. 4. Word count — script is capped at 3 words/second × duration (≤15 words at 5s, ≤30 at 10s, ≤45 at 15s).
CLI
agent-media product-acting \
--product-image https://cdn.example.com/product.png \
--actor sofia \
--about "A premium perfume with a warm vanilla dry-down" \
--template product-in-hand \
--acting-style honest-review \
--duration 5 \
--sync
agent-media product-acting \
--product-image https://cdn.example.com/product.png \
--actor naomi \
--script "I did not expect this perfume to smell this expensive." \
--template car-selfie \
--acting-style shocked \
--syncREST API
curl -X POST https://api.agent-media.ai/v1/generate/product_acting_ugc \
-H "Authorization: Bearer ma_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_image_url": "https://cdn.example.com/perfume.png",
"actor_slug": "sarah",
"product_name": "Rose Noir",
"product_description": "Premium rose perfume with a warm vanilla dry-down.",
"template": "product-in-hand",
"acting_style": "honest-review",
"duration": 5,
"subtitle_style": "hormozi"
}'Flags
| Flag | Values | Default | Description |
|---|---|---|---|
--product-image <url> | HTTPS URL | - | Product image URL (required) |
--actor <slug> | actor slug | - | AI actor (required) |
--actor-variant-id <id> | UUID | - | Optional actor look/variant |
--name <text> | text | - | Product name |
--about <text> | text | - | Product description used for generated script |
--script <text> | text | generated | Exact actor line |
--template <slug> | product-in-hand, mirror-selfie, bathroom-reaction, kitchen-counter, car-selfie, couch-review, expert-interview, product-closeup | product-in-hand | Scenario framing |
--acting-style <slug> | raw-selfie, shocked, angry, excited, dramatic, weird-hook, casual-demo, honest-review | raw-selfie | Delivery energy |
--visual-style <text> | text | - | Extra pose, camera, or environment direction |
--duration <s> | 5, 10, 15 | 5 | Duration |
--subtitles / --no-subtitles | boolean | subtitles on | Burn synced subtitles |
--webhook-url <url> | HTTPS URL | - | Completion callback |
-s, --sync | boolean | off | Wait for completion |
Credit cost: 30 × duration + 50 credits, plus 5 credits when the API generates the script. Runtime: usually several minutes.
Show Your App Videos
Generate a video where an AI actor holds a phone that displays your app screenshot and reads your script, with Hormozi-style word-by-word subtitles burned in.
Requirements (enforced server-side): 1. Vertical app screenshot — PNG, JPEG, or WebP, height > width (phone portrait). The API rejects landscape. 2. Public URL — screenshot must be reachable. Host on R2, S3, your own CDN, etc. Local files are not accepted by the CLI for this command. 3. Script word count — capped at 3 words/second × duration (≤15 words at 5s, ≤30 at 10s, ≤45 at 15s).
CLI
# Random actor (recommended — variety is key)
agent-media show-your-app \
--app-screenshot https://cdn.example.com/my-app.png \
--script "You really need to try this app — it generates UGC videos in seconds." \
--duration 5 --sync
# Specific actor
agent-media show-your-app \
--app-screenshot https://cdn.example.com/my-app.png \
--script "Try this app, it changed everything for me." \
--actor sarah --duration 10 --syncREST API
curl -X POST https://api.agent-media.ai/v1/generate/show_your_app \
-H "Authorization: Bearer ma_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"app_screenshot_url": "https://cdn.example.com/my-app.png",
"script": "You really need to try this app.",
"duration": 5
}'Flags
| Flag | Description | Default |
|---|---|---|
--app-screenshot <url> | Public URL of vertical app screenshot (required) | - |
--script <text> | What the actor reads (required, ≤3 words/sec × duration) | - |
--actor <slug> | Specific actor slug | random from pool |
--duration <s> | 5, 10, or 15 seconds | 5 |
--subtitle-style <style> | hormozi or none | hormozi |
--webhook-url <url> | HTTPS completion callback | - |
-s, --sync | Wait for completion | off |
Credit cost: 75 flat. Runtime: 4–8 minutes (GPT Image + Seedance 2.0).
Persona Management
Save voice + face combos for consistent UGC across videos:
# Create a persona from voice sample + face photo
agent-media persona create --name "brand-voice" --voice ./sample.mp3 --face ./photo.png
# List personas
agent-media persona list
# Use in UGC
agent-media ugc "Your script..." --persona brand-voice --sync
# Delete
agent-media persona delete <persona-id>Add Subtitles to Any Video
agent-media subtitle <video-path-or-job-id> --style hormozi --sync
agent-media subtitle ./my-video.mp4 --style bold --syncPricing & Credit Deduction
Plans
| Plan | Price | Monthly Credits | ~10s Videos | ~5s Videos |
|---|---|---|---|---|
| Creator | $39/mo | 3,900 | ~13 | ~26 |
| Pro | $69/mo | 6,900 | ~23 | ~46 |
| Pro Plus | $129/mo | 12,900 | ~43 | ~86 |
Pay-as-you-go credit pack: 3,900 credits for $39 (one-time purchase, never expires).
How credits are deducted
Rate: 30 credits per second of video. 1 credit = $0.01.
| Duration | Credits deducted | Dollar value |
|---|---|---|
| 5s video | 150 credits | $1.50 |
| 10s video | 300 credits | $3.00 |
| 15s video | 450 credits | $4.50 |
| Product Acting setup frame | +50 credits | +$0.50 |
| AI script generation | +5 credits | +$0.05 |
| Subtitles only | 50 credits | $0.50 |
Deduction order: Monthly credits are used first (they expire at period end), then purchased credits (never expire).
Refunds: If the video generation fails, credits are automatically refunded. Canceled jobs are also refunded.
Run agent-media credits to check balance before generating.
Job Management
agent-media status <job-id> # Check job status
agent-media list # List recent jobs
agent-media download <job-id> # Download output media
agent-media cancel <job-id> # Cancel and refund credits
agent-media retry <job-id> # Retry a failed jobAccount
agent-media whoami # Current user info
agent-media credits # Credit balance
agent-media subscribe # Manage subscription
agent-media login / logout # AuthenticationREST API (v2)
agent-media also has a REST API for programmatic access. Interactive docs at https://agent-media.ai/docs/api-reference
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /v1/generate/ugc_video | Generate a UGC video |
| POST | /v1/generate/product_review | Generate a product review video |
| POST | /v1/generate/show_your_app | Actor holding phone with your app screenshot + Hormozi subs |
| POST | /v1/generate/product_acting_ugc | Actor presents or reacts to your product image |
| POST | /v1/generate/subtitle | Add subtitles to a video |
| GET | /v1/actors | List available AI actors |
| GET | /v1/videos/{jobId} | Check job status |
Auth
Authorization: Bearer ma_YOUR_API_KEYSDKs
TypeScript (npm install @agentmedia/sdk):
import { AgentMedia } from '@agentmedia/sdk';
const client = new AgentMedia({ apiKey: 'ma_xxx' });
const video = await client.createVideo({ script: '...', actor_slug: 'sofia' });
const app = await client.createShowYourApp({ app_screenshot_url: '...', script: '...' });
const product = await client.createProductActing({
product_image_url: 'https://cdn.example.com/product.png',
actor_slug: 'sofia',
product_description: 'Premium perfume with a warm vanilla dry-down',
});Python (pip install agent-media):
from agent_media import AgentMedia
client = AgentMedia(api_key="ma_xxx")
video = client.create_video(script="...", actor_slug="sofia")
app = client.create_show_your_app(app_screenshot_url="...", script="...")
product = client.create_product_acting(
product_image_url="https://cdn.example.com/product.png",
actor_slug="sofia",
product_description="Premium perfume with a warm vanilla dry-down",
)curl Example
# Generate a video
curl -X POST https://api.agent-media.ai/v1/generate/ugc_video \
-H "Authorization: Bearer ma_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"script": "Your 50+ character script here...",
"actor_slug": "sofia",
"tone": "energetic"
}'
# Check status
curl https://api.agent-media.ai/v1/videos/{job_id} \
-H "Authorization: Bearer ma_YOUR_KEY"MCP Server (Claude Code / Cursor)
{
"mcpServers": {
"agent-media": {
"command": "npx",
"args": ["@agentmedia/mcp-server"],
"env": { "AGENT_MEDIA_API_KEY": "ma_xxx" }
}
}
}Exposes tools: create_video, show_your_app, product_acting_ugc, list_actors, get_video_status.
OpenAPI Spec
Download at https://agent-media.ai/openapi.json — import into Postman, Insomnia, or any OpenAPI tool.
Checklist Before Every UGC Command
Before running ANY agent-media ugc command, verify:
- [ ]
--actoris included (no actor = no talking head = broken video). Pick a RANDOM actor if user didn't specify one! - [ ]
--styleis included. Pick a RANDOM style if user didn't specify one — never always use hormozi! - [ ] Script word count matches duration EXACTLY (5s=10-12, 10s=22-25, 15s=33-37 words — too few = pauses, too many = robotic)
- [ ]
--syncis appended - [ ] For SaaS reviews:
--broll --broll-imageswith 1-3 screenshot URLs - [ ] For SaaS reviews: product name appears 2-3 times in script
- [ ] For PIP mode:
--pipflag is set,--actoris included, duration ≤ 15s - [ ] For PIP mode: do NOT also pass
--broll(PIP auto-generates overlays) - [ ] For Product Acting:
--product-image,--actor, and either--scriptor--aboutare included - [ ] Credits are sufficient (
agent-media credits)
{
"name": "agent-media",
"owner": {
"name": "Nevo David",
"email": "nevo@postiz.com"
},
"metadata": {
"description": "AI UGC video production — generate complete videos with talking heads, B-roll, subtitles, and SaaS review videos from the terminal",
"version": "2.0.0"
},
"plugins": [
{
"name": "agent-media",
"description": "Produce complete UGC videos with AI talking heads, B-roll, voiceover, and animated subtitles. Generate SaaS review videos, manage personas, and add subtitles — all from the terminal via the agent-media CLI.",
"source": "./",
"strict": false,
"skills": [
"./"
]
}
]
}
{
"name": "agent-media",
"version": "1.4.3",
"description": "AI UGC video production — generate complete videos with talking heads, B-roll, subtitles, and SaaS review videos from the terminal",
"author": {
"name": "Nevo David",
"email": "nevo@postiz.com",
"url": "https://github.com/nevo-david"
},
"homepage": "https://agent-media.ai",
"repository": "https://github.com/gitroomhq/agent-media",
"license": "Apache-2.0",
"keywords": [
"ai",
"video",
"ugc",
"talking-head",
"ai-video-generator",
"saas-review",
"text-to-video",
"cli",
"agent-media"
]
}
node_modules/
dist/
*.tgz
.env
.env.*
.DS_Store
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format.
Copyright 2026 agent-media contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
{
"name": "agent-media-cli",
"version": "1.4.3",
"type": "module",
"private": false,
"license": "Apache-2.0",
"description": "CLI tool for AI-powered UGC video generation.",
"homepage": "https://agent-media.ai",
"repository": {
"type": "git",
"url": "git+https://github.com/gitroomhq/agent-media.git"
},
"keywords": [
"ai",
"video",
"image",
"generation",
"cli",
"terminal",
"text-to-video",
"text-to-image",
"image-to-video",
"ugc",
"ai-video-generator",
"talking-head",
"ai-ugc",
"saas-review",
"ai-actor"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"agent-media": "./dist/index.js"
},
"files": [
"dist",
"README.md",
"SKILL.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"prepublishOnly": "npm run build"
},
"dependencies": {
"chalk": "^5.4.1",
"commander": "^13.1.0",
"open": "^10.1.0",
"ora": "^8.2.0"
},
"devDependencies": {
"@types/node": "^22.13.4",
"typescript": "^5.7.3"
}
}
agent-media
Claude Code skill for AI UGC video production and media generation via the agent-media CLI.
What it does
This skill gives Claude Code the ability to produce complete UGC videos, Product Acting UGC, and SaaS review videos on your behalf using the agent-media CLI.
Flagship: UGC Pipeline — Turn a script into a complete video with AI talking heads, B-roll cutaways, voiceover, animated subtitles, and background music. One command.
Product Acting UGC — Turn a product image into a creator-style product-in-hand video with an actor, scenario template, spoken script, and synced subtitles.
SaaS Review Videos — Provide a product name, the AI writes the script and produces a review video with an AI actor.
Install
1. Install the CLI
npm install -g agent-media-cli2. Log in
agent-media login3. Add the skill to Claude Code
npx --yes skills add gitroomhq/agent-media --agent claude-code --yesOr manually copy SKILL.md into your project's .claude/skills/agent-media/ directory.
To refresh both the npm CLI and Claude Code skill later:
agent-media update --forceUsage
Once installed, ask Claude Code things like:
- "Create a 10-second UGC video from this script using actor sofia"
- "Make a SaaS review video for Linear with an enthusiastic angle"
- "Create a Product Acting UGC video from this perfume image"
- "Add hormozi-style subtitles to this video"
- "Create a persona from my voice sample and face photo"
- "Check my credit balance"
Quick examples
# UGC video with AI actor
agent-media ugc "Ever wonder why some videos go viral?" --actor sofia --sync
# SaaS review video
agent-media review --saas "Linear" --actor sofia --angle enthusiastic --sync
# UGC with B-roll + product screenshots
agent-media ugc "Your script..." --actor marcus --broll --broll-images https://example.com/shot.png --sync
# Add subtitles to any video
agent-media subtitle ./video.mp4 --style hormozi --sync
# Product Acting UGC from a product image URL
agent-media product-acting \
--product-image https://cdn.example.com/product.png \
--actor sofia \
--about "A premium perfume with a warm vanilla dry-down" \
--syncLinks
- agent-media.ai — Dashboard & account
- agent-media-cli on npm
- Documentation
License
Apache-2.0
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media actor` command group.
*
* Browse and preview actors from the actor library.
*
* Subcommands:
* list — List actors with optional filters
* preview — Show details for a specific actor
*/
import type { Command } from 'commander';
import chalk from 'chalk';
import {
detectOutputMode,
printJson,
printQuiet,
createSpinner,
} from '../lib/output.js';
import { AgentMediaAPI } from '../lib/api.js';
interface ActorRecord {
id: string;
name: string;
slug: string;
gender: string;
age: number;
age_range: string;
nationality: string;
style: string;
actor_type: string;
portrait_url: string;
voice_id: string;
voice_gender: string;
lip_sync_engine: string;
}
export function registerActorCommand(program: Command): void {
const actor = program
.command('actor')
.description('Browse the actor library (200 AI actors for UGC videos)');
// ── actor list ──────────────────────────────────────────────────────────
actor
.command('list')
.description('List actors with optional filters')
.option('--gender <gender>', 'Filter by gender: female, male')
.option('--type <type>', 'Filter by type: Young Adult, Professional, Mom, Elder, Casual')
.option('--age <range>', 'Filter by age range: 18-25, 26-35, 36-50, 51+')
.option('--search <name>', 'Search by name')
.action(async (cmdOpts) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
}>();
const mode = detectOutputMode(globalOpts);
try {
const api = new AgentMediaAPI(); // no auth needed for public endpoint
const spinner = createSpinner('Fetching actors...');
if (mode === 'human') spinner.start();
const params = new URLSearchParams();
if (cmdOpts.gender) params.set('gender', cmdOpts.gender);
if (cmdOpts.type) params.set('actor_type', cmdOpts.type);
if (cmdOpts.age) params.set('age_range', cmdOpts.age);
if (cmdOpts.search) params.set('search', cmdOpts.search);
const result = await api.listActors(params);
if (mode === 'human') spinner.succeed(`Found ${result.total} actor(s)`);
if (mode === 'json') {
printJson(result);
return;
}
if (mode === 'quiet') {
(result.actors as ActorRecord[]).forEach((a) => printQuiet(a.slug));
return;
}
if (result.actors.length === 0) {
console.log(chalk.yellow(' No actors match your filters.'));
return;
}
// Table output
console.log('');
console.log(
chalk.gray(' Slug'.padEnd(20)) +
chalk.gray('Name'.padEnd(15)) +
chalk.gray('Gender'.padEnd(10)) +
chalk.gray('Age'.padEnd(8)) +
chalk.gray('Type'.padEnd(18)) +
chalk.gray('Voice'),
);
console.log(chalk.gray(' ' + '─'.repeat(85)));
for (const a of result.actors as ActorRecord[]) {
console.log(
` ${chalk.cyan(a.slug.padEnd(18))}` +
`${a.name.padEnd(15)}` +
`${a.gender.padEnd(10)}` +
`${String(a.age).padEnd(8)}` +
`${a.actor_type.padEnd(18)}` +
`${chalk.dim(a.voice_id)}`,
);
}
console.log('');
console.log(chalk.dim(` Use: agent-media ugc "script..." --actor <slug> --sync`));
} catch (err) {
if (mode === 'human') {
console.error(chalk.red(`Error: ${(err as Error).message}`));
}
process.exitCode = 1;
}
});
// ── actor preview ───────────────────────────────────────────────────────
actor
.command('preview <slug>')
.description('Show details for a specific actor')
.action(async (slug: string) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
}>();
const mode = detectOutputMode(globalOpts);
try {
const api = new AgentMediaAPI();
const spinner = createSpinner(`Fetching actor ${slug}...`);
if (mode === 'human') spinner.start();
const result = await api.getActor(slug);
if (mode === 'human') spinner.succeed(`Actor: ${result.actor.name}`);
if (mode === 'json') {
printJson(result.actor);
return;
}
if (mode === 'quiet') {
printQuiet(result.actor.slug as string);
return;
}
const a = result.actor as unknown as ActorRecord;
console.log('');
console.log(` ${chalk.bold(a.name)} ${chalk.dim(`(${a.slug})`)}`);
console.log(` ${chalk.gray('Gender:')} ${a.gender}`);
console.log(` ${chalk.gray('Age:')} ${a.age} (${a.age_range})`);
console.log(` ${chalk.gray('Nationality:')} ${a.nationality}`);
console.log(` ${chalk.gray('Style:')} ${a.style}`);
console.log(` ${chalk.gray('Type:')} ${a.actor_type}`);
console.log(` ${chalk.gray('Voice:')} ${a.voice_id}`);
console.log(` ${chalk.gray('Lip Sync:')} ${a.lip_sync_engine}`);
console.log(` ${chalk.gray('Portrait:')} ${a.portrait_url}`);
console.log('');
console.log(chalk.dim(` Usage: agent-media ugc "your script..." --actor ${a.slug} --sync`));
} catch (err) {
if (mode === 'human') {
console.error(chalk.red(`Error: ${(err as Error).message}`));
}
process.exitCode = 1;
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media alias` command group.
*
* Manages user-defined command aliases stored at ~/.agent-media/aliases.json.
* Aliases expand to full CLI invocations, allowing frequently used commands
* to be accessed with shorter names.
*
* Subcommands:
* - `agent-media alias set <name> <expansion>` -- create or update an alias
* - `agent-media alias list` -- show all aliases
* - `agent-media alias delete <name>` -- remove an alias
*
* Supports human, JSON, and quiet output modes via global flags.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import chalk from 'chalk';
import { CONFIG_DIR } from '../lib/config.js';
import { detectOutputMode, printJson, printQuiet } from '../lib/output.js';
import { CLIError, handleError } from '../lib/errors.js';
// ── Constants ────────────────────────────────────────────────────────────────
const ALIASES_FILE = join(CONFIG_DIR, 'aliases.json');
const MAX_ALIAS_NAME_LENGTH = 50;
const MAX_EXPANSION_LENGTH = 500;
/**
* Reserved names that cannot be used as aliases because they conflict with
* built-in commands.
*/
const RESERVED_NAMES = new Set([
'alias',
'apikey',
'cancel',
'config',
'credits',
'debug',
'delete',
'doctor',
'download',
'generate',
'help',
'inspect',
'list',
'login',
'logout',
'models',
'plan',
'pricing',
'profile',
'retry',
'status',
'subscribe',
'text',
'usage',
'version',
'whoami',
]);
// ── Alias Store ──────────────────────────────────────────────────────────────
type AliasMap = Record<string, string>;
/**
* Ensure the config directory exists.
*/
function ensureConfigDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
}
}
/**
* Load aliases from disk. Returns an empty object if the file does not
* exist or is malformed.
*/
function loadAliases(): AliasMap {
if (!existsSync(ALIASES_FILE)) {
return {};
}
try {
const raw = readFileSync(ALIASES_FILE, 'utf-8');
const data = JSON.parse(raw) as unknown;
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return {};
}
return data as AliasMap;
} catch {
return {};
}
}
/**
* Persist the aliases map to disk.
*/
function saveAliases(aliases: AliasMap): void {
ensureConfigDir();
writeFileSync(ALIASES_FILE, JSON.stringify(aliases, null, 2) + '\n', 'utf-8');
}
/**
* Validate an alias name.
*/
function validateAliasName(name: string): void {
if (name.length === 0) {
throw new CLIError('Alias name must not be empty.', {
code: 'INVALID_ALIAS_NAME',
});
}
if (name.length > MAX_ALIAS_NAME_LENGTH) {
throw new CLIError(
`Alias name must be ${MAX_ALIAS_NAME_LENGTH} characters or fewer.`,
{ code: 'INVALID_ALIAS_NAME' },
);
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new CLIError(
'Alias name may only contain letters, digits, hyphens, and underscores.',
{ code: 'INVALID_ALIAS_NAME' },
);
}
if (RESERVED_NAMES.has(name.toLowerCase())) {
throw new CLIError(`"${name}" is a built-in command and cannot be used as an alias.`, {
code: 'RESERVED_ALIAS_NAME',
suggestion: 'Choose a different name that does not conflict with a built-in command.',
});
}
}
// ── Command Registration ─────────────────────────────────────────────────────
export function registerAliasCommand(program: Command): void {
const aliasCmd = program
.command('alias')
.description('Manage command aliases');
// ── agent-media alias set <name> <expansion> ──────────────────────────
aliasCmd
.command('set <name> <expansion>')
.description('Create or update a command alias')
.action((name: string, expansion: string) => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
validateAliasName(name);
if (expansion.length === 0) {
throw new CLIError('Expansion must not be empty.', {
code: 'INVALID_ALIAS_EXPANSION',
});
}
if (expansion.length > MAX_EXPANSION_LENGTH) {
throw new CLIError(
`Expansion must be ${MAX_EXPANSION_LENGTH} characters or fewer.`,
{ code: 'INVALID_ALIAS_EXPANSION' },
);
}
const aliases = loadAliases();
const isUpdate = name in aliases;
aliases[name] = expansion;
saveAliases(aliases);
switch (mode) {
case 'json':
printJson({ name, expansion, updated: isUpdate, success: true });
break;
case 'quiet':
printQuiet(name);
break;
default: {
const verb = isUpdate ? 'Updated' : 'Created';
console.log(
chalk.green(`\u2713 ${verb} alias ${chalk.bold(name)} = ${chalk.cyan(expansion)}`),
);
break;
}
}
} catch (error: unknown) {
handleError(error);
}
});
// ── agent-media alias list ────────────────────────────────────────────
aliasCmd
.command('list')
.description('List all command aliases')
.action(() => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
const aliases = loadAliases();
const entries = Object.entries(aliases);
if (entries.length === 0) {
switch (mode) {
case 'json':
printJson({});
break;
case 'quiet':
// No output for quiet mode on empty result
break;
default:
console.log();
console.log(
chalk.dim(" No aliases defined. Create one with 'agent-media alias set <name> <expansion>'."),
);
console.log();
break;
}
return;
}
switch (mode) {
case 'json':
printJson(aliases);
break;
case 'quiet':
printQuiet(entries.map(([name]) => name));
break;
default: {
console.log();
console.log(chalk.bold(` Aliases (${entries.length})`));
console.log(chalk.dim(` ${ALIASES_FILE}`));
console.log();
// Calculate column width for alignment
const maxNameLen = Math.max(...entries.map(([n]) => n.length));
for (const [name, expansion] of entries) {
console.log(` ${chalk.bold(name.padEnd(maxNameLen))} ${chalk.cyan(expansion)}`);
}
console.log();
break;
}
}
} catch (error: unknown) {
handleError(error);
}
});
// ── agent-media alias delete <name> ───────────────────────────────────
aliasCmd
.command('delete <name>')
.description('Remove a command alias')
.action((name: string) => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
const aliases = loadAliases();
if (!(name in aliases)) {
throw new CLIError(`Alias "${name}" not found.`, {
code: 'ALIAS_NOT_FOUND',
suggestion: "Run 'agent-media alias list' to see all aliases.",
});
}
delete aliases[name];
saveAliases(aliases);
switch (mode) {
case 'json':
printJson({ name, deleted: true, success: true });
break;
case 'quiet':
printQuiet('deleted');
break;
default:
console.log(chalk.green(`\u2713 Deleted alias ${chalk.bold(name)}`));
break;
}
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media apikey` command group.
*
* Manages user API keys: list, create, and revoke. API keys are used
* for authenticating CLI and programmatic access to the agent-media
* platform.
*
* Subcommands:
* - `agent-media apikey list` -- list all active API keys
* - `agent-media apikey create <name>` -- create a new API key
* - `agent-media apikey revoke <key-id>` -- revoke an existing key
*
* Supports human, JSON, and quiet output modes via global flags.
*/
import { createInterface } from 'node:readline';
import type { Command } from 'commander';
import chalk from 'chalk';
import {
detectOutputMode,
printJson,
printQuiet,
createSpinner,
} from '../lib/output.js';
import { getApiKey, resolveProfileName } from '../lib/credentials.js';
import {
AgentMediaAPI,
type ApiKeyRecord,
} from '../lib/api.js';
import { CLIError, handleError } from '../lib/errors.js';
/**
* Require authentication and return the API key or throw.
*/
function requireAuth(profileName: string): string {
const apiKey = getApiKey(profileName);
if (!apiKey) {
throw new CLIError('Not logged in.', {
code: 'NOT_AUTHENTICATED',
suggestion: "Run 'agent-media login' to authenticate.",
});
}
return apiKey;
}
/**
* Format a date string into a short human-readable label.
* Returns "Never" for null/undefined values.
*/
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
const month = months[date.getMonth()]!;
const day = date.getDate();
// If same year, show "Mon DD"; otherwise "Mon DD, YYYY"
if (date.getFullYear() === now.getFullYear()) {
return `${month} ${day}`;
}
return `${month} ${day}, ${date.getFullYear()}`;
}
/**
* Pad or truncate a string to an exact column width.
* Strips ANSI codes for length calculation.
*/
function fixedWidth(str: string, width: number): string {
// eslint-disable-next-line no-control-regex
const stripped = str.replace(/\x1B\[[0-9;]*m/g, '');
if (stripped.length >= width) return str;
return str + ' '.repeat(width - stripped.length);
}
/**
* Truncate a string to a maximum length, appending ellipsis if needed.
*/
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '\u2026';
}
/**
* Print a formatted table of API keys.
*/
function printKeyTable(keys: ApiKeyRecord[]): void {
const COL_PREFIX = 10;
const COL_NAME = 14;
const COL_CREATED = 12;
const COL_LAST_USED = 12;
const header =
fixedWidth('PREFIX', COL_PREFIX) +
fixedWidth('NAME', COL_NAME) +
fixedWidth('CREATED', COL_CREATED) +
'LAST USED';
const separator =
'\u2500'.repeat(COL_PREFIX) +
'\u2500'.repeat(COL_NAME) +
'\u2500'.repeat(COL_CREATED) +
'\u2500'.repeat(COL_LAST_USED);
console.log();
console.log(chalk.bold(` API Keys (${keys.length})`));
console.log();
console.log(` ${chalk.bold(header)}`);
console.log(` ${chalk.dim(separator)}`);
for (const key of keys) {
const prefix = fixedWidth(key.key_prefix, COL_PREFIX);
const name = fixedWidth(truncate(key.name, COL_NAME - 2), COL_NAME);
const created = fixedWidth(formatDate(key.created_at), COL_CREATED);
const lastUsed = formatDate(key.last_used_at);
console.log(` ${prefix}${name}${created}${lastUsed}`);
}
console.log();
}
/**
* Ask a yes/no question on stdin and return the result.
* Defaults to "no" (destructive action requires explicit confirmation).
*/
function askConfirmation(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(question, (answer) => {
rl.close();
const trimmed = answer.trim().toLowerCase();
resolve(trimmed === 'y' || trimmed === 'yes');
});
});
}
export function registerApikeyCommand(program: Command): void {
const apikeyCmd = program
.command('apikey')
.description('Manage API keys');
// ── agent-media apikey list ──────────────────────────────────────────────
apikeyCmd
.command('list')
.description('List all active API keys')
.action(async () => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const spinner = createSpinner('Fetching API keys...');
if (mode === 'human') spinner.start();
const api = new AgentMediaAPI(apiKey);
const keys = await api.listApiKeys();
if (mode === 'human') spinner.stop();
// ── Empty state ──────────────────────────────────────────────
if (keys.length === 0) {
switch (mode) {
case 'json':
printJson([]);
break;
case 'quiet':
// No output for quiet mode on empty result
break;
default:
console.log();
console.log(
chalk.dim(
" No API keys found. Create one with 'agent-media apikey create <name>'.",
),
);
console.log();
break;
}
return;
}
// ── Output ───────────────────────────────────────────────────
switch (mode) {
case 'json':
printJson(keys);
break;
case 'quiet':
printQuiet(keys.map((k) => k.key_prefix));
break;
default:
printKeyTable(keys);
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// ── agent-media apikey create <name> ─────────────────────────────────────
apikeyCmd
.command('create <name>')
.description('Create a new API key')
.action(async (name: string) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const spinner = createSpinner('Creating API key...');
if (mode === 'human') spinner.start();
const api = new AgentMediaAPI(apiKey);
const result = await api.createApiKey(name);
if (mode === 'human') spinner.stop();
switch (mode) {
case 'json':
printJson({
id: result.id,
name: result.name,
api_key: result.key,
key_prefix: result.key_prefix,
});
break;
case 'quiet':
printQuiet(result.key);
break;
default:
console.log();
console.log(
` ${chalk.green('\u2713')} API key created: ${chalk.bold(`"${result.name}"`)}`,
);
console.log();
console.log(` ${chalk.cyan(result.key)}`);
console.log();
console.log(
` ${chalk.yellow('\u26A0')} Copy this key now. You won't see it again.`,
);
console.log();
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// ── agent-media apikey revoke <key-id> ──────────────────────────────────
apikeyCmd
.command('revoke <key-id>')
.description('Revoke an API key')
.option('-f, --force', 'Skip confirmation prompt')
.action(async (keyId: string, cmdOpts: { force?: boolean }) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const api = new AgentMediaAPI(apiKey);
// Fetch the key list to show details before confirming
if (mode === 'human' && !cmdOpts.force) {
const spinner = createSpinner('Looking up API key...');
spinner.start();
const keys = await api.listApiKeys();
const targetKey = keys.find((k) => k.id === keyId);
spinner.stop();
if (targetKey) {
console.log();
console.log(` ${chalk.bold('Key:')} ${targetKey.key_prefix}`);
console.log(` ${chalk.bold('Name:')} ${targetKey.name}`);
console.log(` ${chalk.bold('Created:')} ${formatDate(targetKey.created_at)}`);
console.log();
}
const confirmed = await askConfirmation(
` Revoke API key ${chalk.cyan(keyId.slice(0, 8))}? This cannot be undone. [y/N] `,
);
if (!confirmed) {
console.log(chalk.dim(' Revocation cancelled.'));
return;
}
}
const revokeSpinner = createSpinner('Revoking API key...');
if (mode === 'human') revokeSpinner.start();
const result = await api.revokeManagedApiKey(keyId);
if (mode === 'human') revokeSpinner.succeed('API key revoked');
switch (mode) {
case 'json':
printJson({
id: result.id,
revoked: result.revoked,
});
break;
case 'quiet':
printQuiet('revoked');
break;
default:
console.log();
console.log(` ${chalk.green('API key revoked successfully.')}`);
console.log(
chalk.dim(' Any applications using this key will lose access immediately.'),
);
console.log();
break;
}
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media cancel <job-id>` command.
*
* Cancels an active generation job (submitted/queued/processing)
* and refunds the credits. Use --all to cancel all active jobs.
*/
import type { Command } from 'commander';
import chalk from 'chalk';
import {
detectOutputMode,
printJson,
printQuiet,
createSpinner,
} from '../lib/output.js';
import { getApiKey, resolveProfileName } from '../lib/credentials.js';
import { AgentMediaAPI } from '../lib/api.js';
import { CLIError, handleError } from '../lib/errors.js';
interface CancelOptions {
all?: boolean;
}
export function registerCancelCommand(program: Command): void {
program
.command('cancel [job-id]')
.description('Cancel an active generation job and refund credits')
.option('--all', 'Cancel all active (submitted/queued/processing) jobs')
.action(async (jobId: string | undefined, cmdOpts: CancelOptions) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = getApiKey(profileName);
if (!apiKey) {
throw new CLIError('Not logged in.', {
code: 'NOT_AUTHENTICATED',
suggestion: "Run 'agent-media login' to authenticate.",
});
}
if (!jobId && !cmdOpts.all) {
throw new CLIError('Provide a job ID or use --all.', {
code: 'MISSING_ARGUMENT',
suggestion: "Usage: agent-media cancel <job-id> or agent-media cancel --all",
});
}
try {
const api = new AgentMediaAPI(apiKey);
// ── Cancel all active jobs ──────────────────────────────────────
if (cmdOpts.all) {
const spinner = createSpinner('Finding active jobs...');
if (mode === 'human') spinner.start();
const { jobs } = await api.listJobs({ limit: 100 });
const activeJobs = jobs.filter((j) =>
['submitted', 'queued', 'processing'].includes(j.status),
);
if (activeJobs.length === 0) {
if (mode === 'human') {
spinner.stop();
console.log(chalk.dim('\n No active jobs to cancel.\n'));
} else if (mode === 'json') {
printJson({ canceled: [], count: 0, credits_refunded: 0 });
} else {
printQuiet('0');
}
return;
}
if (mode === 'human') {
spinner.text = `Canceling ${activeJobs.length} active job${activeJobs.length === 1 ? '' : 's'}...`;
}
let totalRefunded = 0;
const canceled: string[] = [];
for (const job of activeJobs) {
try {
const result = await api.cancelJob(job.id);
if (result.canceled) {
canceled.push(job.id);
totalRefunded += result.credits_refunded;
}
} catch {
// Skip jobs that can't be canceled
}
}
if (mode === 'human') {
spinner.succeed(
`Canceled ${canceled.length} job${canceled.length === 1 ? '' : 's'}, refunded ${chalk.green(String(totalRefunded))} credits`,
);
console.log();
} else if (mode === 'json') {
printJson({ canceled, count: canceled.length, credits_refunded: totalRefunded });
} else {
printQuiet(String(canceled.length));
}
return;
}
// ── Cancel single job ───────────────────────────────────────────
const spinner = createSpinner('Canceling job...');
if (mode === 'human') spinner.start();
const result = await api.cancelJob(jobId!);
if (mode === 'human') {
spinner.succeed(
`Job ${chalk.cyan(jobId!.slice(0, 8))} canceled, ${chalk.green(String(result.credits_refunded))} credits refunded`,
);
console.log();
} else if (mode === 'json') {
printJson(result);
} else {
printQuiet('canceled');
}
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media completions <shell>` command.
*
* Generates shell completion scripts for bash, zsh, and fish.
* Users pipe the output into their shell's configuration to enable
* tab-completion of commands, options, and arguments.
*
* Usage:
* agent-media completions bash >> ~/.bashrc
* agent-media completions zsh >> ~/.zshrc
* agent-media completions fish > ~/.config/fish/completions/agent-media.fish
*/
import type { Command } from 'commander';
import { CLIError, handleError } from '../lib/errors.js';
/** All registered command names (used in completion scripts). */
const COMMANDS = [
'actor',
'alias',
'apikey',
'cancel',
'completions',
'config',
'credits',
'debug',
'delete',
'doctor',
'download',
'inspect',
'list',
'login',
'logout',
'persona',
'plan',
'profile',
'status',
'subscribe',
'subtitle',
'ugc',
'update',
'usage',
'version',
'whoami',
];
/** Global option flags available on all commands. */
const GLOBAL_FLAGS = [
'--json',
'--quiet',
'--no-color',
'--verbose',
'--profile',
'--help',
'--version',
];
/**
* Generate a Bash completion script.
*/
function generateBash(): string {
const cmds = COMMANDS.join(' ');
const flags = GLOBAL_FLAGS.join(' ');
return `# agent-media bash completion
# Generated by: agent-media completions bash
# Add to ~/.bashrc or ~/.bash_profile:
# eval "$(agent-media completions bash)"
_agent_media_completions() {
local cur prev commands global_flags
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="${cmds}"
global_flags="${flags}"
# Complete subcommands when at position 1
if [[ \${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
# Complete global flags
if [[ "\${cur}" == -* ]]; then
COMPREPLY=( $(compgen -W "\${global_flags}" -- "\${cur}") )
return 0
fi
# Command-specific completions
case "\${COMP_WORDS[1]}" in
completions)
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
;;
config)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "get set list reset export import" -- "\${cur}") )
elif [[ \${COMP_CWORD} -eq 3 && ("\${prev}" == "get" || "\${prev}" == "set") ]]; then
COMPREPLY=( $(compgen -W "api_url output_format default_model auto_open_browser download_dir" -- "\${cur}") )
fi
;;
credits)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "estimate history topup" -- "\${cur}") )
fi
;;
ugc)
# UGC has many options but no positional subcommands to complete
;;
actor)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "list preview" -- "\${cur}") )
fi
;;
persona)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "list create delete" -- "\${cur}") )
fi
;;
apikey)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "list create revoke" -- "\${cur}") )
fi
;;
profile)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "list use delete export" -- "\${cur}") )
fi
;;
alias)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "set remove list" -- "\${cur}") )
fi
;;
esac
return 0
}
complete -F _agent_media_completions agent-media
`;
}
/**
* Generate a Zsh completion script.
*/
function generateZsh(): string {
const cmdLines = COMMANDS.map((cmd) => ` '${cmd}:${cmd} command'`).join(' \\\n');
return `#compdef agent-media
# agent-media zsh completion
# Generated by: agent-media completions zsh
# Add to ~/.zshrc:
# eval "$(agent-media completions zsh)"
# Or save to a file in your $fpath:
# agent-media completions zsh > ~/.zsh/completions/_agent-media
_agent-media() {
local -a commands
commands=(
${cmdLines}
)
local -a global_flags
global_flags=(
'--json[Output as JSON]'
'--quiet[Minimal output]'
'--no-color[Disable colored output]'
'--verbose[Show debug output]'
'--profile[Use a specific credential profile]:profile name'
'--help[Show help]'
'--version[Show version]'
)
_arguments -C \\
'1:command:->command' \\
'*::arg:->args' \\
\${global_flags}
case "\$state" in
command)
_describe -t commands 'agent-media command' commands
;;
args)
case "\$words[1]" in
completions)
_values 'shell' bash zsh fish
;;
config)
local -a config_subcmds
config_subcmds=(get set list reset export import)
if (( CURRENT == 2 )); then
_describe -t subcommands 'config subcommand' config_subcmds
elif (( CURRENT == 3 )) && [[ "\$words[2]" == (get|set) ]]; then
local -a config_keys
config_keys=(api_url output_format default_model auto_open_browser download_dir)
_describe -t keys 'config key' config_keys
fi
;;
credits)
local -a credits_subcmds
credits_subcmds=(estimate history topup)
_describe -t subcommands 'credits subcommand' credits_subcmds
;;
ugc)
# UGC has many options but no positional subcommands
;;
actor)
local -a actor_subcmds
actor_subcmds=(list preview)
_describe -t subcommands 'actor subcommand' actor_subcmds
;;
persona)
local -a persona_subcmds
persona_subcmds=(list create delete)
_describe -t subcommands 'persona subcommand' persona_subcmds
;;
apikey)
local -a apikey_subcmds
apikey_subcmds=(list create revoke)
_describe -t subcommands 'apikey subcommand' apikey_subcmds
;;
profile)
local -a profile_subcmds
profile_subcmds=(list use delete export)
_describe -t subcommands 'profile subcommand' profile_subcmds
;;
alias)
local -a alias_subcmds
alias_subcmds=(set remove list)
_describe -t subcommands 'alias subcommand' alias_subcmds
;;
download|status|inspect|cancel|delete)
_message 'job ID'
;;
esac
;;
esac
}
_agent-media "\$@"
`;
}
/**
* Generate a Fish completion script.
*/
function generateFish(): string {
const cmdCompletions = COMMANDS.map(
(cmd) => `complete -c agent-media -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
).join('\n');
return `# agent-media fish completion
# Generated by: agent-media completions fish
# Save to: ~/.config/fish/completions/agent-media.fish
# Disable file completions by default
complete -c agent-media -f
# Global flags
complete -c agent-media -l json -d "Output as JSON"
complete -c agent-media -l quiet -d "Minimal output"
complete -c agent-media -l no-color -d "Disable colored output"
complete -c agent-media -l verbose -d "Show debug output"
complete -c agent-media -l profile -d "Use a specific credential profile" -x
complete -c agent-media -l help -d "Show help"
complete -c agent-media -l version -d "Show version"
# Subcommands
${cmdCompletions}
# completions subcommand
complete -c agent-media -n "__fish_seen_subcommand_from completions" -a "bash zsh fish" -d "Shell type"
# config subcommand
complete -c agent-media -n "__fish_seen_subcommand_from config" -a "get set list reset export import" -d "Config action"
# credits subcommand
complete -c agent-media -n "__fish_seen_subcommand_from credits" -a "estimate history topup" -d "Credits action"
# ugc subcommand (no positional completions, uses flags)
# actor subcommand
complete -c agent-media -n "__fish_seen_subcommand_from actor" -a "list preview" -d "Actor action"
# persona subcommand
complete -c agent-media -n "__fish_seen_subcommand_from persona" -a "list create delete" -d "Persona action"
# apikey subcommand
complete -c agent-media -n "__fish_seen_subcommand_from apikey" -a "list create revoke" -d "API key action"
# profile subcommand
complete -c agent-media -n "__fish_seen_subcommand_from profile" -a "list use delete export" -d "Profile action"
# alias subcommand
complete -c agent-media -n "__fish_seen_subcommand_from alias" -a "set remove list" -d "Alias action"
`;
}
export function registerCompletionsCommand(program: Command): void {
program
.command('completions <shell>')
.description(
'Generate shell completion scripts\n\n' +
'Usage:\n' +
' agent-media completions bash >> ~/.bashrc\n' +
' agent-media completions zsh >> ~/.zshrc\n' +
' agent-media completions fish > ~/.config/fish/completions/agent-media.fish',
)
.action((shell: string) => {
try {
const shellLower = shell.toLowerCase();
switch (shellLower) {
case 'bash':
process.stdout.write(generateBash());
break;
case 'zsh':
process.stdout.write(generateZsh());
break;
case 'fish':
process.stdout.write(generateFish());
break;
default:
throw new CLIError(`Unsupported shell: "${shell}"`, {
code: 'UNSUPPORTED_SHELL',
suggestion: 'Supported shells: bash, zsh, fish',
});
}
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media config` command.
*
* Manages CLI configuration stored at ~/.agent-media/config.json.
* Supports get, set, list, and reset subcommands for controlling
* default behavior of the CLI.
*
* Supported keys:
* - api_url: Supabase Edge Functions URL
* - output_format: default output format (human/json/quiet)
* - default_model: default model for generate command
* - auto_open_browser: whether to auto-open browser on login (default: true)
* - download_dir: default download directory
*/
import { readFileSync } from 'node:fs';
import type { Command } from 'commander';
import chalk from 'chalk';
import {
loadConfig,
saveConfig,
getConfigValue,
setConfigValue,
resetConfig,
CONFIG_FILE,
VALID_CONFIG_KEYS,
CONFIG_DEFAULTS,
isValidConfigKey,
type ConfigData,
} from '../lib/config.js';
import { detectOutputMode, printJson, printQuiet } from '../lib/output.js';
import { handleError } from '../lib/errors.js';
import { CLIError } from '../lib/errors.js';
export function registerConfigCommand(program: Command): void {
const configCmd = program
.command('config')
.description('Manage CLI configuration');
// agent-media config set <key> <value>
configCmd
.command('set <key> <value>')
.description('Set a configuration value')
.action((key: string, value: string) => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
if (!isValidConfigKey(key)) {
throw new CLIError(`Unknown config key: "${key}"`, {
code: 'INVALID_CONFIG_KEY',
suggestion: `Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`,
});
}
// Validate specific keys
if (key === 'output_format' && !['human', 'json', 'quiet'].includes(value)) {
throw new CLIError(`Invalid output_format: "${value}"`, {
code: 'INVALID_CONFIG_VALUE',
suggestion: 'Valid values: human, json, quiet',
});
}
if (key === 'auto_open_browser' && !['true', 'false'].includes(value)) {
throw new CLIError(`Invalid auto_open_browser: "${value}"`, {
code: 'INVALID_CONFIG_VALUE',
suggestion: 'Valid values: true, false',
});
}
// Coerce boolean values
const coerced = key === 'auto_open_browser' ? value === 'true' : value;
setConfigValue(key, coerced);
switch (mode) {
case 'json':
printJson({ key, value: coerced, success: true });
break;
case 'quiet':
printQuiet(String(coerced));
break;
default:
console.log(chalk.green(`\u2713 Set ${chalk.bold(key)} = ${coerced}`));
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// agent-media config get <key>
configCmd
.command('get <key>')
.description('Get a configuration value')
.action((key: string) => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
if (!isValidConfigKey(key)) {
throw new CLIError(`Unknown config key: "${key}"`, {
code: 'INVALID_CONFIG_KEY',
suggestion: `Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`,
});
}
const value = getConfigValue(key);
const defaultValue = CONFIG_DEFAULTS[key];
const effectiveValue = value ?? defaultValue;
switch (mode) {
case 'json':
printJson({
key,
value: effectiveValue ?? null,
is_default: value === undefined,
});
break;
case 'quiet':
printQuiet(effectiveValue !== undefined ? String(effectiveValue) : '');
break;
default:
if (value !== undefined) {
console.log(`${chalk.bold(key)} = ${value}`);
} else if (defaultValue !== undefined) {
console.log(`${chalk.bold(key)} = ${defaultValue} ${chalk.dim('(default)')}`);
} else {
console.log(`${chalk.bold(key)} = ${chalk.dim('(not set)')}`);
}
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// agent-media config list
configCmd
.command('list')
.description('List all configuration values')
.action(() => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
const config = loadConfig();
switch (mode) {
case 'json': {
// Merge defaults with current config for a complete picture
const merged: Record<string, unknown> = {};
for (const key of VALID_CONFIG_KEYS) {
merged[key] = config[key] ?? CONFIG_DEFAULTS[key] ?? null;
}
printJson(merged);
break;
}
case 'quiet': {
const lines: string[] = [];
for (const key of VALID_CONFIG_KEYS) {
const val = config[key] ?? CONFIG_DEFAULTS[key];
lines.push(`${key}=${val !== undefined ? val : ''}`);
}
printQuiet(lines);
break;
}
default: {
console.log();
console.log(chalk.bold(' Configuration'));
console.log(chalk.dim(` ${CONFIG_FILE}`));
console.log();
for (const key of VALID_CONFIG_KEYS) {
const value = config[key];
const defaultValue = CONFIG_DEFAULTS[key];
if (value !== undefined) {
console.log(` ${chalk.bold(key)} = ${value}`);
} else if (defaultValue !== undefined) {
console.log(
` ${chalk.bold(key)} = ${chalk.dim(String(defaultValue))} ${chalk.dim('(default)')}`,
);
} else {
console.log(` ${chalk.bold(key)} = ${chalk.dim('(not set)')}`);
}
}
console.log();
break;
}
}
} catch (error: unknown) {
handleError(error);
}
});
// agent-media config reset
configCmd
.command('reset')
.description('Reset all configuration to defaults')
.action(() => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
resetConfig();
switch (mode) {
case 'json':
printJson({ success: true, message: 'Configuration reset to defaults.' });
break;
case 'quiet':
printQuiet('reset');
break;
default:
console.log(chalk.green('\u2713 Configuration reset to defaults.'));
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// agent-media config export
configCmd
.command('export')
.description('Output full configuration as JSON to stdout')
.action(() => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
const config = loadConfig();
// Build a complete view: merge defaults with stored values
const merged: Record<string, unknown> = {};
for (const key of VALID_CONFIG_KEYS) {
merged[key] = config[key] ?? CONFIG_DEFAULTS[key] ?? null;
}
switch (mode) {
case 'json':
case 'quiet':
// Both modes output raw JSON for piping
printJson(merged);
break;
default:
// Human mode: also output raw JSON (this is the export)
printJson(merged);
break;
}
} catch (error: unknown) {
handleError(error);
}
});
// agent-media config import <file>
configCmd
.command('import <file>')
.description('Import configuration from a JSON file, merging into current config')
.action((file: string) => {
const globalOpts = program.opts<{ json?: boolean; quiet?: boolean }>();
const mode = detectOutputMode(globalOpts);
try {
// 1. Read and parse the file
let raw: string;
try {
raw = readFileSync(file, 'utf-8');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown read error';
throw new CLIError(`Failed to read file: ${msg}`, {
code: 'FILE_READ_ERROR',
suggestion: 'Verify the file path exists and is readable.',
});
}
let imported: unknown;
try {
imported = JSON.parse(raw);
} catch {
throw new CLIError('File is not valid JSON.', {
code: 'INVALID_JSON',
suggestion: 'Ensure the file contains a valid JSON object.',
});
}
// 2. Validate schema: must be a plain object
if (imported === null || typeof imported !== 'object' || Array.isArray(imported)) {
throw new CLIError('Imported config must be a JSON object.', {
code: 'INVALID_CONFIG_SCHEMA',
suggestion: 'The file should contain a JSON object like { "key": "value" }.',
});
}
const importedObj = imported as Record<string, unknown>;
// 3. Validate all keys are recognized config keys
const unknownKeys: string[] = [];
for (const key of Object.keys(importedObj)) {
if (!isValidConfigKey(key)) {
unknownKeys.push(key);
}
}
if (unknownKeys.length > 0) {
throw new CLIError(
`Unknown config key${unknownKeys.length > 1 ? 's' : ''}: ${unknownKeys.join(', ')}`,
{
code: 'INVALID_CONFIG_KEY',
suggestion: `Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`,
},
);
}
// 4. Validate individual values
if ('output_format' in importedObj) {
const val = importedObj['output_format'];
if (typeof val !== 'string' || !['human', 'json', 'quiet'].includes(val)) {
throw new CLIError(`Invalid output_format: "${val}"`, {
code: 'INVALID_CONFIG_VALUE',
suggestion: 'Valid values: human, json, quiet',
});
}
}
if ('auto_open_browser' in importedObj) {
const val = importedObj['auto_open_browser'];
if (typeof val !== 'boolean' && val !== 'true' && val !== 'false') {
throw new CLIError(`Invalid auto_open_browser: "${val}"`, {
code: 'INVALID_CONFIG_VALUE',
suggestion: 'Valid values: true, false',
});
}
// Normalize string booleans
if (typeof val === 'string') {
importedObj['auto_open_browser'] = val === 'true';
}
}
// 5. Merge into current config and save
const current = loadConfig();
const merged: ConfigData = { ...current, ...importedObj };
saveConfig(merged);
const importedKeyCount = Object.keys(importedObj).length;
switch (mode) {
case 'json':
printJson({
success: true,
keys_imported: importedKeyCount,
config: merged,
});
break;
case 'quiet':
printQuiet(String(importedKeyCount));
break;
default:
console.log(
chalk.green(
`\u2713 Imported ${importedKeyCount} config key${importedKeyCount !== 1 ? 's' : ''} from ${chalk.bold(file)}`,
),
);
break;
}
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media credits` command group.
*
* Displays the authenticated user's credit balance with a breakdown
* of monthly remaining, purchased, and total credits. Shows plan tier
* context and uses color-coded output to indicate credit health.
*
* Subcommands:
* - `agent-media credits` (default) -- show balance
* - `agent-media credits estimate <model>` -- estimate cost for a generation
* - `agent-media credits history` -- show credit transaction history
*
* Requires a valid API key. If not logged in, prompts the user to
* authenticate via `agent-media login`.
*/
import type { Command } from 'commander';
import chalk from 'chalk';
import { detectOutputMode, printJson, printQuiet, createSpinner } from '../lib/output.js';
import { getApiKey, resolveProfileName } from '../lib/credentials.js';
import { AgentMediaAPI, type CostEstimateResponse, type CreditTransaction, type UpdateAutoTopUpConfigParams } from '../lib/api.js';
import { CLIError, handleError } from '../lib/errors.js';
/** Valid transaction types for the --type filter. */
const VALID_TRANSACTION_TYPES = ['debit', 'credit', 'refund', 'reset'];
/**
* Thresholds for credit health color coding (as a fraction of monthly allowance).
*/
const LOW_THRESHOLD = 0.2;
const ZERO_THRESHOLD = 0;
/**
* Require authentication and return the API key or throw.
*/
function requireAuth(profileName: string): string {
const apiKey = getApiKey(profileName);
if (!apiKey) {
throw new CLIError('Not logged in.', {
code: 'NOT_AUTHENTICATED',
suggestion: "Run 'agent-media login' to authenticate.",
});
}
return apiKey;
}
export function registerCreditsCommand(program: Command): void {
const creditsCmd = program
.command('credits')
.description('Show credit balance and usage')
.action(async () => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const spinner = createSpinner('Fetching credit balance...');
if (mode === 'human') spinner.start();
const api = new AgentMediaAPI(apiKey);
const data = await api.getCredits();
if (mode === 'human') spinner.stop();
const monthlyRemaining = data.credits.monthly_remaining;
const monthlyAllowance = data.credits.monthly_allowance ?? monthlyRemaining;
const purchased = data.credits.purchased;
const total = data.credits.total;
const planTier = data.plan.tier;
switch (mode) {
case 'json':
printJson({
monthly_remaining: monthlyRemaining,
monthly_allowance: monthlyAllowance,
purchased,
total,
plan_tier: planTier,
});
break;
case 'quiet':
printQuiet(String(total));
break;
default: {
// Determine color based on credit health
const ratio = monthlyAllowance > 0
? monthlyRemaining / monthlyAllowance
: 0;
const creditColor =
monthlyRemaining <= ZERO_THRESHOLD
? chalk.red
: ratio <= LOW_THRESHOLD
? chalk.yellow
: chalk.green;
const planLabel = planTier.charAt(0).toUpperCase() + planTier.slice(1);
console.log();
console.log(chalk.bold(' Credit Balance'));
console.log();
console.log(
` ${chalk.bold('Plan:')} ${planLabel} plan`,
);
console.log(
` ${chalk.bold('Monthly:')} ${creditColor(`${monthlyRemaining}`)} / ${monthlyAllowance} remaining`,
);
console.log(
` ${chalk.bold('Purchased:')} ${purchased}`,
);
console.log(
` ${chalk.bold('Total:')} ${creditColor(String(total))}`,
);
console.log();
// Contextual summary line
console.log(
chalk.dim(
` ${planLabel} plan: ${monthlyRemaining}/${monthlyAllowance} monthly` +
(purchased > 0 ? ` + ${purchased} purchased` : ''),
),
);
console.log();
break;
}
}
} catch (error: unknown) {
handleError(error);
}
});
// ── agent-media credits estimate <model> ──────────────────────────────────
creditsCmd
.command('estimate <model>')
.description('Estimate cost for a generation without submitting')
.option('--duration <seconds>', 'Video duration in seconds')
.option('--resolution <res>', 'Output resolution (e.g., 720p, 1080p)')
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g., 16:9, 9:16, 1:1)')
.action(
async (
model: string,
cmdOpts: { duration?: string; resolution?: string; aspectRatio?: string },
) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const spinner = createSpinner('Calculating estimate...');
if (mode === 'human') spinner.start();
const api = new AgentMediaAPI(apiKey);
const estimate = (await api.submitGeneration({
modelSlug: model,
prompt: '__estimate__',
duration: cmdOpts.duration ? parseInt(cmdOpts.duration, 10) : undefined,
resolution: cmdOpts.resolution,
aspectRatio: cmdOpts.aspectRatio,
dryRun: true,
})) as CostEstimateResponse;
if (mode === 'human') spinner.stop();
switch (mode) {
case 'json':
printJson({
model,
credits: estimate.credits,
cost_usd: estimate.costUsd,
available_credits: estimate.availableCredits,
can_afford: estimate.canAfford,
breakdown: {
plan_credits: estimate.breakdown.planCredits,
purchased_credits: estimate.breakdown.purchasedCredits,
},
});
break;
case 'quiet':
printQuiet(String(estimate.credits));
break;
default: {
const affordColor = estimate.canAfford ? chalk.green : chalk.red;
const affordLabel = estimate.canAfford ? 'Yes' : 'No';
console.log();
console.log(chalk.bold(' Cost Estimate'));
console.log();
console.log(
` ${chalk.bold('Model:')} ${estimate.modelDisplayName}`,
);
console.log(
` ${chalk.bold('Credits:')} ${chalk.yellow(String(estimate.credits))}` +
` (${chalk.dim(`$${estimate.costUsd.toFixed(2)}`)})`,
);
console.log(
` ${chalk.bold('Balance:')} ${estimate.availableCredits} credits`,
);
console.log(
` ${chalk.bold('Affordable:')} ${affordColor(affordLabel)}`,
);
if (estimate.breakdown.planCredits > 0 || estimate.breakdown.purchasedCredits > 0) {
console.log();
console.log(chalk.bold(' Breakdown'));
if (estimate.breakdown.planCredits > 0) {
console.log(
` Plan credits: ${estimate.breakdown.planCredits}`,
);
}
if (estimate.breakdown.purchasedCredits > 0) {
console.log(
` Purchased credits: ${estimate.breakdown.purchasedCredits}`,
);
}
}
console.log();
if (!estimate.canAfford) {
console.log(
chalk.yellow(
' You need more credits. Visit https://agent-media.ai/billing',
),
);
console.log();
}
break;
}
}
} catch (error: unknown) {
handleError(error);
}
},
);
// ── agent-media credits history ───────────────────────────────────────────
creditsCmd
.command('history')
.description('Show credit transaction history')
.option('-n, --limit <n>', 'Number of transactions to show', '20')
.option('-t, --type <type>', 'Filter by type: debit, credit, refund, reset')
.action(
async (cmdOpts: { limit?: string; type?: string }) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
// Validate --type flag
if (cmdOpts.type && !VALID_TRANSACTION_TYPES.includes(cmdOpts.type)) {
throw new CLIError(`Invalid transaction type: ${cmdOpts.type}`, {
code: 'INVALID_ARGUMENT',
suggestion: `Valid types: ${VALID_TRANSACTION_TYPES.join(', ')}`,
});
}
// Parse --limit flag
const limit = parseInt(cmdOpts.limit ?? '20', 10);
if (isNaN(limit) || limit < 1) {
throw new CLIError('--limit must be a positive integer.', {
code: 'INVALID_ARGUMENT',
suggestion: 'Provide a number greater than 0.',
});
}
try {
const spinner = createSpinner('Fetching credit history...');
if (mode === 'human') spinner.start();
const api = new AgentMediaAPI(apiKey);
const transactions = await api.getCreditHistory({
limit,
type: cmdOpts.type,
});
if (mode === 'human') spinner.stop();
// ── Empty state ──────────────────────────────────────────
if (transactions.length === 0) {
switch (mode) {
case 'json':
printJson([]);
break;
case 'quiet':
// No output for quiet mode on empty result
break;
default:
console.log();
console.log(
chalk.dim(' No credit transactions found.'),
);
console.log();
break;
}
return;
}
// ── Output ───────────────────────────────────────────────
switch (mode) {
case 'json':
printJson(transactions);
break;
case 'quiet':
printQuiet(
transactions.map(
(tx) => `${tx.type}\t${tx.amount}\t${tx.description}`,
),
);
break;
default:
printCreditHistoryTable(transactions, cmdOpts.type);
break;
}
} catch (error: unknown) {
handleError(error);
}
},
);
// ── agent-media credits topup ─────────────────────────────────────────────
const VALID_PACKS = new Set(['pack_500', 'pack_1000', 'pack_2000', 'pack_5000']);
const PACK_LABELS: Record<string, string> = {
pack_500: '500 credits ($9)',
pack_1000: '1,000 credits ($19)',
pack_2000: '2,000 credits ($35)',
pack_5000: '5,000 credits ($79)',
};
creditsCmd
.command('topup')
.description('Manage auto-top-up settings for credits')
.option('--enable', 'Enable auto-top-up')
.option('--disable', 'Disable auto-top-up')
.option('--threshold <credits>', 'Set credit threshold (min: 10)')
.option('--pack <slug>', 'Set pack: pack_500, pack_1000, pack_2000, pack_5000')
.option('--max <count>', 'Set max monthly top-ups (1-10)')
.action(
async (cmdOpts: {
enable?: boolean;
disable?: boolean;
threshold?: string;
pack?: string;
max?: string;
}) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
const apiKey = requireAuth(profileName);
try {
const api = new AgentMediaAPI(apiKey);
// Determine if this is a read or write operation
const hasUpdates =
cmdOpts.enable !== undefined ||
cmdOpts.disable !== undefined ||
cmdOpts.threshold !== undefined ||
cmdOpts.pack !== undefined ||
cmdOpts.max !== undefined;
if (hasUpdates) {
// Build the update payload
const updates: UpdateAutoTopUpConfigParams = {};
if (cmdOpts.enable) {
updates.enabled = true;
}
if (cmdOpts.disable) {
updates.enabled = false;
}
if (cmdOpts.threshold !== undefined) {
const threshold = parseInt(cmdOpts.threshold, 10);
if (isNaN(threshold) || threshold < 10) {
throw new CLIError('Threshold must be an integer >= 10.', {
code: 'INVALID_THRESHOLD',
suggestion: 'Example: agent-media credits topup --threshold 50',
});
}
updates.threshold_credits = threshold;
}
if (cmdOpts.pack !== undefined) {
if (!VALID_PACKS.has(cmdOpts.pack)) {
throw new CLIError(
`Invalid pack slug: ${cmdOpts.pack}`,
{
code: 'INVALID_PACK',
suggestion: `Valid packs: ${[...VALID_PACKS].join(', ')}`,
},
);
}
updates.pack_slug = cmdOpts.pack;
}
if (cmdOpts.max !== undefined) {
const max = parseInt(cmdOpts.max, 10);
if (isNaN(max) || max < 1 || max > 10) {
throw new CLIError('Max monthly top-ups must be between 1 and 10.', {
code: 'INVALID_MAX_MONTHLY',
suggestion: 'Example: agent-media credits topup --max 3',
});
}
updates.max_monthly_topups = max;
}
const spinner = createSpinner('Updating auto-top-up settings...');
if (mode === 'human') spinner.start();
const config = await api.updateAutoTopUpConfig(updates);
if (mode === 'human') spinner.stop();
switch (mode) {
case 'json':
printJson({
enabled: config.enabled,
threshold_credits: config.threshold_credits,
pack_slug: config.pack_slug,
max_monthly_topups: config.max_monthly_topups,
updated_at: config.updated_at,
});
break;
case 'quiet':
printQuiet(config.enabled ? 'enabled' : 'disabled');
break;
default:
console.log();
console.log(chalk.green(' Auto-top-up settings updated.'));
console.log();
printTopUpConfig(config.enabled, config.threshold_credits, config.pack_slug, config.max_monthly_topups);
break;
}
} else {
// Read-only: show current config
const spinner = createSpinner('Fetching auto-top-up settings...');
if (mode === 'human') spinner.start();
const config = await api.getAutoTopUpConfig();
if (mode === 'human') spinner.stop();
switch (mode) {
case 'json':
printJson({
enabled: config.enabled,
threshold_credits: config.threshold_credits,
pack_slug: config.pack_slug,
max_monthly_topups: config.max_monthly_topups,
updated_at: config.updated_at,
});
break;
case 'quiet':
printQuiet(config.enabled ? 'enabled' : 'disabled');
break;
default:
console.log();
console.log(chalk.bold(' Auto Top-Up Configuration'));
console.log();
printTopUpConfig(config.enabled, config.threshold_credits, config.pack_slug, config.max_monthly_topups);
break;
}
}
} catch (error: unknown) {
handleError(error);
}
},
);
/** Color coding for transaction types. */
const TX_TYPE_COLORS: Record<string, (text: string) => string> = {
debit: chalk.red,
credit: chalk.green,
refund: chalk.yellow,
reset: chalk.blue,
};
/**
* Pad or truncate a string to an exact fixed width for column alignment.
* Strips ANSI codes when measuring length.
*/
function fixedWidth(str: string, width: number): string {
const stripped = str.replace(
// eslint-disable-next-line no-control-regex
/\x1B\[[0-9;]*m/g,
'',
);
if (stripped.length >= width) return str;
return str + ' '.repeat(width - stripped.length);
}
/**
* Format a date string into a short human-readable label (e.g., "Feb 17").
*/
function formatShortDate(dateStr: string): string {
const date = new Date(dateStr);
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return `${months[date.getMonth()]} ${date.getDate().toString().padStart(2, ' ')}`;
}
/**
* Format a credit amount with sign and alignment.
*/
function formatAmount(amount: number): string {
if (amount > 0) return chalk.green(`+${amount}`);
if (amount < 0) return chalk.red(String(amount));
return chalk.dim('0');
}
/**
* Print a formatted table of credit transactions.
*/
function printCreditHistoryTable(
transactions: CreditTransaction[],
typeFilter?: string,
): void {
const COL_DATE = 10;
const COL_TYPE = 10;
const COL_AMOUNT = 10;
const header =
fixedWidth('DATE', COL_DATE) +
fixedWidth('TYPE', COL_TYPE) +
fixedWidth('AMOUNT', COL_AMOUNT) +
'DESCRIPTION';
const separator =
'\u2500'.repeat(COL_DATE) +
'\u2500'.repeat(COL_TYPE) +
'\u2500'.repeat(COL_AMOUNT) +
'\u2500'.repeat(30);
console.log();
console.log(chalk.bold(' Credit History'));
console.log();
console.log(` ${chalk.bold(header)}`);
console.log(` ${chalk.dim(separator)}`);
for (const tx of transactions) {
const dateCol = fixedWidth(formatShortDate(tx.created_at), COL_DATE);
const colorize = TX_TYPE_COLORS[tx.type] ?? chalk.white;
const typeCol = fixedWidth(colorize(tx.type.toUpperCase()), COL_TYPE);
const amountCol = fixedWidth(formatAmount(tx.amount), COL_AMOUNT);
const desc = tx.description;
console.log(` ${dateCol}${typeCol}${amountCol}${desc}`);
}
console.log();
// Summary line
const filterStr = typeFilter ? ` (filtered: type=${typeFilter})` : '';
console.log(
chalk.dim(` Showing ${transactions.length} transactions${filterStr}`),
);
console.log();
}
/** Helper: print human-readable auto-top-up config. */
function printTopUpConfig(
enabled: boolean,
threshold: number,
packSlug: string,
maxMonthly: number,
): void {
const statusColor = enabled ? chalk.green : chalk.red;
const statusLabel = enabled ? 'Enabled' : 'Disabled';
const packLabel = PACK_LABELS[packSlug] ?? packSlug;
console.log(
` ${chalk.bold('Status:')} ${statusColor(statusLabel)}`,
);
console.log(
` ${chalk.bold('Threshold:')} ${threshold} credits`,
);
console.log(
` ${chalk.bold('Pack:')} ${packLabel}`,
);
console.log(
` ${chalk.bold('Max/month:')} ${maxMonthly}`,
);
console.log();
if (!enabled) {
console.log(
chalk.dim(
' Enable with: agent-media credits topup --enable',
),
);
console.log();
}
}
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media debug` command group.
*
* Provides deep-inspection subcommands for troubleshooting generation
* jobs and credit balances. Intended for power users and support
* engineers who need to trace the full lifecycle of a job or verify
* credit consistency.
*
* Subcommands:
* debug job <job-id> - Fetch job details, credit transactions,
* webhook history, and dead-letter entries.
* Renders a chronological timeline.
* debug credits - Fetch balance, transactions, and run a
* client-side reconciliation check.
*
* Both subcommands support --json and --quiet output modes.
*/
import type { Command } from 'commander';
import chalk from 'chalk';
import { detectOutputMode, printJson, printQuiet } from '../lib/output.js';
import { getApiKey, resolveProfileName } from '../lib/credentials.js';
import { AgentMediaAPI } from '../lib/api.js';
import { CLIError, handleError } from '../lib/errors.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
/**
* Resolve the authenticated API client or throw.
*/
function resolveApi(profileName: string): AgentMediaAPI {
const apiKey = getApiKey(profileName);
if (!apiKey) {
throw new CLIError('Not authenticated', {
code: 'AUTH_REQUIRED',
suggestion: 'Run `agent-media login` to authenticate.',
});
}
return new AgentMediaAPI(apiKey);
}
/**
* Format an ISO timestamp to a concise local string.
*/
function fmtTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
/**
* Format a relative duration from an ISO timestamp.
*/
function fmtRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
/**
* Return a colored status label for a job status.
*/
function colorStatus(status: string): string {
switch (status) {
case 'completed':
return chalk.green(status);
case 'failed':
case 'canceled':
return chalk.red(status);
case 'processing':
case 'submitted':
return chalk.yellow(status);
default:
return chalk.dim(status);
}
}
// ── Subcommand: debug job ──────────────────────────────────────────────────────
async function debugJob(
jobId: string,
profileName: string,
mode: 'human' | 'json' | 'quiet',
): Promise<void> {
const api = resolveApi(profileName);
const info = await api.getJobDebugInfo(jobId);
// ── JSON mode ──────────────────────────────────────────────────────────────
if (mode === 'json') {
printJson(info);
return;
}
// ── Quiet mode ─────────────────────────────────────────────────────────────
if (mode === 'quiet') {
printQuiet(info.job.id);
return;
}
// ── Human mode: render timeline ────────────────────────────────────────────
const { job, transactions, webhookHistory, deadLetterEntries } = info;
console.log();
console.log(chalk.bold(` Job Debug: ${chalk.cyan(job.id)}`));
console.log();
// Job summary
console.log(chalk.dim(' ─── Job Details ───────────────────────────────────'));
console.log(` Status: ${colorStatus(job.status)}`);
console.log(` Model: ${chalk.white(job.model_slug)}`);
console.log(` Operation: ${job.operation}`);
console.log(` Credits: ${chalk.yellow(String(job.credit_cost))} charged${job.credits_refunded ? chalk.green(' (refunded)') : ''}`);
if (job.provider_slug) {
console.log(` Provider: ${job.provider_slug}`);
}
if (job.provider_job_id) {
console.log(` Provider ID: ${chalk.dim(job.provider_job_id)}`);
}
if (job.error_message) {
console.log(` Error: ${chalk.red(job.error_message)}`);
}
if (job.error_code) {
console.log(` Error Code: ${chalk.dim(job.error_code)}`);
}
console.log(` Created: ${fmtTime(job.created_at)} (${fmtRelative(job.created_at)})`);
if (job.started_at) {
console.log(` Started: ${fmtTime(job.started_at)}`);
}
if (job.completed_at) {
console.log(` Completed: ${fmtTime(job.completed_at)}`);
}
// Timeline: merge and sort all events chronologically
interface TimelineEvent {
time: string;
type: string;
label: string;
detail: string;
}
const events: TimelineEvent[] = [];
// Job lifecycle events
events.push({
time: job.created_at,
type: 'job',
label: 'Job Created',
detail: `${job.operation} on ${job.model_slug}`,
});
if (job.started_at) {
events.push({
time: job.started_at,
type: 'job',
label: 'Processing Started',
detail: job.provider_slug ?? 'unknown provider',
});
}
if (job.completed_at) {
events.push({
time: job.completed_at,
type: 'job',
label: job.status === 'completed' ? 'Completed' : `Ended (${job.status})`,
detail: job.error_message ?? 'Success',
});
}
// Credit transactions
for (const tx of transactions) {
events.push({
time: tx.created_at,
type: 'credit',
label: `Credit ${tx.type}`,
detail: `${tx.amount > 0 ? '+' : ''}${tx.amount} credits: ${tx.description}`,
});
}
// Webhook events
for (const wh of webhookHistory) {
events.push({
time: wh.created_at,
type: 'webhook',
label: `Webhook: ${wh.event}`,
detail: wh.status,
});
}
// Dead letter entries
for (const dl of deadLetterEntries) {
events.push({
time: dl.created_at,
type: 'dead-letter',
label: 'Dead Letter',
detail: `${dl.error} (${dl.attempts} attempts)`,
});
}
// Sort chronologically
events.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
if (events.length > 0) {
console.log();
console.log(chalk.dim(' ─── Timeline ──────────────────────────────────────'));
console.log();
for (let i = 0; i < events.length; i++) {
const event = events[i]!;
const isLast = i === events.length - 1;
const connector = isLast ? ' ' : ' ';
const typeColors: Record<string, (s: string) => string> = {
job: chalk.cyan,
credit: chalk.yellow,
webhook: chalk.magenta,
'dead-letter': chalk.red,
};
const colorFn = typeColors[event.type] ?? chalk.white;
const bullet = isLast ? '\u2514' : '\u251c';
const line = isLast ? ' ' : '\u2502';
console.log(` ${chalk.dim(bullet + '\u2500')} ${chalk.dim(fmtTime(event.time))} ${colorFn(event.label)}`);
console.log(` ${chalk.dim(line)} ${chalk.dim(event.detail)}`);
}
}
console.log();
}
// ── Subcommand: debug credits ──────────────────────────────────────────────────
async function debugCredits(
profileName: string,
mode: 'human' | 'json' | 'quiet',
): Promise<void> {
const api = resolveApi(profileName);
const info = await api.getCreditDebugInfo();
// ── JSON mode ──────────────────────────────────────────────────────────────
if (mode === 'json') {
printJson(info);
return;
}
// ── Quiet mode ─────────────────────────────────────────────────────────────
if (mode === 'quiet') {
printQuiet(String(info.balance.total));
return;
}
// ── Human mode ─────────────────────────────────────────────────────────────
const { balance, transactions, reconciliation } = info;
console.log();
console.log(chalk.bold(' Credit Debug'));
console.log();
// Balance summary
console.log(chalk.dim(' ─── Current Balance ───────────────────────────────'));
console.log(` Plan Credits: ${chalk.cyan(String(balance.plan_credits))}`);
console.log(` Purchased Credits: ${chalk.cyan(String(balance.purchased_credits))}`);
console.log(` Total: ${chalk.bold.green(String(balance.total))}`);
// Reconciliation
console.log();
console.log(chalk.dim(' ─── Reconciliation ────────────────────────────────'));
const reconIcon = reconciliation.isBalanced
? chalk.green('\u2713')
: chalk.red('\u2717');
const reconLabel = reconciliation.isBalanced
? chalk.green('Balanced')
: chalk.red('MISMATCH');
console.log(` Status: ${reconIcon} ${reconLabel}`);
console.log(` Expected Balance: ${chalk.white(String(reconciliation.expectedBalance))}`);
console.log(` Actual Balance: ${chalk.white(String(reconciliation.actualBalance))}`);
if (!reconciliation.isBalanced) {
const sign = reconciliation.discrepancy > 0 ? '+' : '';
console.log(
` Discrepancy: ${chalk.red(`${sign}${reconciliation.discrepancy}`)}`,
);
}
if (reconciliation.lastCheckedAt) {
console.log(
` Last Checked: ${fmtTime(reconciliation.lastCheckedAt)} (${fmtRelative(reconciliation.lastCheckedAt)})`,
);
}
// Recent transactions
if (transactions.length > 0) {
console.log();
console.log(chalk.dim(' ─── Recent Transactions ───────────────────────────'));
console.log();
// Table header
const headers = ['Time', 'Type', 'Amount', 'Plan After', 'Purch After', 'Description'];
const widths = [18, 8, 8, 11, 11, 30];
const headerLine = headers
.map((h, i) => h.padEnd(widths[i]!))
.join(' ');
console.log(` ${chalk.bold(headerLine)}`);
console.log(` ${widths.map((w) => '\u2500'.repeat(w)).join(' ')}`);
for (const tx of transactions.slice(0, 20)) {
const time = fmtTime(tx.created_at).padEnd(widths[0]!);
const type = tx.type.padEnd(widths[1]!);
const amount = (tx.amount > 0 ? `+${tx.amount}` : String(tx.amount)).padEnd(widths[2]!);
const planAfter = String(tx.plan_credits_after).padEnd(widths[3]!);
const purchAfter = String(tx.purchased_credits_after).padEnd(widths[4]!);
const desc = tx.description.substring(0, widths[5]!);
const amountColor = tx.amount >= 0 ? chalk.green : chalk.red;
console.log(
` ${chalk.dim(time)} ${type} ${amountColor(amount)} ${planAfter} ${purchAfter} ${chalk.dim(desc)}`,
);
}
if (transactions.length > 20) {
console.log(chalk.dim(` ... and ${transactions.length - 20} more transactions`));
}
}
console.log();
}
// ── Register command group ─────────────────────────────────────────────────────
export function registerDebugCommand(program: Command): void {
const debug = program
.command('debug')
.description('Debug and troubleshoot jobs and credits');
debug
.command('job <job-id>')
.description('Inspect a job with full timeline, transactions, and webhook history')
.action(async (jobId: string) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
try {
await debugJob(jobId, profileName, mode);
} catch (error: unknown) {
handleError(error);
}
});
debug
.command('credits')
.description('Inspect credit balance, transactions, and reconciliation status')
.action(async () => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
const profileName = resolveProfileName(globalOpts.profile);
try {
await debugCredits(profileName, mode);
} catch (error: unknown) {
handleError(error);
}
});
}
// Copyright 2026 agent-media contributors. Apache-2.0 license.
/**
* `agent-media logout` command.
*
* Clears stored credentials for the current (or specified) profile.
* With --all, removes all profiles and the credentials file entirely.
*
* Optionally revokes the API key server-side so it can no longer
* be used for authentication.
*/
import type { Command } from 'commander';
import chalk from 'chalk';
import { detectOutputMode, printJson, printQuiet } from '../lib/output.js';
import {
resolveProfileName,
deleteProfile,
clearAllCredentials,
getProfile,
} from '../lib/credentials.js';
import { AgentMediaAPI } from '../lib/api.js';
import { handleError } from '../lib/errors.js';
export function registerLogoutCommand(program: Command): void {
program
.command('logout')
.description('Clear stored credentials')
.option('--all', 'Remove all profiles and credentials')
.action(async (cmdOpts: { all?: boolean }) => {
const globalOpts = program.opts<{
json?: boolean;
quiet?: boolean;
profile?: string;
}>();
const mode = detectOutputMode(globalOpts);
try {
if (cmdOpts.all) {
// Attempt server-side revocation for all profiles before clearing
// (best effort -- don't fail if the server is unreachable)
clearAllCredentials();
switch (mode) {
case 'json':
printJson({ success: true, message: 'All credentials removed.' });
break;
case 'quiet':
printQuiet('logged_out');
break;
default:
console.log(chalk.green('\u2713 All credentials removed.'));
break;
}
return;
}
// Single profile logout
const profileName = resolveProfileName(globalOpts.profile);
const profile = getProfile(profileName);
if (!profile) {
switch (mode) {
case 'json':
printJson({ success: false, message: `No profile "${profileName}" found.` });
break;
case 'quiet':
printQuiet('not_found');
break;
default:
console.log(
chalk.yellow(`No credentials found for profile "${profileName}".`),
);
break;
}
return;
}
// Best-effort server-side key revocation
try {
const api = new AgentMediaAPI(profile.apiKey);
await api.revokeApiKey();
} catch {
// Server unreachable or key already revoked -- continue with local cleanup
}
const deleted = deleteProfile(profileName);
if (deleted) {
switch (mode) {
case 'json':
printJson({
success: true,
profile: profileName,
email: profile.email,
});
break;
case 'quiet':
printQuiet('logged_out');
break;
default:
console.log(
chalk.green(
`\u2713 Logged out from ${chalk.bold(profile.email || profileName)}` +
(profileName !== 'default'
? ` (profile: ${profileName})`
: ''),
),
);
break;
}
}
} catch (error: unknown) {
handleError(error);
}
});
}