@mynthio/cli 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -11
  2. package/dist/bin.js +2602 -9
  3. package/package.json +16 -16
package/dist/bin.js CHANGED
@@ -1,11 +1,2545 @@
1
1
  #!/usr/bin/env node
2
- import J from'chalk';import {Command,Option,Help}from'commander';import {z}from'zod';import {readFile,mkdir,writeFile,rename,rm,stat}from'fs/promises';import {hostname,homedir}from'os';import {resolve,join,dirname,extname,basename}from'path';import {spawn}from'child_process';import {existsSync}from'fs';import dr from'ora';import Tn from'fuzzysort';var be="client_01KATK792RR5ZCHMF5YMNN1ZSE",Xe=process.env.MYNTH_WORKOS_API_URL??"https://api.workos.com",Ze="0.0.22",qe=e=>e.replace(/\/+$/,""),Ve=e=>e!==void 0&&e.length>0?e:void 0,q=()=>({apiUrl:qe(process.env.MYNTH_API_URL??"https://api.mynth.io"),docsUrl:qe(process.env.MYNTH_DOCS_URL??"https://docs.mynth.io"),envApiKey:Ve(process.env.MYNTH_API_KEY),envDestination:Ve(process.env.MYNTH_DESTINATION),debug:process.env.MYNTH_DEBUG==="1"||process.env.MYNTH_DEBUG==="true"});var b={error:1,usage:2,auth:3,insufficientCredits:4,moderation:5,rateLimited:6},y=class extends Error{constructor(t,n){super(t,n),this.name=new.target.name;}},l=class extends y{},$=class extends y{},k=class extends y{status;code;constructor(t,n){super(t,n),this.status=n.status,this.code=n.code;}},O=class extends y{code;constructor(t,n,o){super(n,o),this.code=t;}},Hn={UNAUTHORIZED:b.auth,INSUFFICIENT_SCOPE:b.auth,VALIDATION_ERROR:b.usage,INSUFFICIENT_BALANCE:b.insufficientCredits,SPENDING_LIMIT_EXCEEDED:b.insufficientCredits,RESTRICTED_CONTENT:b.moderation},Qe=e=>{if(e instanceof l)return b.usage;if(e instanceof $)return b.auth;if(e instanceof k){let n=e.code!==void 0?Hn[e.code]:void 0;return n!==void 0?n:e.status===401||e.status===403?b.auth:e.status===429?b.rateLimited:b.error}let t=e?.code;return typeof t=="string"&&t.startsWith("commander.")?b.usage:b.error},ve=e=>{let t=e.result,n=[...(e.errors??[]).map(o=>o.code),...(t?.images??[]).map(o=>o.error?.code)].filter(o=>typeof o=="string");return n.find(o=>o==="RESTRICTED_CONTENT")??n[0]},et=e=>ve(e)==="RESTRICTED_CONTENT"?b.moderation:b.error;var a=(e="")=>{process.stdout.write(`${e}
3
- `);},T=(e="")=>{process.stderr.write(`${e}
4
- `);},p=e=>{a(JSON.stringify(e,null,2));},g={ok:J.green("\u2713"),fail:J.red("\u2717"),pending:J.yellow("\u2026")},V=e=>e==="completed"?g.ok:e==="failed"?g.fail:g.pending,F=(e,t,n="s")=>`${e} ${t}${e===1?"":n}`,tt=(e,t=2)=>{let n=" ".repeat(t);return e.split(`
5
- `).map(o=>`${n}${o}`).join(`
6
- `)};var _=z.lazy(()=>z.union([z.string(),z.number(),z.boolean(),z.null(),z.array(_),z.record(_)])),nt=e=>z.object({data:e}),Gn=z.object({id:z.string(),email:z.string(),first_name:z.string().nullable().optional(),last_name:z.string().nullable().optional()}),ot=z.object({device_code:z.string(),user_code:z.string(),verification_uri:z.string(),verification_uri_complete:z.string().optional(),expires_in:z.number(),interval:z.number().optional()}),rt=z.object({access_token:z.string(),refresh_token:z.string(),user:Gn.optional()}),it=z.object({error:z.string().optional(),error_description:z.string().optional(),message:z.string().optional(),code:z.string().optional()}),at=z.object({kind:z.literal("api_key"),api_key:z.string(),id:z.string().optional()}),R=["generate","manage","keys"],Ie=z.object({raw:z.string(),apiKey:z.object({id:z.string(),name:z.string().optional(),keyPreview:z.string(),scopes:z.array(z.string())})}),qn=z.union([z.string(),z.number()]).nullable().optional(),st=z.object({id:z.string(),name:z.string().nullable(),keyPreview:z.string(),scopes:z.array(z.string()),spendingLimit:qn,spendingLimitPeriod:z.string().nullable().optional(),createdAt:z.string()}),Vn=z.union([z.object({mode:z.literal("unlimited")}),z.object({mode:z.literal("limited"),limit:z.string(),period:z.string(),used:z.string(),remaining:z.string()})]),dt=z.object({userId:z.string(),auth:z.object({method:z.string(),apiKey:z.object({id:z.string(),name:z.string().nullable(),keyPreview:z.string(),scopes:z.array(z.string()).optional(),spending:Vn.optional()}).optional()})}),lt=z.object({balance:z.string(),reserved:z.string(),available:z.string(),currency:z.string()}),Xn=z.object({type:z.string(),kind:z.string().optional(),min:z.number().optional(),max:z.number()}),Zn=z.object({inputs:z.object({rules:z.array(Xn),maxTotal:z.number().optional()}).optional()}),Qn=z.object({perImage:z.object({base:z.string(),"4k":z.string().optional()}),perInput:z.string().optional()}),eo=z.object({perSecond:z.record(z.string(),z.string()),audio:z.object({perSecond:z.string()}).optional()}),ct=z.object({id:z.string(),displayName:z.string().nullable(),type:z.string(),modes:z.record(z.string(),Zn),pricing:z.union([Qn,eo]).nullable()}),X=z.enum(["pending","completed","failed"]),pt=z.object({id:z.string(),type:z.string(),status:X,userId:z.string().optional(),apiKeyId:z.string().nullable().optional(),cost:z.string().nullable(),request:_.optional(),result:_.nullable(),errors:z.array(z.object({code:z.string(),message:z.string().optional()})).nullish(),createdAt:z.string(),updatedAt:z.string()}),mt=z.object({id:z.string(),type:z.string(),status:z.string(),cost:z.string().nullable(),createdAt:z.string(),updatedAt:z.string()}),ut=z.object({id:z.string(),type:z.string(),status:X,result:_.nullable()}),gt=z.object({urls:z.array(z.string())}),ft=z.object({taskId:z.string(),estimatedCost:z.string().optional(),access:z.object({publicAccessToken:z.string()}).optional()}),yt=z.object({estimatedCost:z.string(),currency:z.string(),estimateKind:z.enum(["exact","upper_bound"])}),ht=z.object({level:z.string()}),wt=z.object({alt:z.string()}),kt=z.object({score:z.number(),summary:z.string(),findings:z.array(z.object({finding:z.string(),category:z.string(),severity:z.string(),where:z.string(),confidence:z.string()})).optional(),strengths:z.array(z.object({strength:z.string(),confidence:z.string()})).optional()}),$e=z.object({code:z.string(),message:z.string().optional()}),bt=z.union([z.object({status:z.literal("success"),name:z.string()}),z.object({status:z.literal("failed"),name:z.string(),error:$e})]),to=z.union([z.object({status:z.literal("success"),id:z.string().optional(),url:z.string().nullable().optional(),mynth_url:z.string().optional(),size:z.string().optional(),format:z.string().optional(),rating:z.union([z.object({status:z.literal("success"),level:z.string()}),z.object({status:z.literal("failed"),error:$e})]).optional(),destination:bt.optional()}),z.object({status:z.literal("failed"),error:$e})]),vt=z.object({model:z.string().optional(),images:z.array(to).optional(),magic_prompt:z.object({positive:z.string(),negative:z.string().optional()}).optional()}),At=z.object({image:z.object({id:z.string(),url:z.string().nullable(),mynth_url:z.string(),size:z.string(),format:z.string(),destination:bt.optional()})}),U=z.object({id:z.string(),name:z.string(),provider:z.object({id:z.string()}).catchall(_),config:z.object({path_template:z.string(),url_template:z.string().optional()}).partial(),createdAt:z.string(),updatedAt:z.string()}),$t=z.union([z.literal("all"),z.array(z.string())]),It=z.object({id:z.string(),enabled:z.boolean(),url:z.string(),secret:z.string(),events:$t,apiKeyIds:z.array(z.string()).nullish(),oauthEnabled:z.boolean().optional(),createdAt:z.string().optional()}),xt=z.object({id:z.string(),enabled:z.boolean().optional(),url:z.string(),events:$t,apiKeyIds:z.array(z.string()).nullish(),oauthEnabled:z.boolean().optional()});var no=e=>{if(e===void 0)return "";let t=new URLSearchParams;for(let[n,o]of Object.entries(e))o!==void 0&&t.set(n,String(o));return t.size>0?`?${t}`:""},oo=e=>e===void 0?{headers:{}}:e instanceof FormData?{body:e,headers:{}}:{body:JSON.stringify(e),headers:{"Content-Type":"application/json"}},ro=async e=>{try{return await e.text()}catch{return ""}},io=async(e,t)=>{let n=await ro(e),o,r;try{let s=JSON.parse(n);typeof s.code=="string"&&(o=s.code),typeof s.message=="string"?r=s.message:typeof s.error=="string"&&(r=s.error);}catch{}return new k(`${t} failed (${e.status}): ${r??n??"no body"}`,{status:e.status,...o!==void 0?{code:o}:{}})},Z=class{constructor(t,n){this.tokens=n;this.baseUrl=t.apiUrl;}baseUrl;async send(t,n,o={}){let r=await this.attempt(n,o);if(r.ok)return r;throw await io(r,t)}async fetch(t,n,o,r={}){let s=await this.send(t,n,r),d;try{d=await s.json();}catch(h){throw new k(`${t} returned invalid JSON: ${h.message}`,{status:s.status,cause:h})}let c=nt(o).safeParse(d);if(!c.success)throw new k(`${t} returned an unexpected response shape`,{status:s.status,cause:c.error});return c.data.data}async call(t,n,o={}){await this.send(t,n,o);}async attempt(t,n){let{body:o,headers:r}=oo(n.body),s=n.auth===false?void 0:n.token??await this.tokens.token();try{return await fetch(`${this.baseUrl}${t}${no(n.query)}`,{method:n.method??(n.body!==void 0?"POST":"GET"),headers:{...r,...s!==void 0?{Authorization:`Bearer ${s}`}:{}},...o!==void 0?{body:o}:{}})}catch(d){throw new k(`request to ${t} failed: ${d.message}`,{status:0,cause:d})}}};var ao=2048,so=/^[A-Za-z0-9._~%-]+$/,lo=e=>{let t=e.trim();if(t.length===0)throw new l("documentation path must not be empty");if(t.length>ao)throw new l("documentation path is too long");if(t.startsWith("//")||t.includes("://"))throw new l("documentation path must be a path, not a URL");if(/[?#\\]/.test(t))throw new l("documentation path must not contain a query, fragment, or backslash");let n=t.startsWith("/")?t.slice(1):t;if(n.endsWith(".md"))throw new l("documentation path must not include the .md suffix");let o=n.split("/");if(o.some(r=>!so.test(r)||r==="."||r===".."))throw new l("documentation path contains an invalid segment");return o.join("/")},co=e=>e.length>500?`${e.slice(0,500)}\u2026`:e,Tt=async(e,t)=>{let n;try{n=await fetch(e);}catch(r){throw new k(`${t} failed: ${r.message}`,{status:0,cause:r})}let o=await n.text().catch(()=>"");if(!n.ok)throw new k(`${t} failed (${n.status}): ${co(o)||"no body"}`,{status:n.status});return o},Q=class{constructor(t){this.docsUrl=t;}async get(t){let n=lo(t),o=n.split("/").map(encodeURIComponent).join("/");return {path:n,content:await Tt(`${this.docsUrl}/${o}.md`,`docs fetch for ${n}`)}}list(){return Tt(`${this.docsUrl}/llms.txt`,"docs index fetch")}};var ko="credentials.json",bo=384,vo=()=>{let e=process.env.XDG_CONFIG_HOME;return join(e!==void 0&&e.length>0?e:join(homedir(),".config"),"mynth")},Ao=async e=>{try{return await stat(e),true}catch{return false}},ee=class{filePath;constructor(){this.filePath=join(vo(),ko);}async get(){if(!await Ao(this.filePath))return;let t;try{t=await readFile(this.filePath,"utf8");}catch(r){throw new y(`could not read ${this.filePath}: ${r.message}`,{cause:r})}let n;try{n=JSON.parse(t);}catch(r){throw new y(`${this.filePath} is not valid JSON`,{cause:r})}let o=at.safeParse(n);if(!o.success)throw new y(`${this.filePath} has an unexpected shape; run \`mynth auth login\``,{cause:o.error});return o.data}async set(t){try{await mkdir(dirname(this.filePath),{recursive:true,mode:448});let n=`${this.filePath}.${process.pid}.tmp`;await writeFile(n,JSON.stringify(t),{encoding:"utf8",mode:bo}),await rename(n,this.filePath);}catch(n){throw new y(`could not write ${this.filePath}: ${n.message}`,{cause:n})}}async clear(){try{await rm(this.filePath,{force:true});}catch(t){throw new y(`could not delete ${this.filePath}: ${t.message}`,{cause:t})}}};var te=class{constructor(t){this.config=t;this.store=new ee,this.envApiKeySet=t.envApiKey!==void 0;}store;envApiKeySet;cached;async token(){if(this.config.envApiKey!==void 0)return this.config.envApiKey;if(this.cached!==void 0)return this.cached;let t=await this.read();if(t===void 0)throw new $("not authenticated: run `mynth auth login` or set MYNTH_API_KEY");return this.cached=t.api_key,this.cached}async status(){if(this.envApiKeySet)return {kind:"env"};let t=await this.read().catch(()=>{});return t===void 0?{kind:"none"}:{kind:"stored",credentials:t}}async save(t){await this.store.set(t),this.cached=void 0;}async clear(){await this.store.clear(),this.cached=void 0;}async read(){try{return await this.store.get()}catch(t){throw new $(`could not read stored credentials: ${t.message}`,{cause:t})}}};var Rt=()=>{let e=q(),t=new te(e);return {config:e,session:t,api:new Z(e,t),docs:new Q(e.docsUrl)}};var Te=["...........",".....4.....","....343....",".....2.....",".344.2.443.","..4432344..",".....2.....",".233.2.332.","..3322233..",".....1.....","..........."],K={1:"#318267",2:"#00a27a",3:"#33bb92",4:"#6bd1ad"},$o=(e,t)=>{let n=o=>o!==".";return !n(e)&&!n(t)?" ":J.level===0?n(e)&&n(t)?"\u2588":n(e)?"\u2580":"\u2584":n(t)?n(e)?e===t?J.hex(K[e])("\u2588"):J.hex(K[e]).bgHex(K[t])("\u2580"):J.hex(K[t])("\u2584"):J.hex(K[e])("\u2580")},Ct=()=>{let e=[];for(let t=0;t<Te.length;t+=2){let n=Te[t],o=Te[t+1]??"",r="";for(let s=0;s<n.length;s+=1)r+=$o(n[s],o[s]??".");e.push(r.trimEnd());}return e.filter(t=>t.length>0).join(`
7
- `)};var Et=(e,t)=>e.fetch("api key create","/api-key",Ie,{body:{name:t.name,scopes:t.scopes},token:t.token}),Pt=(e,t)=>e.fetch("api key create","/api-key",Ie,{body:{name:t.name,scopes:t.scopes}}),Ot=e=>e.fetch("api key list","/api-key",z.array(st)),ne=(e,t)=>e.call("api key delete",`/api-key/${t}`,{method:"DELETE"});var C=(e,t,n)=>{if(e.length===0){a(n);return}let o=e.map(d=>t.map(c=>c.value(d))),r=t.map((d,c)=>Math.max(d.header.length,...o.map(h=>h[c].length))),s=d=>d.map((c,h)=>h===d.length-1?c:c.padEnd(r[h])).join(" ");a(s(t.map(d=>d.header)));for(let d of o)a(s(d));};var f=()=>new Option("--json","Output machine-readable JSON instead of a human-readable summary"),S=()=>new Option("--yes","Confirm the destructive action (required; there is no interactive prompt)");var St=["generate"],To=e=>{if(e===void 0)return St;let t=e.split(",").map(o=>o.trim()).filter(Boolean),n=t.filter(o=>!R.includes(o));if(t.length===0||n.length>0)throw new l(`invalid --scopes: ${n.join(", ")||"empty"}. Valid scopes: ${R.join(", ")}`);return t},jt=e=>{let t=new Command("api-key").description("Manage Mynth API keys");return t.command("create").description("Create an API key. The key itself is shown once, on success.").argument("<name>","Name for the key, e.g. the app or environment it belongs to").option("--scopes <list>",`Comma-separated scopes (default: ${St.join(",")}). Widening beyond \`generate\` requires the dashboard: the API refuses scope escalation from a CLI session.`).addOption(f()).action(async(n,o)=>{let r=await Pt(e.api,{name:n,scopes:To(o.scopes)});if(o.json){p({key:r.raw,...r.apiKey});return}a(`${g.ok} Created API key "${r.apiKey.name??n}"`),a(` ID: ${r.apiKey.id}`),a(` Scopes: ${r.apiKey.scopes.join(", ")}`),a(""),a(` ${r.raw}`),a(" Save this now \u2014 it is shown only once and cannot be retrieved again.");}),t.command("list").description("List active API keys").addOption(f()).action(async n=>{let o=await Ot(e.api);if(n.json){p(o);return}C(o,[{header:"ID",value:r=>r.id},{header:"Name",value:r=>r.name??"-"},{header:"Preview",value:r=>r.keyPreview},{header:"Scopes",value:r=>r.scopes.join(",")},{header:"Limit",value:r=>r.spendingLimit===null||r.spendingLimit===void 0?"-":`$${r.spendingLimit}/${r.spendingLimitPeriod??"period"}`},{header:"Created",value:r=>r.createdAt}],"No API keys found.");}),t.command("delete").description("Revoke an API key").argument("<id>","API key ID").addOption(S()).addOption(f()).action(async(n,o)=>{if(o.yes!==true)throw new l("refusing to delete without --yes");if(await ne(e.api,n),o.json){p({deleted:n});return}a(`${g.ok} Revoked API key ${n}`);}),t};var oe=e=>e.fetch("me","/me",dt),Dt=e=>e.fetch("balance","/balance",lt);var Ro="urn:ietf:params:oauth:grant-type:device_code",Nt=async e=>{try{return await e.json()}catch{return {}}},Lt=async(e,t,n)=>{try{return await fetch(`${Xe}${e}`,{method:"POST",body:t,headers:{Accept:"application/json",...typeof t=="string"?{"Content-Type":"application/json"}:{}}})}catch(o){throw new y(`${n} failed: ${o.message}`,{cause:o})}},Mt=async(e,t)=>{let n=it.catch({}).parse(await Nt(e)),o=n.error??n.code??"workos_error";return new O(o,n.error_description??n.message??`${t} failed`)},Ft=async(e,t,n)=>{let o=t.safeParse(await Nt(e));if(!o.success)throw new y(`${n} returned an unexpected response`,{cause:o.error});return o.data},Ut=async()=>{let e=await Lt("/user_management/authorize/device",new URLSearchParams({client_id:be}),"device authorization");if(!e.ok)throw await Mt(e,"device authorization");return Ft(e,ot,"device authorization")},Jt=async e=>{let t=await Lt("/user_management/authenticate",JSON.stringify({grant_type:Ro,client_id:be,device_code:e}),"device token exchange");if(!t.ok)throw await Mt(t,"device token exchange");return Ft(t,rt,"device token exchange")};var re=e=>new Promise(t=>setTimeout(t,e)),Kt=async(e,t,n)=>{let o=Array.from({length:e.length}),r=0,s=async()=>{for(;;){let d=r++;if(d>=e.length)return;o[d]=await n(e[d],d);}};return await Promise.all(Array.from({length:Math.min(t,e.length)},s)),o};var zt=()=>{let e=process.env;return !(e.CI!==void 0||e.SSH_CONNECTION!==void 0||e.SSH_TTY!==void 0||process.stdout.isTTY!==true||process.platform==="linux"&&(e.DISPLAY===void 0&&e.WAYLAND_DISPLAY===void 0||existsSync("/.dockerenv")))},Po=e=>{switch(process.platform){case "darwin":return {command:"open",args:[e]};case "win32":return {command:"cmd",args:["/c","start","",e]};default:return {command:"xdg-open",args:[e]}}},Wt=async e=>{try{let{protocol:o}=new URL(e);if(o!=="https:"&&o!=="http:")return false}catch{return false}let{command:t,args:n}=Po(e);return new Promise(o=>{try{let r=spawn(t,[...n],{detached:true,stdio:"ignore"});r.on("error",()=>o(false)),r.unref(),setTimeout(()=>o(true),150).unref();}catch{o(false);}})};var _o="https://mynth.io/dashboard",Bt=e=>`${_o}/keys/${e}`,So=5e3,jo=5,Do=()=>`mynth-cli (${hostname()})`,No=async(e,t,n)=>{let o=t;for(;;){if(Date.now()>=n)throw new y("device code expired before approval");try{return await Jt(e)}catch(r){if(!(r instanceof O))throw r;if(r.code==="access_denied")throw new $("login was denied");if(r.code==="expired_token")throw new $("device code expired; run `mynth auth login` again");if(r.code!=="authorization_pending"&&r.code!=="slow_down")throw r;r.code==="slow_down"&&(o+=So),await re(o);}}},Lo=e=>new Command("login").description("Authenticate with Mynth and store a long-lived API key").option("--scopes <list>",`Comma-separated scopes for the created key (default: ${R.join(",")})`).option("--no-browser","Print the login URL instead of opening a browser").addOption(f()).action(async t=>{if(e.session.envApiKeySet)throw new $("MYNTH_API_KEY is set and takes precedence over login. Unset it to log in, or keep using the env API key.");let n=t.scopes!==void 0?t.scopes.split(",").map(P=>P.trim()).filter(Boolean):[...R],o=n.filter(P=>!R.includes(P));if(n.length===0||o.length>0)throw new $(`invalid --scopes: ${o.join(", ")||"empty"}. Valid scopes: ${R.join(", ")}`);let r=await Ut(),s=r.verification_uri_complete??r.verification_uri,d=r.verification_uri_complete!==void 0,c=t.browser!==false&&zt()?await Wt(s):false;a(""),a(` ${c?"Opened":"Open"}: ${s}`),a(d?` Check the page shows this code: ${r.user_code}`:` Enter this code: ${r.user_code}`),a(""),a("Waiting for confirmation...");let h=await No(r.device_code,(r.interval??jo)*1e3,Date.now()+r.expires_in*1e3),v=Do(),w=await Et(e.api,{name:v,scopes:n,token:h.access_token});await e.session.save({kind:"api_key",api_key:w.raw,id:w.apiKey.id});let I=h.user?.email??h.user?.id??"unknown user";if(t.json){p({user:h.user?.id??null,apiKey:{id:w.apiKey.id,name:w.apiKey.name??v,keyPreview:w.apiKey.keyPreview,scopes:w.apiKey.scopes},storedAt:e.session.store.filePath});return}a(`${g.ok} Logged in as ${I}`),a(` Created API key "${v}" with scopes: ${w.apiKey.scopes.join(", ")}`),a(` Stored in ${e.session.store.filePath}`),a(` Set a spending limit or change scopes: ${Bt(w.apiKey.id)}`);}),Mo=e=>new Command("logout").description("Revoke this machine's API key and clear local credentials").action(async()=>{let t=await e.session.status(),n=t.kind==="stored"?t.credentials.id:void 0,o=false;if(n!==void 0)try{await ne(e.api,n),o=true;}catch(r){T(`Warning: could not revoke API key ${n}: ${r.message}`),T(`Revoke it manually at ${Bt(n)}`);}await e.session.clear(),a(`${g.ok} Local credentials cleared${o?" and API key revoked":""}`),n===void 0&&t.kind==="stored"&&a(" The stored key was not created by `auth login`, so it was left active."),e.session.envApiKeySet&&a("Note: MYNTH_API_KEY is still set in your environment and will still be used.");}),Fo=e=>new Command("status").description("Show how this machine is authenticated, without calling the API").addOption(f()).action(async t=>{let n=await e.session.status();if(t.json){p({source:n.kind,...n.kind==="stored"?{apiKeyId:n.credentials.id??null,path:e.session.store.filePath}:{}});return}switch(n.kind){case "env":a("Authenticated via env: MYNTH_API_KEY");return;case "none":a("Not authenticated. Run `mynth auth login`, or set an API key.");return;case "stored":{let{id:o}=n.credentials;a("Authenticated via stored API key"),o!==void 0&&a(` key: ${o}`),a(` stored: ${e.session.store.filePath}`),a(" Run `mynth whoami` for its current scopes and spending limit.");}}}),Re=e=>new Command("whoami").description("Print the active Mynth identity, verified against the API").addOption(f()).action(async t=>{let n=await e.session.status();if(n.kind==="none")throw new $("not authenticated: run `mynth auth login` or set MYNTH_API_KEY");let o=await oe(e.api);if(t.json){p({source:n.kind,...o});return}a(n.kind==="env"?"env:MYNTH_API_KEY":"api-key"),a(` user: ${o.userId}`),a(` method: ${o.auth.method}`);let r=o.auth.apiKey;r!==void 0&&(a(` key: ${r.name??"unnamed"} (${r.keyPreview})`),r.scopes!==void 0&&r.scopes.length>0&&a(` scopes: ${r.scopes.join(", ")}`),r.spending?.mode==="limited"&&a(` spend: $${r.spending.used} of $${r.spending.limit} per ${r.spending.period} ($${r.spending.remaining} left)`));}),Yt=e=>new Command("auth").description("Manage Mynth authentication").addCommand(Lo(e)).addCommand(Mo(e)).addCommand(Fo(e)).addCommand(Re(e));var Ht=e=>new Command("balance").description("Show account balance, and the active API key's spending limit").addOption(f()).action(async t=>{let n=await Dt(e.api),r=(await oe(e.api).catch(()=>{}))?.auth.apiKey?.spending;if(t.json){p({...n,...r!==void 0?{spending:r}:{}});return}if(a(`Balance: $${n.balance}`),a(`Reserved: $${n.reserved}`),a(`Available: $${n.available}`),r!==void 0){if(a(""),r.mode==="unlimited"){a("API key spending: unlimited");return}a(`API key limit: $${r.limit} / ${r.period}`),a(` used: $${r.used}`),a(` remaining: $${r.remaining}`);}});var qt={".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".webp":"image/webp"},Ce=Object.keys(qt),Ee=async()=>{try{let e="";process.stdin.setEncoding("utf8");for await(let t of process.stdin)e+=t;return e}catch(e){throw new y("could not read stdin",{cause:e})}},W=async e=>{if(e==="-")return Ee();try{return await readFile(e,"utf8")}catch(t){throw new l(`could not read ${e}: ${t.message}`)}},Vt=async e=>{let t=await W(e);try{return JSON.parse(t)}catch(n){throw new l(`invalid JSON in ${e}: ${n.message}`)}},Xt=async e=>{let t=extname(e).toLowerCase(),n=qt[t];if(n===void 0)throw new l(`unsupported image extension "${t}" for ${e} (allowed: ${Ce.join(", ")})`);let o;try{o=await readFile(e);}catch(r){throw new l(`could not read ${e}: ${r.message}`)}return new File([new Uint8Array(o)],basename(e),{type:n})};var Zt=e=>{let t=new Command("set").description("Set local CLI configuration");t.command("api-key").description("Save an existing Mynth API key to the credentials file").argument("<value>","API key value, or `-` to read it from stdin").action(async o=>{let r=(o==="-"?await Ee():o).trim();if(r.length===0)throw new l("API key is empty");await e.session.save({kind:"api_key",api_key:r}),a(`${g.ok} API key saved to ${e.session.store.filePath}`),e.session.envApiKeySet&&a("Note: MYNTH_API_KEY is also set in your environment and takes precedence.");});let n=new Command("unset").description("Unset local CLI configuration");return n.command("api-key").description("Clear stored Mynth credentials").action(async()=>{await e.session.clear(),a(`${g.ok} Stored credentials cleared`),a("This does not revoke the key; use `mynth auth logout` for a CLI-created key.");}),new Command("config").description("Manage local CLI configuration").addCommand(t).addCommand(n)};var Qt=e=>e.fetch("destination list","/destinations",z.array(U)),en=(e,t)=>e.fetch("destination fetch",`/destinations/${t}`,U),tn=(e,t)=>e.fetch("destination create","/destinations",U,{body:t}),nn=(e,t,n)=>e.fetch("destination update",`/destinations/${t}`,U,{method:"PUT",body:n}),on=(e,t,n)=>e.call("destination test",`/destinations/${t}/test`,{body:{path:n}}),rn=(e,t)=>e.call("destination delete",`/destinations/${t}`,{method:"DELETE"});var Oe=/^[a-z0-9-]+$/,_e=64,Bo=["bunny","r2","s3"],je=["de","uk","ny","la","sg","se","br","jh","syd"],Yo=["default","eu","fedramp"],Ho=(e,t)=>{if(e===void 0)throw new l(`${t} is required`);return e},B=(e,t,n)=>{if(t===void 0)throw new l(`--provider ${e} requires ${n}`);return t},Y=(e,t)=>t!==void 0?{[e]:t}:{},Go=(e,t)=>{switch(t){case "bunny":{let n=e.region;if(n!==void 0&&!je.includes(n))throw new l(`invalid bunny --region "${n}". One of: ${je.join(", ")}`);return {id:"bunny",storage_zone:B(t,e.storageZone,"--storage-zone"),...Y("region",n)}}case "r2":return {id:"r2",account_id:B(t,e.accountId,"--account-id"),bucket:B(t,e.bucket,"--bucket"),...Y("jurisdiction",e.jurisdiction)};case "s3":return {id:"s3",bucket:B(t,e.bucket,"--bucket"),region:B(t,e.region,"--region"),...Y("endpoint",e.endpoint),...Y("force_path_style",e.forcePathStyle)}}},qo=async(e,t)=>{let n=(await W(e)).trim();if(n.startsWith("{"))try{return JSON.parse(n)}catch(o){throw new l(`invalid JSON secret from ${e}: ${o.message}`)}if(t!=="bunny")throw new l(`--provider ${t} needs a JSON secret: { "access_key_id": "...", "secret_access_key": "..." }`);if(n.length===0)throw new l(`secret from ${e} is empty`);return {password:n}},Vo=e=>({path_template:Ho(e.pathTemplate,"--path-template"),...Y("url_template",e.urlTemplate)}),sn=async(e,t,n)=>{if(e.file!==void 0){if(e.provider!==void 0)throw new l("use either --file or the typed --provider flags, not both");let s=await Vt(e.file);if(s===null||typeof s!="object"||Array.isArray(s))throw new l(`${e.file} must contain a JSON object`);return {...s,...t}}let o=e.provider;if(o===void 0)throw new l("--provider or --file is required");let r=e.secret!==void 0?await qo(e.secret,o):void 0;if(r===void 0&&n)throw new l("--secret <path|-> is required (use `-` to read the secret from stdin)");return {...t,provider:Go(e,o),config:Vo(e),...r!==void 0?{secret:r}:{}}},dn=e=>e.addOption(new Option("--provider <id>","Storage provider. Required unless --file is used.").choices([...Bo])).option("--path-template <template>",'Object path template, e.g. "images/{id}"').option("--url-template <template>",'Public URL template; must contain {path}, e.g. "https://cdn.example.com/{path}"').option("--storage-zone <name>","bunny: storage zone name").option("--region <region>",`bunny: one of ${je.join(", ")}. s3: the bucket's region, e.g. us-east-1.`).option("--account-id <id>","r2: Cloudflare account ID").option("--bucket <name>","r2/s3: bucket name").addOption(new Option("--jurisdiction <name>","r2: data jurisdiction").choices([...Yo])).option("--endpoint <url>","s3: custom endpoint for S3-compatible storage").option("--force-path-style","s3: use path-style addressing instead of virtual-hosted").option("--secret <path>","Path to a JSON secret file, or `-` to read it from stdin. bunny also accepts a bare password.").option("--file <path>","Full destination JSON body, or `-` for stdin. Alternative to the flags above.").addOption(f()),Se=e=>{a(`Destination ${e.id}`),a(` Name: ${e.name}`),a(` Provider: ${e.provider.id}`),e.config.path_template!==void 0&&a(` Path: ${e.config.path_template}`),e.config.url_template!==void 0&&a(` URL: ${e.config.url_template}`),a(` Created: ${e.createdAt}`),a(` Updated: ${e.updatedAt}`);},ln=e=>{let t=new Command("destination").description("Manage storage destinations that generated images are delivered to");t.command("list").description("List storage destinations").addOption(f()).action(async r=>{let s=await Qt(e.api);if(r.json){p(s);return}C(s,[{header:"ID",value:d=>d.id},{header:"Name",value:d=>d.name},{header:"Provider",value:d=>d.provider.id},{header:"Created",value:d=>d.createdAt}],"No destinations found.");}),t.command("get").description("Fetch a destination by ID").argument("<id>","Destination ID").addOption(f()).action(async(r,s)=>{let d=await en(e.api,r);if(s.json){p(d);return}Se(d);});let n=t.command("create").description("Create a storage destination").argument("<name>",`Destination slug: 1-${_e} chars, ${Oe.source}. Immutable.`);dn(n).action(async(r,s)=>{if(r.length>_e||!Oe.test(r))throw new l(`invalid destination name "${r}": expected 1-${_e} chars matching ${Oe.source}`);let d=await tn(e.api,await sn(s,{name:r},true));if(s.json){p(d);return}Se(d),a(""),a(`Next: verify the credentials with \`mynth destination test ${d.id}\``);});let o=t.command("update").description("Replace a destination's provider and config. The slug is immutable.").argument("<id>","Destination ID");return dn(o).action(async(r,s)=>{let d=await sn(s,{},false);delete d.name;let c=await nn(e.api,r,d);if(s.json){p(c);return}Se(c);}),t.command("test").description("Verify a destination's credentials by uploading a probe file").argument("<id>","Destination ID").option("--path <path>","Object path to write (defaults to a unique probe path)").addOption(f()).action(async(r,s)=>{let d=s.path??`mynth-cli-test/${Date.now()}.txt`;if(await on(e.api,r,d),s.json){p({id:r,path:d,ok:true});return}a(`${g.ok} Credentials valid (wrote ${d})`);}),t.command("delete").description("Delete a destination").argument("<id>","Destination ID").addOption(S()).addOption(f()).action(async(r,s)=>{if(s.yes!==true)throw new l("refusing to delete without --yes");if(await rn(e.api,r),s.json){p({deleted:r});return}a(`${g.ok} Deleted destination ${r}`);}),t};var cn=e=>{let t=new Command("docs").description("Read Mynth documentation (no authentication)");return t.command("get").description("Fetch a documentation page as Markdown").argument("<path>","Documentation path, without the .md suffix (e.g. guides/async-and-polling)").addOption(f()).action(async(n,o)=>{let r=await e.docs.get(n);if(o.json){p(r);return}a(r.content);}),t.command("list").description("Fetch the complete documentation index").addOption(f()).action(async n=>{let o=await e.docs.list();if(n.json){p({content:o});return}a(o);}),t};var Zo={4:"Production-ready",3:"Usable, with fixes",2:"Not fit for purpose",1:"Discard"},De=e=>e.type==="image.generate",ae=e=>{let t=vt.safeParse(e??{});return t.success?{...t.data,images:t.data.images??[]}:{images:[]}},Ne=e=>e.status==="success"?e.url??e.mynth_url??void 0:void 0,ie=e=>e.message!==void 0?`${e.code}: ${e.message}`:e.code,E=e=>{if(e.length!==0){a(`${g.ok} Uploaded ${F(e.length,"image")}`);for(let t of e)a(` ${t.path} -> ${t.url}`);}},se=(e,t)=>{if(e.length!==0){a(""),a(`${g.ok} Saved ${F(e.length,"image")} to ${t}`);for(let n of e)a(` ${n}`);}},pn=e=>{let{image:t}=e;a(`${g.ok} Removed background (task ${e.taskId})`),e.cost!==null&&a(` Cost: ${e.cost}`),a(` Size: ${t.size} ${t.format}`),a(""),a(` ${g.ok} ${t.url??t.mynth_url}`),t.destination?.status==="failed"&&a(` ${g.fail} destination ${t.destination.name}: ${ie(t.destination.error)}`);},Qo=e=>{let t=ae(e.result),n=t.images,o=n.filter(r=>r.status==="success").length;if(a(`${g.ok} Generated ${o}/${n.length} ${n.length===1?"image":"images"} (task ${e.id})`),t.model!==void 0&&a(` Model: ${t.model}`),e.cost!==null&&a(` Cost: ${e.cost}`),t.magic_prompt!==void 0&&(a(""),a("Enhanced prompt (mynth):"),a(` ${t.magic_prompt.positive}`),t.magic_prompt.negative&&a(` negative: ${t.magic_prompt.negative}`)),n.length!==0){a("");for(let r of n){if(r.status==="failed"){a(` ${g.fail} ${ie(r.error)}`);continue}let s=r.rating?.status==="success"?` [${r.rating.level}]`:"";a(` ${g.ok} ${Ne(r)??"(no url)"}${s}`),r.destination?.status==="failed"&&a(` ${g.fail} destination ${r.destination.name}: ${ie(r.destination.error)}`);}}},Le=e=>{if(a(`${V(e.status)} Task ${e.id}`),a(` Type: ${e.type}`),a(` Status: ${e.status}`),e.cost!==null&&a(` Cost: ${e.cost}`),a(` Created: ${e.createdAt}`),a(` Updated: ${e.updatedAt}`),e.errors!==null&&e.errors!==void 0&&e.errors.length>0){a(""),a("Errors:");for(let t of e.errors)a(` ${g.fail} ${ie(t)}`);}e.result!==null&&e.result!==void 0&&(a(""),a("Result:"),a(tt(JSON.stringify(e.result,null,2))));},de=e=>{if(De(e)&&e.status==="completed"){Qo(e);return}Le(e);},le=e=>{let t=ae(e.result);return {taskId:e.id,status:e.status,images:t.images.map(n=>n.status==="success"?{status:"success",url:n.url??null,mynth_url:n.mynth_url??null,size:n.size,format:n.format,rating:n.rating,destination:n.destination}:{status:"failed",error:n.error}),...t.magic_prompt!==void 0?{magic_prompt:t.magic_prompt}:{},...e.cost!==null?{cost:e.cost}:{},...t.model!==void 0?{model:t.model}:{}}},mn=e=>{if(a(`${g.ok} Reviewed (task ${e.taskId})`),a(` Score: ${e.score}/4 \u2014 ${Zo[e.score]??"unknown"}`),e.cost!==null&&a(` Cost: ${e.cost}`),a(` ${e.url}`),e.summary.length>0&&(a(""),a("Summary"),a(` ${e.summary}`)),e.findings.length>0){a(""),a(`Findings (${e.findings.length})`);for(let t of e.findings)a(` \u2022 [${t.severity}] ${t.category} \u2014 ${t.finding}`),a(` where: ${t.where} (${t.confidence} confidence)`);}if(e.strengths.length>0){a(""),a(`Strengths (${e.strengths.length})`);for(let t of e.strengths)a(` \u2022 ${t.strength} (${t.confidence} confidence)`);}};var x=(e,t=[])=>[...t,e],Me=e=>t=>{let n=Number.parseInt(t,10);if(!Number.isInteger(n)||String(n)!==t)throw new l(`invalid ${e}: "${t}" (expected an integer)`);return n},Fe=e=>t=>{let n=Me(e)(t);if(n<=0)throw new l(`invalid ${e}: "${t}" (expected a positive integer)`);return n},ce=(e,t)=>{let n;try{n=JSON.parse(e);}catch(o){throw new l(`invalid JSON in ${t}: ${o.message}`)}if(n===null||typeof n!="object"||Array.isArray(n))throw new l(`${t} must be a JSON object`);return n},pe=e=>/^https?:\/\//i.test(e);var H=10,j=async(e,t)=>{if(t.length===0)throw new l("no files to upload");if(t.length>H)throw new l(`too many files: ${t.length} (max ${H})`);let n=new FormData;for(let r of await Promise.all(t.map(Xt)))n.append("images",r);let{urls:o}=await e.fetch("upload","/image/upload",gt,{body:n});if(o.length!==t.length)throw new l(`upload returned ${o.length} URLs for ${t.length} files`);return t.map((r,s)=>({path:r,url:o[s]}))},G=(e,t,n)=>e.fetch(`image ${t}`,`/image/${t}`,ft,{body:n}),un=(e,t)=>e.fetch("estimate","/image/generate/estimate",yt,{body:t});var Ue=1800*1e3,er=12e3,tr=2500,nr=5e3,or=500,rr=new Set([0,404,408,429]),ir=e=>e instanceof k&&(rr.has(e.status)||e.status>=500),ar=10,Je=(e,t)=>e.fetch("task fetch",`/tasks/${t}`,pt),fn=(e,t={})=>e.fetch("task list","/tasks",z.array(mt),{query:t}),sr=(e,t)=>e.fetch("task status",`/tasks/${t}/status`,z.object({status:X})).then(n=>n.status),yn=(e,t)=>e.fetch("task result",`/tasks/${t}/result`,ut),D=async(e,t,n=Ue)=>{let o=Date.now(),r=0;for(;;){try{let c=await sr(e,t);if(r=0,c!=="pending")return await Je(e,t)}catch(c){if(!ir(c)||++r>ar)throw c}let s=Date.now()-o;if(s>=n)throw new k(`task ${t} did not complete within ${Math.round(n/1e3)}s; it may still finish, check \`mynth task get ${t}\``,{status:0});let d=s<er?tr:nr;await re(d+Math.floor(Math.random()*or));}};var lr=["Priming the canvas","Summoning pixels","Mixing digital pigments","Whispering to the model","Dreaming up details","Sketching silhouettes","Arranging composition","Weaving light and shadow","Polishing reflections","Sculpting atmosphere","Tracing final strokes"],cr=2800,pr=e=>{let t=e.slice();for(let n=t.length-1;n>0;n--){let o=Math.floor(Math.random()*(n+1));[t[n],t[o]]=[t[o],t[n]];}return t},N=async(e,t={})=>{if(process.stderr.isTTY!==true)return e;let n=pr(t.messages??lr),o=0,r=dr({text:n[0]??"Working",stream:process.stderr}).start(),s=setInterval(()=>{o++,r.text=n[o%n.length]??"Working";},cr);try{return await e}finally{clearInterval(s),r.stop();}};var ue=2,ge=7,L=async(e,t)=>{if(pe(t))return {url:t,uploads:[]};let n=await j(e.api,[t]);return {url:n[0].url,uploads:n}},fe=e=>e.option("--destination <name>","Slug of a configured storage destination to deliver results to. Defaults to MYNTH_DESTINATION.").option("--webhook-url <url>","Deliver this task's events to this URL (repeatable, max 5)",x).option("--no-dashboard-webhooks","Skip dashboard-configured webhooks for this task"),ye=(e,t)=>{let n=t.destination??e.config.envDestination,o=t.webhookUrl??[],r=t.dashboardWebhooks===false,s=o.length===0&&!r?void 0:{...r?{dashboard:false}:{},...o.length>0?{custom:o.map(d=>({url:d}))}:{}};return {...n!==void 0?{destination:n}:{},...s!==void 0?{webhook:s}:{}}},he=async(e,t)=>{let n=await G(e.api,t.endpoint,{...t.body,access:{pat:{enabled:true}}}),o=n.access?.publicAccessToken;if(t.json){p({taskId:n.taskId,...n.estimatedCost!==void 0?{estimatedCost:n.estimatedCost}:{},...o!==void 0?{access:{publicAccessToken:o}}:{}});return}a(`${g.ok} Task created: ${n.taskId}`),o!==void 0&&a(` Public access token: ${o}`),a(` Await it with: mynth task wait ${n.taskId}`);},mr=z.array(z.object({value:z.string(),description:z.string()})),ur=e=>{let t=e.indexOf("=");if(t<=0)throw new l(`invalid --level "${e}": expected "value=description"`);let n={value:e.slice(0,t).trim(),description:e.slice(t+1).trim()};if(n.value.length===0||n.description.length===0)throw new l(`invalid --level "${e}": value and description must both be non-empty`);return n},hn=(e,t)=>{let n;try{n=JSON.parse(e);}catch(r){throw new l(`invalid JSON in ${t}: ${r.message}`)}let o=mr.safeParse(n);if(!o.success)throw new l(`invalid levels in ${t}: expected an array of { value, description }`);return o.data},we=async e=>{let t=e.level??[],n=[t.length>0?"--level":void 0,e.levelsFile!==void 0?"--levels-file":void 0,e.levelsJson!==void 0?"--levels-json":void 0].filter(s=>s!==void 0);if(n.length===0)return;if(n.length>1)throw new l(`conflicting level options: ${n.join(", ")} \u2014 use only one`);let o=t.length>0?t.map(ur):e.levelsFile!==void 0?hn(await W(e.levelsFile),e.levelsFile):hn(e.levelsJson??"[]","--levels-json");if(o.length<ue||o.length>ge)throw new l(`levels must have between ${ue} and ${ge} items (got ${o.length})`);let r=new Set;for(let s of o){if(r.has(s.value))throw new l(`duplicate level value: "${s.value}"`);r.add(s.value);}return o},gr=(e,t)=>{let n=ve(e);return new k(`${t} task ${e.id} failed${n!==void 0?` (${n})`:""}`,{status:0,...n!==void 0?{code:n}:{}})},M=async(e,t)=>{let n=await G(e.api,t.endpoint,t.body),o=D(e.api,n.taskId),r=t.quiet?await o:await N(o);if(r.status!=="completed")throw gr(r,t.endpoint);let s=t.schema.safeParse(r.result);if(!s.success)throw new k(`${t.endpoint} task ${r.id} returned an unexpected result`,{status:0,cause:s.error});return {taskId:r.id,cost:r.cost,result:s.data}};var yr=["low","high"],ze="Image URL (http/https), or a local image file to upload first",hr=e=>e.option("-l, --level <value>",`Custom rating level as "value=description" (repeatable, ${ue}-${ge} items). Example: -l safe="No explicit content" -l nsfw="Contains nudity"`,x).option("--levels-file <path>",'JSON file holding an array of { "value", "description" }, or `-` for stdin. Use when descriptions contain shell metacharacters.').option("--levels-json <json>","Inline JSON array of { value, description }."),wr=e=>{let t=new Command("rate").description("Classify an image against the default sfw/nsfw levels, or custom ones").argument("<image>",ze).addOption(f());return hr(t).action(async(n,o)=>{let r=await we(o),{url:s,uploads:d}=await L(e,n),{taskId:c,cost:h,result:v}=await M(e,{endpoint:"rate",body:r!==void 0?{url:s,mode:"custom",levels:r}:{url:s},schema:ht,quiet:o.json===true});if(o.json){p({taskId:c,cost:h,...v});return}E(d),a(`${g.ok} Rated (task ${c})`),a(` ${v.level} ${s}`);}),t},kr=e=>new Command("alt").description("Generate accessibility alt text for an image").argument("<image>",ze).addOption(f()).action(async(t,n)=>{let{url:o,uploads:r}=await L(e,t),{taskId:s,cost:d,result:c}=await M(e,{endpoint:"alt",body:{url:o},schema:wt,quiet:n.json===true});if(n.json){p({taskId:s,cost:d,...c});return}E(r),a(`${g.ok} Generated alt text (task ${s})`),a(` ${c.alt}`),a(` ${o}`);}),br=e=>new Command("review").description("Review image quality with a multi-model panel (score, findings, strengths)").argument("<image>",ze).addOption(new Option("--effort <level>",'"high" (default) runs five strong vision models; "low" runs three smaller ones for faster, cheaper triage').choices([...yr])).addOption(f()).action(async(t,n)=>{let{url:o,uploads:r}=await L(e,t),{taskId:s,cost:d,result:c}=await M(e,{endpoint:"review",body:{url:o,...n.effort!==void 0?{effort:n.effort}:{}},schema:kt,quiet:n.json===true});if(n.json){p({taskId:s,cost:d,...c});return}E(r),mn({taskId:s,cost:d,url:o,...c,findings:c.findings??[],strengths:c.strengths??[]});}),wn=e=>[wr(e),kr(e),br(e)];var xr=4,Tr=(e,t,n)=>{try{let o=new URL(e).pathname.split("/").filter(Boolean).pop();if(o!==void 0&&o.length>0)return decodeURIComponent(o)}catch{}return `${t}-${n}`},ke=async e=>{let t=resolve(e.directory);try{await mkdir(t,{recursive:true});}catch(n){throw new y(`could not create ${t}: ${n.message}`,{cause:n})}return Kt(e.urls,xr,async(n,o)=>{let r;try{r=await fetch(n);}catch(d){throw new y(`download failed for ${n}: ${d.message}`,{cause:d})}if(!r.ok)throw new y(`download failed for ${n} (${r.status})`);let s=join(t,Tr(n,e.fallbackPrefix,o));try{await writeFile(s,new Uint8Array(await r.arrayBuffer()));}catch(d){throw new y(`could not write ${s}: ${d.message}`,{cause:d})}return s})};var We=20,Pr=["png","jpg","webp"],Be=["auto","source","reference"],Or="https://dry-run.mynth.io/input",_r=e=>{let t=e.indexOf(":"),n,o=e;if(t>0&&!/^https?:/i.test(e)){let r=e.slice(0,t);if(!Be.includes(r))throw new l(`invalid --input role "${r}". Expected one of: ${Be.join(", ")}`);n=r,o=e.slice(t+1);}if(o.length===0)throw new l(`invalid --input "${e}": missing path or URL`);return {...n!==void 0?{role:n}:{},value:o,isLocalFile:!pe(o)}},Sr=async(e,t,n,o)=>{let r=await we(t),s=r!==void 0?{mode:"custom",levels:r}:t.contentRating===true?true:void 0,d=n.map(c=>({type:"image",...c.role!==void 0?{as:c.role}:{},source:{type:"url",url:c.isLocalFile?o.get(c.value)??Or:c.value}}));return {prompt:t.prompt??"",...t.model!==void 0?{model:t.model}:{},...t.negative!==void 0?{negative_prompt:t.negative}:{},...t.magicPrompt===true?{magic_prompt:true}:{},...t.size!==void 0?{size:t.size}:{},...t.count!==void 0?{count:t.count}:{},...t.format!==void 0?{output:{format:t.format}}:{},...d.length>0?{inputs:d}:{},...ye(e,t),...s!==void 0?{rating:s}:{},...t.metadata!==void 0?{metadata:ce(t.metadata,"--metadata")}:{}}},jr=(e,t)=>{let n=ae(e.result).images.map(Ne).filter(o=>o!==void 0);return n.length===0?Promise.resolve([]):ke({urls:n,directory:t,fallbackPrefix:e.id})},kn=e=>{let t=new Command("generate").description("Generate images with Mynth").addHelpText("after",`
8
- Browse models with: mynth models list`).option("-p, --prompt <text>","Text prompt describing the image to generate").option("-n, --negative <text>","Negative prompt: elements to exclude").option("--magic-prompt","Let Mynth expand the prompt before generating").option("-m, --model <id>",'Model ID (e.g. "black-forest-labs/flux.2-pro"). Default: auto').option("-s, --size <size>",'Size preset or aspect ratio: "square", "portrait", "landscape", "1:1", "16:9", "16:9_4k", "auto", ...').option("-c, --count <number>","Number of images to generate (default: 1)",Me("--count")).addOption(new Option("-f, --format <format>","Output image format").choices([...Pr])).option("-i, --input <value>",`Input image as "[role:]path-or-url" (repeatable, up to ${We}). Roles: ${Be.join(", ")}. Examples: -i ./img.jpg, -i source:https://example.com/a.png`,x).option("-o, --output-dir <dir>","Directory to save generated images into. Created if missing. Ignored with --async.").option("--metadata <json>","Inline JSON object attached to the task (max 2KB)").option("--content-rating","Classify each image with the default sfw/nsfw levels. For custom levels use --level.").option("-l, --level <value>",'Custom rating level as "value=description" (repeatable, 2-7)',x).option("--levels-file <path>","JSON file of custom rating levels, or `-` for stdin").option("--levels-json <json>","Inline JSON array of custom rating levels");return fe(t).option("--dry-run","Validate the request and print the estimated cost without generating").option("--async","Print the task ID immediately instead of waiting for the result").option("--detailed","Include the full task record in --json output").addOption(f()),t.action(async n=>{let o=n.input??[];if(o.length>We)throw new l(`too many --input values: ${o.length} (max ${We})`);let r=o.map(_r),s=[...new Set(r.filter(A=>A.isLocalFile).map(A=>A.value))],d=s.length>0&&n.dryRun!==true?await j(e.api,s):[],c=await Sr(e,n,r,new Map(d.map(A=>[A.path,A.url])));if(n.dryRun===true){let A=await un(e.api,c);if(n.json){p(A);return}let Yn=A.estimateKind==="upper_bound"?" (upper bound)":"";a(`${g.ok} Estimated cost: $${A.estimatedCost}${Yn}`);return}if(n.async===true){await he(e,{endpoint:"generate",body:c,json:n.json===true});return}let h=await G(e.api,"generate",c),v=D(e.api,h.taskId),w=n.json?await v:await N(v),I=n.outputDir!==void 0?resolve(n.outputDir):void 0,P=I!==void 0?await jr(w,I):[];if(n.json){let A=n.detailed===true?w:le(w);p(I!==void 0?{...A,downloadedFiles:P}:A);return}E(d),de(w),I!==void 0&&se(P,I);}),t};var Mr=["png","webp"],bn=e=>{let t=new Command("remove-background").description("Remove the background from an image. Mynth picks the model.").argument("<image>","Image URL (http/https), or a local image file to upload first").addOption(new Option("-f, --format <format>","Output format. Default: whatever the provider returns").choices([...Mr])).option("-o, --output-dir <dir>","Directory to save the result into. Created if missing. Ignored with --async.").option("--metadata <json>","Inline JSON object attached to the task (max 2KB)");return fe(t).option("--async","Print the task ID immediately instead of waiting for the result").addOption(f()),t.action(async(n,o)=>{let{url:r,uploads:s}=await L(e,n),d={url:r,...o.format!==void 0?{output:{format:o.format}}:{},...ye(e,o),...o.metadata!==void 0?{metadata:ce(o.metadata,"--metadata")}:{}};if(o.async===true){await he(e,{endpoint:"remove-background",body:d,json:o.json===true});return}let{taskId:c,cost:h,result:v}=await M(e,{endpoint:"remove-background",body:d,schema:At,quiet:o.json===true}),w=o.outputDir!==void 0?resolve(o.outputDir):void 0,I=w!==void 0?await ke({urls:[v.image.url??v.image.mynth_url],directory:w,fallbackPrefix:c}):[];if(o.json){p({taskId:c,cost:h,...v,...w!==void 0?{downloadedFiles:I}:{}});return}E(s),pn({taskId:c,cost:h,...v}),w!==void 0&&se(I,w);}),t};var vn=e=>new Command("upload").description("Upload local images to Mynth's temporary input storage").argument("<files...>",`Local image files (${Ce.join(", ")})`).addOption(f()).action(async(t,n)=>{if(t.length>H)throw new l(`too many files: ${t.length} (max ${H})`);let o=await j(e.api,t);if(n.json){p({images:o});return}a(`${g.ok} Uploaded ${F(o.length,"image")}`);for(let{path:r,url:s}of o)a(` ${r}`),a(` -> ${s}`);});var An=e=>{let t=new Command("image").description("Generate, upload, analyze, and remove backgrounds from images");t.addCommand(kn(e)),t.addCommand(vn(e)),t.addCommand(bn(e));for(let n of wn(e))t.addCommand(n);return t};var $n=e=>e.fetch("models list","/models",z.array(ct),{auth:false});var Rn={txt2img:"txt->img",img2img:"img->img",txt2vid:"txt->vid",img2vid:"img->vid"},Cn=e=>e.id.split("/")[0],Ye=e=>"perImage"in e,En=e=>e.type==="video"?"/s":"",Wr=e=>{let t=e.pricing;return t===null?[]:Ye(t)?[t.perImage.base]:Object.values(t.perSecond)},Pn=e=>{let t;for(let n of Wr(e)){let o=Number.parseFloat(n);Number.isFinite(o)&&(t===void 0||o<t.value)&&(t={raw:n,value:o});}return t},In=e=>Pn(e)?.value,Br=e=>{let t=Pn(e);return t===void 0?"-":`${t.raw}${En(e)}`},On=e=>{let t=e.pricing;if(t!==null)return Ye(t)?t.perImage["4k"]:t.perSecond["4k"]},Yr=e=>{let t=On(e);return t===void 0?"-":`${t}${En(e)}`},Hr=e=>{let t=e.pricing;if(!(t===null||!Ye(t)))return t.perInput},xn=e=>t=>{let n=Number.parseFloat(t);if(!Number.isFinite(n)||n<0)throw new l(`invalid ${e}: "${t}" (expected a non-negative number)`);return n},_n=.3,Gr=(e,t)=>Tn.go(t,e,{keys:["id","displayName"],threshold:_n}).map(n=>n.obj),qr=(e,t)=>{let n=[...new Set(e.map(Cn))];return n.includes(t)?new Set([t]):new Set(Tn.go(t,n,{threshold:_n}).map(o=>o.target))},Vr=(e,t)=>{let n=e;if(t.org!==void 0){let o=qr(n,t.org);n=n.filter(r=>o.has(Cn(r)));}if(t.type!==void 0){let o=t.type;n=n.filter(r=>r.type===o);}if(t["4k"]&&(n=n.filter(o=>On(o)!==void 0)),t.capability!==void 0){let o=Rn[t.capability];n=n.filter(r=>o in r.modes);}if(t.maxPrice!==void 0){let o=t.maxPrice;n=n.filter(r=>(In(r)??1/0)<=o);}if(t.minPrice!==void 0){let o=t.minPrice;n=n.filter(r=>(In(r)??-1/0)>=o);}return t.search!==void 0&&(n=Gr(n,t.search)),n},Sn=e=>{let t=new Command("models").description("Browse the public Mynth model catalog");return t.command("list").description("List available generation models and their pricing").option("-s, --search <query>","Fuzzy match against model ID (which includes the org) and name").option("--org <org>","Only models from this org, fuzzy matched (e.g. bfl, google)").option("--type <type>","Only models of this media type (image, video)").option("--max-price <usd>","Only models at or below this price (per image, or per second for video)",xn("--max-price")).option("--min-price <usd>","Only models at or above this price (per image, or per second for video)",xn("--min-price")).option("--4k","Only models with 4K pricing").addOption(new Option("--capability <capability>","Only models serving this generation mode").choices(Object.keys(Rn))).addOption(f()).action(async n=>{let o=Vr(await $n(e.api),n);if(n.json){p(o);return}C(o,[{header:"ID",value:r=>r.id},{header:"Name",value:r=>r.displayName??"-"},{header:"Type",value:r=>r.type},{header:"Modes",value:r=>Object.keys(r.modes).join(",")||"-"},{header:"Price",value:r=>Br(r)},{header:"4K",value:r=>Yr(r)},{header:"Input fee",value:r=>Hr(r)??"-"}],"No models matched the filters.");}),t};var jn=Ue/1e3,Zr=e=>e.option("--detailed","Include the full task record instead of a compact summary"),Dn=e=>{let t=new Command("task").description("Inspect and await Mynth tasks");t.command("get").description("Fetch a task by ID").argument("<id>","Task ID").addOption(f()).action(async(o,r)=>{let s=await Je(e.api,o);if(r.json){p(s);return}Le(s);}),t.command("result").description("Print a task's result payload as JSON").argument("<id>","Task ID").action(async o=>{p((await yn(e.api,o)).result);});let n=t.command("wait").description("Block until a task completes or fails, then print it").argument("<id>","Task ID").option("--timeout <seconds>",`Max seconds to wait before giving up (default: ${jn})`,Fe("--timeout")).addOption(f());return Zr(n).action(async(o,r)=>{let s=D(e.api,o,(r.timeout??jn)*1e3),d=r.json?await s:await N(s);if(d.status==="failed"&&(process.exitCode=et(d)),r.json){let c=!r.detailed&&De(d);p(c?le(d):d);return}de(d);}),t.command("list").description("List recent tasks, newest first").option("--limit <number>","Max tasks to return (1-100, default: 20)",Fe("--limit")).option("--after <id>","Cursor: return tasks created before this task ID").addOption(f()).action(async o=>{let r=await fn(e.api,{...o.limit!==void 0?{limit:o.limit}:{},...o.after!==void 0?{after:o.after}:{}});if(o.json){p({tasks:r});return}C(r,[{header:"",value:s=>V(s.status)},{header:"ID",value:s=>s.id},{header:"Type",value:s=>s.type},{header:"Status",value:s=>s.status},{header:"Cost",value:s=>s.cost??"-"},{header:"Created",value:s=>s.createdAt}],"No tasks found.");}),t};var Nn=(e,t)=>e.fetch("webhook create","/webhook",It,{body:t}),Ln=(e,t,n)=>e.fetch("webhook update",`/webhook/${t}`,xt,{method:"PUT",body:n}),Mn=(e,t)=>e.call("webhook delete",`/webhook/${t}`,{method:"DELETE"});var He=["task.completed","task.failed","task.image.generate.completed","task.image.generate.failed","task.image.rate.completed","task.image.rate.failed","task.image.alt.completed","task.image.alt.failed","task.image.review.completed","task.image.review.failed","task.image.remove_background.completed","task.image.remove_background.failed","task.video.generate.completed","task.video.generate.failed"],ei=e=>{if(e===void 0||e.length===0)throw new l("at least one --event is required");if(e.includes("all"))return "all";for(let t of e)if(!He.includes(t))throw new l(`unknown event "${t}". Valid events: all, ${He.join(", ")}`);return e},ti=e=>{if(e.enabled===true&&e.disabled===true)throw new l("--enabled and --disabled are mutually exclusive");return e.disabled!==true},Fn=e=>({enabled:ti(e),url:e.url,events:ei(e.event),...e.apiKeyId!==void 0?{apiKeyIds:e.apiKeyId}:{},...e.oauthEvents===true?{oauthEnabled:true}:{}}),Un=e=>Array.isArray(e)?e.join(", "):String(e),Jn=e=>e.requiredOption("--url <url>","Destination URL for webhook deliveries").option("-e, --event <name>",`Event to subscribe to (repeatable). Use \`all\` for every event. One of: ${He.join(", ")}`,x).option("--api-key-id <id>","Only deliver tasks created by this API key (repeatable). Omit to deliver for every key.",x).option("--oauth-events","Also deliver tasks created by OAuth sessions (this CLI, the playground). Off by default.").addOption(f()),Kn=e=>{let t=new Command("webhook").description("Manage registered webhooks"),n=t.command("create").description("Register a webhook. The signing secret is shown once, on success.").option("--disabled","Create the webhook disabled (default: enabled)");Jn(n).action(async r=>{let s=await Nn(e.api,Fn(r));if(r.json){p(s);return}a(`${g.ok} Webhook ${s.id} created`),a(` URL: ${s.url}`),a(` Enabled: ${s.enabled}`),a(` Events: ${Un(s.events)}`),s.oauthEnabled!==void 0&&a(` OAuth: ${s.oauthEnabled}`),a(""),a(` Signing secret: ${s.secret}`),a(" Save this now \u2014 it is shown only once and cannot be retrieved again.");});let o=t.command("update").description("Replace a webhook's configuration. Every field is sent, so pass them all.").argument("<id>","Webhook ID").option("--enabled","Enable the webhook").option("--disabled","Disable the webhook");return Jn(o).action(async(r,s)=>{let d=await Ln(e.api,r,Fn(s));if(s.json){p(d);return}a(`${g.ok} Webhook ${d.id} updated`),a(` URL: ${d.url}`),d.enabled!==void 0&&a(` Enabled: ${d.enabled}`),a(` Events: ${Un(d.events)}`),d.oauthEnabled!==void 0&&a(` OAuth: ${d.oauthEnabled}`);}),t.command("delete").description("Delete a webhook").argument("<id>","Webhook ID").addOption(S()).addOption(f()).action(async(r,s)=>{if(s.yes!==true)throw new l("refusing to delete without --yes");if(await Mn(e.api,r),s.json){p({deleted:r});return}a(`${g.ok} Deleted webhook ${r}`);}),t};var ri=100,ii=`
2
+ import chalk from "chalk";
3
+ import { Command, Help, Option } from "commander";
4
+ import { z } from "zod";
5
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir, hostname } from "node:os";
7
+ import { basename, dirname, extname, join, resolve } from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import ora from "ora";
11
+ import fuzzysort from "fuzzysort";
12
+ //#region src/config.ts
13
+ /**
14
+ * Build-time and environment configuration.
15
+ *
16
+ * `MYNTH_WORKOS_CLIENT_ID` and `MYNTH_CLI_VERSION` are replaced with string
17
+ * literals by tsdown (see `tsdown.config.ts`), so the `process.env` lookups do
18
+ * not exist in the published bundle and users cannot override them.
19
+ */
20
+ const WORKOS_CLIENT_ID = "client_01KATK792RR5ZCHMF5YMNN1ZSE";
21
+ /** Overridable so the device-login flow can be exercised against a test server. */
22
+ const WORKOS_API_URL = process.env["MYNTH_WORKOS_API_URL"] ?? "https://api.workos.com";
23
+ const CLI_VERSION = "0.0.23";
24
+ const trimTrailingSlash = (url) => url.replace(/\/+$/, "");
25
+ const nonEmpty = (value) => value !== void 0 && value.length > 0 ? value : void 0;
26
+ const loadConfig = () => ({
27
+ apiUrl: trimTrailingSlash(process.env["MYNTH_API_URL"] ?? "https://api.mynth.io"),
28
+ docsUrl: trimTrailingSlash(process.env["MYNTH_DOCS_URL"] ?? "https://mynth.io/docs"),
29
+ envApiKey: nonEmpty(process.env["MYNTH_API_KEY"]),
30
+ envDestination: nonEmpty(process.env["MYNTH_DESTINATION"]),
31
+ debug: process.env["MYNTH_DEBUG"] === "1" || process.env["MYNTH_DEBUG"] === "true"
32
+ });
33
+ //#endregion
34
+ //#region src/errors.ts
35
+ /**
36
+ * Stable exit-code contract so scripts and agents can branch without parsing
37
+ * error prose. Documented in `mynth --help` and the README; keep all three in
38
+ * sync.
39
+ */
40
+ const EXIT_CODES = {
41
+ success: 0,
42
+ error: 1,
43
+ usage: 2,
44
+ auth: 3,
45
+ insufficientCredits: 4,
46
+ moderation: 5,
47
+ rateLimited: 6
48
+ };
49
+ var CliError = class extends Error {
50
+ constructor(message, options) {
51
+ super(message, options);
52
+ this.name = new.target.name;
53
+ }
54
+ };
55
+ /** Bad arguments, flags, or local input. Exits 2. */
56
+ var UsageError = class extends CliError {};
57
+ /** Missing, invalid, or insufficient credentials. Exits 3. */
58
+ var AuthError = class extends CliError {};
59
+ /** A non-2xx API response, or a transport failure (`status: 0`). */
60
+ var ApiError = class extends CliError {
61
+ status;
62
+ code;
63
+ constructor(message, options) {
64
+ super(message, options);
65
+ this.status = options.status;
66
+ this.code = options.code;
67
+ }
68
+ };
69
+ /** An OAuth device-flow response that the login loop reacts to by `code`. */
70
+ var DeviceFlowError = class extends CliError {
71
+ code;
72
+ constructor(code, message, options) {
73
+ super(message, options);
74
+ this.code = code;
75
+ }
76
+ };
77
+ const EXIT_BY_API_CODE = {
78
+ UNAUTHORIZED: EXIT_CODES.auth,
79
+ INSUFFICIENT_SCOPE: EXIT_CODES.auth,
80
+ VALIDATION_ERROR: EXIT_CODES.usage,
81
+ INSUFFICIENT_BALANCE: EXIT_CODES.insufficientCredits,
82
+ SPENDING_LIMIT_EXCEEDED: EXIT_CODES.insufficientCredits,
83
+ RESTRICTED_CONTENT: EXIT_CODES.moderation
84
+ };
85
+ const exitCodeForError = (error) => {
86
+ if (error instanceof UsageError) return EXIT_CODES.usage;
87
+ if (error instanceof AuthError) return EXIT_CODES.auth;
88
+ if (error instanceof ApiError) {
89
+ const byCode = error.code !== void 0 ? EXIT_BY_API_CODE[error.code] : void 0;
90
+ if (byCode !== void 0) return byCode;
91
+ if (error.status === 401 || error.status === 403) return EXIT_CODES.auth;
92
+ if (error.status === 429) return EXIT_CODES.rateLimited;
93
+ return EXIT_CODES.error;
94
+ }
95
+ const code = error?.code;
96
+ if (typeof code === "string" && code.startsWith("commander.")) return EXIT_CODES.usage;
97
+ return EXIT_CODES.error;
98
+ };
99
+ /**
100
+ * A failed task carries codes at the task level and per generated item; a
101
+ * moderation block anywhere means the request was moderated.
102
+ */
103
+ const taskFailureCode = (task) => {
104
+ const result = task.result;
105
+ const codes = [...(task.errors ?? []).map((error) => error.code), ...(result?.images ?? []).map((image) => image["error"]?.code)].filter((code) => typeof code === "string");
106
+ return codes.find((code) => code === "RESTRICTED_CONTENT") ?? codes[0];
107
+ };
108
+ const exitCodeForFailedTask = (task) => taskFailureCode(task) === "RESTRICTED_CONTENT" ? EXIT_CODES.moderation : EXIT_CODES.error;
109
+ //#endregion
110
+ //#region src/output/print.ts
111
+ const print = (message = "") => {
112
+ process.stdout.write(`${message}\n`);
113
+ };
114
+ const printErr = (message = "") => {
115
+ process.stderr.write(`${message}\n`);
116
+ };
117
+ const printJson = (value) => {
118
+ print(JSON.stringify(value, null, 2));
119
+ };
120
+ const glyph = {
121
+ ok: chalk.green("✓"),
122
+ fail: chalk.red("✗"),
123
+ pending: chalk.yellow("…")
124
+ };
125
+ const glyphForStatus = (status) => status === "completed" ? glyph.ok : status === "failed" ? glyph.fail : glyph.pending;
126
+ const plural = (count, singular, suffix = "s") => `${count} ${singular}${count === 1 ? "" : suffix}`;
127
+ const indent = (text, spaces = 2) => {
128
+ const pad = " ".repeat(spaces);
129
+ return text.split("\n").map((line) => `${pad}${line}`).join("\n");
130
+ };
131
+ //#endregion
132
+ //#region src/api/schemas.ts
133
+ /**
134
+ * Zod mirrors of the Mynth API response shapes. Only the fields the CLI reads
135
+ * are declared; unknown fields are ignored, so additive API changes never break
136
+ * a released CLI.
137
+ */
138
+ const jsonValue = z.lazy(() => z.union([
139
+ z.string(),
140
+ z.number(),
141
+ z.boolean(),
142
+ z.null(),
143
+ z.array(jsonValue),
144
+ z.record(jsonValue)
145
+ ]));
146
+ /** Every API endpoint wraps its payload in `{ data: ... }`. */
147
+ const envelope = (data) => z.object({ data });
148
+ const workosUser = z.object({
149
+ id: z.string(),
150
+ email: z.string(),
151
+ first_name: z.string().nullable().optional(),
152
+ last_name: z.string().nullable().optional()
153
+ });
154
+ const deviceAuthorization = z.object({
155
+ device_code: z.string(),
156
+ user_code: z.string(),
157
+ verification_uri: z.string(),
158
+ verification_uri_complete: z.string().optional(),
159
+ expires_in: z.number(),
160
+ interval: z.number().optional()
161
+ });
162
+ const tokenResponse = z.object({
163
+ access_token: z.string(),
164
+ refresh_token: z.string(),
165
+ user: workosUser.optional()
166
+ });
167
+ const workosErrorResponse = z.object({
168
+ error: z.string().optional(),
169
+ error_description: z.string().optional(),
170
+ message: z.string().optional(),
171
+ code: z.string().optional()
172
+ });
173
+ /**
174
+ * The only credential the CLI stores. `auth login` exchanges a short-lived
175
+ * WorkOS session for a long-lived API key, so no OAuth token ever reaches disk
176
+ * and there is nothing to refresh.
177
+ */
178
+ const credentials = z.object({
179
+ kind: z.literal("api_key"),
180
+ api_key: z.string(),
181
+ /** Present when the CLI minted the key, so `auth logout` can revoke it. */
182
+ id: z.string().optional()
183
+ });
184
+ const API_KEY_SCOPES = [
185
+ "generate",
186
+ "manage",
187
+ "keys"
188
+ ];
189
+ /** The raw key is returned exactly once, by the create endpoint. */
190
+ const createdApiKey = z.object({
191
+ raw: z.string(),
192
+ apiKey: z.object({
193
+ id: z.string(),
194
+ name: z.string().optional(),
195
+ keyPreview: z.string(),
196
+ scopes: z.array(z.string())
197
+ })
198
+ });
199
+ /** Money fields come back as decimal strings, but tolerate numbers. */
200
+ const decimal = z.union([z.string(), z.number()]).nullable().optional();
201
+ const apiKey = z.object({
202
+ id: z.string(),
203
+ name: z.string().nullable(),
204
+ keyPreview: z.string(),
205
+ scopes: z.array(z.string()),
206
+ spendingLimit: decimal,
207
+ spendingLimitPeriod: z.string().nullable().optional(),
208
+ createdAt: z.string()
209
+ });
210
+ const apiKeySpending = z.union([z.object({ mode: z.literal("unlimited") }), z.object({
211
+ mode: z.literal("limited"),
212
+ limit: z.string(),
213
+ period: z.string(),
214
+ used: z.string(),
215
+ remaining: z.string()
216
+ })]);
217
+ const me = z.object({
218
+ userId: z.string(),
219
+ auth: z.object({
220
+ method: z.string(),
221
+ apiKey: z.object({
222
+ id: z.string(),
223
+ name: z.string().nullable(),
224
+ keyPreview: z.string(),
225
+ scopes: z.array(z.string()).optional(),
226
+ spending: apiKeySpending.optional()
227
+ }).optional()
228
+ })
229
+ });
230
+ const balance = z.object({
231
+ balance: z.string(),
232
+ reserved: z.string(),
233
+ available: z.string(),
234
+ currency: z.string()
235
+ });
236
+ const modelInputRule = z.object({
237
+ type: z.string(),
238
+ kind: z.string().optional(),
239
+ min: z.number().optional(),
240
+ max: z.number()
241
+ });
242
+ const modelMode = z.object({ inputs: z.object({
243
+ rules: z.array(modelInputRule),
244
+ maxTotal: z.number().optional()
245
+ }).optional() });
246
+ const imagePricing = z.object({
247
+ perImage: z.object({
248
+ base: z.string(),
249
+ "4k": z.string().optional()
250
+ }),
251
+ perInput: z.string().optional()
252
+ });
253
+ const videoPricing = z.object({
254
+ perSecond: z.record(z.string(), z.string()),
255
+ audio: z.object({ perSecond: z.string() }).optional()
256
+ });
257
+ /**
258
+ * Media types, mode names, and input kinds are read as open strings for the same
259
+ * reason task types are: the API adds them and a released CLI must still render
260
+ * them. Only the pricing shapes are narrowed, because the two are read per key.
261
+ */
262
+ const model = z.object({
263
+ id: z.string(),
264
+ displayName: z.string().nullable(),
265
+ type: z.string(),
266
+ modes: z.record(z.string(), modelMode),
267
+ pricing: z.union([imagePricing, videoPricing]).nullable()
268
+ });
269
+ const taskStatus = z.enum([
270
+ "pending",
271
+ "completed",
272
+ "failed"
273
+ ]);
274
+ /**
275
+ * Task types are not narrowed to a closed set: the API adds new ones (video,
276
+ * and more later) and a released CLI must still render them.
277
+ */
278
+ const task = z.object({
279
+ id: z.string(),
280
+ type: z.string(),
281
+ status: taskStatus,
282
+ userId: z.string().optional(),
283
+ apiKeyId: z.string().nullable().optional(),
284
+ cost: z.string().nullable(),
285
+ request: jsonValue.optional(),
286
+ result: jsonValue.nullable(),
287
+ errors: z.array(z.object({
288
+ code: z.string(),
289
+ message: z.string().optional()
290
+ })).nullish(),
291
+ createdAt: z.string(),
292
+ updatedAt: z.string()
293
+ });
294
+ const taskListItem = z.object({
295
+ id: z.string(),
296
+ type: z.string(),
297
+ status: z.string(),
298
+ cost: z.string().nullable(),
299
+ createdAt: z.string(),
300
+ updatedAt: z.string()
301
+ });
302
+ /** `GET /tasks/:id/result` — a lighter, PAT-readable view of a task. */
303
+ const taskResult = z.object({
304
+ id: z.string(),
305
+ type: z.string(),
306
+ status: taskStatus,
307
+ result: jsonValue.nullable()
308
+ });
309
+ const uploadResult = z.object({ urls: z.array(z.string()) });
310
+ /** Shared 201 envelope of every async create-task endpoint. */
311
+ const createdTask = z.object({
312
+ taskId: z.string(),
313
+ estimatedCost: z.string().optional(),
314
+ access: z.object({ publicAccessToken: z.string() }).optional()
315
+ });
316
+ const estimate = z.object({
317
+ estimatedCost: z.string(),
318
+ currency: z.string(),
319
+ estimateKind: z.enum(["exact", "upper_bound"])
320
+ });
321
+ const imageRateResult = z.object({ level: z.string() });
322
+ const imageAltResult = z.object({ alt: z.string() });
323
+ const imageReviewResult = z.object({
324
+ score: z.number(),
325
+ summary: z.string(),
326
+ findings: z.array(z.object({
327
+ finding: z.string(),
328
+ category: z.string(),
329
+ severity: z.string(),
330
+ where: z.string(),
331
+ confidence: z.string()
332
+ })).optional(),
333
+ strengths: z.array(z.object({
334
+ strength: z.string(),
335
+ confidence: z.string()
336
+ })).optional()
337
+ });
338
+ const imageError = z.object({
339
+ code: z.string(),
340
+ message: z.string().optional()
341
+ });
342
+ const imageDestination = z.union([z.object({
343
+ status: z.literal("success"),
344
+ name: z.string()
345
+ }), z.object({
346
+ status: z.literal("failed"),
347
+ name: z.string(),
348
+ error: imageError
349
+ })]);
350
+ const generatedImage = z.union([z.object({
351
+ status: z.literal("success"),
352
+ id: z.string().optional(),
353
+ url: z.string().nullable().optional(),
354
+ mynth_url: z.string().optional(),
355
+ size: z.string().optional(),
356
+ format: z.string().optional(),
357
+ rating: z.union([z.object({
358
+ status: z.literal("success"),
359
+ level: z.string()
360
+ }), z.object({
361
+ status: z.literal("failed"),
362
+ error: imageError
363
+ })]).optional(),
364
+ destination: imageDestination.optional()
365
+ }), z.object({
366
+ status: z.literal("failed"),
367
+ error: imageError
368
+ })]);
369
+ const imageGenerateResult = z.object({
370
+ model: z.string().optional(),
371
+ images: z.array(generatedImage).optional(),
372
+ magic_prompt: z.object({
373
+ positive: z.string(),
374
+ negative: z.string().optional()
375
+ }).optional()
376
+ });
377
+ const imageRemoveBackgroundResult = z.object({ image: z.object({
378
+ id: z.string(),
379
+ url: z.string().nullable(),
380
+ mynth_url: z.string(),
381
+ size: z.string(),
382
+ format: z.string(),
383
+ destination: imageDestination.optional()
384
+ }) });
385
+ const destination = z.object({
386
+ id: z.string(),
387
+ name: z.string(),
388
+ provider: z.object({ id: z.string() }).catchall(jsonValue),
389
+ config: z.object({
390
+ path_template: z.string(),
391
+ url_template: z.string().optional()
392
+ }).partial(),
393
+ createdAt: z.string(),
394
+ updatedAt: z.string()
395
+ });
396
+ const webhookEvents = z.union([z.literal("all"), z.array(z.string())]);
397
+ /** The HMAC signing secret is only ever returned by the create endpoint. */
398
+ const createdWebhook = z.object({
399
+ id: z.string(),
400
+ enabled: z.boolean(),
401
+ url: z.string(),
402
+ secret: z.string(),
403
+ events: webhookEvents,
404
+ apiKeyIds: z.array(z.string()).nullish(),
405
+ oauthEnabled: z.boolean().optional(),
406
+ createdAt: z.string().optional()
407
+ });
408
+ const updatedWebhook = z.object({
409
+ id: z.string(),
410
+ enabled: z.boolean().optional(),
411
+ url: z.string(),
412
+ events: webhookEvents,
413
+ apiKeyIds: z.array(z.string()).nullish(),
414
+ oauthEnabled: z.boolean().optional()
415
+ });
416
+ //#endregion
417
+ //#region src/api/client.ts
418
+ const buildQuery = (query) => {
419
+ if (query === void 0) return "";
420
+ const params = new URLSearchParams();
421
+ for (const [key, value] of Object.entries(query)) if (value !== void 0) params.set(key, String(value));
422
+ return params.size > 0 ? `?${params}` : "";
423
+ };
424
+ const buildBody$1 = (body) => {
425
+ if (body === void 0) return { headers: {} };
426
+ if (body instanceof FormData) return {
427
+ body,
428
+ headers: {}
429
+ };
430
+ return {
431
+ body: JSON.stringify(body),
432
+ headers: { "Content-Type": "application/json" }
433
+ };
434
+ };
435
+ const readText = async (response) => {
436
+ try {
437
+ return await response.text();
438
+ } catch {
439
+ return "";
440
+ }
441
+ };
442
+ /**
443
+ * Turns a non-2xx response into an `ApiError`, preserving the server's `code`
444
+ * (UNAUTHORIZED, INSUFFICIENT_BALANCE, ...) so it can drive the exit code.
445
+ */
446
+ const toApiError = async (response, label) => {
447
+ const text = await readText(response);
448
+ let code;
449
+ let message;
450
+ try {
451
+ const parsed = JSON.parse(text);
452
+ if (typeof parsed.code === "string") code = parsed.code;
453
+ if (typeof parsed.message === "string") message = parsed.message;
454
+ else if (typeof parsed.error === "string") message = parsed.error;
455
+ } catch {}
456
+ return new ApiError(`${label} failed (${response.status}): ${message ?? text ?? "no body"}`, {
457
+ status: response.status,
458
+ ...code !== void 0 ? { code } : {}
459
+ });
460
+ };
461
+ var ApiClient = class {
462
+ baseUrl;
463
+ tokens;
464
+ constructor(config, tokens) {
465
+ this.baseUrl = config.apiUrl;
466
+ this.tokens = tokens;
467
+ }
468
+ /** Performs a request and throws `ApiError` on transport failure or non-2xx. */
469
+ async send(label, path, options = {}) {
470
+ const response = await this.attempt(path, options);
471
+ if (response.ok) return response;
472
+ throw await toApiError(response, label);
473
+ }
474
+ /** Performs a request and parses the `{ data }` envelope with `schema`. */
475
+ async fetch(label, path, schema, options = {}) {
476
+ const response = await this.send(label, path, options);
477
+ let json;
478
+ try {
479
+ json = await response.json();
480
+ } catch (cause) {
481
+ throw new ApiError(`${label} returned invalid JSON: ${cause.message}`, {
482
+ status: response.status,
483
+ cause
484
+ });
485
+ }
486
+ const parsed = envelope(schema).safeParse(json);
487
+ if (!parsed.success) throw new ApiError(`${label} returned an unexpected response shape`, {
488
+ status: response.status,
489
+ cause: parsed.error
490
+ });
491
+ return parsed.data.data;
492
+ }
493
+ /** Performs a request whose success response has no body (204). */
494
+ async call(label, path, options = {}) {
495
+ await this.send(label, path, options);
496
+ }
497
+ async attempt(path, options) {
498
+ const { body, headers } = buildBody$1(options.body);
499
+ const auth = options.auth === false ? void 0 : options.token ?? await this.tokens.token();
500
+ try {
501
+ return await fetch(`${this.baseUrl}${path}${buildQuery(options.query)}`, {
502
+ method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
503
+ headers: {
504
+ ...headers,
505
+ ...auth !== void 0 ? { Authorization: `Bearer ${auth}` } : {}
506
+ },
507
+ ...body !== void 0 ? { body } : {}
508
+ });
509
+ } catch (cause) {
510
+ throw new ApiError(`request to ${path} failed: ${cause.message}`, {
511
+ status: 0,
512
+ cause
513
+ });
514
+ }
515
+ }
516
+ };
517
+ //#endregion
518
+ //#region src/api/docs.ts
519
+ const MAX_PATH_LENGTH = 2048;
520
+ const SAFE_SEGMENT = /^[A-Za-z0-9._~%-]+$/;
521
+ /**
522
+ * Documentation paths become URL path segments on the docs host, so they are
523
+ * validated rather than escaped: no URLs, no queries, no traversal.
524
+ */
525
+ const normalizePath = (raw) => {
526
+ const path = raw.trim();
527
+ if (path.length === 0) throw new UsageError("documentation path must not be empty");
528
+ if (path.length > MAX_PATH_LENGTH) throw new UsageError("documentation path is too long");
529
+ if (path.startsWith("//") || path.includes("://")) throw new UsageError("documentation path must be a path, not a URL");
530
+ if (/[?#\\]/.test(path)) throw new UsageError("documentation path must not contain a query, fragment, or backslash");
531
+ const trimmed = path.startsWith("/") ? path.slice(1) : path;
532
+ if (trimmed.endsWith(".md")) throw new UsageError("documentation path must not include the .md suffix");
533
+ const segments = trimmed.split("/");
534
+ if (segments.some((segment) => !SAFE_SEGMENT.test(segment) || segment === "." || segment === "..")) throw new UsageError("documentation path contains an invalid segment");
535
+ return segments.join("/");
536
+ };
537
+ const truncate = (body) => body.length > 500 ? `${body.slice(0, 500)}…` : body;
538
+ const fetchText = async (url, label) => {
539
+ let response;
540
+ try {
541
+ response = await fetch(url);
542
+ } catch (cause) {
543
+ throw new ApiError(`${label} failed: ${cause.message}`, {
544
+ status: 0,
545
+ cause
546
+ });
547
+ }
548
+ const body = await response.text().catch(() => "");
549
+ if (!response.ok) throw new ApiError(`${label} failed (${response.status}): ${truncate(body) || "no body"}`, { status: response.status });
550
+ return body;
551
+ };
552
+ /** The docs site is public and served from its own host, so it bypasses ApiClient. */
553
+ var DocsClient = class {
554
+ docsUrl;
555
+ constructor(docsUrl) {
556
+ this.docsUrl = docsUrl;
557
+ }
558
+ async get(path) {
559
+ const normalized = normalizePath(path);
560
+ const encoded = normalized.split("/").map(encodeURIComponent).join("/");
561
+ return {
562
+ path: normalized,
563
+ content: await fetchText(`${this.docsUrl}/${encoded}.md`, `docs fetch for ${normalized}`)
564
+ };
565
+ }
566
+ /** llms.txt lives at the site root by convention, whatever path the pages sit under. */
567
+ list() {
568
+ return fetchText(new URL("/llms.txt", this.docsUrl).href, "docs index fetch");
569
+ }
570
+ };
571
+ //#endregion
572
+ //#region src/auth/credentials.ts
573
+ const FILE_NAME = "credentials.json";
574
+ const FILE_MODE = 384;
575
+ /**
576
+ * Credentials live in a `0600` file, the same as vercel, gcloud, npm and
577
+ * wrangler.
578
+ *
579
+ * A system keychain was tried and removed. A keychain ACL binds to a code
580
+ * signature, and there is no mynth binary to bind to — only a JS file run by
581
+ * whichever `node` is on PATH. macOS therefore prompted on every read, which is
582
+ * unanswerable for this CLI's main callers: agents, CI, containers and SSH. The
583
+ * protection was close to nil anyway, since anything able to run `node` could
584
+ * read the secret back out through the CLI itself.
585
+ */
586
+ const configDir = () => {
587
+ const xdg = process.env["XDG_CONFIG_HOME"];
588
+ return join(xdg !== void 0 && xdg.length > 0 ? xdg : join(homedir(), ".config"), "mynth");
589
+ };
590
+ const exists = async (path) => {
591
+ try {
592
+ await stat(path);
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ };
598
+ var CredentialsStore = class {
599
+ filePath;
600
+ constructor() {
601
+ this.filePath = join(configDir(), FILE_NAME);
602
+ }
603
+ async get() {
604
+ if (!await exists(this.filePath)) return void 0;
605
+ let raw;
606
+ try {
607
+ raw = await readFile(this.filePath, "utf8");
608
+ } catch (cause) {
609
+ throw new CliError(`could not read ${this.filePath}: ${cause.message}`, { cause });
610
+ }
611
+ let parsed;
612
+ try {
613
+ parsed = JSON.parse(raw);
614
+ } catch (cause) {
615
+ throw new CliError(`${this.filePath} is not valid JSON`, { cause });
616
+ }
617
+ const result = credentials.safeParse(parsed);
618
+ if (!result.success) throw new CliError(`${this.filePath} has an unexpected shape; run \`mynth auth login\``, { cause: result.error });
619
+ return result.data;
620
+ }
621
+ async set(credentials) {
622
+ try {
623
+ await mkdir(dirname(this.filePath), {
624
+ recursive: true,
625
+ mode: 448
626
+ });
627
+ const temporary = `${this.filePath}.${process.pid}.tmp`;
628
+ await writeFile(temporary, JSON.stringify(credentials), {
629
+ encoding: "utf8",
630
+ mode: FILE_MODE
631
+ });
632
+ await rename(temporary, this.filePath);
633
+ } catch (cause) {
634
+ throw new CliError(`could not write ${this.filePath}: ${cause.message}`, { cause });
635
+ }
636
+ }
637
+ async clear() {
638
+ try {
639
+ await rm(this.filePath, { force: true });
640
+ } catch (cause) {
641
+ throw new CliError(`could not delete ${this.filePath}: ${cause.message}`, { cause });
642
+ }
643
+ }
644
+ };
645
+ //#endregion
646
+ //#region src/auth/session.ts
647
+ /**
648
+ * Resolves the API key for outgoing requests: `MYNTH_API_KEY` first, then the
649
+ * stored key. Keys do not expire, so there is nothing to refresh and no expiry
650
+ * to track.
651
+ */
652
+ var Session = class {
653
+ store;
654
+ envApiKeySet;
655
+ cached;
656
+ config;
657
+ constructor(config) {
658
+ this.config = config;
659
+ this.store = new CredentialsStore();
660
+ this.envApiKeySet = config.envApiKey !== void 0;
661
+ }
662
+ async token() {
663
+ if (this.config.envApiKey !== void 0) return this.config.envApiKey;
664
+ if (this.cached !== void 0) return this.cached;
665
+ const stored = await this.read();
666
+ if (stored === void 0) throw new AuthError("not authenticated: run `mynth auth login` or set MYNTH_API_KEY");
667
+ this.cached = stored.api_key;
668
+ return this.cached;
669
+ }
670
+ async status() {
671
+ if (this.envApiKeySet) return { kind: "env" };
672
+ const stored = await this.read().catch(() => void 0);
673
+ return stored === void 0 ? { kind: "none" } : {
674
+ kind: "stored",
675
+ credentials: stored
676
+ };
677
+ }
678
+ async save(credentials) {
679
+ await this.store.set(credentials);
680
+ this.cached = void 0;
681
+ }
682
+ async clear() {
683
+ await this.store.clear();
684
+ this.cached = void 0;
685
+ }
686
+ async read() {
687
+ try {
688
+ return await this.store.get();
689
+ } catch (cause) {
690
+ throw new AuthError(`could not read stored credentials: ${cause.message}`, { cause });
691
+ }
692
+ }
693
+ };
694
+ //#endregion
695
+ //#region src/app.ts
696
+ const createApp = () => {
697
+ const config = loadConfig();
698
+ const session = new Session(config);
699
+ return {
700
+ config,
701
+ session,
702
+ api: new ApiClient(config, session),
703
+ docs: new DocsClient(config.docsUrl)
704
+ };
705
+ };
706
+ //#endregion
707
+ //#region src/output/logo.ts
708
+ /**
709
+ * The mint sprig glyph from the web app (`apps/app` nav pixel glyph), drawn with
710
+ * half-block characters so one terminal cell carries two vertically stacked
711
+ * pixels — which is what makes the pixels come out square.
712
+ *
713
+ * Digits are palette indices, `.` is transparent: unpainted cells keep the
714
+ * terminal's own background, so the glyph sits on light and dark themes alike.
715
+ */
716
+ const MINT = [
717
+ "...........",
718
+ ".....4.....",
719
+ "....343....",
720
+ ".....2.....",
721
+ ".344.2.443.",
722
+ "..4432344..",
723
+ ".....2.....",
724
+ ".233.2.332.",
725
+ "..3322233..",
726
+ ".....1.....",
727
+ "..........."
728
+ ];
729
+ /**
730
+ * Mint (oklch hue 168) at four steps, same ramp as the web palette but with the
731
+ * lightness range pulled in: the web's top shades are near-white and vanish on a
732
+ * light terminal. These keep ≥2:1 contrast against both black and white.
733
+ */
734
+ const PALETTE = {
735
+ "1": "#318267",
736
+ "2": "#00a27a",
737
+ "3": "#33bb92",
738
+ "4": "#6bd1ad"
739
+ };
740
+ const paint = (top, bottom) => {
741
+ const filled = (pixel) => pixel !== ".";
742
+ if (!filled(top) && !filled(bottom)) return " ";
743
+ if (chalk.level === 0) return filled(top) && filled(bottom) ? "█" : filled(top) ? "▀" : "▄";
744
+ if (!filled(bottom)) return chalk.hex(PALETTE[top])("▀");
745
+ if (!filled(top)) return chalk.hex(PALETTE[bottom])("▄");
746
+ if (top === bottom) return chalk.hex(PALETTE[top])("█");
747
+ return chalk.hex(PALETTE[top]).bgHex(PALETTE[bottom])("▀");
748
+ };
749
+ /** The glyph, at most 11 cells wide, one line per two grid rows. */
750
+ const logo = () => {
751
+ const lines = [];
752
+ for (let row = 0; row < MINT.length; row += 2) {
753
+ const top = MINT[row];
754
+ const bottom = MINT[row + 1] ?? "";
755
+ let line = "";
756
+ for (let column = 0; column < top.length; column += 1) line += paint(top[column], bottom[column] ?? ".");
757
+ lines.push(line.trimEnd());
758
+ }
759
+ return lines.filter((line) => line.length > 0).join("\n");
760
+ };
761
+ //#endregion
762
+ //#region src/api/api-keys.ts
763
+ /**
764
+ * Minting a key with `manage` or `keys` scope requires OAuth: the API rejects
765
+ * scope escalation from an API-key session. `token` therefore carries the
766
+ * short-lived access token from the device flow.
767
+ */
768
+ const createApiKey = (client, args) => client.fetch("api key create", "/api-key", createdApiKey, {
769
+ body: {
770
+ name: args.name,
771
+ scopes: args.scopes
772
+ },
773
+ token: args.token
774
+ });
775
+ /**
776
+ * Creates a key with the current session's credentials. An API-key session can
777
+ * only ever mint `generate` keys; the API rejects anything wider with
778
+ * SCOPE_ESCALATION and explains why.
779
+ */
780
+ const createApiKeyAsSelf = (client, args) => client.fetch("api key create", "/api-key", createdApiKey, { body: {
781
+ name: args.name,
782
+ scopes: args.scopes
783
+ } });
784
+ const listApiKeys = (client) => client.fetch("api key list", "/api-key", z.array(apiKey));
785
+ const deleteApiKey = (client, id) => client.call("api key delete", `/api-key/${id}`, { method: "DELETE" });
786
+ //#endregion
787
+ //#region src/output/table.ts
788
+ /**
789
+ * Renders left-aligned, space-padded columns. The last column is never padded,
790
+ * so trailing whitespace never ends up in piped output.
791
+ */
792
+ const printTable = (rows, columns, emptyMessage) => {
793
+ if (rows.length === 0) {
794
+ print(emptyMessage);
795
+ return;
796
+ }
797
+ const cells = rows.map((row) => columns.map((column) => column.value(row)));
798
+ const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((cell) => cell[index].length)));
799
+ const line = (values) => values.map((value, index) => index === values.length - 1 ? value : value.padEnd(widths[index])).join(" ");
800
+ print(line(columns.map((column) => column.header)));
801
+ for (const cell of cells) print(line(cell));
802
+ };
803
+ //#endregion
804
+ //#region src/commands/options.ts
805
+ const jsonOption = () => new Option("--json", "Output machine-readable JSON instead of a human-readable summary");
806
+ const yesOption = () => new Option("--yes", "Confirm the destructive action (required; there is no interactive prompt)");
807
+ //#endregion
808
+ //#region src/commands/api-key.ts
809
+ const DEFAULT_SCOPES = ["generate"];
810
+ const parseScopes = (raw) => {
811
+ if (raw === void 0) return DEFAULT_SCOPES;
812
+ const scopes = raw.split(",").map((scope) => scope.trim()).filter(Boolean);
813
+ const unknown = scopes.filter((scope) => !API_KEY_SCOPES.includes(scope));
814
+ if (scopes.length === 0 || unknown.length > 0) throw new UsageError(`invalid --scopes: ${unknown.join(", ") || "empty"}. Valid scopes: ${API_KEY_SCOPES.join(", ")}`);
815
+ return scopes;
816
+ };
817
+ const apiKeyCommand = (app) => {
818
+ const apiKey = new Command("api-key").description("Manage Mynth API keys");
819
+ apiKey.command("create").description("Create an API key. The key itself is shown once, on success.").argument("<name>", "Name for the key, e.g. the app or environment it belongs to").option("--scopes <list>", `Comma-separated scopes (default: ${DEFAULT_SCOPES.join(",")}). Widening beyond \`generate\` requires the dashboard: the API refuses scope escalation from a CLI session.`).addOption(jsonOption()).action(async (name, options) => {
820
+ const created = await createApiKeyAsSelf(app.api, {
821
+ name,
822
+ scopes: parseScopes(options.scopes)
823
+ });
824
+ if (options.json) {
825
+ printJson({
826
+ key: created.raw,
827
+ ...created.apiKey
828
+ });
829
+ return;
830
+ }
831
+ print(`${glyph.ok} Created API key "${created.apiKey.name ?? name}"`);
832
+ print(` ID: ${created.apiKey.id}`);
833
+ print(` Scopes: ${created.apiKey.scopes.join(", ")}`);
834
+ print("");
835
+ print(` ${created.raw}`);
836
+ print(" Save this now — it is shown only once and cannot be retrieved again.");
837
+ });
838
+ apiKey.command("list").description("List active API keys").addOption(jsonOption()).action(async (options) => {
839
+ const keys = await listApiKeys(app.api);
840
+ if (options.json) {
841
+ printJson(keys);
842
+ return;
843
+ }
844
+ printTable(keys, [
845
+ {
846
+ header: "ID",
847
+ value: (key) => key.id
848
+ },
849
+ {
850
+ header: "Name",
851
+ value: (key) => key.name ?? "-"
852
+ },
853
+ {
854
+ header: "Preview",
855
+ value: (key) => key.keyPreview
856
+ },
857
+ {
858
+ header: "Scopes",
859
+ value: (key) => key.scopes.join(",")
860
+ },
861
+ {
862
+ header: "Limit",
863
+ value: (key) => key.spendingLimit === null || key.spendingLimit === void 0 ? "-" : `$${key.spendingLimit}/${key.spendingLimitPeriod ?? "period"}`
864
+ },
865
+ {
866
+ header: "Created",
867
+ value: (key) => key.createdAt
868
+ }
869
+ ], "No API keys found.");
870
+ });
871
+ apiKey.command("delete").description("Revoke an API key").argument("<id>", "API key ID").addOption(yesOption()).addOption(jsonOption()).action(async (id, options) => {
872
+ if (options.yes !== true) throw new UsageError("refusing to delete without --yes");
873
+ await deleteApiKey(app.api, id);
874
+ if (options.json) {
875
+ printJson({ deleted: id });
876
+ return;
877
+ }
878
+ print(`${glyph.ok} Revoked API key ${id}`);
879
+ });
880
+ return apiKey;
881
+ };
882
+ //#endregion
883
+ //#region src/api/account.ts
884
+ const getMe = (client) => client.fetch("me", "/me", me);
885
+ const getBalance = (client) => client.fetch("balance", "/balance", balance);
886
+ //#endregion
887
+ //#region src/auth/workos.ts
888
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
889
+ const readJson = async (response) => {
890
+ try {
891
+ return await response.json();
892
+ } catch {
893
+ return {};
894
+ }
895
+ };
896
+ const post = async (path, body, label) => {
897
+ try {
898
+ return await fetch(`${WORKOS_API_URL}${path}`, {
899
+ method: "POST",
900
+ body,
901
+ headers: {
902
+ Accept: "application/json",
903
+ ...typeof body === "string" ? { "Content-Type": "application/json" } : {}
904
+ }
905
+ });
906
+ } catch (cause) {
907
+ throw new CliError(`${label} failed: ${cause.message}`, { cause });
908
+ }
909
+ };
910
+ const failure = async (response, label) => {
911
+ const body = workosErrorResponse.catch({}).parse(await readJson(response));
912
+ return new DeviceFlowError(body.error ?? body.code ?? "workos_error", body.error_description ?? body.message ?? `${label} failed`);
913
+ };
914
+ const parse = async (response, schema, label) => {
915
+ const parsed = schema.safeParse(await readJson(response));
916
+ if (!parsed.success) throw new CliError(`${label} returned an unexpected response`, { cause: parsed.error });
917
+ return parsed.data;
918
+ };
919
+ /** Step 1: ask WorkOS for a user code and verification URL. */
920
+ const requestDeviceAuthorization = async () => {
921
+ const response = await post("/user_management/authorize/device", new URLSearchParams({ client_id: WORKOS_CLIENT_ID }), "device authorization");
922
+ if (!response.ok) throw await failure(response, "device authorization");
923
+ return parse(response, deviceAuthorization, "device authorization");
924
+ };
925
+ /**
926
+ * Step 2: exchange the device code for a session. Throws a `DeviceFlowError`
927
+ * with `authorization_pending` / `slow_down` / `expired_token` /
928
+ * `access_denied`, which the login loop uses to decide whether to keep waiting.
929
+ *
930
+ * The access token is used once, to mint an API key, and never stored.
931
+ */
932
+ const exchangeDeviceCode = async (deviceCode) => {
933
+ const response = await post("/user_management/authenticate", JSON.stringify({
934
+ grant_type: DEVICE_GRANT,
935
+ client_id: WORKOS_CLIENT_ID,
936
+ device_code: deviceCode
937
+ }), "device token exchange");
938
+ if (!response.ok) throw await failure(response, "device token exchange");
939
+ return parse(response, tokenResponse, "device token exchange");
940
+ };
941
+ //#endregion
942
+ //#region src/utils/async.ts
943
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
944
+ /** Runs `fn` over `items` with at most `limit` in flight, preserving order. */
945
+ const mapLimit = async (items, limit, fn) => {
946
+ const results = Array.from({ length: items.length });
947
+ let next = 0;
948
+ const worker = async () => {
949
+ while (true) {
950
+ const index = next++;
951
+ if (index >= items.length) return;
952
+ results[index] = await fn(items[index], index);
953
+ }
954
+ };
955
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
956
+ return results;
957
+ };
958
+ //#endregion
959
+ //#region src/utils/browser.ts
960
+ /**
961
+ * Whether this looks like a person at a graphical desktop. Deliberately
962
+ * conservative: the login URL is always printed, so a false negative costs a
963
+ * copy-paste, while a false positive fires a browser at an agent, a CI runner,
964
+ * or someone's SSH session.
965
+ */
966
+ const canOpenBrowser = () => {
967
+ const env = process.env;
968
+ if (env["CI"] !== void 0) return false;
969
+ if (env["SSH_CONNECTION"] !== void 0 || env["SSH_TTY"] !== void 0) return false;
970
+ if (process.stdout.isTTY !== true) return false;
971
+ if (process.platform === "linux") {
972
+ if (env["DISPLAY"] === void 0 && env["WAYLAND_DISPLAY"] === void 0) return false;
973
+ if (existsSync("/.dockerenv")) return false;
974
+ }
975
+ return true;
976
+ };
977
+ const opener = (url) => {
978
+ switch (process.platform) {
979
+ case "darwin": return {
980
+ command: "open",
981
+ args: [url]
982
+ };
983
+ case "win32": return {
984
+ command: "cmd",
985
+ args: [
986
+ "/c",
987
+ "start",
988
+ "",
989
+ url
990
+ ]
991
+ };
992
+ default: return {
993
+ command: "xdg-open",
994
+ args: [url]
995
+ };
996
+ }
997
+ };
998
+ /**
999
+ * Opens `url` in the default browser. Resolves false if it could not be
1000
+ * launched, so the caller falls back to printing the URL.
1001
+ *
1002
+ * The URL arrives in an HTTP response, so it is parsed and scheme-checked
1003
+ * before reaching a process, and passed as an argument rather than interpolated
1004
+ * into a shell command.
1005
+ */
1006
+ const openInBrowser = async (url) => {
1007
+ try {
1008
+ const { protocol } = new URL(url);
1009
+ if (protocol !== "https:" && protocol !== "http:") return false;
1010
+ } catch {
1011
+ return false;
1012
+ }
1013
+ const { command, args } = opener(url);
1014
+ return new Promise((resolve) => {
1015
+ try {
1016
+ const child = spawn(command, [...args], {
1017
+ detached: true,
1018
+ stdio: "ignore"
1019
+ });
1020
+ child.on("error", () => resolve(false));
1021
+ child.unref();
1022
+ setTimeout(() => resolve(true), 150).unref();
1023
+ } catch {
1024
+ resolve(false);
1025
+ }
1026
+ });
1027
+ };
1028
+ //#endregion
1029
+ //#region src/commands/auth.ts
1030
+ const DASHBOARD_URL = "https://mynth.io/dashboard";
1031
+ const keyUrl = (id) => `${DASHBOARD_URL}/keys/${id}`;
1032
+ const SLOW_DOWN_STEP_MS = 5e3;
1033
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
1034
+ const keyName = () => `mynth-cli (${hostname()})`;
1035
+ /**
1036
+ * Polls WorkOS until the user approves the device code in their browser.
1037
+ * `authorization_pending` and `slow_down` are the only codes worth waiting on.
1038
+ */
1039
+ const awaitApproval = async (deviceCode, intervalMs, expiresAtMs) => {
1040
+ let interval = intervalMs;
1041
+ while (true) {
1042
+ if (Date.now() >= expiresAtMs) throw new CliError("device code expired before approval");
1043
+ try {
1044
+ return await exchangeDeviceCode(deviceCode);
1045
+ } catch (error) {
1046
+ if (!(error instanceof DeviceFlowError)) throw error;
1047
+ if (error.code === "access_denied") throw new AuthError("login was denied");
1048
+ if (error.code === "expired_token") throw new AuthError("device code expired; run `mynth auth login` again");
1049
+ if (error.code !== "authorization_pending" && error.code !== "slow_down") throw error;
1050
+ if (error.code === "slow_down") interval += SLOW_DOWN_STEP_MS;
1051
+ await sleep(interval);
1052
+ }
1053
+ }
1054
+ };
1055
+ /**
1056
+ * Logging in trades a WorkOS session for a long-lived API key and stores only
1057
+ * the key. WorkOS sessions cap out at 7 days (2 days idle) and their refresh
1058
+ * tokens rotate, which races when an agent runs commands concurrently; an API
1059
+ * key has neither problem, and can be inspected, limited and revoked from the
1060
+ * dashboard.
1061
+ */
1062
+ const login = (app) => new Command("login").description("Authenticate with Mynth and store a long-lived API key").option("--scopes <list>", `Comma-separated scopes for the created key (default: ${API_KEY_SCOPES.join(",")})`).option("--no-browser", "Print the login URL instead of opening a browser").addOption(jsonOption()).action(async (options) => {
1063
+ if (app.session.envApiKeySet) throw new AuthError("MYNTH_API_KEY is set and takes precedence over login. Unset it to log in, or keep using the env API key.");
1064
+ const scopes = options.scopes !== void 0 ? options.scopes.split(",").map((scope) => scope.trim()).filter(Boolean) : [...API_KEY_SCOPES];
1065
+ const unknown = scopes.filter((scope) => !API_KEY_SCOPES.includes(scope));
1066
+ if (scopes.length === 0 || unknown.length > 0) throw new AuthError(`invalid --scopes: ${unknown.join(", ") || "empty"}. Valid scopes: ${API_KEY_SCOPES.join(", ")}`);
1067
+ const device = await requestDeviceAuthorization();
1068
+ const url = device.verification_uri_complete ?? device.verification_uri;
1069
+ const prefilled = device.verification_uri_complete !== void 0;
1070
+ const opened = options.browser !== false && canOpenBrowser() ? await openInBrowser(url) : false;
1071
+ print("");
1072
+ print(` ${opened ? "Opened" : "Open"}: ${url}`);
1073
+ print(prefilled ? ` Check the page shows this code: ${device.user_code}` : ` Enter this code: ${device.user_code}`);
1074
+ print("");
1075
+ print("Waiting for confirmation...");
1076
+ const session = await awaitApproval(device.device_code, (device.interval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1e3, Date.now() + device.expires_in * 1e3);
1077
+ const name = keyName();
1078
+ const created = await createApiKey(app.api, {
1079
+ name,
1080
+ scopes,
1081
+ token: session.access_token
1082
+ });
1083
+ await app.session.save({
1084
+ kind: "api_key",
1085
+ api_key: created.raw,
1086
+ id: created.apiKey.id
1087
+ });
1088
+ const who = session.user?.email ?? session.user?.id ?? "unknown user";
1089
+ if (options.json) {
1090
+ printJson({
1091
+ user: session.user?.id ?? null,
1092
+ apiKey: {
1093
+ id: created.apiKey.id,
1094
+ name: created.apiKey.name ?? name,
1095
+ keyPreview: created.apiKey.keyPreview,
1096
+ scopes: created.apiKey.scopes
1097
+ },
1098
+ storedAt: app.session.store.filePath
1099
+ });
1100
+ return;
1101
+ }
1102
+ print(`${glyph.ok} Logged in as ${who}`);
1103
+ print(` Created API key "${name}" with scopes: ${created.apiKey.scopes.join(", ")}`);
1104
+ print(` Stored in ${app.session.store.filePath}`);
1105
+ print(` Set a spending limit or change scopes: ${keyUrl(created.apiKey.id)}`);
1106
+ });
1107
+ const logout = (app) => new Command("logout").description("Revoke this machine's API key and clear local credentials").action(async () => {
1108
+ const status = await app.session.status();
1109
+ const id = status.kind === "stored" ? status.credentials.id : void 0;
1110
+ let revoked = false;
1111
+ if (id !== void 0) try {
1112
+ await deleteApiKey(app.api, id);
1113
+ revoked = true;
1114
+ } catch (error) {
1115
+ printErr(`Warning: could not revoke API key ${id}: ${error.message}`);
1116
+ printErr(`Revoke it manually at ${keyUrl(id)}`);
1117
+ }
1118
+ await app.session.clear();
1119
+ print(`${glyph.ok} Local credentials cleared${revoked ? " and API key revoked" : ""}`);
1120
+ if (id === void 0 && status.kind === "stored") print(` The stored key was not created by \`auth login\`, so it was left active.`);
1121
+ if (app.session.envApiKeySet) print("Note: MYNTH_API_KEY is still set in your environment and will still be used.");
1122
+ });
1123
+ const status = (app) => new Command("status").description("Show how this machine is authenticated, without calling the API").addOption(jsonOption()).action(async (options) => {
1124
+ const current = await app.session.status();
1125
+ if (options.json) {
1126
+ printJson({
1127
+ source: current.kind,
1128
+ ...current.kind === "stored" ? {
1129
+ apiKeyId: current.credentials.id ?? null,
1130
+ path: app.session.store.filePath
1131
+ } : {}
1132
+ });
1133
+ return;
1134
+ }
1135
+ switch (current.kind) {
1136
+ case "env":
1137
+ print("Authenticated via env: MYNTH_API_KEY");
1138
+ return;
1139
+ case "none":
1140
+ print("Not authenticated. Run `mynth auth login`, or set an API key.");
1141
+ return;
1142
+ case "stored": {
1143
+ const { id } = current.credentials;
1144
+ print("Authenticated via stored API key");
1145
+ if (id !== void 0) print(` key: ${id}`);
1146
+ print(` stored: ${app.session.store.filePath}`);
1147
+ print(" Run `mynth whoami` for its current scopes and spending limit.");
1148
+ }
1149
+ }
1150
+ });
1151
+ /**
1152
+ * Verifies credentials against the API, so a revoked key fails here instead of
1153
+ * part-way through a real command.
1154
+ */
1155
+ const whoamiCommand = (app) => new Command("whoami").description("Print the active Mynth identity, verified against the API").addOption(jsonOption()).action(async (options) => {
1156
+ const current = await app.session.status();
1157
+ if (current.kind === "none") throw new AuthError("not authenticated: run `mynth auth login` or set MYNTH_API_KEY");
1158
+ const me = await getMe(app.api);
1159
+ if (options.json) {
1160
+ printJson({
1161
+ source: current.kind,
1162
+ ...me
1163
+ });
1164
+ return;
1165
+ }
1166
+ print(current.kind === "env" ? "env:MYNTH_API_KEY" : "api-key");
1167
+ print(` user: ${me.userId}`);
1168
+ print(` method: ${me.auth.method}`);
1169
+ const key = me.auth.apiKey;
1170
+ if (key === void 0) return;
1171
+ print(` key: ${key.name ?? "unnamed"} (${key.keyPreview})`);
1172
+ if (key.scopes !== void 0 && key.scopes.length > 0) print(` scopes: ${key.scopes.join(", ")}`);
1173
+ if (key.spending?.mode === "limited") print(` spend: $${key.spending.used} of $${key.spending.limit} per ${key.spending.period} ($${key.spending.remaining} left)`);
1174
+ });
1175
+ const authCommand = (app) => new Command("auth").description("Manage Mynth authentication").addCommand(login(app)).addCommand(logout(app)).addCommand(status(app)).addCommand(whoamiCommand(app));
1176
+ //#endregion
1177
+ //#region src/commands/balance.ts
1178
+ /**
1179
+ * Spending limits live on `/me` (they belong to the API key, not the account),
1180
+ * so an API-key session fetches both and prints them together.
1181
+ */
1182
+ const balanceCommand = (app) => new Command("balance").description("Show account balance, and the active API key's spending limit").addOption(jsonOption()).action(async (options) => {
1183
+ const balance = await getBalance(app.api);
1184
+ const spending = (await getMe(app.api).catch(() => void 0))?.auth.apiKey?.spending;
1185
+ if (options.json) {
1186
+ printJson({
1187
+ ...balance,
1188
+ ...spending !== void 0 ? { spending } : {}
1189
+ });
1190
+ return;
1191
+ }
1192
+ print(`Balance: $${balance.balance}`);
1193
+ print(`Reserved: $${balance.reserved}`);
1194
+ print(`Available: $${balance.available}`);
1195
+ if (spending === void 0) return;
1196
+ print("");
1197
+ if (spending.mode === "unlimited") {
1198
+ print("API key spending: unlimited");
1199
+ return;
1200
+ }
1201
+ print(`API key limit: $${spending.limit} / ${spending.period}`);
1202
+ print(` used: $${spending.used}`);
1203
+ print(` remaining: $${spending.remaining}`);
1204
+ });
1205
+ //#endregion
1206
+ //#region src/utils/files.ts
1207
+ const IMAGE_MIME_BY_EXTENSION = {
1208
+ ".jpg": "image/jpeg",
1209
+ ".jpeg": "image/jpeg",
1210
+ ".png": "image/png",
1211
+ ".webp": "image/webp"
1212
+ };
1213
+ const SUPPORTED_IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_BY_EXTENSION);
1214
+ const readStdin = async () => {
1215
+ try {
1216
+ let data = "";
1217
+ process.stdin.setEncoding("utf8");
1218
+ for await (const chunk of process.stdin) data += chunk;
1219
+ return data;
1220
+ } catch (cause) {
1221
+ throw new CliError("could not read stdin", { cause });
1222
+ }
1223
+ };
1224
+ /** Reads a file, or stdin when `path` is `-`. */
1225
+ const readTextInput = async (path) => {
1226
+ if (path === "-") return readStdin();
1227
+ try {
1228
+ return await readFile(path, "utf8");
1229
+ } catch (cause) {
1230
+ throw new UsageError(`could not read ${path}: ${cause.message}`);
1231
+ }
1232
+ };
1233
+ const readJsonInput = async (path) => {
1234
+ const contents = await readTextInput(path);
1235
+ try {
1236
+ return JSON.parse(contents);
1237
+ } catch (cause) {
1238
+ throw new UsageError(`invalid JSON in ${path}: ${cause.message}`);
1239
+ }
1240
+ };
1241
+ const readImageFile = async (path) => {
1242
+ const extension = extname(path).toLowerCase();
1243
+ const mime = IMAGE_MIME_BY_EXTENSION[extension];
1244
+ if (mime === void 0) throw new UsageError(`unsupported image extension "${extension}" for ${path} (allowed: ${SUPPORTED_IMAGE_EXTENSIONS.join(", ")})`);
1245
+ let bytes;
1246
+ try {
1247
+ bytes = await readFile(path);
1248
+ } catch (cause) {
1249
+ throw new UsageError(`could not read ${path}: ${cause.message}`);
1250
+ }
1251
+ return new File([new Uint8Array(bytes)], basename(path), { type: mime });
1252
+ };
1253
+ //#endregion
1254
+ //#region src/commands/config.ts
1255
+ const configCommand = (app) => {
1256
+ const set = new Command("set").description("Set local CLI configuration");
1257
+ set.command("api-key").description("Save an existing Mynth API key to the credentials file").argument("<value>", "API key value, or `-` to read it from stdin").action(async (value) => {
1258
+ const key = (value === "-" ? await readStdin() : value).trim();
1259
+ if (key.length === 0) throw new UsageError("API key is empty");
1260
+ await app.session.save({
1261
+ kind: "api_key",
1262
+ api_key: key
1263
+ });
1264
+ print(`${glyph.ok} API key saved to ${app.session.store.filePath}`);
1265
+ if (app.session.envApiKeySet) print("Note: MYNTH_API_KEY is also set in your environment and takes precedence.");
1266
+ });
1267
+ const unset = new Command("unset").description("Unset local CLI configuration");
1268
+ unset.command("api-key").description("Clear stored Mynth credentials").action(async () => {
1269
+ await app.session.clear();
1270
+ print(`${glyph.ok} Stored credentials cleared`);
1271
+ print("This does not revoke the key; use `mynth auth logout` for a CLI-created key.");
1272
+ });
1273
+ return new Command("config").description("Manage local CLI configuration").addCommand(set).addCommand(unset);
1274
+ };
1275
+ //#endregion
1276
+ //#region src/api/destinations.ts
1277
+ const listDestinations = (client) => client.fetch("destination list", "/destinations", z.array(destination));
1278
+ const getDestination = (client, id) => client.fetch("destination fetch", `/destinations/${id}`, destination);
1279
+ const createDestination = (client, body) => client.fetch("destination create", "/destinations", destination, { body });
1280
+ const updateDestination = (client, id, body) => client.fetch("destination update", `/destinations/${id}`, destination, {
1281
+ method: "PUT",
1282
+ body
1283
+ });
1284
+ /** Writes a probe object; a credential problem surfaces as a non-2xx response. */
1285
+ const testDestination = (client, id, path) => client.call("destination test", `/destinations/${id}/test`, { body: { path } });
1286
+ const deleteDestination = (client, id) => client.call("destination delete", `/destinations/${id}`, { method: "DELETE" });
1287
+ //#endregion
1288
+ //#region src/commands/destination.ts
1289
+ const NAME_PATTERN = /^[a-z0-9-]+$/;
1290
+ const MAX_NAME_LENGTH = 64;
1291
+ const PROVIDER_IDS = [
1292
+ "bunny",
1293
+ "r2",
1294
+ "s3"
1295
+ ];
1296
+ const BUNNY_REGIONS = [
1297
+ "de",
1298
+ "uk",
1299
+ "ny",
1300
+ "la",
1301
+ "sg",
1302
+ "se",
1303
+ "br",
1304
+ "jh",
1305
+ "syd"
1306
+ ];
1307
+ const R2_JURISDICTIONS = [
1308
+ "default",
1309
+ "eu",
1310
+ "fedramp"
1311
+ ];
1312
+ const required = (value, flag) => {
1313
+ if (value === void 0) throw new UsageError(`${flag} is required`);
1314
+ return value;
1315
+ };
1316
+ const requireFor = (provider, value, flag) => {
1317
+ if (value === void 0) throw new UsageError(`--provider ${provider} requires ${flag}`);
1318
+ return value;
1319
+ };
1320
+ const optional = (key, value) => value !== void 0 ? { [key]: value } : {};
1321
+ const buildProvider = (options, provider) => {
1322
+ switch (provider) {
1323
+ case "bunny": {
1324
+ const region = options.region;
1325
+ if (region !== void 0 && !BUNNY_REGIONS.includes(region)) throw new UsageError(`invalid bunny --region "${region}". One of: ${BUNNY_REGIONS.join(", ")}`);
1326
+ return {
1327
+ id: "bunny",
1328
+ storage_zone: requireFor(provider, options.storageZone, "--storage-zone"),
1329
+ ...optional("region", region)
1330
+ };
1331
+ }
1332
+ case "r2": return {
1333
+ id: "r2",
1334
+ account_id: requireFor(provider, options.accountId, "--account-id"),
1335
+ bucket: requireFor(provider, options.bucket, "--bucket"),
1336
+ ...optional("jurisdiction", options.jurisdiction)
1337
+ };
1338
+ case "s3": return {
1339
+ id: "s3",
1340
+ bucket: requireFor(provider, options.bucket, "--bucket"),
1341
+ region: requireFor(provider, options.region, "--region"),
1342
+ ...optional("endpoint", options.endpoint),
1343
+ ...optional("force_path_style", options.forcePathStyle)
1344
+ };
1345
+ }
1346
+ };
1347
+ /**
1348
+ * Secrets are read from a file or stdin, never from argv, so they do not leak
1349
+ * into shell history or `ps` output. Bunny's single-field secret also accepts
1350
+ * the bare password.
1351
+ */
1352
+ const readSecret = async (path, provider) => {
1353
+ const raw = (await readTextInput(path)).trim();
1354
+ if (raw.startsWith("{")) try {
1355
+ return JSON.parse(raw);
1356
+ } catch (cause) {
1357
+ throw new UsageError(`invalid JSON secret from ${path}: ${cause.message}`);
1358
+ }
1359
+ if (provider !== "bunny") throw new UsageError(`--provider ${provider} needs a JSON secret: { "access_key_id": "...", "secret_access_key": "..." }`);
1360
+ if (raw.length === 0) throw new UsageError(`secret from ${path} is empty`);
1361
+ return { password: raw };
1362
+ };
1363
+ const buildConfig = (options) => ({
1364
+ path_template: required(options.pathTemplate, "--path-template"),
1365
+ ...optional("url_template", options.urlTemplate)
1366
+ });
1367
+ /**
1368
+ * A destination body comes either wholesale from `--file` (scripting) or from
1369
+ * typed flags (interactive). Mixing the two would make precedence ambiguous.
1370
+ */
1371
+ const buildBody = async (options, extra, secretRequired) => {
1372
+ if (options.file !== void 0) {
1373
+ if (options.provider !== void 0) throw new UsageError("use either --file or the typed --provider flags, not both");
1374
+ const body = await readJsonInput(options.file);
1375
+ if (body === null || typeof body !== "object" || Array.isArray(body)) throw new UsageError(`${options.file} must contain a JSON object`);
1376
+ return {
1377
+ ...body,
1378
+ ...extra
1379
+ };
1380
+ }
1381
+ const provider = options.provider;
1382
+ if (provider === void 0) throw new UsageError("--provider or --file is required");
1383
+ const secret = options.secret !== void 0 ? await readSecret(options.secret, provider) : void 0;
1384
+ if (secret === void 0 && secretRequired) throw new UsageError("--secret <path|-> is required (use `-` to read the secret from stdin)");
1385
+ return {
1386
+ ...extra,
1387
+ provider: buildProvider(options, provider),
1388
+ config: buildConfig(options),
1389
+ ...secret !== void 0 ? { secret } : {}
1390
+ };
1391
+ };
1392
+ const addProviderOptions = (command) => command.addOption(new Option("--provider <id>", "Storage provider. Required unless --file is used.").choices([...PROVIDER_IDS])).option("--path-template <template>", "Object path template, e.g. \"images/{id}\"").option("--url-template <template>", "Public URL template; must contain {path}, e.g. \"https://cdn.example.com/{path}\"").option("--storage-zone <name>", "bunny: storage zone name").option("--region <region>", `bunny: one of ${BUNNY_REGIONS.join(", ")}. s3: the bucket's region, e.g. us-east-1.`).option("--account-id <id>", "r2: Cloudflare account ID").option("--bucket <name>", "r2/s3: bucket name").addOption(new Option("--jurisdiction <name>", "r2: data jurisdiction").choices([...R2_JURISDICTIONS])).option("--endpoint <url>", "s3: custom endpoint for S3-compatible storage").option("--force-path-style", "s3: use path-style addressing instead of virtual-hosted").option("--secret <path>", "Path to a JSON secret file, or `-` to read it from stdin. bunny also accepts a bare password.").option("--file <path>", "Full destination JSON body, or `-` for stdin. Alternative to the flags above.").addOption(jsonOption());
1393
+ const renderOne = (destination) => {
1394
+ print(`Destination ${destination.id}`);
1395
+ print(` Name: ${destination.name}`);
1396
+ print(` Provider: ${destination.provider.id}`);
1397
+ if (destination.config.path_template !== void 0) print(` Path: ${destination.config.path_template}`);
1398
+ if (destination.config.url_template !== void 0) print(` URL: ${destination.config.url_template}`);
1399
+ print(` Created: ${destination.createdAt}`);
1400
+ print(` Updated: ${destination.updatedAt}`);
1401
+ };
1402
+ const destinationCommand = (app) => {
1403
+ const destination = new Command("destination").description("Manage storage destinations that generated images are delivered to");
1404
+ destination.command("list").description("List storage destinations").addOption(jsonOption()).action(async (options) => {
1405
+ const data = await listDestinations(app.api);
1406
+ if (options.json) {
1407
+ printJson(data);
1408
+ return;
1409
+ }
1410
+ printTable(data, [
1411
+ {
1412
+ header: "ID",
1413
+ value: (item) => item.id
1414
+ },
1415
+ {
1416
+ header: "Name",
1417
+ value: (item) => item.name
1418
+ },
1419
+ {
1420
+ header: "Provider",
1421
+ value: (item) => item.provider.id
1422
+ },
1423
+ {
1424
+ header: "Created",
1425
+ value: (item) => item.createdAt
1426
+ }
1427
+ ], "No destinations found.");
1428
+ });
1429
+ destination.command("get").description("Fetch a destination by ID").argument("<id>", "Destination ID").addOption(jsonOption()).action(async (id, options) => {
1430
+ const data = await getDestination(app.api, id);
1431
+ if (options.json) {
1432
+ printJson(data);
1433
+ return;
1434
+ }
1435
+ renderOne(data);
1436
+ });
1437
+ const create = destination.command("create").description("Create a storage destination").argument("<name>", `Destination slug: 1-${MAX_NAME_LENGTH} chars, ${NAME_PATTERN.source}. Immutable.`);
1438
+ addProviderOptions(create).action(async (name, options) => {
1439
+ if (name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) throw new UsageError(`invalid destination name "${name}": expected 1-${MAX_NAME_LENGTH} chars matching ${NAME_PATTERN.source}`);
1440
+ const data = await createDestination(app.api, await buildBody(options, { name }, true));
1441
+ if (options.json) {
1442
+ printJson(data);
1443
+ return;
1444
+ }
1445
+ renderOne(data);
1446
+ print("");
1447
+ print(`Next: verify the credentials with \`mynth destination test ${data.id}\``);
1448
+ });
1449
+ const update = destination.command("update").description("Replace a destination's provider and config. The slug is immutable.").argument("<id>", "Destination ID");
1450
+ addProviderOptions(update).action(async (id, options) => {
1451
+ const body = await buildBody(options, {}, false);
1452
+ delete body["name"];
1453
+ const data = await updateDestination(app.api, id, body);
1454
+ if (options.json) {
1455
+ printJson(data);
1456
+ return;
1457
+ }
1458
+ renderOne(data);
1459
+ });
1460
+ destination.command("test").description("Verify a destination's credentials by uploading a probe file").argument("<id>", "Destination ID").option("--path <path>", "Object path to write (defaults to a unique probe path)").addOption(jsonOption()).action(async (id, options) => {
1461
+ const path = options.path ?? `mynth-cli-test/${Date.now()}.txt`;
1462
+ await testDestination(app.api, id, path);
1463
+ if (options.json) {
1464
+ printJson({
1465
+ id,
1466
+ path,
1467
+ ok: true
1468
+ });
1469
+ return;
1470
+ }
1471
+ print(`${glyph.ok} Credentials valid (wrote ${path})`);
1472
+ });
1473
+ destination.command("delete").description("Delete a destination").argument("<id>", "Destination ID").addOption(yesOption()).addOption(jsonOption()).action(async (id, options) => {
1474
+ if (options.yes !== true) throw new UsageError("refusing to delete without --yes");
1475
+ await deleteDestination(app.api, id);
1476
+ if (options.json) {
1477
+ printJson({ deleted: id });
1478
+ return;
1479
+ }
1480
+ print(`${glyph.ok} Deleted destination ${id}`);
1481
+ });
1482
+ return destination;
1483
+ };
1484
+ //#endregion
1485
+ //#region src/commands/docs.ts
1486
+ const docsCommand = (app) => {
1487
+ const docs = new Command("docs").description("Read Mynth documentation (no authentication)");
1488
+ docs.command("get").description("Fetch a documentation page as Markdown").argument("<path>", "Documentation path, without the .md suffix (e.g. concepts/tasks)").addOption(jsonOption()).action(async (path, options) => {
1489
+ const page = await app.docs.get(path);
1490
+ if (options.json) {
1491
+ printJson(page);
1492
+ return;
1493
+ }
1494
+ print(page.content);
1495
+ });
1496
+ docs.command("list").description("Fetch the complete documentation index").addOption(jsonOption()).action(async (options) => {
1497
+ const content = await app.docs.list();
1498
+ if (options.json) {
1499
+ printJson({ content });
1500
+ return;
1501
+ }
1502
+ print(content);
1503
+ });
1504
+ return docs;
1505
+ };
1506
+ //#endregion
1507
+ //#region src/output/render.ts
1508
+ /**
1509
+ * Renderers shared by the `image` and `task` commands, so `image generate` and
1510
+ * `task wait` print an identical result for the same task.
1511
+ */
1512
+ /** Labels for the 1–4 review score scale (higher is better). */
1513
+ const REVIEW_SCORE_LABELS = {
1514
+ 4: "Production-ready",
1515
+ 3: "Usable, with fixes",
1516
+ 2: "Not fit for purpose",
1517
+ 1: "Discard"
1518
+ };
1519
+ const isImageGenerateTask = (task) => task.type === "image.generate";
1520
+ /** Parses `task.result` as an image generation result, tolerating older shapes. */
1521
+ const readImageGenerateResult = (result) => {
1522
+ const parsed = imageGenerateResult.safeParse(result ?? {});
1523
+ if (!parsed.success) return { images: [] };
1524
+ return {
1525
+ ...parsed.data,
1526
+ images: parsed.data.images ?? []
1527
+ };
1528
+ };
1529
+ const imageUrl = (image) => image.status === "success" ? image.url ?? image.mynth_url ?? void 0 : void 0;
1530
+ const formatError = (error) => error.message !== void 0 ? `${error.code}: ${error.message}` : error.code;
1531
+ const renderUploads = (uploads) => {
1532
+ if (uploads.length === 0) return;
1533
+ print(`${glyph.ok} Uploaded ${plural(uploads.length, "image")}`);
1534
+ for (const upload of uploads) print(` ${upload.path} -> ${upload.url}`);
1535
+ };
1536
+ const renderDownloads = (files, directory) => {
1537
+ if (files.length === 0) return;
1538
+ print("");
1539
+ print(`${glyph.ok} Saved ${plural(files.length, "image")} to ${directory}`);
1540
+ for (const file of files) print(` ${file}`);
1541
+ };
1542
+ const renderRemoveBackground = (removal) => {
1543
+ const { image } = removal;
1544
+ print(`${glyph.ok} Removed background (task ${removal.taskId})`);
1545
+ if (removal.cost !== null) print(` Cost: ${removal.cost}`);
1546
+ print(` Size: ${image.size} ${image.format}`);
1547
+ print("");
1548
+ print(` ${glyph.ok} ${image.url ?? image.mynth_url}`);
1549
+ if (image.destination?.status === "failed") print(` ${glyph.fail} destination ${image.destination.name}: ${formatError(image.destination.error)}`);
1550
+ };
1551
+ const renderImageGenerateTask = (task) => {
1552
+ const result = readImageGenerateResult(task.result);
1553
+ const images = result.images;
1554
+ const succeeded = images.filter((image) => image.status === "success").length;
1555
+ print(`${glyph.ok} Generated ${succeeded}/${images.length} ${images.length === 1 ? "image" : "images"} (task ${task.id})`);
1556
+ if (result.model !== void 0) print(` Model: ${result.model}`);
1557
+ if (task.cost !== null) print(` Cost: ${task.cost}`);
1558
+ if (result.magic_prompt !== void 0) {
1559
+ print("");
1560
+ print("Enhanced prompt (mynth):");
1561
+ print(` ${result.magic_prompt.positive}`);
1562
+ if (result.magic_prompt.negative) print(` negative: ${result.magic_prompt.negative}`);
1563
+ }
1564
+ if (images.length === 0) return;
1565
+ print("");
1566
+ for (const image of images) {
1567
+ if (image.status === "failed") {
1568
+ print(` ${glyph.fail} ${formatError(image.error)}`);
1569
+ continue;
1570
+ }
1571
+ const rating = image.rating?.status === "success" ? ` [${image.rating.level}]` : "";
1572
+ print(` ${glyph.ok} ${imageUrl(image) ?? "(no url)"}${rating}`);
1573
+ if (image.destination?.status === "failed") print(` ${glyph.fail} destination ${image.destination.name}: ${formatError(image.destination.error)}`);
1574
+ }
1575
+ };
1576
+ /** Fallback rendering for any task type the CLI has no bespoke renderer for. */
1577
+ const renderTask = (task) => {
1578
+ print(`${glyphForStatus(task.status)} Task ${task.id}`);
1579
+ print(` Type: ${task.type}`);
1580
+ print(` Status: ${task.status}`);
1581
+ if (task.cost !== null) print(` Cost: ${task.cost}`);
1582
+ print(` Created: ${task.createdAt}`);
1583
+ print(` Updated: ${task.updatedAt}`);
1584
+ if (task.errors !== null && task.errors !== void 0 && task.errors.length > 0) {
1585
+ print("");
1586
+ print("Errors:");
1587
+ for (const error of task.errors) print(` ${glyph.fail} ${formatError(error)}`);
1588
+ }
1589
+ if (task.result !== null && task.result !== void 0) {
1590
+ print("");
1591
+ print("Result:");
1592
+ print(indent(JSON.stringify(task.result, null, 2)));
1593
+ }
1594
+ };
1595
+ /** `image generate` and `task wait` share this entry point. */
1596
+ const renderTaskResult = (task) => {
1597
+ if (isImageGenerateTask(task) && task.status === "completed") {
1598
+ renderImageGenerateTask(task);
1599
+ return;
1600
+ }
1601
+ renderTask(task);
1602
+ };
1603
+ /** Compact JSON view of a generation task; `--detailed` prints the raw task. */
1604
+ const summarizeTask = (task) => {
1605
+ const result = readImageGenerateResult(task.result);
1606
+ return {
1607
+ taskId: task.id,
1608
+ status: task.status,
1609
+ images: result.images.map((image) => image.status === "success" ? {
1610
+ status: "success",
1611
+ url: image.url ?? null,
1612
+ mynth_url: image.mynth_url ?? null,
1613
+ size: image.size,
1614
+ format: image.format,
1615
+ rating: image.rating,
1616
+ destination: image.destination
1617
+ } : {
1618
+ status: "failed",
1619
+ error: image.error
1620
+ }),
1621
+ ...result.magic_prompt !== void 0 ? { magic_prompt: result.magic_prompt } : {},
1622
+ ...task.cost !== null ? { cost: task.cost } : {},
1623
+ ...result.model !== void 0 ? { model: result.model } : {}
1624
+ };
1625
+ };
1626
+ const renderReview = (review) => {
1627
+ print(`${glyph.ok} Reviewed (task ${review.taskId})`);
1628
+ print(` Score: ${review.score}/4 — ${REVIEW_SCORE_LABELS[review.score] ?? "unknown"}`);
1629
+ if (review.cost !== null) print(` Cost: ${review.cost}`);
1630
+ print(` ${review.url}`);
1631
+ if (review.summary.length > 0) {
1632
+ print("");
1633
+ print("Summary");
1634
+ print(` ${review.summary}`);
1635
+ }
1636
+ if (review.findings.length > 0) {
1637
+ print("");
1638
+ print(`Findings (${review.findings.length})`);
1639
+ for (const item of review.findings) {
1640
+ print(` • [${item.severity}] ${item.category} — ${item.finding}`);
1641
+ print(` where: ${item.where} (${item.confidence} confidence)`);
1642
+ }
1643
+ }
1644
+ if (review.strengths.length > 0) {
1645
+ print("");
1646
+ print(`Strengths (${review.strengths.length})`);
1647
+ for (const item of review.strengths) print(` • ${item.strength} (${item.confidence} confidence)`);
1648
+ }
1649
+ };
1650
+ //#endregion
1651
+ //#region src/utils/parse.ts
1652
+ /** Commander reducer for repeatable options: `-e a -e b` -> `["a", "b"]`. */
1653
+ const collect = (value, previous = []) => [...previous, value];
1654
+ const parseInteger = (label) => (value) => {
1655
+ const parsed = Number.parseInt(value, 10);
1656
+ if (!Number.isInteger(parsed) || String(parsed) !== value) throw new UsageError(`invalid ${label}: "${value}" (expected an integer)`);
1657
+ return parsed;
1658
+ };
1659
+ const parsePositiveInteger = (label) => (value) => {
1660
+ const parsed = parseInteger(label)(value);
1661
+ if (parsed <= 0) throw new UsageError(`invalid ${label}: "${value}" (expected a positive integer)`);
1662
+ return parsed;
1663
+ };
1664
+ const parseJsonObject = (raw, origin) => {
1665
+ let parsed;
1666
+ try {
1667
+ parsed = JSON.parse(raw);
1668
+ } catch (cause) {
1669
+ throw new UsageError(`invalid JSON in ${origin}: ${cause.message}`);
1670
+ }
1671
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new UsageError(`${origin} must be a JSON object`);
1672
+ return parsed;
1673
+ };
1674
+ const isHttpUrl = (value) => /^https?:\/\//i.test(value);
1675
+ /** Uploads local files to Mynth's temporary input storage and returns their URLs. */
1676
+ const uploadImages = async (client, paths) => {
1677
+ if (paths.length === 0) throw new UsageError("no files to upload");
1678
+ if (paths.length > 10) throw new UsageError(`too many files: ${paths.length} (max 10)`);
1679
+ const form = new FormData();
1680
+ for (const file of await Promise.all(paths.map(readImageFile))) form.append("images", file);
1681
+ const { urls } = await client.fetch("upload", "/image/upload", uploadResult, { body: form });
1682
+ if (urls.length !== paths.length) throw new UsageError(`upload returned ${urls.length} URLs for ${paths.length} files`);
1683
+ return paths.map((path, index) => ({
1684
+ path,
1685
+ url: urls[index]
1686
+ }));
1687
+ };
1688
+ /** POSTs a request body to an async image endpoint and returns the created task. */
1689
+ const createImageTask = (client, endpoint, body) => client.fetch(`image ${endpoint}`, `/image/${endpoint}`, createdTask, { body });
1690
+ /** Validates the request server-side and prices it without generating anything. */
1691
+ const estimateGeneration = (client, body) => client.fetch("estimate", "/image/generate/estimate", estimate, { body });
1692
+ //#endregion
1693
+ //#region src/api/tasks.ts
1694
+ const DEFAULT_WAIT_TIMEOUT_MS = 18e5;
1695
+ const FAST_PHASE_MS = 12e3;
1696
+ const FAST_INTERVAL_MS = 2500;
1697
+ const SLOW_INTERVAL_MS = 5e3;
1698
+ const JITTER_MS = 500;
1699
+ const RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
1700
+ 0,
1701
+ 404,
1702
+ 408,
1703
+ 429
1704
+ ]);
1705
+ const isRetryable = (error) => error instanceof ApiError && (RETRYABLE_STATUSES.has(error.status) || error.status >= 500);
1706
+ const MAX_CONSECUTIVE_FAILURES = 10;
1707
+ const getTask = (client, id) => client.fetch("task fetch", `/tasks/${id}`, task);
1708
+ const listTasks = (client, query = {}) => client.fetch("task list", "/tasks", z.array(taskListItem), { query });
1709
+ /** Cache-backed; this is what polling loops hit. */
1710
+ const getTaskStatus = (client, id) => client.fetch("task status", `/tasks/${id}/status`, z.object({ status: taskStatus })).then((data) => data.status);
1711
+ /** Just the task's output — lighter than the full task record. */
1712
+ const getTaskResult = (client, id) => client.fetch("task result", `/tasks/${id}/result`, taskResult);
1713
+ /**
1714
+ * Polls until the task settles, then returns the full task. A `failed` task is
1715
+ * returned rather than thrown, so callers can render the failure and pick their
1716
+ * own exit code. Transient API failures are absorbed (see
1717
+ * `MAX_CONSECUTIVE_FAILURES`); the last one is rethrown once the budget is gone.
1718
+ */
1719
+ const waitForTask = async (client, id, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) => {
1720
+ const startedAt = Date.now();
1721
+ let failures = 0;
1722
+ while (true) {
1723
+ try {
1724
+ const status = await getTaskStatus(client, id);
1725
+ failures = 0;
1726
+ if (status !== "pending") return await getTask(client, id);
1727
+ } catch (error) {
1728
+ if (!isRetryable(error) || ++failures > MAX_CONSECUTIVE_FAILURES) throw error;
1729
+ }
1730
+ const elapsed = Date.now() - startedAt;
1731
+ if (elapsed >= timeoutMs) throw new ApiError(`task ${id} did not complete within ${Math.round(timeoutMs / 1e3)}s; it may still finish, check \`mynth task get ${id}\``, { status: 0 });
1732
+ await sleep((elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS) + Math.floor(Math.random() * JITTER_MS));
1733
+ }
1734
+ };
1735
+ //#endregion
1736
+ //#region src/output/spinner.ts
1737
+ const DEFAULT_MESSAGES = [
1738
+ "Priming the canvas",
1739
+ "Summoning pixels",
1740
+ "Mixing digital pigments",
1741
+ "Whispering to the model",
1742
+ "Dreaming up details",
1743
+ "Sketching silhouettes",
1744
+ "Arranging composition",
1745
+ "Weaving light and shadow",
1746
+ "Polishing reflections",
1747
+ "Sculpting atmosphere",
1748
+ "Tracing final strokes"
1749
+ ];
1750
+ const ROTATE_INTERVAL_MS = 2800;
1751
+ const shuffled = (items) => {
1752
+ const copy = items.slice();
1753
+ for (let i = copy.length - 1; i > 0; i--) {
1754
+ const j = Math.floor(Math.random() * (i + 1));
1755
+ [copy[i], copy[j]] = [copy[j], copy[i]];
1756
+ }
1757
+ return copy;
1758
+ };
1759
+ /**
1760
+ * Wraps a long-running promise in a spinner on stderr. A no-op when stderr is
1761
+ * not a TTY, so piped and `--json` output stays clean.
1762
+ */
1763
+ const withSpinner = async (promise, options = {}) => {
1764
+ if (process.stderr.isTTY !== true) return promise;
1765
+ const messages = shuffled(options.messages ?? DEFAULT_MESSAGES);
1766
+ let index = 0;
1767
+ const spinner = ora({
1768
+ text: messages[0] ?? "Working",
1769
+ stream: process.stderr
1770
+ }).start();
1771
+ const rotate = setInterval(() => {
1772
+ index++;
1773
+ spinner.text = messages[index % messages.length] ?? "Working";
1774
+ }, ROTATE_INTERVAL_MS);
1775
+ try {
1776
+ return await promise;
1777
+ } finally {
1778
+ clearInterval(rotate);
1779
+ spinner.stop();
1780
+ }
1781
+ };
1782
+ /**
1783
+ * Commands take an image as either a URL or a local path. A local path is
1784
+ * uploaded first; the uploads are returned so they can be reported.
1785
+ */
1786
+ const resolveImage = async (app, input) => {
1787
+ if (isHttpUrl(input)) return {
1788
+ url: input,
1789
+ uploads: []
1790
+ };
1791
+ const uploads = await uploadImages(app.api, [input]);
1792
+ return {
1793
+ url: uploads[0].url,
1794
+ uploads
1795
+ };
1796
+ };
1797
+ /** Destination and webhook flags shared by commands that create a deliverable image. */
1798
+ const addDeliveryOptions = (command) => command.option("--destination <name>", "Slug of a configured storage destination to deliver results to. Defaults to MYNTH_DESTINATION.").option("--webhook-url <url>", "Deliver this task's events to this URL (repeatable, max 5)", collect).option("--no-dashboard-webhooks", "Skip dashboard-configured webhooks for this task");
1799
+ /** Builds the `destination` and `webhook` request fields from the delivery flags. */
1800
+ const buildDelivery = (app, options) => {
1801
+ const destination = options.destination ?? app.config.envDestination;
1802
+ const custom = options.webhookUrl ?? [];
1803
+ const disableDashboard = options.dashboardWebhooks === false;
1804
+ const webhook = custom.length === 0 && !disableDashboard ? void 0 : {
1805
+ ...disableDashboard ? { dashboard: false } : {},
1806
+ ...custom.length > 0 ? { custom: custom.map((url) => ({ url })) } : {}
1807
+ };
1808
+ return {
1809
+ ...destination !== void 0 ? { destination } : {},
1810
+ ...webhook !== void 0 ? { webhook } : {}
1811
+ };
1812
+ };
1813
+ /**
1814
+ * Creates a task without waiting and prints its ID. A public access token lets
1815
+ * browser or CI code poll the task without the API key, so it is requested here.
1816
+ */
1817
+ const createTaskAsync = async (app, args) => {
1818
+ const created = await createImageTask(app.api, args.endpoint, {
1819
+ ...args.body,
1820
+ access: { pat: { enabled: true } }
1821
+ });
1822
+ const token = created.access?.publicAccessToken;
1823
+ if (args.json) {
1824
+ printJson({
1825
+ taskId: created.taskId,
1826
+ ...created.estimatedCost !== void 0 ? { estimatedCost: created.estimatedCost } : {},
1827
+ ...token !== void 0 ? { access: { publicAccessToken: token } } : {}
1828
+ });
1829
+ return;
1830
+ }
1831
+ print(`${glyph.ok} Task created: ${created.taskId}`);
1832
+ if (token !== void 0) print(` Public access token: ${token}`);
1833
+ print(` Await it with: mynth task wait ${created.taskId}`);
1834
+ };
1835
+ const levelArray = z.array(z.object({
1836
+ value: z.string(),
1837
+ description: z.string()
1838
+ }));
1839
+ const parseLevelPair = (raw) => {
1840
+ const separator = raw.indexOf("=");
1841
+ if (separator <= 0) throw new UsageError(`invalid --level "${raw}": expected "value=description"`);
1842
+ const level = {
1843
+ value: raw.slice(0, separator).trim(),
1844
+ description: raw.slice(separator + 1).trim()
1845
+ };
1846
+ if (level.value.length === 0 || level.description.length === 0) throw new UsageError(`invalid --level "${raw}": value and description must both be non-empty`);
1847
+ return level;
1848
+ };
1849
+ const parseLevelsJson = (source, origin) => {
1850
+ let parsed;
1851
+ try {
1852
+ parsed = JSON.parse(source);
1853
+ } catch (cause) {
1854
+ throw new UsageError(`invalid JSON in ${origin}: ${cause.message}`);
1855
+ }
1856
+ const result = levelArray.safeParse(parsed);
1857
+ if (!result.success) throw new UsageError(`invalid levels in ${origin}: expected an array of { value, description }`);
1858
+ return result.data;
1859
+ };
1860
+ /**
1861
+ * Custom rating levels can come from repeated `--level value=description`, a
1862
+ * JSON file, or inline JSON — exactly one source at a time.
1863
+ */
1864
+ const resolveLevels = async (options) => {
1865
+ const pairs = options.level ?? [];
1866
+ const sources = [
1867
+ pairs.length > 0 ? "--level" : void 0,
1868
+ options.levelsFile !== void 0 ? "--levels-file" : void 0,
1869
+ options.levelsJson !== void 0 ? "--levels-json" : void 0
1870
+ ].filter((source) => source !== void 0);
1871
+ if (sources.length === 0) return void 0;
1872
+ if (sources.length > 1) throw new UsageError(`conflicting level options: ${sources.join(", ")} — use only one`);
1873
+ const levels = pairs.length > 0 ? pairs.map(parseLevelPair) : options.levelsFile !== void 0 ? parseLevelsJson(await readTextInput(options.levelsFile), options.levelsFile) : parseLevelsJson(options.levelsJson ?? "[]", "--levels-json");
1874
+ if (levels.length < 2 || levels.length > 7) throw new UsageError(`levels must have between 2 and 7 items (got ${levels.length})`);
1875
+ const seen = /* @__PURE__ */ new Set();
1876
+ for (const level of levels) {
1877
+ if (seen.has(level.value)) throw new UsageError(`duplicate level value: "${level.value}"`);
1878
+ seen.add(level.value);
1879
+ }
1880
+ return levels;
1881
+ };
1882
+ const failedTaskError = (task, label) => {
1883
+ const code = taskFailureCode(task);
1884
+ return new ApiError(`${label} task ${task.id} failed${code !== void 0 ? ` (${code})` : ""}`, {
1885
+ status: 0,
1886
+ ...code !== void 0 ? { code } : {}
1887
+ });
1888
+ };
1889
+ /**
1890
+ * Creates a single-image task, waits for it, and validates its result. The
1891
+ * commands only differ by endpoint and result shape.
1892
+ */
1893
+ const runImageTask = async (app, args) => {
1894
+ const created = await createImageTask(app.api, args.endpoint, args.body);
1895
+ const pending = waitForTask(app.api, created.taskId);
1896
+ const task = args.quiet ? await pending : await withSpinner(pending);
1897
+ if (task.status !== "completed") throw failedTaskError(task, args.endpoint);
1898
+ const parsed = args.schema.safeParse(task.result);
1899
+ if (!parsed.success) throw new ApiError(`${args.endpoint} task ${task.id} returned an unexpected result`, {
1900
+ status: 0,
1901
+ cause: parsed.error
1902
+ });
1903
+ return {
1904
+ taskId: task.id,
1905
+ cost: task.cost,
1906
+ result: parsed.data
1907
+ };
1908
+ };
1909
+ //#endregion
1910
+ //#region src/commands/image/analyze.ts
1911
+ const REVIEW_EFFORTS = ["low", "high"];
1912
+ const IMAGE_ARGUMENT_HELP = "Image URL (http/https), or a local image file to upload first";
1913
+ const addLevelOptions = (command) => command.option("-l, --level <value>", `Custom rating level as "value=description" (repeatable, 2-7 items). Example: -l safe="No explicit content" -l nsfw="Contains nudity"`, collect).option("--levels-file <path>", "JSON file holding an array of { \"value\", \"description\" }, or `-` for stdin. Use when descriptions contain shell metacharacters.").option("--levels-json <json>", "Inline JSON array of { value, description }.");
1914
+ const rateCommand = (app) => {
1915
+ const rate = new Command("rate").description("Classify an image against the default sfw/nsfw levels, or custom ones").argument("<image>", IMAGE_ARGUMENT_HELP).addOption(jsonOption());
1916
+ addLevelOptions(rate).action(async (input, options) => {
1917
+ const levels = await resolveLevels(options);
1918
+ const { url, uploads } = await resolveImage(app, input);
1919
+ const { taskId, cost, result } = await runImageTask(app, {
1920
+ endpoint: "rate",
1921
+ body: levels !== void 0 ? {
1922
+ url,
1923
+ mode: "custom",
1924
+ levels
1925
+ } : { url },
1926
+ schema: imageRateResult,
1927
+ quiet: options.json === true
1928
+ });
1929
+ if (options.json) {
1930
+ printJson({
1931
+ taskId,
1932
+ cost,
1933
+ ...result
1934
+ });
1935
+ return;
1936
+ }
1937
+ renderUploads(uploads);
1938
+ print(`${glyph.ok} Rated (task ${taskId})`);
1939
+ print(` ${result.level} ${url}`);
1940
+ });
1941
+ return rate;
1942
+ };
1943
+ const altCommand = (app) => new Command("alt").description("Generate accessibility alt text for an image").argument("<image>", IMAGE_ARGUMENT_HELP).addOption(jsonOption()).action(async (input, options) => {
1944
+ const { url, uploads } = await resolveImage(app, input);
1945
+ const { taskId, cost, result } = await runImageTask(app, {
1946
+ endpoint: "alt",
1947
+ body: { url },
1948
+ schema: imageAltResult,
1949
+ quiet: options.json === true
1950
+ });
1951
+ if (options.json) {
1952
+ printJson({
1953
+ taskId,
1954
+ cost,
1955
+ ...result
1956
+ });
1957
+ return;
1958
+ }
1959
+ renderUploads(uploads);
1960
+ print(`${glyph.ok} Generated alt text (task ${taskId})`);
1961
+ print(` ${result.alt}`);
1962
+ print(` ${url}`);
1963
+ });
1964
+ const reviewCommand = (app) => new Command("review").description("Review image quality with a multi-model panel (score, findings, strengths)").argument("<image>", IMAGE_ARGUMENT_HELP).addOption(new Option("--effort <level>", "\"high\" (default) runs five strong vision models; \"low\" runs three smaller ones for faster, cheaper triage").choices([...REVIEW_EFFORTS])).addOption(jsonOption()).action(async (input, options) => {
1965
+ const { url, uploads } = await resolveImage(app, input);
1966
+ const { taskId, cost, result } = await runImageTask(app, {
1967
+ endpoint: "review",
1968
+ body: {
1969
+ url,
1970
+ ...options.effort !== void 0 ? { effort: options.effort } : {}
1971
+ },
1972
+ schema: imageReviewResult,
1973
+ quiet: options.json === true
1974
+ });
1975
+ if (options.json) {
1976
+ printJson({
1977
+ taskId,
1978
+ cost,
1979
+ ...result
1980
+ });
1981
+ return;
1982
+ }
1983
+ renderUploads(uploads);
1984
+ renderReview({
1985
+ taskId,
1986
+ cost,
1987
+ url,
1988
+ ...result,
1989
+ findings: result.findings ?? [],
1990
+ strengths: result.strengths ?? []
1991
+ });
1992
+ });
1993
+ const analysisCommands = (app) => [
1994
+ rateCommand(app),
1995
+ altCommand(app),
1996
+ reviewCommand(app)
1997
+ ];
1998
+ //#endregion
1999
+ //#region src/utils/download.ts
2000
+ const CONCURRENCY = 4;
2001
+ const filenameFor = (url, fallbackPrefix, index) => {
2002
+ try {
2003
+ const last = new URL(url).pathname.split("/").filter(Boolean).pop();
2004
+ if (last !== void 0 && last.length > 0) return decodeURIComponent(last);
2005
+ } catch {}
2006
+ return `${fallbackPrefix}-${index}`;
2007
+ };
2008
+ /** Downloads URLs into `directory`, creating it if needed. Returns file paths. */
2009
+ const downloadAll = async (args) => {
2010
+ const directory = resolve(args.directory);
2011
+ try {
2012
+ await mkdir(directory, { recursive: true });
2013
+ } catch (cause) {
2014
+ throw new CliError(`could not create ${directory}: ${cause.message}`, { cause });
2015
+ }
2016
+ return mapLimit(args.urls, CONCURRENCY, async (url, index) => {
2017
+ let response;
2018
+ try {
2019
+ response = await fetch(url);
2020
+ } catch (cause) {
2021
+ throw new CliError(`download failed for ${url}: ${cause.message}`, { cause });
2022
+ }
2023
+ if (!response.ok) throw new CliError(`download failed for ${url} (${response.status})`);
2024
+ const path = join(directory, filenameFor(url, args.fallbackPrefix, index));
2025
+ try {
2026
+ await writeFile(path, new Uint8Array(await response.arrayBuffer()));
2027
+ } catch (cause) {
2028
+ throw new CliError(`could not write ${path}: ${cause.message}`, { cause });
2029
+ }
2030
+ return path;
2031
+ });
2032
+ };
2033
+ //#endregion
2034
+ //#region src/commands/image/generate.ts
2035
+ const MAX_INPUTS = 20;
2036
+ const OUTPUT_FORMATS$1 = [
2037
+ "png",
2038
+ "jpg",
2039
+ "webp"
2040
+ ];
2041
+ const INPUT_ROLES = [
2042
+ "auto",
2043
+ "source",
2044
+ "reference"
2045
+ ];
2046
+ /**
2047
+ * Stands in for local files during `--dry-run`: the estimate only depends on
2048
+ * input count and roles, so nothing is uploaded.
2049
+ */
2050
+ const DRY_RUN_INPUT_URL = "https://dry-run.mynth.io/input";
2051
+ /** Parses `[role:]path-or-url`. A bare `https://...` keeps its colon. */
2052
+ const parseInput = (raw) => {
2053
+ const separator = raw.indexOf(":");
2054
+ let role;
2055
+ let value = raw;
2056
+ if (separator > 0 && !/^https?:/i.test(raw)) {
2057
+ const candidate = raw.slice(0, separator);
2058
+ if (!INPUT_ROLES.includes(candidate)) throw new UsageError(`invalid --input role "${candidate}". Expected one of: ${INPUT_ROLES.join(", ")}`);
2059
+ role = candidate;
2060
+ value = raw.slice(separator + 1);
2061
+ }
2062
+ if (value.length === 0) throw new UsageError(`invalid --input "${raw}": missing path or URL`);
2063
+ return {
2064
+ ...role !== void 0 ? { role } : {},
2065
+ value,
2066
+ isLocalFile: !isHttpUrl(value)
2067
+ };
2068
+ };
2069
+ const buildRequest = async (app, options, inputs, uploadedByPath) => {
2070
+ const levels = await resolveLevels(options);
2071
+ const rating = levels !== void 0 ? {
2072
+ mode: "custom",
2073
+ levels
2074
+ } : options.contentRating === true ? true : void 0;
2075
+ const resolvedInputs = inputs.map((input) => ({
2076
+ type: "image",
2077
+ ...input.role !== void 0 ? { as: input.role } : {},
2078
+ source: {
2079
+ type: "url",
2080
+ url: input.isLocalFile ? uploadedByPath.get(input.value) ?? DRY_RUN_INPUT_URL : input.value
2081
+ }
2082
+ }));
2083
+ return {
2084
+ prompt: options.prompt,
2085
+ ...options.model !== void 0 ? { model: options.model } : {},
2086
+ ...options.negative !== void 0 ? { negative_prompt: options.negative } : {},
2087
+ ...options.magicPrompt === true ? { magic_prompt: true } : {},
2088
+ ...options.size !== void 0 ? { size: options.size } : {},
2089
+ ...options.count !== void 0 ? { count: options.count } : {},
2090
+ ...options.format !== void 0 ? { output: { format: options.format } } : {},
2091
+ ...resolvedInputs.length > 0 ? { inputs: resolvedInputs } : {},
2092
+ ...buildDelivery(app, options),
2093
+ ...rating !== void 0 ? { rating } : {},
2094
+ ...options.metadata !== void 0 ? { metadata: parseJsonObject(options.metadata, "--metadata") } : {}
2095
+ };
2096
+ };
2097
+ const downloadImages = (task, directory) => {
2098
+ const urls = readImageGenerateResult(task.result).images.map(imageUrl).filter((url) => url !== void 0);
2099
+ return urls.length === 0 ? Promise.resolve([]) : downloadAll({
2100
+ urls,
2101
+ directory,
2102
+ fallbackPrefix: task.id
2103
+ });
2104
+ };
2105
+ const generateCommand = (app) => {
2106
+ const generate = new Command("generate").description("Generate images with Mynth").addHelpText("after", "\nBrowse models with: mynth models list").requiredOption("-p, --prompt <text>", "Text prompt describing the image to generate").option("-n, --negative <text>", "Negative prompt: elements to exclude").option("--magic-prompt", "Let Mynth expand the prompt before generating").option("-m, --model <id>", "Model ID (e.g. \"black-forest-labs/flux.2-pro\"). Default: auto").option("-s, --size <size>", "Size preset or aspect ratio: \"square\", \"portrait\", \"landscape\", \"1:1\", \"16:9\", \"16:9_4k\", \"auto\", ...").option("-c, --count <number>", "Number of images to generate (default: 1)", parseInteger("--count")).addOption(new Option("-f, --format <format>", "Output image format").choices([...OUTPUT_FORMATS$1])).option("-i, --input <value>", `Input image as "[role:]path-or-url" (repeatable, up to ${MAX_INPUTS}). Roles: ${INPUT_ROLES.join(", ")}. Examples: -i ./img.jpg, -i source:https://example.com/a.png`, collect).option("-o, --output-dir <dir>", "Directory to save generated images into. Created if missing. Ignored with --async.").option("--metadata <json>", "Inline JSON object attached to the task (max 2KB)").option("--content-rating", "Classify each image with the default sfw/nsfw levels. For custom levels use --level.").option("-l, --level <value>", "Custom rating level as \"value=description\" (repeatable, 2-7)", collect).option("--levels-file <path>", "JSON file of custom rating levels, or `-` for stdin").option("--levels-json <json>", "Inline JSON array of custom rating levels");
2107
+ addDeliveryOptions(generate).option("--dry-run", "Validate the request and print the estimated cost without generating").option("--async", "Print the task ID immediately instead of waiting for the result").option("--detailed", "Include the full task record in --json output").addOption(jsonOption());
2108
+ generate.action(async (options) => {
2109
+ const rawInputs = options.input ?? [];
2110
+ if (rawInputs.length > MAX_INPUTS) throw new UsageError(`too many --input values: ${rawInputs.length} (max ${MAX_INPUTS})`);
2111
+ const inputs = rawInputs.map(parseInput);
2112
+ const localPaths = [...new Set(inputs.filter((input) => input.isLocalFile).map((input) => input.value))];
2113
+ const uploads = localPaths.length > 0 && options.dryRun !== true ? await uploadImages(app.api, localPaths) : [];
2114
+ const request = await buildRequest(app, options, inputs, new Map(uploads.map((upload) => [upload.path, upload.url])));
2115
+ if (options.dryRun === true) {
2116
+ const estimate = await estimateGeneration(app.api, request);
2117
+ if (options.json) {
2118
+ printJson(estimate);
2119
+ return;
2120
+ }
2121
+ const qualifier = estimate.estimateKind === "upper_bound" ? " (upper bound)" : "";
2122
+ print(`${glyph.ok} Estimated cost: $${estimate.estimatedCost}${qualifier}`);
2123
+ return;
2124
+ }
2125
+ if (options.async === true) {
2126
+ await createTaskAsync(app, {
2127
+ endpoint: "generate",
2128
+ body: request,
2129
+ json: options.json === true
2130
+ });
2131
+ return;
2132
+ }
2133
+ const created = await createImageTask(app.api, "generate", request);
2134
+ const pending = waitForTask(app.api, created.taskId);
2135
+ const task = options.json ? await pending : await withSpinner(pending);
2136
+ const outputDir = options.outputDir !== void 0 ? resolve(options.outputDir) : void 0;
2137
+ const downloaded = outputDir !== void 0 ? await downloadImages(task, outputDir) : [];
2138
+ if (options.json) {
2139
+ const base = options.detailed === true ? task : summarizeTask(task);
2140
+ printJson(outputDir !== void 0 ? {
2141
+ ...base,
2142
+ downloadedFiles: downloaded
2143
+ } : base);
2144
+ return;
2145
+ }
2146
+ renderUploads(uploads);
2147
+ renderTaskResult(task);
2148
+ if (outputDir !== void 0) renderDownloads(downloaded, outputDir);
2149
+ });
2150
+ return generate;
2151
+ };
2152
+ //#endregion
2153
+ //#region src/commands/image/remove-background.ts
2154
+ const OUTPUT_FORMATS = ["png", "webp"];
2155
+ const removeBackgroundCommand = (app) => {
2156
+ const command = new Command("remove-background").description("Remove the background from an image. Mynth picks the model.").argument("<image>", "Image URL (http/https), or a local image file to upload first").addOption(new Option("-f, --format <format>", "Output format. Default: whatever the provider returns").choices([...OUTPUT_FORMATS])).option("-o, --output-dir <dir>", "Directory to save the result into. Created if missing. Ignored with --async.").option("--metadata <json>", "Inline JSON object attached to the task (max 2KB)");
2157
+ addDeliveryOptions(command).option("--async", "Print the task ID immediately instead of waiting for the result").addOption(jsonOption());
2158
+ command.action(async (input, options) => {
2159
+ const { url, uploads } = await resolveImage(app, input);
2160
+ const body = {
2161
+ url,
2162
+ ...options.format !== void 0 ? { output: { format: options.format } } : {},
2163
+ ...buildDelivery(app, options),
2164
+ ...options.metadata !== void 0 ? { metadata: parseJsonObject(options.metadata, "--metadata") } : {}
2165
+ };
2166
+ if (options.async === true) {
2167
+ await createTaskAsync(app, {
2168
+ endpoint: "remove-background",
2169
+ body,
2170
+ json: options.json === true
2171
+ });
2172
+ return;
2173
+ }
2174
+ const { taskId, cost, result } = await runImageTask(app, {
2175
+ endpoint: "remove-background",
2176
+ body,
2177
+ schema: imageRemoveBackgroundResult,
2178
+ quiet: options.json === true
2179
+ });
2180
+ const outputDir = options.outputDir !== void 0 ? resolve(options.outputDir) : void 0;
2181
+ const downloaded = outputDir !== void 0 ? await downloadAll({
2182
+ urls: [result.image.url ?? result.image.mynth_url],
2183
+ directory: outputDir,
2184
+ fallbackPrefix: taskId
2185
+ }) : [];
2186
+ if (options.json) {
2187
+ printJson({
2188
+ taskId,
2189
+ cost,
2190
+ ...result,
2191
+ ...outputDir !== void 0 ? { downloadedFiles: downloaded } : {}
2192
+ });
2193
+ return;
2194
+ }
2195
+ renderUploads(uploads);
2196
+ renderRemoveBackground({
2197
+ taskId,
2198
+ cost,
2199
+ ...result
2200
+ });
2201
+ if (outputDir !== void 0) renderDownloads(downloaded, outputDir);
2202
+ });
2203
+ return command;
2204
+ };
2205
+ //#endregion
2206
+ //#region src/commands/image/upload.ts
2207
+ const uploadCommand = (app) => new Command("upload").description("Upload local images to Mynth's temporary input storage").argument("<files...>", `Local image files (${SUPPORTED_IMAGE_EXTENSIONS.join(", ")})`).addOption(jsonOption()).action(async (files, options) => {
2208
+ if (files.length > 10) throw new UsageError(`too many files: ${files.length} (max 10)`);
2209
+ const uploaded = await uploadImages(app.api, files);
2210
+ if (options.json) {
2211
+ printJson({ images: uploaded });
2212
+ return;
2213
+ }
2214
+ print(`${glyph.ok} Uploaded ${plural(uploaded.length, "image")}`);
2215
+ for (const { path, url } of uploaded) {
2216
+ print(` ${path}`);
2217
+ print(` -> ${url}`);
2218
+ }
2219
+ });
2220
+ //#endregion
2221
+ //#region src/commands/image/index.ts
2222
+ const imageCommand = (app) => {
2223
+ const image = new Command("image").description("Generate, upload, analyze, and remove backgrounds from images");
2224
+ image.addCommand(generateCommand(app));
2225
+ image.addCommand(uploadCommand(app));
2226
+ image.addCommand(removeBackgroundCommand(app));
2227
+ for (const command of analysisCommands(app)) image.addCommand(command);
2228
+ return image;
2229
+ };
2230
+ //#endregion
2231
+ //#region src/api/models.ts
2232
+ /** The model catalog is public — no credentials required. */
2233
+ const listModels = (client) => client.fetch("models list", "/models", z.array(model), { auth: false });
2234
+ //#endregion
2235
+ //#region src/commands/models.ts
2236
+ /** The catalog names modes `txt->img`; the flag spells them the way a shell can. */
2237
+ const MODE_BY_CAPABILITY = {
2238
+ txt2img: "txt->img",
2239
+ img2img: "img->img",
2240
+ txt2vid: "txt->vid",
2241
+ img2vid: "img->vid"
2242
+ };
2243
+ /** Model IDs are `org/name`, so the org is the id prefix — the API has no separate field. */
2244
+ const orgOf = (model) => model.id.split("/")[0];
2245
+ const isImagePricing = (pricing) => "perImage" in pricing;
2246
+ /** Image models are priced per image, video models per second of output. */
2247
+ const rateUnit = (model) => model.type === "video" ? "/s" : "";
2248
+ const rates = (model) => {
2249
+ const pricing = model.pricing;
2250
+ if (pricing === null) return [];
2251
+ return isImagePricing(pricing) ? [pricing.perImage.base] : Object.values(pricing.perSecond);
2252
+ };
2253
+ /**
2254
+ * The cheapest rate a model charges, so one price column and one pair of price
2255
+ * filters span both media types. The raw string is kept for display, because it
2256
+ * carries the precision the catalog published.
2257
+ */
2258
+ const cheapestRate = (model) => {
2259
+ let best;
2260
+ for (const raw of rates(model)) {
2261
+ const value = Number.parseFloat(raw);
2262
+ if (Number.isFinite(value) && (best === void 0 || value < best.value)) best = {
2263
+ raw,
2264
+ value
2265
+ };
2266
+ }
2267
+ return best;
2268
+ };
2269
+ const basePrice = (model) => cheapestRate(model)?.value;
2270
+ const priceLabel = (model) => {
2271
+ const rate = cheapestRate(model);
2272
+ return rate === void 0 ? "-" : `${rate.raw}${rateUnit(model)}`;
2273
+ };
2274
+ const price4k = (model) => {
2275
+ const pricing = model.pricing;
2276
+ if (pricing === null) return void 0;
2277
+ return isImagePricing(pricing) ? pricing.perImage["4k"] : pricing.perSecond["4k"];
2278
+ };
2279
+ const price4kLabel = (model) => {
2280
+ const raw = price4k(model);
2281
+ return raw === void 0 ? "-" : `${raw}${rateUnit(model)}`;
2282
+ };
2283
+ const inputFee = (model) => {
2284
+ const pricing = model.pricing;
2285
+ if (pricing === null || !isImagePricing(pricing)) return void 0;
2286
+ return pricing.perInput;
2287
+ };
2288
+ const parsePrice = (label) => (value) => {
2289
+ const parsed = Number.parseFloat(value);
2290
+ if (!Number.isFinite(parsed) || parsed < 0) throw new UsageError(`invalid ${label}: "${value}" (expected a non-negative number)`);
2291
+ return parsed;
2292
+ };
2293
+ /**
2294
+ * Fuzzy rather than substring so `bfl flux`, `qwen3`, or `gemini flash` all land
2295
+ * on the right model. Matching runs over the id and the display name; the id
2296
+ * carries the org, so one query covers both name and vendor.
2297
+ *
2298
+ * fuzzysort only scores targets the query is a subsequence of, so nonsense
2299
+ * matches nothing regardless of threshold. 0.3 exists solely to keep typos
2300
+ * (`sedream` scores 0.36) while intentional queries score 0.7-0.95.
2301
+ */
2302
+ const SEARCH_THRESHOLD = .3;
2303
+ const search = (models, query) => fuzzysort.go(query, models, {
2304
+ keys: ["id", "displayName"],
2305
+ threshold: SEARCH_THRESHOLD
2306
+ }).map((result) => result.obj);
2307
+ /** `--org bfl` should find black-forest-labs, so the org is fuzzy-matched too. */
2308
+ const matchOrgs = (models, query) => {
2309
+ const orgs = [...new Set(models.map(orgOf))];
2310
+ if (orgs.includes(query)) return /* @__PURE__ */ new Set([query]);
2311
+ return new Set(fuzzysort.go(query, orgs, { threshold: SEARCH_THRESHOLD }).map((r) => r.target));
2312
+ };
2313
+ const applyFilters = (models, flags) => {
2314
+ let result = models;
2315
+ if (flags.org !== void 0) {
2316
+ const orgs = matchOrgs(result, flags.org);
2317
+ result = result.filter((model) => orgs.has(orgOf(model)));
2318
+ }
2319
+ if (flags.type !== void 0) {
2320
+ const type = flags.type;
2321
+ result = result.filter((model) => model.type === type);
2322
+ }
2323
+ if (flags["4k"]) result = result.filter((model) => price4k(model) !== void 0);
2324
+ if (flags.capability !== void 0) {
2325
+ const mode = MODE_BY_CAPABILITY[flags.capability];
2326
+ result = result.filter((model) => mode in model.modes);
2327
+ }
2328
+ if (flags.maxPrice !== void 0) {
2329
+ const max = flags.maxPrice;
2330
+ result = result.filter((model) => (basePrice(model) ?? Infinity) <= max);
2331
+ }
2332
+ if (flags.minPrice !== void 0) {
2333
+ const min = flags.minPrice;
2334
+ result = result.filter((model) => (basePrice(model) ?? -Infinity) >= min);
2335
+ }
2336
+ if (flags.search !== void 0) result = search(result, flags.search);
2337
+ return result;
2338
+ };
2339
+ const modelsCommand = (app) => {
2340
+ const models = new Command("models").description("Browse the public Mynth model catalog");
2341
+ models.command("list").description("List available generation models and their pricing").option("-s, --search <query>", "Fuzzy match against model ID (which includes the org) and name").option("--org <org>", "Only models from this org, fuzzy matched (e.g. bfl, google)").option("--type <type>", "Only models of this media type (image, video)").option("--max-price <usd>", "Only models at or below this price (per image, or per second for video)", parsePrice("--max-price")).option("--min-price <usd>", "Only models at or above this price (per image, or per second for video)", parsePrice("--min-price")).option("--4k", "Only models with 4K pricing").addOption(new Option("--capability <capability>", "Only models serving this generation mode").choices(Object.keys(MODE_BY_CAPABILITY))).addOption(jsonOption()).action(async (options) => {
2342
+ const data = applyFilters(await listModels(app.api), options);
2343
+ if (options.json) {
2344
+ printJson(data);
2345
+ return;
2346
+ }
2347
+ printTable(data, [
2348
+ {
2349
+ header: "ID",
2350
+ value: (model) => model.id
2351
+ },
2352
+ {
2353
+ header: "Name",
2354
+ value: (model) => model.displayName ?? "-"
2355
+ },
2356
+ {
2357
+ header: "Type",
2358
+ value: (model) => model.type
2359
+ },
2360
+ {
2361
+ header: "Modes",
2362
+ value: (model) => Object.keys(model.modes).join(",") || "-"
2363
+ },
2364
+ {
2365
+ header: "Price",
2366
+ value: (model) => priceLabel(model)
2367
+ },
2368
+ {
2369
+ header: "4K",
2370
+ value: (model) => price4kLabel(model)
2371
+ },
2372
+ {
2373
+ header: "Input fee",
2374
+ value: (model) => inputFee(model) ?? "-"
2375
+ }
2376
+ ], "No models matched the filters.");
2377
+ });
2378
+ return models;
2379
+ };
2380
+ //#endregion
2381
+ //#region src/commands/task.ts
2382
+ const DEFAULT_WAIT_TIMEOUT_SECONDS = DEFAULT_WAIT_TIMEOUT_MS / 1e3;
2383
+ const detailedOption = (command) => command.option("--detailed", "Include the full task record instead of a compact summary");
2384
+ const taskCommand = (app) => {
2385
+ const task = new Command("task").description("Inspect and await Mynth tasks");
2386
+ task.command("get").description("Fetch a task by ID").argument("<id>", "Task ID").addOption(jsonOption()).action(async (id, options) => {
2387
+ const data = await getTask(app.api, id);
2388
+ if (options.json) {
2389
+ printJson(data);
2390
+ return;
2391
+ }
2392
+ renderTask(data);
2393
+ });
2394
+ task.command("result").description("Print a task's result payload as JSON").argument("<id>", "Task ID").action(async (id) => {
2395
+ printJson((await getTaskResult(app.api, id)).result);
2396
+ });
2397
+ const wait = task.command("wait").description("Block until a task completes or fails, then print it").argument("<id>", "Task ID").option("--timeout <seconds>", `Max seconds to wait before giving up (default: ${DEFAULT_WAIT_TIMEOUT_SECONDS})`, parsePositiveInteger("--timeout")).addOption(jsonOption());
2398
+ detailedOption(wait).action(async (id, options) => {
2399
+ const pending = waitForTask(app.api, id, (options.timeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS) * 1e3);
2400
+ const data = options.json ? await pending : await withSpinner(pending);
2401
+ if (data.status === "failed") process.exitCode = exitCodeForFailedTask(data);
2402
+ if (options.json) {
2403
+ const compact = !options.detailed && isImageGenerateTask(data);
2404
+ printJson(compact ? summarizeTask(data) : data);
2405
+ return;
2406
+ }
2407
+ renderTaskResult(data);
2408
+ });
2409
+ task.command("list").description("List recent tasks, newest first").option("--limit <number>", "Max tasks to return (1-100, default: 20)", parsePositiveInteger("--limit")).option("--after <id>", "Cursor: return tasks created before this task ID").addOption(jsonOption()).action(async (options) => {
2410
+ const tasks = await listTasks(app.api, {
2411
+ ...options.limit !== void 0 ? { limit: options.limit } : {},
2412
+ ...options.after !== void 0 ? { after: options.after } : {}
2413
+ });
2414
+ if (options.json) {
2415
+ printJson({ tasks });
2416
+ return;
2417
+ }
2418
+ printTable(tasks, [
2419
+ {
2420
+ header: "",
2421
+ value: (item) => glyphForStatus(item.status)
2422
+ },
2423
+ {
2424
+ header: "ID",
2425
+ value: (item) => item.id
2426
+ },
2427
+ {
2428
+ header: "Type",
2429
+ value: (item) => item.type
2430
+ },
2431
+ {
2432
+ header: "Status",
2433
+ value: (item) => item.status
2434
+ },
2435
+ {
2436
+ header: "Cost",
2437
+ value: (item) => item.cost ?? "-"
2438
+ },
2439
+ {
2440
+ header: "Created",
2441
+ value: (item) => item.createdAt
2442
+ }
2443
+ ], "No tasks found.");
2444
+ });
2445
+ return task;
2446
+ };
2447
+ //#endregion
2448
+ //#region src/api/webhooks.ts
2449
+ const createWebhook = (client, body) => client.fetch("webhook create", "/webhook", createdWebhook, { body });
2450
+ const updateWebhook = (client, id, body) => client.fetch("webhook update", `/webhook/${id}`, updatedWebhook, {
2451
+ method: "PUT",
2452
+ body
2453
+ });
2454
+ const deleteWebhook = (client, id) => client.call("webhook delete", `/webhook/${id}`, { method: "DELETE" });
2455
+ //#endregion
2456
+ //#region src/commands/webhook.ts
2457
+ /** Mirrors `webhook_event` in the API's database schema, minus the `all` shorthand. */
2458
+ const SUBSCRIBABLE_EVENTS = [
2459
+ "task.completed",
2460
+ "task.failed",
2461
+ "task.image.generate.completed",
2462
+ "task.image.generate.failed",
2463
+ "task.image.rate.completed",
2464
+ "task.image.rate.failed",
2465
+ "task.image.alt.completed",
2466
+ "task.image.alt.failed",
2467
+ "task.image.review.completed",
2468
+ "task.image.review.failed",
2469
+ "task.image.remove_background.completed",
2470
+ "task.image.remove_background.failed",
2471
+ "task.video.generate.completed",
2472
+ "task.video.generate.failed"
2473
+ ];
2474
+ /**
2475
+ * `all` is a server-side shorthand and must be the whole subscription; every
2476
+ * other value is checked here so a typo fails before the request.
2477
+ */
2478
+ const resolveEvents = (raw) => {
2479
+ if (raw === void 0 || raw.length === 0) throw new UsageError("at least one --event is required");
2480
+ if (raw.includes("all")) return "all";
2481
+ for (const event of raw) if (!SUBSCRIBABLE_EVENTS.includes(event)) throw new UsageError(`unknown event "${event}". Valid events: all, ${SUBSCRIBABLE_EVENTS.join(", ")}`);
2482
+ return raw;
2483
+ };
2484
+ const resolveEnabled = (options) => {
2485
+ if (options.enabled === true && options.disabled === true) throw new UsageError("--enabled and --disabled are mutually exclusive");
2486
+ return options.disabled !== true;
2487
+ };
2488
+ const toBody = (options) => ({
2489
+ enabled: resolveEnabled(options),
2490
+ url: options.url,
2491
+ events: resolveEvents(options.event),
2492
+ ...options.apiKeyId !== void 0 ? { apiKeyIds: options.apiKeyId } : {},
2493
+ ...options.oauthEvents === true ? { oauthEnabled: true } : {}
2494
+ });
2495
+ const eventsLabel = (events) => Array.isArray(events) ? events.join(", ") : String(events);
2496
+ const addSharedOptions = (command) => command.requiredOption("--url <url>", "Destination URL for webhook deliveries").option("-e, --event <name>", `Event to subscribe to (repeatable). Use \`all\` for every event. One of: ${SUBSCRIBABLE_EVENTS.join(", ")}`, collect).option("--api-key-id <id>", "Only deliver tasks created by this API key (repeatable). Omit to deliver for every key.", collect).option("--oauth-events", "Also deliver tasks that have no API key, such as playground runs. Off by default.").addOption(jsonOption());
2497
+ const webhookCommand = (app) => {
2498
+ const webhook = new Command("webhook").description("Manage registered webhooks");
2499
+ const create = webhook.command("create").description("Register a webhook. The signing secret is shown once, on success.").option("--disabled", "Create the webhook disabled (default: enabled)");
2500
+ addSharedOptions(create).action(async (options) => {
2501
+ const data = await createWebhook(app.api, toBody(options));
2502
+ if (options.json) {
2503
+ printJson(data);
2504
+ return;
2505
+ }
2506
+ print(`${glyph.ok} Webhook ${data.id} created`);
2507
+ print(` URL: ${data.url}`);
2508
+ print(` Enabled: ${data.enabled}`);
2509
+ print(` Events: ${eventsLabel(data.events)}`);
2510
+ if (data.oauthEnabled !== void 0) print(` OAuth: ${data.oauthEnabled}`);
2511
+ print("");
2512
+ print(` Signing secret: ${data.secret}`);
2513
+ print(" Save this now — it is shown only once and cannot be retrieved again.");
2514
+ });
2515
+ const update = webhook.command("update").description("Replace a webhook's configuration. Every field is sent, so pass them all.").argument("<id>", "Webhook ID").option("--enabled", "Enable the webhook").option("--disabled", "Disable the webhook");
2516
+ addSharedOptions(update).action(async (id, options) => {
2517
+ const data = await updateWebhook(app.api, id, toBody(options));
2518
+ if (options.json) {
2519
+ printJson(data);
2520
+ return;
2521
+ }
2522
+ print(`${glyph.ok} Webhook ${data.id} updated`);
2523
+ print(` URL: ${data.url}`);
2524
+ if (data.enabled !== void 0) print(` Enabled: ${data.enabled}`);
2525
+ print(` Events: ${eventsLabel(data.events)}`);
2526
+ if (data.oauthEnabled !== void 0) print(` OAuth: ${data.oauthEnabled}`);
2527
+ });
2528
+ webhook.command("delete").description("Delete a webhook").argument("<id>", "Webhook ID").addOption(yesOption()).addOption(jsonOption()).action(async (id, options) => {
2529
+ if (options.yes !== true) throw new UsageError("refusing to delete without --yes");
2530
+ await deleteWebhook(app.api, id);
2531
+ if (options.json) {
2532
+ printJson({ deleted: id });
2533
+ return;
2534
+ }
2535
+ print(`${glyph.ok} Deleted webhook ${id}`);
2536
+ });
2537
+ return webhook;
2538
+ };
2539
+ //#endregion
2540
+ //#region src/program.ts
2541
+ const HELP_WIDTH = 100;
2542
+ const EXIT_CODE_HELP = `
9
2543
  Exit codes:
10
2544
  0 success
11
2545
  1 error (network, server, or unexpected failure)
@@ -19,5 +2553,64 @@ Environment:
19
2553
  MYNTH_API_KEY API key; takes precedence over stored credentials
20
2554
  MYNTH_DESTINATION default --destination for image generation
21
2555
  MYNTH_DEBUG=1 print error causes and stack details to stderr
22
- XDG_CONFIG_HOME where credentials are stored (default: ~/.config)`,Ge=class extends Help{optionTerm(t){return `(${t.flags.replaceAll("<","").replaceAll(">","")})`}subcommandTerm(t){let n=t.registeredArguments.map(o=>o.required?`${o.name()}${o.variadic?"...":""}`:`[${o.name()}]`).join(" ");return n.length>0?`${t.name()} ${n}`:t.name()}},zn=e=>{e.configureHelp({helpWidth:ri}),e.createHelp=()=>new Ge,e.exitOverride();for(let t of e.commands)zn(t);},Wn=()=>{let e=Rt(),t=new Command("mynth").description("Official Mynth CLI").version(Ze).addHelpText("before",({error:n})=>(n?process.stderr.isTTY:process.stdout.isTTY)?`${Ct()}
23
- `:"").addHelpText("after",ii);for(let n of [jt(e),Yt(e),Ht(e),Zt(e),ln(e),cn(e),An(e),Sn(e),Dn(e),Kn(e),Re(e)])t.addCommand(n);return zn(t),t};var Bn=e=>e instanceof Error?e.message:String(e);try{await Wn().parseAsync(process.argv);}catch(e){let t=e?.code;t==="commander.helpDisplayed"||t==="commander.version"||((typeof t!="string"||!t.startsWith("commander."))&&(T(Bn(e)),q().debug&&e instanceof Error&&(T("=== MYNTH_DEBUG ==="),T(e.stack??e.message),e.cause!==void 0&&T(`cause: ${Bn(e.cause)}`))),process.exitCode=Qe(e));}
2556
+ XDG_CONFIG_HOME where credentials are stored (default: ~/.config)`;
2557
+ /**
2558
+ * Renders options as `(--flag value)` and subcommands with their arguments,
2559
+ * which reads better than Commander's default in a dense command tree.
2560
+ */
2561
+ var MynthHelp = class extends Help {
2562
+ optionTerm(option) {
2563
+ return `(${option.flags.replaceAll("<", "").replaceAll(">", "")})`;
2564
+ }
2565
+ subcommandTerm(command) {
2566
+ const args = command.registeredArguments.map((argument) => argument.required ? `${argument.name()}${argument.variadic ? "..." : ""}` : `[${argument.name()}]`).join(" ");
2567
+ return args.length > 0 ? `${command.name()} ${args}` : command.name();
2568
+ }
2569
+ };
2570
+ /** Commander does not inherit help config or exitOverride into `addCommand`ed subtrees. */
2571
+ const applyToTree = (command) => {
2572
+ command.configureHelp({ helpWidth: HELP_WIDTH });
2573
+ command.createHelp = () => new MynthHelp();
2574
+ command.exitOverride();
2575
+ for (const child of command.commands) applyToTree(child);
2576
+ };
2577
+ const createProgram = () => {
2578
+ const app = createApp();
2579
+ const program = new Command("mynth").description("Official Mynth CLI").version(CLI_VERSION).addHelpText("before", ({ error }) => (error ? process.stderr.isTTY : process.stdout.isTTY) ? `${logo()}\n` : "").addHelpText("after", EXIT_CODE_HELP);
2580
+ for (const command of [
2581
+ apiKeyCommand(app),
2582
+ authCommand(app),
2583
+ balanceCommand(app),
2584
+ configCommand(app),
2585
+ destinationCommand(app),
2586
+ docsCommand(app),
2587
+ imageCommand(app),
2588
+ modelsCommand(app),
2589
+ taskCommand(app),
2590
+ webhookCommand(app),
2591
+ whoamiCommand(app)
2592
+ ]) program.addCommand(command);
2593
+ applyToTree(program);
2594
+ return program;
2595
+ };
2596
+ //#endregion
2597
+ //#region src/bin.ts
2598
+ const message = (error) => error instanceof Error ? error.message : String(error);
2599
+ try {
2600
+ await createProgram().parseAsync(process.argv);
2601
+ } catch (error) {
2602
+ const code = error?.code;
2603
+ if (code === "commander.helpDisplayed" || code === "commander.version") {} else {
2604
+ if (typeof code !== "string" || !code.startsWith("commander.")) {
2605
+ printErr(message(error));
2606
+ if (loadConfig().debug && error instanceof Error) {
2607
+ printErr("=== MYNTH_DEBUG ===");
2608
+ printErr(error.stack ?? error.message);
2609
+ if (error.cause !== void 0) printErr(`cause: ${message(error.cause)}`);
2610
+ }
2611
+ }
2612
+ process.exitCode = exitCodeForError(error);
2613
+ }
2614
+ }
2615
+ //#endregion
2616
+ export {};