@appeeky/spec 0.1.0 → 0.2.0

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 (46) hide show
  1. package/README.md +158 -5
  2. package/agents/appeeky-ads.md +50 -0
  3. package/agents/appeeky-aso.md +58 -0
  4. package/agents/appeeky-releases.md +47 -0
  5. package/agents/appeeky-reviews.md +53 -0
  6. package/agents/appeeky-social.md +54 -0
  7. package/agents/appeeky-ugc.md +56 -0
  8. package/agents/cofounder.md +36 -0
  9. package/dist/ads.d.ts +5 -2
  10. package/dist/ads.js +8 -4
  11. package/dist/agents.d.ts +47 -0
  12. package/dist/agents.js +88 -0
  13. package/dist/cli.js +582 -9
  14. package/dist/dataforseo.d.ts +139 -0
  15. package/dist/dataforseo.js +354 -0
  16. package/dist/experiments.d.ts +4 -4
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.js +4 -0
  19. package/dist/report.d.ts +5 -0
  20. package/dist/report.js +42 -0
  21. package/dist/reviews.d.ts +2 -2
  22. package/dist/schema.d.ts +95 -2
  23. package/dist/schema.js +35 -0
  24. package/dist/search-ads.d.ts +137 -0
  25. package/dist/search-ads.js +354 -0
  26. package/dist/seo.d.ts +87 -0
  27. package/dist/seo.js +391 -0
  28. package/dist/setup.js +39 -6
  29. package/dist/social.d.ts +129 -0
  30. package/dist/social.js +189 -0
  31. package/dist/transport.d.ts +1 -0
  32. package/dist/transport.js +1 -1
  33. package/dist/ugc-core.js +1 -0
  34. package/dist/ugc-local.d.ts +116 -0
  35. package/dist/ugc-local.js +174 -0
  36. package/dist/ugc.d.ts +149 -0
  37. package/dist/ugc.js +111 -0
  38. package/dist/visibility-core.js +96 -0
  39. package/dist/visibility-local.d.ts +159 -0
  40. package/dist/visibility-local.js +246 -0
  41. package/dist/visibility.d.ts +3 -0
  42. package/dist/visibility.js +1 -1
  43. package/package.json +7 -3
  44. package/skills/appeeky-spec/SKILL.md +14 -18
  45. package/skills/appeeky-spec-experiments/SKILL.md +12 -2
  46. package/skills/appeeky-spec-visibility/SKILL.md +33 -6
package/dist/cli.js CHANGED
@@ -5,10 +5,18 @@ import { join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { Command, Option } from "commander";
7
7
  import { adsSnapshot, CHANNEL_LABELS, saveAdsSnapshot } from "./ads.js";
8
+ import { COFOUNDER_COMMAND, installAgents, parseTargets, TARGET_DIRS } from "./agents.js";
8
9
  import { attributionClient, attributionClients, attributionMetricSource, createExperimentLink, hasClients, IOS_LINK_NOTE, iosOnlyLink, isClient, linkResult, linkStore } from "./attribution.js";
9
10
  import { pullCompetitors, similarApps } from "./competitors.js";
11
+ import { draftIssues, fetchSocial, readAccounts, readDrafts, resolveAccounts, saveSocial, sendDraft } from "./social.js";
12
+ import { fetchUgc, findPersona, generateBrief, quoteBrief, readBriefs, readPersonas, saveUgc } from "./ugc.js";
13
+ import { generateLocal, loadCore, planLocalClip, readAppeekyPersona, readLocalPersona } from "./ugc-local.js";
10
14
  import { categoryFile, categoryMetricSource, categoryPlans, chartSources, previousRank, readCategoryHistory, readCategoryRanks, saveCategoryRanks } from "./category.js";
11
15
  import { applySync, bootstrapIntents, languageFor, planSync, pullVisibility, readRemoteIntents, scanVisibility, visibilityMetricSource, visibilityTarget } from "./visibility.js";
16
+ import { appContext, daysSinceLocalScan, KEY_NAMES, loadVisibilityCore, localVisibilityMetricSource, planLocalScan, scanLocal } from "./visibility-local.js";
17
+ import { pullSearchAds, SearchAdsClient, searchAdsMetricSource, searchAdsSource } from "./search-ads.js";
18
+ import { daysSinceRanks, pullDomain, pullKeywords, pullRanks, quoteSeo, readDataForSeo, webKeywords, webRankMetricSource } from "./dataforseo.js";
19
+ import { checkSite, llmsTxt, pullSearchConsole, readServiceAccount, searchConsoleProperty, seoMetricSource } from "./seo.js";
12
20
  import { attributionBlock, initHosted, initSelfHosted, nextSteps, specStores } from "./attribution-setup.js";
13
21
  import { pullData } from "./data.js";
14
22
  import { combineSources } from "./metrics.js";
@@ -17,7 +25,7 @@ import { AppeekyApi, appeekyRankSource, loadPopularity, metricSourceFor } from "
17
25
  import { closeExperiment, dueExperiments, experimentId, readExperiments, writeExperiment } from "./experiments.js";
18
26
  import { appendPlaybook, experimentApp, measureExperiments, playbookLine, verifyReleases } from "./measure.js";
19
27
  import { formatChange, isMetricName, METRICS, metricAfter, metricBefore } from "./metrics.js";
20
- import { loadProjectAtRef, specReport } from "./report.js";
28
+ import { loadProjectAtRef, outboxReport, specReport } from "./report.js";
21
29
  import { checkScreenshots, pullScreenshots } from "./screenshots.js";
22
30
  import { appendKeyword, crossLocalizationSuggestions, keywordSuggestions } from "./suggest.js";
23
31
  import { firstRankSource, itunesRankSource, rankAppIds, snapshotRanks } from "./verify.js";
@@ -283,6 +291,10 @@ export function buildProgram() {
283
291
  if (result.status !== 0)
284
292
  console.log(c.yellow(" The skills did not install. Run `npx skills add appeeky/appeeky-spec` later."));
285
293
  }
294
+ if (await agree(`Add the specialist agents and the /${COFOUNDER_COMMAND} review for Claude Code?`)) {
295
+ printInstalled(await installAgents(root, ["claude"]));
296
+ console.log(c.dim(` In Claude Code, run /${COFOUNDER_COMMAND} for a growth review. Other agents: \`appeeky-spec agents install --for opencode\`.`));
297
+ }
286
298
  step(6, "Where you stand");
287
299
  if (pulled && targets.length)
288
300
  await program.parseAsync(["coverage", "--country", country], { from: "user" });
@@ -330,6 +342,27 @@ export function buildProgram() {
330
342
  console.log(` ${c.dim(x.fix)}`);
331
343
  }
332
344
  }
345
+ function printInstalled(files) {
346
+ const mark = { written: c.green("✓"), unchanged: c.dim("="), kept: c.yellow("!") };
347
+ for (const f of files)
348
+ console.log(` ${mark[f.status]} ${f.path}${f.status === "kept" ? c.dim(" (yours differs: kept, --force to replace)") : ""}`);
349
+ }
350
+ const agents = program.command("agents").description("Specialist subagents (ASO, reviews, ads, releases, UGC, social) and the co-founder review, for your coding agent");
351
+ agents
352
+ .command("install")
353
+ .description(`Write the specialists and the /${COFOUNDER_COMMAND} command into this repo (${Object.values(TARGET_DIRS).map((d) => d.agents).join(", ")})`)
354
+ .option("--for <targets>", "claude, opencode or all, comma separated", "claude")
355
+ .option("--force", "Replace files you changed", false)
356
+ .action(async (opts) => {
357
+ const root = resolve(g().cwd);
358
+ const files = await installAgents(root, parseTargets(opts.for), { force: opts.force });
359
+ if (g().json) {
360
+ printJson({ root, files });
361
+ return;
362
+ }
363
+ printInstalled(files);
364
+ console.log(c.dim(`Commit them. Then run /${COFOUNDER_COMMAND} in your agent, or schedule it (examples/weekly/).`));
365
+ });
333
366
  program
334
367
  .command("doctor")
335
368
  .description("What is set up for this repo, what is missing, and how to fix it")
@@ -579,15 +612,16 @@ export function buildProgram() {
579
612
  });
580
613
  program
581
614
  .command("ads")
582
- .description("Spend and installs of your paid channels: Apple Search Ads, plus the Meta and TikTok campaigns under ads: (needs APPEEKY_API_KEY)")
615
+ .description("Spend and installs of your paid channels: Apple Search Ads (from Appeeky, or appeeky/search-ads/ pulled with your own key), plus the Meta and TikTok campaigns under ads: (needs APPEEKY_API_KEY)")
583
616
  .option("--days <n>", "How many days, up to yesterday", "30")
584
617
  .option("--save", "Write the numbers to appeeky/ads/<date>.yaml", false)
585
618
  .action(async (opts) => {
586
619
  const project = await load();
587
620
  const a = api();
588
- if (!a)
589
- throw new Error("Set APPEEKY_API_KEY: ad data comes from the channels you connected in Appeeky.");
590
- const snapshot = await adsSnapshot(a, project.spec, experimentApp(project, {}), Number(opts.days) || 30);
621
+ const own = searchAdsMetricSource(project.root);
622
+ if (!a && !own)
623
+ throw new Error("Set APPEEKY_API_KEY (ad data from the channels you connected in Appeeky), or pull Apple Search Ads with your own key: appeeky-spec search-ads pull.");
624
+ const snapshot = await adsSnapshot(a ?? null, project.spec, experimentApp(project, {}), Number(opts.days) || 30, new Date(), own);
591
625
  const saved = opts.save ? await saveAdsSnapshot(project.root, snapshot) : undefined;
592
626
  if (g().json) {
593
627
  printJson({ ...snapshot, ...(saved ? { saved } : {}) });
@@ -741,6 +775,222 @@ export function buildProgram() {
741
775
  for (const f of failed)
742
776
  console.log(`${c.red(f.id)} ${c.dim(f.message)}`);
743
777
  });
778
+ const social = program.command("social").description("Organic social media in the repo: accounts, posts and their results, and posts sent through a pull request (needs APPEEKY_API_KEY)");
779
+ social
780
+ .command("pull")
781
+ .description("Write the connected accounts, recent posts and their analytics to appeeky/social/, and summary.md")
782
+ .option("--limit <n>", "How many recent posts", "100")
783
+ .action(async (opts) => {
784
+ const project = await load();
785
+ const a = api();
786
+ if (!a)
787
+ throw new Error("Set APPEEKY_API_KEY: posts come from the social accounts connected in Appeeky.");
788
+ const data = await fetchSocial(a, { limit: Number(opts.limit) || 100 });
789
+ await saveSocial(project.root, data);
790
+ if (g().json) {
791
+ printJson({ accounts: data.accounts.length, posts: data.posts.length });
792
+ return;
793
+ }
794
+ console.log(`${c.bold("Accounts")} ${data.accounts.map((x) => `${x.platform} (${x.name})`).join(", ") || c.dim("none connected")}`);
795
+ console.log(`${c.bold("Posts")} ${data.posts.length} · ${data.posts.filter((p) => p.platforms.some((x) => x.publishedAt)).length} published`);
796
+ console.log(c.dim("Written to appeeky/social/: accounts.yaml, posts.jsonl, summary.md."));
797
+ });
798
+ social
799
+ .command("check")
800
+ .description("Check the drafts in appeeky/social/drafts/: connected accounts, length, media, time")
801
+ .action(async () => {
802
+ const project = await load();
803
+ const accounts = await readAccounts(project.root);
804
+ const drafts = (await readDrafts(project.root)).filter((d) => d.status === "draft");
805
+ const results = drafts.map((d) => ({ file: d.file.replace(project.root + "/", ""), issues: draftIssues(d, accounts) }));
806
+ if (g().json)
807
+ printJson({ drafts: results });
808
+ else if (!results.length)
809
+ console.log(c.dim("No drafts in appeeky/social/drafts/."));
810
+ else
811
+ for (const r of results)
812
+ console.log(`${r.issues.length ? c.red("✗") : c.green("✓")} ${r.file}${r.issues.length ? `\n ${c.dim(r.issues.join("; "))}` : ""}`);
813
+ if (results.some((r) => r.issues.length))
814
+ process.exitCode = 1;
815
+ });
816
+ social
817
+ .command("push")
818
+ .description("Send the drafts in appeeky/social/drafts/: each goes out now, or at its `when`")
819
+ .option("--yes", "Send without asking", false)
820
+ .action(async (opts) => {
821
+ const project = await load();
822
+ const a = api();
823
+ if (!a)
824
+ throw new Error("Set APPEEKY_API_KEY: posts go out through the social accounts connected in Appeeky.");
825
+ const accounts = await readAccounts(project.root);
826
+ const drafts = (await readDrafts(project.root)).filter((d) => d.status === "draft");
827
+ const blocked = drafts.filter((d) => draftIssues(d, accounts).length);
828
+ if (blocked.length)
829
+ throw new Error(`Fix first (appeeky-spec social check): ${blocked.map((d) => `${d.file.replace(project.root + "/", "")} (${draftIssues(d, accounts).join("; ")})`).join("; ")}`);
830
+ if (drafts.length === 0) {
831
+ if (g().json)
832
+ printJson({ sent: [] });
833
+ else
834
+ console.log(c.dim("No drafts to send."));
835
+ return;
836
+ }
837
+ if (!g().json)
838
+ for (const d of drafts) {
839
+ const to = resolveAccounts(d, accounts).accounts.map((x) => `${x.platform} (${x.name})`).join(", ");
840
+ console.log(`${c.bold(d.file.replace(project.root + "/", ""))} ${c.dim(`→ ${to}, ${d.when ? `at ${d.when}` : "now"}`)}\n ${d.text}`);
841
+ }
842
+ if (!opts.yes && !(await confirm(`Publish ${drafts.length} post${drafts.length === 1 ? "" : "s"}? They go out on the accounts above.`)))
843
+ return;
844
+ const sent = [];
845
+ const failed = [];
846
+ for (const d of drafts) {
847
+ const file = d.file.replace(project.root + "/", "");
848
+ try {
849
+ sent.push({ file, postId: await sendDraft(a, d, accounts) });
850
+ }
851
+ catch (e) {
852
+ failed.push({ file, message: e.message });
853
+ }
854
+ }
855
+ if (g().json)
856
+ printJson({ sent, failed });
857
+ else {
858
+ console.log(c.green(`Sent ${sent.length}.`));
859
+ for (const f of failed)
860
+ console.log(`${c.red(f.file)} ${c.dim(f.message)}`);
861
+ }
862
+ if (failed.length)
863
+ process.exitCode = 1;
864
+ });
865
+ const ugc = program.command("ugc").description("UGC from your Appeeky creator personas: clips in the repo, and videos made from briefs (needs APPEEKY_API_KEY)");
866
+ ugc
867
+ .command("pull")
868
+ .description("Write your creator personas and their clips to appeeky/ugc/")
869
+ .option("--limit <n>", "How many recent clips", "100")
870
+ .action(async (opts) => {
871
+ const project = await load();
872
+ const a = api();
873
+ if (!a)
874
+ throw new Error("Set APPEEKY_API_KEY: personas and clips come from Appeeky UGC.");
875
+ const data = await fetchUgc(a, { limit: Number(opts.limit) || 100 });
876
+ await saveUgc(project.root, data);
877
+ if (g().json) {
878
+ printJson({ personas: data.personas.length, clips: data.clips.length });
879
+ return;
880
+ }
881
+ console.log(`${c.bold("Personas")} ${data.personas.map((p) => `${p.name}${p.ready ? "" : c.dim(" (no look yet)")}`).join(", ") || c.dim("none yet: create one in Appeeky (UGC)")}`);
882
+ console.log(`${c.bold("Clips")} ${data.clips.length}`);
883
+ console.log(c.dim("Written to appeeky/ugc/: personas.yaml, clips.jsonl."));
884
+ });
885
+ ugc
886
+ .command("generate")
887
+ .description("Make a clip from each draft brief in appeeky/ugc/briefs/ (uses Appeeky media credits, or your own fal key with --local)")
888
+ .option("--dry-run", "Only show what each clip would cost", false)
889
+ .option("--yes", "Generate without asking", false)
890
+ .option("--local", "Render here with your own FAL_KEY (fal bills you; no Appeeky credits)", false)
891
+ .action(async (opts) => {
892
+ const project = await load();
893
+ if (opts.local)
894
+ return generateLocalClips(project.root, opts);
895
+ const a = api();
896
+ if (!a)
897
+ throw new Error("Set APPEEKY_API_KEY: clips are made with Appeeky UGC.");
898
+ const personas = await readPersonas(project.root);
899
+ const briefs = (await readBriefs(project.root)).filter((b) => b.status === "draft");
900
+ const unknown = briefs.filter((b) => !findPersona(b, personas));
901
+ if (unknown.length)
902
+ throw new Error(`Unknown persona (run \`appeeky-spec ugc pull\`): ${unknown.map((b) => `${b.persona} in ${b.file.replace(project.root + "/", "")}`).join("; ")}`);
903
+ const notReady = briefs.filter((b) => findPersona(b, personas).ready === false);
904
+ if (notReady.length)
905
+ throw new Error(`Pick a look (reference images) in Appeeky UGC first for: ${[...new Set(notReady.map((b) => b.persona))].join(", ")}`);
906
+ if (briefs.length === 0) {
907
+ if (g().json)
908
+ printJson({ briefs: [] });
909
+ else
910
+ console.log(c.dim("No draft briefs in appeeky/ugc/briefs/."));
911
+ return;
912
+ }
913
+ const quotes = [];
914
+ for (const b of briefs)
915
+ quotes.push({ file: b.file.replace(project.root + "/", ""), persona: b.persona, ...(await quoteBrief(a, b, findPersona(b, personas))) });
916
+ const credits = quotes.reduce((n, q) => n + q.credits, 0);
917
+ if (!g().json)
918
+ for (const q of quotes)
919
+ console.log(`${c.bold(q.file)} ${c.dim(`${q.persona} · ${q.credits} credits${q.affordable ? "" : " · not enough credits"}`)}`);
920
+ if (opts.dryRun) {
921
+ if (g().json)
922
+ printJson({ dryRun: true, briefs: quotes, credits });
923
+ else
924
+ console.log(c.dim(`\n${credits} credits in all. Nothing was made.`));
925
+ return;
926
+ }
927
+ if (quotes.some((q) => !q.affordable))
928
+ throw new Error("Not enough media credits for every brief. Top up in Appeeky, or remove a brief.");
929
+ if (!opts.yes && !(await confirm(`Make ${briefs.length} clip${briefs.length === 1 ? "" : "s"} for ${credits} credits?`)))
930
+ return;
931
+ const made = [];
932
+ for (const b of briefs)
933
+ made.push({ file: b.file.replace(project.root + "/", ""), ...(await generateBrief(a, b, findPersona(b, personas))) });
934
+ if (g().json)
935
+ printJson({ made, credits });
936
+ else
937
+ console.log(c.green(`Started ${made.length}. They render in Appeeky; \`appeeky-spec ugc pull\` records them when done.`));
938
+ });
939
+ async function generateLocalClips(root, opts) {
940
+ const falKey = process.env.FAL_KEY?.trim();
941
+ if (!falKey && !opts.dryRun)
942
+ throw new Error("Set FAL_KEY (fal.ai → API keys): local clips are billed to your fal account.");
943
+ const briefs = (await readBriefs(root)).filter((b) => b.status === "draft");
944
+ if (briefs.length === 0) {
945
+ if (g().json)
946
+ printJson({ briefs: [] });
947
+ else
948
+ console.log(c.dim("No draft briefs in appeeky/ugc/briefs/."));
949
+ return;
950
+ }
951
+ const core = await loadCore();
952
+ const a = api();
953
+ const personas = new Map();
954
+ for (const b of briefs) {
955
+ if (personas.has(b.persona))
956
+ continue;
957
+ const p = (await readLocalPersona(root, b.persona)) ?? (a ? await readAppeekyPersona(a, b.persona) : undefined);
958
+ if (!p)
959
+ throw new Error(`Unknown persona ${b.persona}: add appeeky/ugc/personas/${b.persona.toLowerCase()}.yaml with its looks${a ? "" : ", or set APPEEKY_API_KEY to use one from Appeeky"}.`);
960
+ personas.set(b.persona, p);
961
+ }
962
+ const rel = (f) => f.replace(root + "/", "");
963
+ const quotes = briefs.map((b) => {
964
+ const plan = planLocalClip(core, personas.get(b.persona), b, []);
965
+ return { file: rel(b.file), persona: b.persona, ...plan.clip, costUsd: plan.costUsd };
966
+ });
967
+ const total = Math.round(quotes.reduce((n, q) => n + q.costUsd, 0) * 100) / 100;
968
+ if (!g().json)
969
+ for (const q of quotes)
970
+ console.log(`${c.bold(q.file)} ${c.dim(`${q.persona} · ${q.durationSec}s ${q.aspectRatio} · ${q.videoModelId} · about $${q.costUsd.toFixed(2)} on fal`)}`);
971
+ if (opts.dryRun) {
972
+ if (g().json)
973
+ printJson({ dryRun: true, local: true, briefs: quotes, costUsd: total });
974
+ else
975
+ console.log(c.dim(`\nAbout $${total.toFixed(2)} on your fal account in all. Nothing was made.`));
976
+ return;
977
+ }
978
+ if (!opts.yes && !(await confirm(`Make ${briefs.length} clip${briefs.length === 1 ? "" : "s"} on fal for about $${total.toFixed(2)}?`)))
979
+ return;
980
+ const made = [];
981
+ for (const b of briefs) {
982
+ if (!g().json)
983
+ console.log(c.bold(rel(b.file)));
984
+ const r = await generateLocal(core, root, b, personas.get(b.persona), falKey, { log: (l) => !g().json && console.log(c.dim(` ${l}`)) });
985
+ made.push({ file: rel(b.file), clip: rel(r.file), videoUrl: r.videoUrl, costUsd: r.costUsd });
986
+ if (!g().json)
987
+ console.log(` ${c.green("✓")} ${rel(r.file)}`);
988
+ }
989
+ if (g().json)
990
+ printJson({ made, costUsd: total });
991
+ else
992
+ console.log(c.dim("\nThe clips are in .appeeky/media/ (gitignored); each brief has its videoUrl for a social draft's media:."));
993
+ }
744
994
  const competitors = program.command("competitors").description("Competitors in the repo: their listing, ranks and reviews (needs APPEEKY_API_KEY)");
745
995
  competitors
746
996
  .command("pull")
@@ -802,7 +1052,7 @@ export function buildProgram() {
802
1052
  });
803
1053
  const visibility = program
804
1054
  .command("visibility")
805
- .description("Whether ChatGPT, Claude, Gemini and Perplexity name your app, from Appeeky AI Visibility (needs APPEEKY_API_KEY)");
1055
+ .description("Whether ChatGPT, Claude, Gemini and Perplexity name your app: from Appeeky AI Visibility (APPEEKY_API_KEY), or with --local on your own model keys");
806
1056
  const needKey = () => {
807
1057
  const a = api();
808
1058
  if (!a)
@@ -819,7 +1069,10 @@ export function buildProgram() {
819
1069
  .command("init")
820
1070
  .description("Write the intents and prompts Appeeky tracks into appeeky.spec.yaml; with none yet, have Appeeky write them from your listing")
821
1071
  .option("--country <cc>", "Storefront (default: aiVisibility.countries, or the first under targets)")
1072
+ .option("--local", `Write them with your own model key (${KEY_NAMES}) instead of Appeeky`, false)
822
1073
  .action(async (opts) => {
1074
+ if (opts.local)
1075
+ return initLocal(opts.country);
823
1076
  const project = await load();
824
1077
  const a = needKey();
825
1078
  const t = watched(project);
@@ -935,9 +1188,15 @@ export function buildProgram() {
935
1188
  });
936
1189
  visibility
937
1190
  .command("scan")
938
- .description("Ask the assistants now instead of waiting for the schedule (uses AI Visibility credits)")
1191
+ .description("Ask the assistants now: in Appeeky (AI Visibility credits), or with --local on your own model keys")
939
1192
  .option("--country <cc>", "One storefront only")
1193
+ .option("--local", `Ask them from here with your own keys (${KEY_NAMES}) and write appeeky/visibility/`, false)
1194
+ .option("--every <days>", "With --local: skip a storefront scanned in the last <days> days (for a daily schedule)")
1195
+ .option("--dry-run", "With --local: show the prompts, assistants and cost, and ask nothing", false)
1196
+ .option("-y, --yes", "With --local: do not ask before spending", false)
940
1197
  .action(async (opts) => {
1198
+ if (opts.local)
1199
+ return scanLocalCmd(opts);
941
1200
  const project = await load();
942
1201
  const a = needKey();
943
1202
  const t = watched(project);
@@ -950,6 +1209,312 @@ export function buildProgram() {
950
1209
  }
951
1210
  console.log(c.green(`Scan started for ${countries.map((x) => x.toUpperCase()).join(", ")}.`) + c.dim(" Pull the results in a few minutes: appeeky-spec visibility pull"));
952
1211
  });
1212
+ async function initLocal(country) {
1213
+ const project = await load();
1214
+ const core = await loadVisibilityCore();
1215
+ const helper = core.helperFor(process.env);
1216
+ if (!helper)
1217
+ throw new Error(`Set ${KEY_NAMES}: the intents and prompts are written by your own model.`);
1218
+ const t = visibilityTarget(project);
1219
+ const countries = country ? [country.toLowerCase()] : (t?.countries ?? [Object.keys(project.spec.targets)[0] ?? "us"]);
1220
+ const spec = project.spec;
1221
+ spec.aiVisibility ??= { intents: {} };
1222
+ let cost = 0;
1223
+ const report = [];
1224
+ for (const cc of countries) {
1225
+ const have = spec.aiVisibility.intents[cc] ?? [];
1226
+ if (have.length) {
1227
+ report.push({ country: cc, intents: have.length, skipped: true });
1228
+ continue;
1229
+ }
1230
+ const r = await core.generateIntents(helper, appContext(project, cc));
1231
+ cost += r.costUsd;
1232
+ spec.aiVisibility.intents[cc] = r.intents;
1233
+ report.push({ country: cc, intents: r.intents.length });
1234
+ }
1235
+ if (country && t && !t.countries.includes(countries[0]))
1236
+ spec.aiVisibility.countries = [...t.countries, countries[0]];
1237
+ if (report.some((r) => !r.skipped && r.intents))
1238
+ await writeSpec(project.root, spec);
1239
+ if (g().json) {
1240
+ printJson({ model: helper.modelId, costUsd: cost, report });
1241
+ return;
1242
+ }
1243
+ for (const r of report) {
1244
+ if (r.skipped)
1245
+ console.log(c.dim(`${r.country.toUpperCase()}: already has ${r.intents} intents in appeeky.spec.yaml; edit them there.`));
1246
+ else
1247
+ console.log(`${c.green("✓")} ${r.country.toUpperCase()}: wrote ${r.intents} intents to aiVisibility.intents in appeeky.spec.yaml`);
1248
+ }
1249
+ console.log(c.dim(`\n${helper.modelId} on your key, about $${cost.toFixed(3)}. Edit them, then: appeeky-spec visibility scan --local --dry-run`));
1250
+ }
1251
+ async function scanLocalCmd(opts) {
1252
+ const project = await load();
1253
+ const core = await loadVisibilityCore();
1254
+ const t = visibilityTarget(project);
1255
+ const all = Object.keys(project.spec.aiVisibility?.intents ?? {});
1256
+ const countries = opts.country ? [opts.country.toLowerCase()] : (t?.countries ?? all).filter((cc) => all.includes(cc));
1257
+ if (countries.length === 0)
1258
+ throw new Error("No aiVisibility.intents in appeeky.spec.yaml. Write them with appeeky-spec visibility init --local.");
1259
+ const every = opts.every ? Number(opts.every) : 0;
1260
+ const plans = [];
1261
+ for (const cc of countries) {
1262
+ const since = every ? await daysSinceLocalScan(project.root, cc) : null;
1263
+ if (since !== null && since < every) {
1264
+ if (!g().json)
1265
+ console.log(c.dim(`${cc.toUpperCase()}: scanned ${since} day${since === 1 ? "" : "s"} ago, next in ${every - since}.`));
1266
+ continue;
1267
+ }
1268
+ plans.push(planLocalScan(core, project, cc, process.env));
1269
+ }
1270
+ if (plans.length === 0)
1271
+ return;
1272
+ if (!plans[0].helper || plans[0].models.length === 0)
1273
+ throw new Error(`Set ${KEY_NAMES} to scan with your own keys. Each key adds that assistant.`);
1274
+ const total = plans.reduce((n, p) => n + p.quoteUsd, 0);
1275
+ if (!g().json || opts.dryRun) {
1276
+ for (const p of plans)
1277
+ console.log(`${p.country.toUpperCase()} ${p.intents.length} intents · ${p.prompts} prompts × ${p.models.join(", ")} · about $${p.quoteUsd.toFixed(2)}`);
1278
+ const missing = core.MODELS.filter((m) => !plans[0].models.includes(m));
1279
+ if (missing.length)
1280
+ console.log(c.dim(`Not asked (no key): ${missing.join(", ")}.`));
1281
+ }
1282
+ if (opts.dryRun) {
1283
+ if (g().json)
1284
+ printJson({ plans: plans.map(({ helper, ...p }) => ({ ...p, extractor: helper?.modelId })), totalUsd: total });
1285
+ else
1286
+ console.log(c.dim(`About $${total.toFixed(2)} on your own keys in all. Nothing was asked.`));
1287
+ return;
1288
+ }
1289
+ if (!opts.yes && !(await confirm(`Ask the assistants for about $${total.toFixed(2)} on your own keys?`)))
1290
+ return;
1291
+ const results = [];
1292
+ for (const p of plans) {
1293
+ const r = await scanLocal(core, project, p, process.env, { log: g().json ? undefined : (l) => console.log(c.dim(` ${p.country.toUpperCase()} ${l}`)) });
1294
+ results.push(r);
1295
+ }
1296
+ if (g().json) {
1297
+ printJson(results.map(({ data, ...r }) => ({ ...r, summary: data.summary })));
1298
+ return;
1299
+ }
1300
+ for (const r of results) {
1301
+ const s = r.data.summary;
1302
+ console.log(`${c.green("✓")} ${r.file.replace(project.root + "/", "")} ${c.green(`${s.winning} named`)} · ${c.yellow(`${s.weak} weak`)} · ${c.red(`${s.missing} missing`)} ${c.dim(`$${r.costUsd.toFixed(2)}`)}`);
1303
+ for (const i of r.data.intents.filter((x) => x.status === "missing").slice(0, 3))
1304
+ console.log(` ${c.red("missing")} "${i.label}"${i.namedInstead ? c.dim(`: they name ${i.namedInstead.slice(0, 3).join(", ")}`) : ""}`);
1305
+ for (const ch of r.changes)
1306
+ console.log(` ${c.yellow("changed")} ${ch}`);
1307
+ const failedBy = [...new Set(r.failed.map((f) => f.model))];
1308
+ for (const m of failedBy)
1309
+ console.log(` ${c.red("✗")} ${m}: ${r.failed.find((f) => f.model === m).error}`);
1310
+ }
1311
+ }
1312
+ const searchAds = program.command("search-ads").description("Apple Search Ads on your own API key: this app's spend, installs, keywords and search terms in appeeky/search-ads/");
1313
+ searchAds
1314
+ .command("pull")
1315
+ .description("Write spend and installs per day, keywords and search terms to appeeky/search-ads/ (the Search Ads key on your appstore CLI account, or ASA_* env)")
1316
+ .option("--days <n>", "How many days, up to yesterday", "30")
1317
+ .action(async (opts) => {
1318
+ const project = await load();
1319
+ const { label, source } = await searchAdsSource(project);
1320
+ let r;
1321
+ try {
1322
+ r = await pullSearchAds(project, new SearchAdsClient(source), new Date(), Number(opts.days) || 30);
1323
+ }
1324
+ catch (e) {
1325
+ const msg = e.message;
1326
+ if (label.startsWith("appstore") && /no Apple Search Ads key|Could not start|AUTH_ERROR/i.test(msg)) {
1327
+ throw new Error(`${msg}\nSave the Search Ads key on your App Store Connect CLI account (appstore accounts search-ads <account> --client-id … --team-id … --key-id … --private-key-path …), or set ASA_CLIENT_ID, ASA_TEAM_ID, ASA_KEY_ID and ASA_PRIVATE_KEY.`);
1328
+ }
1329
+ throw e;
1330
+ }
1331
+ if (g().json) {
1332
+ printJson(r);
1333
+ return;
1334
+ }
1335
+ const money = (n) => `${n.toFixed(2)}${r.currency ? ` ${r.currency}` : ""}`;
1336
+ if (r.campaigns.length === 0) {
1337
+ console.log(c.yellow(`No campaign for App Store id ${project.spec.stores.appStore?.appId} in org ${r.orgId}.`));
1338
+ return;
1339
+ }
1340
+ console.log(`${c.green("✓")} appeeky/search-ads/ ${r.campaigns.map((x) => x.name).join(", ")}`);
1341
+ console.log(` ${opts.days} days: ${money(r.last30.spend)} · ${r.last30.installs} installs${r.last30.installs ? ` · ${money(r.last30.spend / r.last30.installs)} each` : ""} · ${r.last30.taps} taps`);
1342
+ console.log(c.dim(` ${r.keywords} keywords, ${r.searchTerms} search terms. Read appeeky/search-ads/summary.md.`));
1343
+ for (const e of r.errors)
1344
+ console.log(` ${c.yellow("!")} ${e}`);
1345
+ });
1346
+ const seo = program.command("seo").description("The app's website in Google and in AI answers: check it, write llms.txt, and pull Search Console with your own service account");
1347
+ const siteUrl = (project, url) => {
1348
+ const u = url ?? project.spec.web?.url;
1349
+ if (!u)
1350
+ throw new Error("Pass the site's URL, or set web.url in appeeky.spec.yaml.");
1351
+ return u;
1352
+ };
1353
+ seo
1354
+ .command("check")
1355
+ .description("Read the site like a crawler: what is missing for search, for the app and for AI assistants")
1356
+ .argument("[url]", "Page to check (default: web.url)")
1357
+ .action(async (url) => {
1358
+ const project = await load();
1359
+ const target = siteUrl(project, url);
1360
+ const checks = await checkSite(project, target);
1361
+ if (g().json) {
1362
+ printJson({ url: target, checks });
1363
+ return;
1364
+ }
1365
+ const titles = { search: "Search", app: "The app", ai: "AI assistants" };
1366
+ for (const area of ["search", "app", "ai"]) {
1367
+ const list = checks.filter((x) => x.area === area);
1368
+ if (!list.length)
1369
+ continue;
1370
+ console.log(c.bold(`\n${titles[area]}`));
1371
+ for (const x of list) {
1372
+ const mark = x.level === "ok" ? c.green("✓") : x.level === "warn" ? c.yellow("!") : c.red("✗");
1373
+ console.log(` ${mark} ${x.message}${x.fix ? c.dim(`\n ${x.fix}`) : ""}`);
1374
+ }
1375
+ }
1376
+ const bad = checks.filter((x) => x.level !== "ok").length;
1377
+ console.log(c.dim(`\n${checks.length - bad} of ${checks.length} fine.`));
1378
+ if (checks.some((x) => x.level === "fail"))
1379
+ process.exitCode = 1;
1380
+ });
1381
+ const dfs = () => {
1382
+ const auth = readDataForSeo(process.env);
1383
+ if (!auth)
1384
+ throw new Error("Set DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD (app.dataforseo.com → API access). DataForSEO bills each call.");
1385
+ return auth;
1386
+ };
1387
+ const quoted = async (project, what, opts) => {
1388
+ const sets = webKeywords(project);
1389
+ if (what !== "domain" && Object.keys(sets).length === 0)
1390
+ throw new Error("No keywords: add web.keywords.<country> (or targets) to appeeky.spec.yaml.");
1391
+ const usd = quoteSeo(project, what);
1392
+ const n = Object.values(sets).reduce((a, k) => a + k.length, 0);
1393
+ if (!g().json || opts.dryRun)
1394
+ console.log(`${what === "domain" ? `${project.spec.web?.url ?? ""}` : `${n} keywords in ${Object.keys(sets).map((x) => x.toUpperCase()).join(", ")}`} · about $${usd.toFixed(2)} on DataForSEO`);
1395
+ if (opts.dryRun) {
1396
+ if (!g().json)
1397
+ console.log(c.dim("Nothing was asked."));
1398
+ else
1399
+ printJson({ what, keywords: sets, quoteUsd: usd });
1400
+ return false;
1401
+ }
1402
+ return opts.yes || (await confirm(`Spend about $${usd.toFixed(2)} on DataForSEO?`));
1403
+ };
1404
+ seo
1405
+ .command("ranks")
1406
+ .description("Where the site and the app's store page rank on Google for each keyword, and whether the AI Overview cites the site (DataForSEO)")
1407
+ .option("--every <days>", "Skip when the last check is newer than <days> days (for a daily schedule)")
1408
+ .option("--dry-run", "Show the keywords and the cost, ask nothing", false)
1409
+ .option("-y, --yes", "Do not ask before spending", false)
1410
+ .action(async (opts) => {
1411
+ const project = await load();
1412
+ const auth = opts.dryRun ? null : dfs();
1413
+ if (opts.every) {
1414
+ const since = await daysSinceRanks(project.root);
1415
+ if (since !== null && since < Number(opts.every)) {
1416
+ if (!g().json)
1417
+ console.log(c.dim(`Checked ${since} day${since === 1 ? "" : "s"} ago; next in ${Number(opts.every) - since}.`));
1418
+ return;
1419
+ }
1420
+ }
1421
+ if (!(await quoted(project, "ranks", opts)))
1422
+ return;
1423
+ const r = await pullRanks(project, auth);
1424
+ if (g().json) {
1425
+ printJson(r);
1426
+ return;
1427
+ }
1428
+ const ranked = r.ranks.filter((x) => x.position !== null);
1429
+ console.log(`${c.green("✓")} appeeky/seo/ranks.md ${ranked.length} of ${r.ranks.length} in the top 100 · ${r.ranks.filter((x) => x.position !== null && x.position <= 10).length} in the top 10 · AI Overview cites you ${r.ranks.filter((x) => x.aiCitesYou).length}× ${c.dim(`$${r.costUsd.toFixed(3)}`)}`);
1430
+ for (const x of r.ranks.slice(0, 10)) {
1431
+ const ai = x.aiOverview ? (x.aiCitesYou ? c.green("AI cites you") : c.dim(`AI cites ${x.aiCited.slice(0, 2).join(", ") || "no one"}`)) : "";
1432
+ console.log(` ${x.country.toUpperCase()} ${x.keyword.padEnd(28)} site ${String(x.position ?? "—").padEnd(4)} store page ${String(x.storePosition ?? "—").padEnd(4)} ${ai}`);
1433
+ }
1434
+ for (const ch of r.changes)
1435
+ console.log(` ${c.yellow("changed")} ${ch}`);
1436
+ for (const e of r.errors)
1437
+ console.log(` ${c.red("✗")} ${e}`);
1438
+ });
1439
+ seo
1440
+ .command("keywords")
1441
+ .description("Search volume, difficulty, CPC and intent for the keywords, and related ideas, into appeeky/seo/keywords.csv (DataForSEO)")
1442
+ .option("--dry-run", "Show the cost, ask nothing", false)
1443
+ .option("-y, --yes", "Do not ask before spending", false)
1444
+ .action(async (opts) => {
1445
+ const project = await load();
1446
+ const auth = opts.dryRun ? null : dfs();
1447
+ if (!(await quoted(project, "keywords", opts)))
1448
+ return;
1449
+ const r = await pullKeywords(project, auth);
1450
+ if (g().json) {
1451
+ printJson(r);
1452
+ return;
1453
+ }
1454
+ const tracked = r.rows.filter((x) => x.tracked);
1455
+ console.log(`${c.green("✓")} appeeky/seo/keywords.csv ${tracked.length} keywords, ${r.rows.length - tracked.length} ideas ${c.dim(`$${r.costUsd.toFixed(3)}`)}`);
1456
+ for (const x of tracked.slice(0, 8))
1457
+ console.log(` ${x.keyword.padEnd(30)} ${String(x.volume ?? "—").padStart(7)}/mo difficulty ${x.difficulty ?? "—"}`);
1458
+ const ideas = r.rows.filter((x) => !x.tracked && (x.volume ?? 0) > 0).slice(0, 5);
1459
+ if (ideas.length)
1460
+ console.log(c.dim(` ideas: ${ideas.map((x) => `${x.keyword} (${x.volume})`).join(", ")}`));
1461
+ });
1462
+ seo
1463
+ .command("domain")
1464
+ .description("What the site ranks for, its estimated visits and the sites it competes with, into appeeky/seo/domain.md (DataForSEO)")
1465
+ .option("--dry-run", "Show the cost, ask nothing", false)
1466
+ .option("-y, --yes", "Do not ask before spending", false)
1467
+ .action(async (opts) => {
1468
+ const project = await load();
1469
+ const auth = opts.dryRun ? null : dfs();
1470
+ if (!(await quoted(project, "domain", opts)))
1471
+ return;
1472
+ const { report, costUsd } = await pullDomain(project, auth);
1473
+ if (g().json) {
1474
+ printJson({ ...report, costUsd });
1475
+ return;
1476
+ }
1477
+ console.log(`${c.green("✓")} appeeky/seo/domain.md ${report.domain} ${report.country.toUpperCase()}: ${report.keywords} keywords · ${report.top10} in the top 10 · ~${report.traffic} visits/mo ${c.dim(`$${costUsd.toFixed(3)}`)}`);
1478
+ if (report.competitors.length)
1479
+ console.log(c.dim(` competes with ${report.competitors.slice(0, 5).map((x) => x.domain).join(", ")}`));
1480
+ });
1481
+ seo
1482
+ .command("llms")
1483
+ .description("Write llms.txt from the listing: what the app is, where to get it, and its description")
1484
+ .option("--out <file>", "Write it to a file, e.g. public/llms.txt (default: print it)")
1485
+ .action(async (opts) => {
1486
+ const project = await load();
1487
+ const text = llmsTxt(project);
1488
+ if (!opts.out) {
1489
+ process.stdout.write(text);
1490
+ return;
1491
+ }
1492
+ const file = resolve(g().cwd, opts.out);
1493
+ await mkdir(resolve(file, ".."), { recursive: true });
1494
+ await writeFile(file, text);
1495
+ if (!g().json)
1496
+ console.log(`${c.green("✓")} ${opts.out}`);
1497
+ });
1498
+ seo
1499
+ .command("pull")
1500
+ .description("Google Search Console clicks, impressions, queries and pages into appeeky/seo/ (needs GSC_CREDENTIALS)")
1501
+ .action(async () => {
1502
+ const project = await load();
1503
+ const sa = await readServiceAccount(process.env, { gcloud: true });
1504
+ if (!sa)
1505
+ throw new Error("Set GSC_CREDENTIALS to a Google service account JSON key (or its path) and add its email as a user of the property in Search Console, or log in on this machine: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/webmasters.readonly,https://www.googleapis.com/auth/cloud-platform");
1506
+ if (!searchConsoleProperty(project))
1507
+ throw new Error("Set web.url in appeeky.spec.yaml first.");
1508
+ const r = await pullSearchConsole(project, sa);
1509
+ if (g().json) {
1510
+ printJson(r);
1511
+ return;
1512
+ }
1513
+ const pct = (a, b) => (b > 0 ? c.dim(` (${a >= b ? "+" : ""}${Math.round(((a - b) / b) * 100)}%)`) : "");
1514
+ console.log(`${c.green("✓")} appeeky/seo/ ${r.property}`);
1515
+ console.log(` last 28 days: ${r.last28.clicks} clicks${pct(r.last28.clicks, r.previous28.clicks)} · ${r.last28.impressions} impressions${pct(r.last28.impressions, r.previous28.impressions)}`);
1516
+ console.log(c.dim(` ${r.queries} queries, ${r.pages} pages. Read appeeky/seo/summary.md.`));
1517
+ });
953
1518
  const category = program.command("category").description("Your rank in the category chart, one row a day in appeeky/category.csv");
954
1519
  category
955
1520
  .command("pull")
@@ -1130,9 +1695,17 @@ export function buildProgram() {
1130
1695
  const a = api();
1131
1696
  const attribution = attributionClients(project.spec, a);
1132
1697
  const visibility = a ? visibilityMetricSource(a, project) : null;
1698
+ const localVisibility = localVisibilityMetricSource(project);
1699
+ const search = seoMetricSource(project.root);
1700
+ const ownAds = searchAdsMetricSource(project.root);
1701
+ const webRank = webRankMetricSource(project.root);
1133
1702
  const metrics = combineSources([
1134
1703
  ...(a ? [metricSourceFor(a, project.spec)] : []),
1135
1704
  ...(visibility ? [visibility] : []),
1705
+ ...(localVisibility ? [localVisibility] : []),
1706
+ ...(search ? [search] : []),
1707
+ ...(ownAds ? [ownAds] : []),
1708
+ ...(webRank ? [webRank] : []),
1136
1709
  categoryMetricSource(project.root),
1137
1710
  reviewMetricSource(project.root),
1138
1711
  ...(hasClients(attribution) ? [attributionMetricSource(attribution)] : []),
@@ -1342,13 +1915,13 @@ export function buildProgram() {
1342
1915
  });
1343
1916
  program
1344
1917
  .command("report")
1345
- .description("Markdown for a pull request: listing changes, check results and coverage against a base ref")
1918
+ .description("Markdown for a pull request: listing changes, check results, coverage against a base ref, and the posts and clips a merge sends out")
1346
1919
  .option("--base <ref>", "Git ref to compare with", "origin/main")
1347
1920
  .option("-o, --output <file>", "Write the report to a file")
1348
1921
  .action(async (opts) => {
1349
1922
  const project = await load();
1350
1923
  const base = await loadProjectAtRef(project.root, opts.base);
1351
- const md = specReport(base, project);
1924
+ const md = specReport(base, project) + (await outboxReport(project.root));
1352
1925
  if (opts.output)
1353
1926
  await writeFile(opts.output, `${md}\n`);
1354
1927
  else