@seoagent-official/seoagent 1.81.1 → 1.81.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -654,7 +654,7 @@ ${t}
654
654
  `),!(s.subtype==="success"&&!s.is_error))return u.error(" Agent did not complete the citation audit (rate limit, auth, or network)."),null;i=String(s.result??"")}return i}catch(o){return process.stdout.write(`
655
655
  `),u.error(` Agent error: ${o.message}`),null}}function Hg(e){if(!e||typeof e!="object")return;let t=e.type;if(t==="text"){let n=e.text;typeof n=="string"&&n&&process.stdout.write(En(n));return}if(t==="tool_use"){let n=String(e.name??"tool"),r=e.input,o=n==="WebSearch"?String(r?.query??""):n==="WebFetch"?String(r?.url??""):"";process.stdout.write(j.dim(` ${j.magenta("\u{1F50E}")} ${n}${o?` ${Wg(o,70)}`:""}
656
656
  `))}}function Wg(e,t){return e.length<=t?e:e.slice(0,t-1)+"\u2026"}function Kg(e){return typeof e!="string"?[]:e.split(",").map(t=>t.trim()).filter(Boolean)}function qg(e){try{if(!Ug(e))return null;let t=Fg(e).filter(o=>o.endsWith(".json")).sort();if(t.length===0)return null;let n=t[t.length-1],r=JSON.parse(Mg(sc(e,n),"utf-8"));return r&&typeof r=="object"&&Array.isArray(r.results)&&typeof r.generated_at=="string"?r:null}catch{return null}}import ft from"picocolors";var Vg=30,zg=/<(script|style|noscript|template|svg|nav|header|footer)\b[^>]*>[\s\S]*?<\/\1>/gi;function Yg(e){let t=e,n=/<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(t);n&&(t=n[1]),t=t.replace(zg," ").replace(/<!--[\s\S]*?-->/g," ").replace(/<[^>]+>/g," ").replace(/&nbsp;/gi," ").replace(/&[a-z]+;/gi," ").replace(/\s+/g," ").trim();let r=t===""?0:t.split(" ").filter(Boolean).length;return{wordCount:r,rendered:r<Vg?"empty":"yes"}}async function Jg(e,t){try{let n=await A(e,{headers:{"user-agent":"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) SEOAgent-CLI"},redirect:"follow"},{attempts:2,timeoutMs:1e4,fetchImpl:t});if(!n.ok)return{status:n.status,rendered:"error",wordCount:0};let r=await n.text(),{wordCount:o,rendered:i}=Yg(r);return{status:n.status,rendered:i,wordCount:o}}catch{return{status:null,rendered:"error",wordCount:0}}}async function ac(e,t={}){let n=Math.max(1,t.concurrency??5),r=new Map,o=[...e],i=0;async function s(){for(;;){let a=o.shift();if(a===void 0)return;r.set(a,await Jg(a,t.fetchImpl)),i+=1,t.onProgress?.(i,e.length)}}return await Promise.all(Array.from({length:Math.min(n,e.length)},()=>s())),r}var wo=300;async function Kt(e={}){if(e.all){await Xg();return}let t=process.cwd();if(!L(t))return;je(t);let n=Ir(t);lc(t);let r=await cc(t,{enrichSitemap:!0,crawl:e.crawl});if(n.reason==="refreshed"?(u.success(n.from&&n.from!==n.to?`Refreshed the SEOAgent skill ${n.from} \u2192 ${n.to}.`:`Refreshed the SEOAgent skill (now ${n.to}).`),u.info("Your `.seoagent/` knowledge was left untouched.")):(u.error(`Could not refresh the skill (${n.reason}).`),process.exitCode=1),r.added>0?u.success(`Re-scanned the codebase: +${r.added} new page${r.added===1?"":"s"} merged into .seoagent/pages.md (${r.total} total).`):r.total>0&&u.info(`Page inventory already current (${r.total} pages).`),r.crawl){let{crawled:o,empty:i,errors:s}=r.crawl;u.success(`Crawled ${o} page${o===1?"":"s"} \u2192 filled Status / Rendered / Word count`+(i>0?`, ${i} render empty`:"")+(s>0?`, ${s} unreachable`:"")+".")}}async function cc(e,t={}){try{let n=_(e),r=n?.domain&&n.domain!=="unknown"?n.domain:null,{pages:o}=oe(e,r);t.enrichSitemap&&r&&await Ai(o,r);let i,s;if(t.crawl&&r){let c=o.map(d=>d.url).filter(d=>/^https?:\/\//.test(d)),l=c.slice(0,wo);if(c.length>wo&&u.info(`Crawling the first ${wo} of ${c.length} pages (cap).`),l.length>0){u.info(`Crawling ${l.length} page${l.length===1?"":"s"}\u2026`),s=await ac(l);let d=0,f=0;for(let p of s.values())p.rendered==="empty"?d++:p.rendered==="error"&&f++;i={crawled:s.size,empty:d,errors:f}}}let a=mn(e,o,s);return{added:a.added,total:a.total,crawl:i}}catch{return{added:0,total:0}}}async function Xg(){let e=ui();if(e.length===0){u.info("No SEOAgent projects are registered on this machine yet. They register themselves on `init` / `sync`.");return}u.message(`Refreshing ${e.length} project${e.length===1?"":"s"} to SEOAgent ${ft.bold(I)}\u2026`);let t=0,n=0;for(let r of e){let o=Ir(r);lc(r);let i=await cc(r),s=i.added>0?ft.cyan(` +${i.added} page${i.added===1?"":"s"}`):"";if(o.reason==="refreshed"){t++;let a=o.from&&o.from!==o.to?ft.dim(` (${o.from} \u2192 ${o.to})`):"";u.message(` ${ft.green("\u2713")} ${r}${a}${s}`)}else n++,u.message(` ${ft.yellow("!")} ${r} ${ft.dim("\u2014 "+o.reason)}${s}`)}u.success(`Refreshed ${t}/${e.length} project${e.length===1?"":"s"} to ${I}.`),n>0&&u.info(`${n} could not be refreshed (see above).`)}function lc(e){try{Le(e)}catch{}}import{spinner as ko,confirm as Qg,isCancel as Zg}from"@clack/prompts";import v from"picocolors";var dc={easy_win:"easy win",striking_distance:"striking distance",competitor_gap:"competitor gap",defend:"defend",low_priority:"low priority"};async function eh(e){let t=process.cwd(),n=on(t);if(!n){u.error(`No .seoagent/project.md found. Run \`${m("init")}\` first.`),process.exitCode=1;return}let r=_(t),o=r?.domain&&r.domain!=="unknown"?r.domain:void 0,i=(e.apiBase||N.BASE).replace(/\/$/,""),a=!!process.stdout.isTTY&&!e.json?ko():null;a?.start(`Looking up ${v.bold(`"${e.keyword}"`)} ${v.dim("via DataForSEO (free peek)\u2026")}`);let c;try{c=await A(`${i}/api/cli/keywords/peek`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({install_id:n,keyword:e.keyword,...o?{domain:o}:{}})})}catch(d){x(a,"error",`Network error: ${d.message}`,v.red("Network error.")),process.exitCode=1;return}let l=null;try{l=await c.json()}catch{l=null}if(e.json){process.stdout.write(JSON.stringify({status:l?.status??"error",http_status:c.status,body:l??null})+`
657
- `);return}if(c.status===429||l?.skipped?.startsWith("rate-limited")){let d=l?.skipped==="rate-limited:ip"?"this network":"this install";x(a,"warn",`Daily peek quota reached for ${d}.`,v.yellow(`Daily peek quota reached for ${d}.`)),u.message(uc()),process.exitCode=1;return}if(!c.ok||!l){x(a,"error",`Peek failed (${c.status})${l?.error?`: ${l.error}`:""}`,v.red(`Peek failed (${c.status}).`)),process.exitCode=1;return}if(l.skipped==="provider-quota"){x(a,"warn","Real keyword data is temporarily unavailable. Try again later.",v.yellow("Real keyword data is temporarily unavailable. Try again later.")),u.message(v.dim("No action needed on your side \u2014 the maintainer has been notified."));return}if(l.skipped==="no-creds"||l.skipped==="no-table"){x(a,"warn","Real keyword data isn\u2019t enabled on the server yet \u2014 try again later.",v.yellow("Real keyword data is not enabled on the server yet."));return}if(!l.opportunity){x(a,"warn",`No data returned for "${e.keyword}".`,v.yellow(`No data returned for "${e.keyword}".`));return}a?.stop(v.green("Got it.")),u.message(th(l.opportunity)),l.remaining&&u.message(`${v.dim("Peeks remaining today:")} ${v.bold(String(l.remaining.install))} ${v.dim("\u2014 "+uc())}`)}function th(e){let n=l=>v.dim(l.padEnd(14)),r=e.volume!=null?v.bold(String(e.volume)):v.dim("n/a"),o=e.difficulty!=null?`${v.bold(String(e.difficulty))} ${v.dim(nh(e.difficulty))}`:v.dim("n/a"),i=dc[e.opportunity]??e.opportunity,s=e.opportunity==="easy_win"?v.bold(v.green(i)):e.opportunity==="striking_distance"?v.bold(v.cyan(i)):e.opportunity==="competitor_gap"?v.bold(v.yellow(i)):v.dim(i),a=[{content:`${v.green("\u25CF")} ${v.bold(e.keyword)}`},{sep:!0},{content:`${n("Volume:")}${r} ${v.dim("/mo")}`},{content:`${n("Difficulty:")}${o}`},{content:`${n("Opportunity:")}${s}`}],c=`${v.bold(v.cyan("Keyword peek"))}`;return D(a,{width:55,title:c})}function nh(e){return e<=20?"easy":e<=40?"moderate":e<=60?"hard":"very hard"}function uc(e=O()){return e?`You're logged in \u2014 run \`${m("keywords")}\` for full enrichment (no quota). Pro unlocks the full inventory beyond the ~25 cap plus \`${m("keywords --discover")}\` (new targets) + \`${m("keywords --competitors")}\` (competitor gaps): ${m("upgrade")}.`:`Log in for the full top-25 enrichment, no quota: ${m("login")}`}function bo(e){let t=e.filter(n=>n.opportunity!=="low_priority");if(t.length!==0){u.message("Top opportunities:");for(let n of t){let r=n.volume!=null?`vol ${n.volume}`:"vol n/a",o=n.difficulty!=null?`KD ${n.difficulty}`:"KD n/a",i=n.our_position!=null?`, you're #${n.our_position}`:", not ranking";u.message(` \u2022 [${dc[n.opportunity]??n.opportunity}] ${n.keyword} \u2014 ${r}, ${o}${i}`)}}}function rh(e){if(e.length!==0){u.message("Top competitor gaps:");for(let t of e){let n=t.volume!=null?`vol ${t.volume}`:"vol n/a",r=t.difficulty!=null?`KD ${t.difficulty}`:"KD n/a",o=t.their_position!=null?`#${t.their_position}`:"top 10";u.message(` \u2022 ${t.keyword} \u2014 ${t.competitor_domain} ranks ${o}, ${n}, ${r}`)}}}async function oh(e){let t=e.all?"all":"suggested";if(t==="all"&&process.stdin.isTTY){let a=await Qg({message:"Delete ALL keywords for this site from the cloud? This cannot be undone."});if(Zg(a)||!a){u.info("No changes.");return}}let n=e.apiBase||e.auth.api_base||N.BASE,r=process.stdout.isTTY?ko():null;r?.start(t==="all"?"Purging all keywords\u2026":"Purging suggested keywords\u2026");let o;try{o=await A(`${n}/api/cli/keywords/purge`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e.auth.user_token}:${e.auth.website_token}`},body:JSON.stringify({scope:t})})}catch(a){x(r,"error",`Network error: ${a.message}`,v.red("Network error.")),process.exitCode=1;return}let i=null;try{i=await o.json()}catch{i=null}if(i?.skipped==="no-table"||i?.skipped==="no-columns"){x(r,"warn","Keyword storage isn\u2019t enabled on the server yet.",v.yellow("Nothing to purge yet."));return}if(!o.ok||i?.status!=="ok"){x(r,"error",`Purge failed (${o.status})${i?.error?`: ${i.error}`:""}`,v.red(`Purge failed (${o.status}).`)),process.exitCode=1;return}let s=i.purged??0;x(r,"success",`Purged ${s} ${t==="all"?"":"suggested "}keyword${s===1?"":"s"}.`,v.green(`Purged ${s} keyword${s===1?"":"s"}.`)),u.message(v.dim(t==="all"?"Cleared the cloud inventory. Re-run `seoagent keywords --seed` / strategy to rebuild it.":"Cleared discovery noise. Your clustered keywords were kept."))}async function Ee(e={}){if(e.peek&&e.peek.trim().length>0){await eh({keyword:e.peek.trim(),apiBase:e.apiBase,json:e.json});return}let t=_(process.cwd()),n=O(t?.domain);if(!n){u.error(`Not logged in. Run \`${m("login")}\` first.`),process.exitCode=1;return}if(e.purge){await oh({all:e.all,apiBase:e.apiBase,auth:n});return}let r=e.apiBase||n.api_base||N.BASE,o=`Bearer ${n.user_token}:${n.website_token}`,i=e.competitors?"competitors":e.discover?"discover":e.seed?"seed":"enrich",s=i==="competitors"?"/api/cli/keywords/competitors":i==="discover"?"/api/cli/keywords/discover":i==="seed"?"/api/cli/keywords/seed":"/api/cli/keywords/enrich",a=i==="competitors"?"Finding keywords your competitors rank for\u2026":i==="discover"?"Discovering new keywords to target\u2026":i==="seed"?"Seeding keywords from your Search Console data\u2026":"Enriching keywords with real volume + difficulty\u2026",l=!!process.stdout.isTTY?ko():null;l?.start(a);let d=i==="competitors"||i==="discover"||i==="seed",f;try{f=await A(`${r}${s}`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:o},body:"{}"},d?{timeoutMs:6e4,attempts:1}:{})}catch(y){x(l,"error",`Network error: ${y.message}`,v.red("Network error.")),process.exitCode=1;return}let p=null;try{p=await f.json()}catch{p=null}if(f.status===402||p?.code==="upgrade_required"){x(l,"warn",i==="competitors"?"Competitor gap analysis is a paid feature.":"Keyword discovery is a paid feature.",v.yellow("Paid feature \u2014 upgrade to continue.")),u.message(i==="competitors"?`Upgrade to see which keywords competitors rank for that you don't: ${v.cyan(ne("cli-keywords-competitors"))}`:`Upgrade to find new keywords to target: ${v.cyan(ne("cli-keywords-discover"))}`),process.exitCode=1;return}if(!f.ok||p?.status!=="ok"){x(l,"error",`Server rejected request (${f.status})${p?.error?`: ${p.error}`:""}`,v.red(`Server rejected request (${f.status}).`)),process.exitCode=1;return}if(p.skipped==="no-gsc-data"){x(l,"warn","No Search Console data to seed from yet. Connect Google Search Console (run `seoagent login` and link GSC) \u2014 or wait for impressions to accrue on a new site.",v.yellow("No Search Console data yet."));return}if(p.skipped==="no-keywords"||p.skipped==="no-seeds"){x(l,"warn","No keywords yet. Seed from Search Console with `seoagent keywords --seed`, or run the keyword strategy (Phase 2) first, then try again.",v.yellow("No keywords yet."));return}if(p.skipped==="no-competitors"){x(l,"warn","No competitors found yet. Add competitors or run strategy discovery first, then try again.",v.yellow("No competitors yet."));return}if(p.skipped==="no-creds"||p.skipped==="no-columns"||p.skipped==="no-table"){x(l,"warn","Real keyword data isn\u2019t enabled on the server yet \u2014 try again later.",v.yellow("Real keyword data is not enabled on the server yet."));return}if(p.skipped){x(l,"warn",`Skipped: ${p.skipped}`,v.yellow(`Skipped: ${p.skipped}`));return}if(i==="competitors"){let y=p.gaps??0;x(l,"success",`Found ${y} competitor gap${y===1?"":"s"} across ${p.competitors?.length??0} competitor${p.competitors?.length===1?"":"s"} (added as "suggested").`,v.green(`${y} competitor gap${y===1?"":"s"} found.`)),rh(p.topGaps??[]),u.message(`Review them in ${v.cyan(".seoagent/keywords.md")} (a read-only projection \u2014 don't hand-edit it). Keep the on-topic ones for your clusters; clear off-topic noise with \`${m("keywords --purge")}\`.`);return}if(i==="discover"){let y=p.discovered??0;x(l,"success",`Discovered ${y} new keyword${y===1?"":"s"} to target (added as "suggested").`,v.green(`${y} new keyword${y===1?"":"s"} discovered.`)),bo(p.opportunities??[]),u.message(`Review them in ${v.cyan(".seoagent/keywords.md")} (a read-only projection \u2014 don't hand-edit it). Relevance-check before adopting; clear off-topic noise with \`${m("keywords --purge")}\`.`);return}if(i==="seed"){let y=p.seeded??0;x(l,"success",`Seeded ${y} keyword${y===1?"":"s"} from Search Console (real impressed queries, added as "suggested").`,v.green(`${y} keyword${y===1?"":"s"} seeded from Search Console.`)),bo(p.opportunities??[]),u.message(`These are queries your site already gets impressions for \u2014 striking-distance wins first. Review in ${v.cyan(".seoagent/keywords.md")} and build clusters from them.`);return}let g=p.enriched??0;x(l,"success",`Enriched ${g} keyword${g===1?"":"s"}.`,v.green(`Enriched ${g} keyword${g===1?"":"s"}.`)),bo(p.opportunities??[]),p.paid||u.message(`${v.dim("Free plan:")} enriched up to ${v.bold(String(p.cap??25))} keywords. ${v.dim("Upgrade for full enrichment + discovery + competitor gap:")} ${v.cyan(ne("cli-keywords-cap"))}`)}import M from"picocolors";function qt(e={}){let t=process.cwd(),n=_(t),r=ve(t);if(e.json){process.stdout.write(JSON.stringify(r,null,2)+`
657
+ `);return}if(c.status===429||l?.skipped?.startsWith("rate-limited")){let d=l?.skipped==="rate-limited:ip"?"this network":"this install";x(a,"warn",`Daily peek quota reached for ${d}.`,v.yellow(`Daily peek quota reached for ${d}.`)),u.message(uc()),process.exitCode=1;return}if(!c.ok||!l){x(a,"error",`Peek failed (${c.status})${l?.error?`: ${l.error}`:""}`,v.red(`Peek failed (${c.status}).`)),process.exitCode=1;return}if(l.skipped==="provider-quota"){x(a,"warn","Real keyword data is temporarily unavailable. Try again later.",v.yellow("Real keyword data is temporarily unavailable. Try again later.")),u.message(v.dim("No action needed on your side \u2014 the maintainer has been notified."));return}if(l.skipped==="no-creds"||l.skipped==="no-table"){x(a,"warn","Real keyword data isn\u2019t enabled on the server yet \u2014 try again later.",v.yellow("Real keyword data is not enabled on the server yet."));return}if(!l.opportunity){x(a,"warn",`No data returned for "${e.keyword}".`,v.yellow(`No data returned for "${e.keyword}".`));return}a?.stop(v.green("Got it.")),u.message(th(l.opportunity)),l.remaining&&u.message(`${v.dim("Peeks remaining today:")} ${v.bold(String(l.remaining.install))} ${v.dim("\u2014 "+uc())}`)}function th(e){let n=l=>v.dim(l.padEnd(14)),r=e.volume!=null?v.bold(String(e.volume)):v.dim("n/a"),o=e.difficulty!=null?`${v.bold(String(e.difficulty))} ${v.dim(nh(e.difficulty))}`:v.dim("n/a"),i=dc[e.opportunity]??e.opportunity,s=e.opportunity==="easy_win"?v.bold(v.green(i)):e.opportunity==="striking_distance"?v.bold(v.cyan(i)):e.opportunity==="competitor_gap"?v.bold(v.yellow(i)):v.dim(i),a=[{content:`${v.green("\u25CF")} ${v.bold(e.keyword)}`},{sep:!0},{content:`${n("Volume:")}${r} ${v.dim("/mo")}`},{content:`${n("Difficulty:")}${o}`},{content:`${n("Opportunity:")}${s}`}],c=`${v.bold(v.cyan("Keyword peek"))}`;return D(a,{width:55,title:c})}function nh(e){return e<=20?"easy":e<=40?"moderate":e<=60?"hard":"very hard"}function uc(e=O()){return e?`You're logged in \u2014 run \`${m("keywords")}\` for full enrichment (no quota). Pro unlocks the full inventory beyond the ~25 cap plus \`${m("keywords --discover")}\` (new targets) + \`${m("keywords --competitors")}\` (competitor gaps): ${m("upgrade")}.`:`Log in for the full top-25 enrichment, no quota: ${m("login")}`}function bo(e){let t=e.filter(n=>n.opportunity!=="low_priority");if(t.length!==0){u.message("Top opportunities:");for(let n of t){let r=n.volume!=null?`vol ${n.volume}`:"vol n/a",o=n.difficulty!=null?`KD ${n.difficulty}`:"KD n/a",i=n.our_position!=null?`, you're #${n.our_position}`:", not ranking";u.message(` \u2022 [${dc[n.opportunity]??n.opportunity}] ${n.keyword} \u2014 ${r}, ${o}${i}`)}}}function rh(e){if(e.length!==0){u.message("Top competitor gaps:");for(let t of e){let n=t.volume!=null?`vol ${t.volume}`:"vol n/a",r=t.difficulty!=null?`KD ${t.difficulty}`:"KD n/a",o=t.their_position!=null?`#${t.their_position}`:"top 10";u.message(` \u2022 ${t.keyword} \u2014 ${t.competitor_domain} ranks ${o}, ${n}, ${r}`)}}}async function oh(e){let t=e.all?"all":"suggested";if(t==="all"&&process.stdin.isTTY){let a=await Qg({message:"Delete ALL keywords for this site from the cloud? This cannot be undone."});if(Zg(a)||!a){u.info("No changes.");return}}let n=e.apiBase||e.auth.api_base||N.BASE,r=process.stdout.isTTY?ko():null;r?.start(t==="all"?"Purging all keywords\u2026":"Purging suggested keywords\u2026");let o;try{o=await A(`${n}/api/cli/keywords/purge`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e.auth.user_token}:${e.auth.website_token}`},body:JSON.stringify({scope:t})})}catch(a){x(r,"error",`Network error: ${a.message}`,v.red("Network error.")),process.exitCode=1;return}let i=null;try{i=await o.json()}catch{i=null}if(i?.skipped==="no-table"||i?.skipped==="no-columns"){x(r,"warn","Keyword storage isn\u2019t enabled on the server yet.",v.yellow("Nothing to purge yet."));return}if(!o.ok||i?.status!=="ok"){x(r,"error",`Purge failed (${o.status})${i?.error?`: ${i.error}`:""}`,v.red(`Purge failed (${o.status}).`)),process.exitCode=1;return}let s=i.purged??0;x(r,"success",`Purged ${s} ${t==="all"?"":"suggested "}keyword${s===1?"":"s"}.`,v.green(`Purged ${s} keyword${s===1?"":"s"}.`)),u.message(v.dim(t==="all"?"Cleared the cloud inventory. Re-run `seoagent keywords --seed` / strategy to rebuild it.":"Cleared discovery noise. Your clustered keywords were kept."))}async function Ee(e={}){if(e.peek&&e.peek.trim().length>0){await eh({keyword:e.peek.trim(),apiBase:e.apiBase,json:e.json});return}let t=_(process.cwd()),n=O(t?.domain);if(!n){u.error(`Not logged in. Run \`${m("login")}\` first.`),process.exitCode=1;return}if(e.purge){await oh({all:e.all,apiBase:e.apiBase,auth:n});return}let r=e.apiBase||n.api_base||N.BASE,o=`Bearer ${n.user_token}:${n.website_token}`,i=e.competitors?"competitors":e.discover?"discover":e.seed?"seed":"enrich",s=i==="competitors"?"/api/cli/keywords/competitors":i==="discover"?"/api/cli/keywords/discover":i==="seed"?"/api/cli/keywords/seed":"/api/cli/keywords/enrich",a=i==="competitors"?"Finding keywords your competitors rank for\u2026":i==="discover"?"Discovering new keywords to target\u2026":i==="seed"?"Seeding keywords from your Search Console data\u2026":"Enriching keywords with real volume + difficulty\u2026",l=!!process.stdout.isTTY?ko():null;l?.start(a);let d=i==="competitors"||i==="discover"||i==="seed",f;try{f=await A(`${r}${s}`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:o},body:"{}"},d?{timeoutMs:6e4,attempts:1}:{})}catch(y){x(l,"error",`Network error: ${y.message}`,v.red("Network error.")),process.exitCode=1;return}let p=null;try{p=await f.json()}catch{p=null}if(f.status===402||p?.code==="upgrade_required"){x(l,"warn",i==="competitors"?"Competitor gap analysis is a paid feature.":"Keyword discovery is a paid feature.",v.yellow("Paid feature \u2014 upgrade to continue.")),u.message(i==="competitors"?`Upgrade to see which keywords competitors rank for that you don't: ${v.cyan(ne("cli-keywords-competitors"))}`:`Upgrade to find new keywords to target: ${v.cyan(ne("cli-keywords-discover"))}`),process.exitCode=1;return}if(!f.ok||p?.status!=="ok"){x(l,"error",`Server rejected request (${f.status})${p?.error?`: ${p.error}`:""}`,v.red(`Server rejected request (${f.status}).`)),process.exitCode=1;return}if(p.skipped==="no-gsc-data"){x(l,"warn","No Search Console data to seed from yet. Connect Google Search Console (run `seoagent login` and link GSC) \u2014 or wait for impressions to accrue on a new site.",v.yellow("No Search Console data yet."));return}if(p.skipped==="no-keywords"||p.skipped==="no-seeds"){x(l,"warn","No keywords yet. Seed from Search Console with `seoagent keywords --seed`, or run the keyword strategy (Phase 2) first, then try again.",v.yellow("No keywords yet."));return}if(p.skipped==="no-competitors"){x(l,"warn","No competitors found yet. Add competitors or run strategy discovery first, then try again.",v.yellow("No competitors yet."));return}if(p.skipped==="no-creds"||p.skipped==="no-columns"||p.skipped==="no-table"){x(l,"warn","Real keyword data isn\u2019t enabled on the server yet \u2014 try again later.",v.yellow("Real keyword data is not enabled on the server yet."));return}if(p.skipped){x(l,"warn",`Skipped: ${p.skipped}`,v.yellow(`Skipped: ${p.skipped}`));return}if(i==="competitors"){let y=p.gaps??0;x(l,"success",`Found ${y} competitor gap${y===1?"":"s"} across ${p.competitors?.length??0} competitor${p.competitors?.length===1?"":"s"} (added as "suggested").`,v.green(`${y} competitor gap${y===1?"":"s"} found.`)),rh(p.topGaps??[]),u.message(`Review them in ${v.cyan(".seoagent/keywords.md")} (a read-only projection \u2014 don't hand-edit it). Keep the on-topic ones for your clusters; clear off-topic noise with \`${m("keywords --purge")}\`. The analyzed competitors are saved to your dashboard's Competitors section.`);return}if(i==="discover"){let y=p.discovered??0;x(l,"success",`Discovered ${y} new keyword${y===1?"":"s"} to target (added as "suggested").`,v.green(`${y} new keyword${y===1?"":"s"} discovered.`)),bo(p.opportunities??[]),u.message(`Review them in ${v.cyan(".seoagent/keywords.md")} (a read-only projection \u2014 don't hand-edit it). Relevance-check before adopting; clear off-topic noise with \`${m("keywords --purge")}\`.`);return}if(i==="seed"){let y=p.seeded??0;x(l,"success",`Seeded ${y} keyword${y===1?"":"s"} from Search Console (real impressed queries, added as "suggested").`,v.green(`${y} keyword${y===1?"":"s"} seeded from Search Console.`)),bo(p.opportunities??[]),u.message(`These are queries your site already gets impressions for \u2014 striking-distance wins first. Review in ${v.cyan(".seoagent/keywords.md")} and build clusters from them.`);return}let g=p.enriched??0;x(l,"success",`Enriched ${g} keyword${g===1?"":"s"}.`,v.green(`Enriched ${g} keyword${g===1?"":"s"}.`)),bo(p.opportunities??[]),p.paid||u.message(`${v.dim("Free plan:")} enriched up to ${v.bold(String(p.cap??25))} keywords. ${v.dim("Upgrade for full enrichment + discovery + competitor gap:")} ${v.cyan(ne("cli-keywords-cap"))}`)}import M from"picocolors";function qt(e={}){let t=process.cwd(),n=_(t),r=ve(t);if(e.json){process.stdout.write(JSON.stringify(r,null,2)+`
658
658
  `);return}if(u.message(ih(r)),!n){u.warn(`No SEOAgent project here. Run \`${m("init")}\` first to persist the provider.`);return}q(t,{image_provider:r.provider}),u.info(`Saved to ${M.cyan(".seoagent/project.md")} ${M.dim(`(image_provider: ${r.provider})`)}`)}function ih(e){let n=i=>M.dim(i.padEnd(12)),r=[];if(e.provider==="none"){r.push({content:`${M.dim("\u25CB")} ${M.bold(M.yellow("No image provider detected"))}`}),r.push({sep:!0}),r.push({content:M.dim("Set ONE of these to enable image generation:")});let i=$i();for(let s of Object.keys(i))r.push({content:`${n(s+":")}${M.cyan(i[s].join(" or "))}`});r.push({sep:!0}),r.push({content:M.dim("Add to your shell, .env.local, or .env, then re-run:")}),r.push({content:` ${M.cyan(m("env-check"))}`})}else{r.push({content:`${M.green("\u2713")} ${M.dim("Image provider:")} ${M.bold(M.green(e.provider))}`}),r.push({sep:!0}),e.matched_key&&r.push({content:`${n("Key:")}${M.bold(e.matched_key)}`}),e.source&&r.push({content:`${n("Source:")}${M.cyan(e.source)}`});let i=e.available_providers.filter(s=>s!==e.provider);i.length>0&&r.push({content:`${n("Available:")}${M.dim(i.join(", "))}`})}let o=`${M.bold(M.cyan("SEOAgent"))} ${M.dim("\xB7")} ${M.dim("env-check")}`;return D(r,{width:64,title:o})}import{existsSync as So,mkdirSync as mc,writeFileSync as gc}from"fs";import{dirname as Hn,isAbsolute as ph,join as Gn,resolve as fh}from"path";import{tmpdir as mh}from"os";import{spinner as gh}from"@clack/prompts";import B from"picocolors";import{Buffer as pc}from"buffer";var sh=6e4,vo=18e4,ah=15e3;async function $o(e){let t=await fetch(e,{signal:AbortSignal.timeout(sh)});if(!t.ok)throw new Error(`Image download failed: ${t.status} ${t.statusText}`);let n=await t.arrayBuffer();return pc.from(n)}async function ch(e){let t=e.size??"1024x1024",n=await fetch("https://api.openai.com/v1/images/generations",{method:"POST",headers:{Authorization:`Bearer ${e.apiKey}`,"Content-Type":"application/json"},body:JSON.stringify({model:"gpt-image-1",prompt:e.prompt,size:t,n:1}),signal:AbortSignal.timeout(vo)});if(!n.ok){let i=await n.text().catch(()=>"");throw new Error(`OpenAI image API ${n.status}: ${i.slice(0,300)}`)}let o=(await n.json()).data?.[0];if(!o)throw new Error("OpenAI image API returned no data");if(o.b64_json)return{bytes:pc.from(o.b64_json,"base64"),contentType:"image/png"};if(o.url)return{bytes:await $o(o.url),contentType:"image/png"};throw new Error("OpenAI image API returned neither b64_json nor url")}async function lh(e){let t=process.env.FAL_MODEL||"fal-ai/flux/schnell",n=await fetch(`https://fal.run/${t}`,{method:"POST",headers:{Authorization:`Key ${e.apiKey}`,"Content-Type":"application/json"},body:JSON.stringify({prompt:e.prompt,image_size:"landscape_16_9",num_images:1}),signal:AbortSignal.timeout(vo)});if(!n.ok){let s=await n.text().catch(()=>"");throw new Error(`fal.ai API ${n.status}: ${s.slice(0,300)}`)}let o=(await n.json()).images?.[0];if(!o?.url)throw new Error("fal.ai API returned no image url");return{bytes:await $o(o.url),contentType:o.content_type??"image/png"}}async function uh(e,t,n=9e4){let r=Date.now(),o=1e3;for(;Date.now()-r<n;){let i=await fetch(`https://api.replicate.com/v1/predictions/${e}`,{headers:{Authorization:`Token ${t}`},signal:AbortSignal.timeout(ah)});if(!i.ok)throw new Error(`Replicate poll failed: ${i.status}`);let s=await i.json();if(s.status==="succeeded"||s.status==="failed"||s.status==="canceled")return s;await new Promise(a=>setTimeout(a,o)),o=Math.min(o*1.5,5e3)}throw new Error("Replicate prediction timed out")}async function dh(e){let t=process.env.REPLICATE_MODEL||"black-forest-labs/flux-schnell",n=await fetch(`https://api.replicate.com/v1/models/${t}/predictions`,{method:"POST",headers:{Authorization:`Token ${e.apiKey}`,"Content-Type":"application/json",Prefer:"wait"},body:JSON.stringify({input:{prompt:e.prompt,aspect_ratio:"16:9"}}),signal:AbortSignal.timeout(vo)});if(!n.ok){let s=await n.text().catch(()=>"");throw new Error(`Replicate API ${n.status}: ${s.slice(0,300)}`)}let r=await n.json();if(r.status!=="succeeded"&&r.status!=="failed"&&(r=await uh(r.id,e.apiKey)),r.status!=="succeeded")throw new Error(`Replicate prediction ${r.status}: ${r.error??""}`);let o=Array.isArray(r.output)?r.output[0]:r.output;if(!o)throw new Error("Replicate prediction returned no output");return{bytes:await $o(o),contentType:"image/png",providerImageId:r.id}}async function fc(e,t){switch(e){case"openai":return ch(t);case"fal":return lh(t);case"replicate":return dh(t);default:throw new Error(`Unknown image provider: ${e}`)}}function hh(e){return e==="openai"||e==="fal"||e==="replicate"}async function Vt(e={}){let t=process.cwd();if(!e.prompt){u.warn(`Missing --prompt. Example: ${m('generate-image --prompt "..." --out content/images/hero.png')}`),process.exitCode=1;return}if(!e.out){u.warn("Missing --out. Example: --out .seoagent/content/images/hero.png"),process.exitCode=1;return}let n=_(t),r=null;if(e.provider){if(!hh(e.provider)){u.warn(`Invalid --provider "${e.provider}". Use one of: openai, fal, replicate.`),process.exitCode=1;return}r=e.provider}else n?.image_provider&&n.image_provider!=="none"?r=n.image_provider:r=ve(t).provider;if(!r||r==="none"){u.warn(`No image generation provider available. Set OPENAI_API_KEY, FAL_KEY, or REPLICATE_API_TOKEN, then run \`${m("env-check")}\`.`),process.exitCode=1;return}let o=vi(t,r);if(!o){u.warn(`Provider "${r}" selected but no API key found. Set the env var, then re-run.`),process.exitCode=1;return}let i=ph(e.out)?e.out:fh(t,e.out),a=!e.silent&&!!process.stdout.isTTY?gh():null;a?.start(`Generating image with ${B.bold(r)} ${B.dim(`(${o.envName})`)}\u2026`);try{let c=await fc(r,{prompt:e.prompt,apiKey:o.key,size:e.size});So(Hn(i))||mc(Hn(i),{recursive:!0}),gc(i,c.bytes),e.silent||x(a,"success",`Wrote ${i} (${c.bytes.length} bytes, ${c.contentType}).`,B.green(`Wrote ${i} ${B.dim(`(${c.bytes.length} bytes, ${c.contentType})`)}`))}catch(c){a?.stop(B.red("Image generation failed."));let l=wh(t,{provider:r,envName:o.envName,prompt:e.prompt,size:e.size,outPath:i,error:c});u.message(yh({provider:r,envName:o.envName,prompt:e.prompt,size:e.size,message:c.message})),l&&u.message(`${B.dim("Full error log:")} ${B.cyan(l)}`),process.exitCode=1}}function yh(e){let n=d=>B.dim(d.padEnd(10)),o=e.prompt.length>54?`${e.prompt.slice(0,53)}\u2026`:e.prompt,i=[{content:`${B.red("\u2717")} ${B.bold(B.red("Image generation failed"))}`},{sep:!0},{content:`${n("Provider:")}${B.bold(e.provider)}`},{content:`${n("Key:")}${e.envName}`},{content:`${n("Prompt:")}${B.dim(`"${o}"`)}`}];e.size&&i.push({content:`${n("Size:")}${e.size}`}),i.push({sep:!0});let s=e.message.replace(/\s+/g," ").trim(),a=64,c=s.length>a?`${s.slice(0,a-1)}\u2026`:s;i.push({content:B.red(c)});let l=`${B.bold(B.cyan("SEOAgent"))} ${B.dim("\xB7")} ${B.dim("generate-image")}`;return D(i,{width:70,title:l})}function wh(e,t){let r=`seoagent-generate-image-${new Date().toISOString().replace(/[:.]/g,"-")}.log`,o=Gn(e,k),i=Gn(o,".errors"),s=So(o)?Gn(i,r):Gn(mh(),r),a=["# SEOAgent generate-image error",`# ${new Date().toISOString()}`,"","## Request",`provider: ${t.provider}`,`env_var: ${t.envName}`,`prompt: ${JSON.stringify(t.prompt)}`,`size: ${t.size??"(default)"}`,`out_path: ${t.outPath}`,`cwd: ${e}`,"","## Error",`message: ${t.error.message}`,"","## Stack",t.error.stack??"(no stack)",""];t.error.cause&&a.push("## Cause",String(t.error.cause),"");try{return So(Hn(s))||mc(Hn(s),{recursive:!0}),gc(s,a.join(`
659
659
  `)),s}catch{return null}}import K from"picocolors";async function zt(e={}){let t=_(process.cwd()),n=O(t?.domain);if(!n){let c=qe();e.json?process.stdout.write(`${JSON.stringify({logged_in:!1,other_sites:c})}
660
660
  `):(u.error(`Not logged in. Run \`${m("login")}\` first.`),c.length>0&&u.info(`(Logged in to: ${c.join(", ")} \u2014 not this project's site.)`)),process.exitCode=1;return}let r=(e.apiBase||n.api_base||N.BASE).replace(/\/$/,""),o=`Bearer ${n.user_token}:${n.website_token}`,i;try{i=await fetch(`${r}/api/cli/whoami`,{method:"GET",headers:{Authorization:o,Accept:"application/json"},signal:AbortSignal.timeout(1e4)})}catch(c){u.error(`Network error: ${c.message}`),process.exitCode=1;return}if(i.status===404){if(e.json){process.stdout.write(`${JSON.stringify({logged_in:!0,server_supports_whoami:!1,api_base:r})}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seoagent-official/seoagent",
3
- "version": "1.81.1",
3
+ "version": "1.81.2",
4
4
  "description": "The persistent AI SEO agent for Claude Code. Audits, keyword strategy, briefs, articles, real product screenshots from your repo, and the autopilot loop (cloud detects → CLI executes → ack closes) — other SEO tools write the prompt, SEOAgent runs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -634,7 +634,7 @@ A **free SEOAgent Cloud account** already gives real DataForSEO volume/difficult
634
634
 
635
635
  **Paid upgrade** lifts the cap and unlocks two paid keyword commands:
636
636
  - `keywords --discover` — DataForSEO `keyword_ideas` seeded from your clusters/audience, classified, with worthwhile new targets added to `seoagent_keywords` as `status='suggested'` for the agent to triage.
637
- - `keywords --competitors` — finds keywords your competitors rank top-10 for that you don't track (discovers competitors via `competitors_domain`, merges with your tracked competitors, pulls each rival's `ranked_keywords`, excludes anything you already track). Gap keywords land in `seoagent_keywords` as `status='suggested'`, `opportunity='competitor_gap'`.
637
+ - `keywords --competitors` — finds keywords your competitors rank top-10 **organic** for that you don't track (discovers competitors via `competitors_domain`, merges with your tracked competitors, pulls each rival's `ranked_keywords`, excludes anything you already track). Gaps are ranked by opportunity (log-damped volume × ease), capped per competitor so one rival can't fill the report. Gap keywords land in `seoagent_keywords` as `status='suggested'`, `opportunity='competitor_gap'`; the analyzed competitor domains are saved to the cloud competitor table (dashboard → Competitors) without overwriting any synced `competitors.md` profiles.
638
638
 
639
639
  > **Cleaning up suggested noise.** `--discover` / `--competitors` add `status='suggested'` rows; on a thin or new site some are off-topic. **Relevance-check every suggested keyword and drop anything off-topic** — high volume / low difficulty is not enough. To clear the noise from the cloud inventory, run `seoagent keywords --purge` (removes only `suggested` rows; your clustered keywords are kept). `--purge --all` resets the whole inventory.
640
640