@appeeky/spec 0.0.0-stage → 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 (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1960 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { Command, Option } from "commander";
7
+ import { adsSnapshot, CHANNEL_LABELS, saveAdsSnapshot } from "./ads.js";
8
+ import { COFOUNDER_COMMAND, installAgents, parseTargets, TARGET_DIRS } from "./agents.js";
9
+ import { attributionClient, attributionClients, attributionMetricSource, createExperimentLink, hasClients, IOS_LINK_NOTE, iosOnlyLink, isClient, linkResult, linkStore } from "./attribution.js";
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";
14
+ import { categoryFile, categoryMetricSource, categoryPlans, chartSources, previousRank, readCategoryHistory, readCategoryRanks, saveCategoryRanks } from "./category.js";
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";
20
+ import { attributionBlock, initHosted, initSelfHosted, nextSteps, specStores } from "./attribution-setup.js";
21
+ import { pullData } from "./data.js";
22
+ import { combineSources } from "./metrics.js";
23
+ import { fetchReviews, readReplies, readReviews, replyIssues, reviewMetricSource, saveReviews, sendReply, writeReply, writeSummary } from "./reviews.js";
24
+ import { AppeekyApi, appeekyRankSource, loadPopularity, metricSourceFor } from "./appeeky-api.js";
25
+ import { closeExperiment, dueExperiments, experimentId, readExperiments, writeExperiment } from "./experiments.js";
26
+ import { appendPlaybook, experimentApp, measureExperiments, playbookLine, verifyReleases } from "./measure.js";
27
+ import { formatChange, isMetricName, METRICS, metricAfter, metricBefore } from "./metrics.js";
28
+ import { loadProjectAtRef, outboxReport, specReport } from "./report.js";
29
+ import { checkScreenshots, pullScreenshots } from "./screenshots.js";
30
+ import { appendKeyword, crossLocalizationSuggestions, keywordSuggestions } from "./suggest.js";
31
+ import { firstRankSource, itunesRankSource, rankAppIds, snapshotRanks } from "./verify.js";
32
+ import { projectCoverage } from "./coverage.js";
33
+ import { invertChanges } from "./diff.js";
34
+ import { loadProject, recordPull, SPEC_DIR, SPEC_FILE, writeListing, writeSpec } from "./files.js";
35
+ import { readReleases, releaseId, releaseMarkdown, writeRelease } from "./history.js";
36
+ import { freeStores, writeLoops } from "./loops.js";
37
+ import { diagnose, findApps, playPackageExists, suggestTargets, targetCandidates, WORKFLOWS } from "./setup.js";
38
+ import { hasErrors, lintProject } from "./lint.js";
39
+ import { c, formatChanges, formatCoverage, formatIssues, printJson, storeLabel } from "./output.js";
40
+ import { configuredStores, planStore, storeHandle } from "./project.js";
41
+ import { STORES } from "./schema.js";
42
+ const round = (v) => (v === undefined ? "?" : Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2));
43
+ /** One playbook line for a closed experiment. */
44
+ function experimentPlaybookLine(e) {
45
+ const value = e.result?.value !== undefined ? ` (${e.metric} ${round(e.baseline)} → ${round(e.result.value)})` : "";
46
+ return `- ${e.startedAt.slice(0, 10)}, ${e.type}${e.persona ? `, ${e.persona}` : ""}: ${e.hypothesis} → ${e.status}${value}${e.result?.note ? `. ${e.result.note}` : ""}`;
47
+ }
48
+ import { STORE_CLIS } from "./transport.js";
49
+ const PLAYBOOK = `# Playbook
50
+
51
+ What worked and what did not for this app. Appeeky Spec and your agent read this
52
+ before proposing a change, so keep each entry short and specific:
53
+
54
+ - YYYY-MM-DD, store, locale: what changed → what happened (and how sure we are)
55
+ `;
56
+ /** An account name from the app name: "Sleep Sounds" → "sleep-sounds". */
57
+ const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "app";
58
+ /** One line of input; Enter gives `fallback`. */
59
+ async function ask(question, fallback = "") {
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ const answer = await rl.question(`${question}${fallback ? c.dim(` (${fallback})`) : ""} `);
62
+ rl.close();
63
+ return answer.trim() || fallback;
64
+ }
65
+ /** A yes/no question that defaults to yes. */
66
+ async function agree(question) {
67
+ return !/^n(o)?$/i.test(await ask(`${question} [Y/n]`));
68
+ }
69
+ /** Keep the private data and cache out of git. */
70
+ async function ignoreLocalData(root) {
71
+ const file = join(root, ".gitignore");
72
+ const current = existsSync(file) ? await readFile(file, "utf8") : "";
73
+ if (/^\.appeeky\/?\s*$/m.test(current))
74
+ return false;
75
+ await writeFile(file, `${current}${current && !current.endsWith("\n") ? "\n" : ""}\n# Appeeky Spec: your analytics, cache and history stay local\n.appeeky/\n`);
76
+ return true;
77
+ }
78
+ /** Write the GitHub workflows that are not there yet. */
79
+ async function writeWorkflows(root) {
80
+ const dir = join(root, ".github", "workflows");
81
+ await mkdir(dir, { recursive: true });
82
+ const written = [];
83
+ for (const [file, body] of Object.entries(WORKFLOWS)) {
84
+ if (existsSync(join(dir, file)))
85
+ continue;
86
+ await writeFile(join(dir, file), body);
87
+ written.push(`.github/workflows/${file}`);
88
+ }
89
+ return written;
90
+ }
91
+ async function confirm(question) {
92
+ if (!process.stdin.isTTY)
93
+ return false;
94
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
95
+ const answer = await rl.question(`${question} [y/N] `);
96
+ rl.close();
97
+ return /^y(es)?$/i.test(answer.trim());
98
+ }
99
+ function storeOption() {
100
+ return new Option("-s, --store <store>", "Only this store").choices([...STORES]);
101
+ }
102
+ export function buildProgram() {
103
+ const program = new Command()
104
+ .name("appeeky-spec")
105
+ .description("Your app's store listing as a spec: check it, measure keyword coverage, diff it with the live store and push it.")
106
+ .version("0.1.0")
107
+ .option("-C, --cwd <dir>", "Run as if started in this folder", process.cwd())
108
+ .option("--json", "Machine-readable output", false)
109
+ .addOption(new Option("--via <mode>", "How to reach the stores (overrides the spec)").choices(["auto", "local", "hosted"]));
110
+ const g = () => program.opts();
111
+ const transportOverrides = () => (g().via ? { via: g().via } : {});
112
+ const load = () => loadProject(g().cwd);
113
+ const api = () => (process.env.APPEEKY_API_KEY ? new AppeekyApi(process.env.APPEEKY_API_KEY, { baseUrl: process.env.APPEEKY_API_URL }) : undefined);
114
+ /** Appeeky API ranks when a key is set (both stores), Apple's public search otherwise (App Store only). */
115
+ const rankSource = () => {
116
+ const a = api();
117
+ return a ? firstRankSource(appeekyRankSource(a), itunesRankSource()) : itunesRankSource();
118
+ };
119
+ program
120
+ .command("init")
121
+ .description(`Create ${SPEC_FILE} and the appeeky/ folder. With no options in a terminal, a guided setup`)
122
+ .option("--name <name>", "App name")
123
+ .option("--app-store <appId>", "App Store app id (numeric) or bundle id")
124
+ .option("--google-play <package>", "Google Play package name")
125
+ .option("--locale <locale>", "Primary locale", "en-US")
126
+ .option("--country <cc>", "First storefront to add targets for", "us")
127
+ .option("--pull", "Pull the live listings right away", false)
128
+ .option("--ci", "Write the GitHub workflows (pull request check, push on merge, daily verify); works on an existing spec too", false)
129
+ .action(async (opts) => {
130
+ const root = resolve(g().cwd);
131
+ const exists = existsSync(join(root, SPEC_FILE));
132
+ if (exists && opts.ci) {
133
+ const written = await writeWorkflows(root);
134
+ if (g().json)
135
+ printJson({ workflows: written });
136
+ else
137
+ console.log(written.length ? written.map((f) => `${c.green("Wrote")} ${f}`).join("\n") : c.dim("The workflows are already there."));
138
+ return;
139
+ }
140
+ if (exists)
141
+ throw new Error(`${SPEC_FILE} already exists in ${root}. Run \`appeeky-spec doctor\` to see what is set up.`);
142
+ const guided = !opts.name && !opts.appStore && !opts.googlePlay && process.stdin.isTTY && !g().json;
143
+ if (guided)
144
+ return wizard(root, opts.locale, opts.country);
145
+ const spec = {
146
+ version: 1,
147
+ app: { name: opts.name ?? "My App", primaryLocale: opts.locale },
148
+ stores: {
149
+ ...(opts.appStore ? { appStore: { appId: opts.appStore, via: "auto" } } : {}),
150
+ ...(opts.googlePlay ? { googlePlay: { packageName: opts.googlePlay, via: "auto" } } : {}),
151
+ },
152
+ targets: { [opts.country.toLowerCase()]: [] },
153
+ };
154
+ await writeSpec(root, spec);
155
+ await mkdir(join(root, SPEC_DIR, "store"), { recursive: true });
156
+ if (!existsSync(join(root, SPEC_DIR, "playbook.md")))
157
+ await writeFile(join(root, SPEC_DIR, "playbook.md"), PLAYBOOK);
158
+ await ignoreLocalData(root);
159
+ const workflows = opts.ci ? await writeWorkflows(root) : [];
160
+ if (!g().json) {
161
+ console.log(`${c.green("Created")} ${SPEC_FILE} and ${SPEC_DIR}/`);
162
+ for (const f of workflows)
163
+ console.log(`${c.green("Wrote")} ${f}`);
164
+ if (!opts.appStore && !opts.googlePlay)
165
+ console.log(c.dim("Add stores.appStore.appId or stores.googlePlay.packageName, then run `appeeky-spec pull`."));
166
+ }
167
+ if (opts.pull && (opts.appStore || opts.googlePlay))
168
+ await program.parseAsync(["pull"], { from: "user" });
169
+ else if (g().json)
170
+ printJson({ root, created: [SPEC_FILE, `${SPEC_DIR}/`, ...workflows] });
171
+ });
172
+ /**
173
+ * The guided setup: find the app, write the spec, check that the stores can be reached and
174
+ * pull, suggest targets from what it already ranks for, add CI and the agent skills, and
175
+ * end on the first coverage table.
176
+ */
177
+ async function wizard(root, locale, country) {
178
+ const step = (n, title) => console.log(`\n${c.bold(`${n}/6`)} ${title}`);
179
+ console.log(c.bold("Appeeky Spec setup") + c.dim(" Enter keeps the suggestion in brackets."));
180
+ step(1, "Your app");
181
+ const term = await ask("What is it called on the App Store?");
182
+ const found = term ? await findApps(term, country).catch(() => []) : [];
183
+ let appStore;
184
+ let name = term || "My App";
185
+ let storeName = term;
186
+ let bundleId = "";
187
+ if (found.length) {
188
+ found.forEach((app, i) => console.log(` ${c.cyan(String(i + 1))} ${app.name} ${c.dim(`${app.developer}${app.rating ? ` · ${app.rating}★` : ""} · ${app.id}`)}`));
189
+ console.log(` ${c.cyan("0")} ${c.dim("None of these / not on the App Store")}`);
190
+ const pick = Number(await ask("Which one?", "1"));
191
+ const app = found[pick - 1];
192
+ if (app) {
193
+ appStore = app.id;
194
+ name = app.name.split(/[:\-–—|]/)[0].trim() || app.name;
195
+ storeName = app.name;
196
+ bundleId = app.bundleId;
197
+ }
198
+ }
199
+ else if (term)
200
+ console.log(c.dim(" Not found on the App Store in this country."));
201
+ if (!appStore) {
202
+ const id = await ask("App Store id (digits), or Enter to skip:");
203
+ if (/^\d{5,}$/.test(id))
204
+ appStore = id;
205
+ }
206
+ let googlePlay;
207
+ const pkg = await ask("Google Play package, or - for none:", bundleId || "-");
208
+ if (pkg && pkg !== "-") {
209
+ const live = await playPackageExists(pkg);
210
+ console.log(live ? ` ${c.green("✓")} found on Google Play` : ` ${c.yellow("!")} no public Google Play page for ${pkg}: kept, check the name`);
211
+ googlePlay = pkg;
212
+ }
213
+ if (!appStore && !googlePlay)
214
+ throw new Error("Give an App Store id or a Google Play package to set up the spec.");
215
+ step(2, "The spec");
216
+ await writeSpec(root, {
217
+ version: 1,
218
+ app: { name, primaryLocale: locale },
219
+ stores: {
220
+ ...(appStore ? { appStore: { appId: appStore, via: "auto" } } : {}),
221
+ ...(googlePlay ? { googlePlay: { packageName: googlePlay, via: "auto" } } : {}),
222
+ },
223
+ targets: { [country]: [] },
224
+ });
225
+ await mkdir(join(root, SPEC_DIR, "store"), { recursive: true });
226
+ if (!existsSync(join(root, SPEC_DIR, "playbook.md")))
227
+ await writeFile(join(root, SPEC_DIR, "playbook.md"), PLAYBOOK);
228
+ const ignored = await ignoreLocalData(root);
229
+ console.log(` ${c.green("✓")} ${SPEC_FILE}, ${SPEC_DIR}/playbook.md${ignored ? ", .appeeky/ in .gitignore" : ""}`);
230
+ step(3, "Store access and your live listing");
231
+ let project = await load();
232
+ let reach = diagnose(root, project).filter((x) => x.area === "App Store" || x.area === "Google Play");
233
+ printChecks(reach);
234
+ // A store that cannot be reached: offer to save the user's own key with the store CLI, once per machine.
235
+ let saved = false;
236
+ for (const x of reach.filter((r) => r.status === "fail")) {
237
+ const store = x.area === "App Store" ? "app-store" : "google-play";
238
+ if (!(await agree(`Save your own ${x.area} key on this machine now?`)))
239
+ continue;
240
+ saved = (await saveStoreKey(store, slug(name), store === "app-store" ? appStore : googlePlay)) || saved;
241
+ }
242
+ if (saved) {
243
+ reach = diagnose(root, project).filter((x) => x.area === "App Store" || x.area === "Google Play");
244
+ printChecks(reach);
245
+ }
246
+ let pulled = false;
247
+ // Pull each store that can be reached, one at a time: one store's problem does not stop the other.
248
+ const reachable = reach.filter((x) => x.status === "ok").map((x) => (x.area === "App Store" ? "app-store" : "google-play"));
249
+ if (reachable.length && (await agree(`Pull your live listing from ${reachable.map((s) => storeLabel(s)).join(" and ")} now?`))) {
250
+ for (const store of reachable) {
251
+ try {
252
+ await program.parseAsync(["pull", "--store", store], { from: "user" });
253
+ pulled = true;
254
+ }
255
+ catch (e) {
256
+ console.log(` ${c.red("✗")} ${e.message}`);
257
+ }
258
+ }
259
+ }
260
+ if (reachable.length < reach.length)
261
+ console.log(c.dim(" Set up access for the rest as above, then run `appeeky-spec pull`."));
262
+ step(4, `The searches to win in ${country.toUpperCase()}`);
263
+ project = await load();
264
+ // Without a pulled listing, the App Store name still says what the app is for.
265
+ const candidates = targetCandidates(project, 14, storeName);
266
+ const ranked = await suggestTargets(candidates, appStore, country, itunesRankSource());
267
+ let targets = [];
268
+ if (ranked.length) {
269
+ for (const r of ranked)
270
+ console.log(` ${r.keyword.padEnd(24)} ${r.rank ? c.green(`#${r.rank}`) : c.dim("not in the top 200")}`);
271
+ const answer = await ask("Keep these? Enter for yes, or type your own, comma separated:", "yes");
272
+ targets = answer === "yes" ? ranked.map((r) => r.keyword) : answer.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
273
+ }
274
+ else {
275
+ const answer = await ask("Type 3 to 5 searches people use to find apps like yours, comma separated:");
276
+ targets = answer.split(",").map((x) => x.trim().toLowerCase()).filter((x) => x && !/^(y|yes)$/.test(x));
277
+ }
278
+ if (targets.length) {
279
+ project.spec.targets[country] = targets;
280
+ await writeSpec(root, project.spec);
281
+ console.log(` ${c.green("✓")} ${targets.length} ${targets.length === 1 ? "target" : "targets"} in ${SPEC_FILE}`);
282
+ }
283
+ step(5, "Automation");
284
+ if (existsSync(join(root, ".git")) && (await agree("Add GitHub Actions (pull request check, push on merge, daily verify)?"))) {
285
+ for (const f of await writeWorkflows(root))
286
+ console.log(` ${c.green("✓")} ${f}`);
287
+ console.log(c.dim(" The push and the daily run need APPEEKY_API_KEY (or your store keys) as repository secrets."));
288
+ }
289
+ if (await agree("Install the agent skills (npx skills add appeeky/appeeky-spec)?")) {
290
+ const result = spawnSync("npx", ["-y", "skills", "add", "appeeky/appeeky-spec"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
291
+ if (result.status !== 0)
292
+ console.log(c.yellow(" The skills did not install. Run `npx skills add appeeky/appeeky-spec` later."));
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
+ }
298
+ step(6, "Where you stand");
299
+ if (pulled && targets.length)
300
+ await program.parseAsync(["coverage", "--country", country], { from: "user" });
301
+ else
302
+ console.log(c.dim(" Pull the listing and add targets, then `appeeky-spec coverage` shows which searches it covers."));
303
+ console.log(`\n${c.bold("Next")}: change a listing file (or ask your agent), then ${c.cyan("appeeky-spec diff")} and ${c.cyan('appeeky-spec push --note "why"')}.`);
304
+ console.log(c.dim("Run `appeeky-spec doctor` any time to see what is set up and what is missing."));
305
+ }
306
+ /**
307
+ * Save a store key with the store's own CLI (`appstore accounts add`, `gps accounts add`), run
308
+ * through npx: the key stays in the CLI's config on this machine, never in the repo.
309
+ */
310
+ async function saveStoreKey(store, account, appId) {
311
+ const cli = STORE_CLIS[store];
312
+ let args;
313
+ if (store === "app-store") {
314
+ console.log(c.dim(" App Store Connect → Users and Access → Integrations → App Store Connect API. Role App Manager to push."));
315
+ const keyId = await ask(" Key ID:");
316
+ const issuer = await ask(" Issuer ID (Enter for an individual key):");
317
+ const path = await ask(" Path to the AuthKey_….p8 file:");
318
+ if (!keyId || !path)
319
+ return false;
320
+ args = ["accounts", "add", account, "--key-id", keyId, ...(issuer ? ["--issuer-id", issuer] : []), "--private-key-path", path, ...(appId ? ["--default-app", appId] : []), "--default"];
321
+ }
322
+ else {
323
+ console.log(c.dim(" A Google Cloud service account with the Android Publisher API, invited in Play Console → Users and permissions."));
324
+ const path = await ask(" Path to its JSON key:");
325
+ if (!path)
326
+ return false;
327
+ args = ["accounts", "add", account, "--credentials", path, ...(appId ? ["--default-package", appId] : []), "--default"];
328
+ }
329
+ const result = spawnSync("npx", ["-y", "-p", cli.npmPackage, cli.bin, ...args], { stdio: "inherit", shell: process.platform === "win32" });
330
+ if (result.status === 0) {
331
+ console.log(` ${c.green("✓")} saved as "${account}" in the ${cli.bin} CLI's config (not in this repo)`);
332
+ return true;
333
+ }
334
+ console.log(` ${c.red("✗")} ${cli.bin} accounts add failed: run it again later with the command above.`);
335
+ return false;
336
+ }
337
+ function printChecks(checks) {
338
+ const mark = { ok: c.green("✓"), warn: c.yellow("!"), fail: c.red("✗") };
339
+ for (const x of checks) {
340
+ console.log(` ${mark[x.status]} ${x.area.padEnd(20)} ${x.detail}`);
341
+ if (x.fix)
342
+ console.log(` ${c.dim(x.fix)}`);
343
+ }
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
+ });
366
+ program
367
+ .command("doctor")
368
+ .description("What is set up for this repo, what is missing, and how to fix it")
369
+ .action(async () => {
370
+ const root = resolve(g().cwd);
371
+ const project = await load().catch(() => null);
372
+ const checks = diagnose(project?.root ?? root, project);
373
+ if (g().json)
374
+ printJson({ checks });
375
+ else {
376
+ printChecks(checks);
377
+ const fails = checks.filter((x) => x.status === "fail").length;
378
+ const warns = checks.filter((x) => x.status === "warn").length;
379
+ console.log(`\n${fails ? c.red(`${fails} to fix`) : c.green("Ready")}${warns ? c.dim(` · ${warns} optional`) : ""}`);
380
+ }
381
+ if (checks.some((x) => x.status === "fail"))
382
+ process.exitCode = 1;
383
+ });
384
+ program
385
+ .command("pull")
386
+ .description("Write the live store listings into appeeky/store/ (overwrites local files)")
387
+ .addOption(storeOption())
388
+ .option("--locale <locales...>", "Only these locales")
389
+ .action(async (opts) => {
390
+ const project = await load();
391
+ const written = [];
392
+ for (const store of configuredStores(project, opts.store)) {
393
+ const handle = storeHandle(project, store, transportOverrides());
394
+ if (!g().json)
395
+ console.log(c.dim(`${storeLabel(store)}: pulling via ${handle.transport.label}…`));
396
+ const state = await handle.adapter.pull();
397
+ for (const [locale, listing] of Object.entries(state.listings)) {
398
+ if (opts.locale && !opts.locale.includes(locale))
399
+ continue;
400
+ written.push({ store, locale, file: await writeListing(project.root, store, locale, listing) });
401
+ }
402
+ await recordPull(project.root, store, state.target);
403
+ if (!g().json)
404
+ console.log(`${storeLabel(store)}: ${Object.keys(state.listings).length} locales from ${state.target}`);
405
+ }
406
+ if (g().json)
407
+ printJson({ written });
408
+ });
409
+ program
410
+ .command("check")
411
+ .alias("lint")
412
+ .description("Check the listings against store limits and ASO rules")
413
+ .option("--strict", "Fail on warnings too", false)
414
+ .action(async (opts) => {
415
+ const project = await load();
416
+ const issues = [...lintProject(project), ...(await checkScreenshots(project.root))];
417
+ if (g().json)
418
+ printJson({ issues });
419
+ else
420
+ console.log(formatIssues(issues));
421
+ if (hasErrors(issues) || (opts.strict && issues.some((i) => i.severity === "warning")))
422
+ process.exitCode = 1;
423
+ });
424
+ program
425
+ .command("coverage")
426
+ .description("Which target keywords the listings index, per storefront")
427
+ .option("--country <cc...>", "Only these storefronts")
428
+ .option("--no-metrics", "Skip popularity from the Appeeky API")
429
+ .action(async (opts) => {
430
+ const project = await load();
431
+ const key = process.env.APPEEKY_API_KEY;
432
+ const targets = Object.fromEntries(Object.entries(project.spec.targets).filter(([cc]) => !opts.country || opts.country.map((x) => x.toLowerCase()).includes(cc)));
433
+ const scoped = { ...project, spec: { ...project.spec, targets } };
434
+ const stores = configuredStores(project);
435
+ let lookup;
436
+ let note;
437
+ if (opts.metrics) {
438
+ const result = await loadPopularity(project.root, key ? new AppeekyApi(key, { baseUrl: process.env.APPEEKY_API_URL }) : undefined, targets, stores, (done, total) => {
439
+ if (!g().json && process.stderr.isTTY)
440
+ process.stderr.write(`\r${c.dim(`popularity ${done}/${total}`)}`);
441
+ });
442
+ if (!g().json && process.stderr.isTTY && result.fetched > 0)
443
+ process.stderr.write("\r\u001b[K");
444
+ lookup = result.lookup;
445
+ if (!key)
446
+ note = "Set APPEEKY_API_KEY to rank targets by search popularity (appeeky.com/dashboard/settings).";
447
+ else if (result.failed.length > 0)
448
+ note = `Popularity missing for ${result.failed.length} keywords: ${result.failed[0]}`;
449
+ }
450
+ const reports = projectCoverage(scoped, lookup);
451
+ const crossLocalization = crossLocalizationSuggestions(scoped);
452
+ if (g().json)
453
+ printJson({ coverage: reports, crossLocalization, note });
454
+ else {
455
+ console.log(formatCoverage(reports));
456
+ for (const x of crossLocalization) {
457
+ const split = x.splitPhrase ? "separate its keyword field with commas and " : "";
458
+ console.log(`\n${c.cyan("tip")} ${x.country.toUpperCase()} also indexes ${x.locale}: ${split}add ${x.add.map((w) => `"${w}"`).join(", ")} to its keyword field → ${c.dim(x.keywords)}`);
459
+ }
460
+ if (note)
461
+ console.log(c.dim(`\n${note}`));
462
+ }
463
+ });
464
+ const plans = async (project, only) => {
465
+ const out = [];
466
+ for (const store of configuredStores(project, only))
467
+ out.push(await planStore(project, storeHandle(project, store, transportOverrides())));
468
+ return out;
469
+ };
470
+ program
471
+ .command("diff")
472
+ .description("Compare the spec with the live store")
473
+ .addOption(storeOption())
474
+ .action(async (opts) => {
475
+ const project = await load();
476
+ const result = await plans(project, opts.store);
477
+ if (g().json) {
478
+ printJson({ stores: result.map((p) => ({ store: p.store, via: p.via, target: p.state.target, changes: p.changes })) });
479
+ return;
480
+ }
481
+ for (const p of result) {
482
+ console.log(c.dim(`${storeLabel(p.store)}: ${p.state.target} via ${p.via}`));
483
+ console.log(formatChanges(p.changes));
484
+ }
485
+ });
486
+ program
487
+ .command("push")
488
+ .description("Push the spec to the stores and record a release in appeeky/releases/")
489
+ .addOption(storeOption())
490
+ .option("-y, --yes", "Do not ask for confirmation", false)
491
+ .option("--dry-run", "Show what would be pushed", false)
492
+ .option("-m, --note <text>", "Why this change: stored with the release")
493
+ .option("--force", "Push even when `check` finds errors", false)
494
+ .option("--create-version <version>", "Create this App Store version first (name, subtitle and keywords need one)")
495
+ .option("--no-baseline", "Do not record search ranks before pushing")
496
+ .action(async (opts) => {
497
+ const project = await load();
498
+ if (opts.createVersion && !opts.dryRun && project.spec.stores.appStore && (!opts.store || opts.store === "app-store")) {
499
+ const handle = storeHandle(project, "app-store", transportOverrides());
500
+ const created = await handle.adapter.createVersion(opts.createVersion);
501
+ if (!g().json)
502
+ console.log(`${c.green("Created")} App Store version ${created.versionString}`);
503
+ }
504
+ const issues = lintProject(project).filter((i) => i.severity === "error");
505
+ if (issues.length > 0 && !opts.force) {
506
+ if (g().json)
507
+ printJson({ error: "check_failed", issues });
508
+ else
509
+ console.error(`${formatIssues(issues)}\n\n${c.red("Fix these first, or pass --force.")}`);
510
+ process.exitCode = 1;
511
+ return;
512
+ }
513
+ const result = await plans(project, opts.store);
514
+ const pushable = result.flatMap((p) => p.changes.filter((ch) => !ch.blocked));
515
+ if (!g().json) {
516
+ for (const p of result) {
517
+ console.log(c.dim(`${storeLabel(p.store)}: ${p.state.target} via ${p.via}`));
518
+ console.log(formatChanges(p.changes));
519
+ }
520
+ }
521
+ if (pushable.length === 0 || opts.dryRun) {
522
+ if (g().json)
523
+ printJson({ dryRun: opts.dryRun, stores: result.map((p) => ({ store: p.store, target: p.state.target, changes: p.changes })) });
524
+ else if (pushable.length === 0)
525
+ console.log(c.dim("\nNothing to push."));
526
+ return;
527
+ }
528
+ if (!opts.yes && !(await confirm(`\nPush ${pushable.length} change${pushable.length === 1 ? "" : "s"}?`))) {
529
+ if (!process.stdin.isTTY && !g().json)
530
+ console.error(c.yellow("Not a terminal: pass --yes to push."));
531
+ process.exitCode = 1;
532
+ return;
533
+ }
534
+ const releases = [];
535
+ for (const p of result) {
536
+ const ready = p.changes.filter((ch) => !ch.blocked);
537
+ if (ready.length === 0)
538
+ continue;
539
+ const handle = storeHandle(project, p.store, transportOverrides());
540
+ // Ranks right before the push are what `verify` compares against later.
541
+ const baseline = opts.baseline && rankAppIds(project)[p.store] ? await snapshotRanks(project, rankSource(), [p.store]).catch(() => null) : null;
542
+ const pushed = await handle.adapter.push(ready, p.state, project.listings[p.store]);
543
+ const now = new Date();
544
+ const record = {
545
+ id: releaseId(now, p.store),
546
+ pushedAt: now.toISOString(),
547
+ store: p.store,
548
+ target: p.state.target,
549
+ via: p.via,
550
+ ...(opts.note ? { note: opts.note } : {}),
551
+ changes: pushed.applied.map(({ locale, field, before, after }) => ({ locale, field, before, after })),
552
+ ...(baseline?.ranks[p.store] ? { baseline: { at: baseline.at, source: baseline.source, ranks: baseline.ranks[p.store] } } : {}),
553
+ };
554
+ const file = await writeRelease(project.root, record);
555
+ releases.push({ ...record, file, skipped: pushed.skipped });
556
+ if (!g().json)
557
+ console.log(`${c.green("Pushed")} ${pushed.applied.length} to ${storeLabel(p.store)} · ${c.dim(file)}`);
558
+ }
559
+ if (releases.length)
560
+ await writeLoops(project.root);
561
+ if (g().json)
562
+ printJson({ releases });
563
+ });
564
+ program
565
+ .command("status")
566
+ .description("Every change being measured: what, the metric that decides it, the next check and the result so far")
567
+ .option("--all", "Show closed loops too", false)
568
+ .action(async (opts) => {
569
+ const project = await load();
570
+ const loops = await writeLoops(project.root);
571
+ const stores = [...(project.spec.stores.appStore ? ["app-store"] : []), ...(project.spec.stores.googlePlay ? ["google-play"] : [])];
572
+ const free = freeStores(loops, stores);
573
+ if (g().json) {
574
+ printJson({ loops, free });
575
+ return;
576
+ }
577
+ const shown = opts.all ? loops : loops.filter((l) => l.state !== "closed");
578
+ if (shown.length === 0)
579
+ console.log(c.dim(opts.all ? "No releases or experiments yet." : "Nothing is being measured. See closed loops with --all."));
580
+ for (const l of shown)
581
+ printLoop(l);
582
+ const closed = loops.length - loops.filter((l) => l.state !== "closed").length;
583
+ if (!opts.all && closed)
584
+ console.log(c.dim(`\n${closed} closed. --all to see them.`));
585
+ console.log(free.length ? `\n${c.green("Free to change:")} ${free.map(storeLabel).join(", ")}` : c.dim("\nEvery store has a release being measured: wait before the next change."));
586
+ console.log(c.dim("Written to appeeky/loops.md."));
587
+ });
588
+ program
589
+ .command("log")
590
+ .description("Releases pushed from this spec, newest first")
591
+ .option("-n, --limit <n>", "How many", "20")
592
+ .option("--markdown", "Print each release as a Markdown summary", false)
593
+ .action(async (opts) => {
594
+ const project = await load();
595
+ const releases = (await readReleases(project.root)).slice(0, Number(opts.limit) || 20);
596
+ if (opts.markdown) {
597
+ console.log(releases.map(releaseMarkdown).join("\n\n"));
598
+ return;
599
+ }
600
+ if (g().json) {
601
+ printJson({ releases });
602
+ return;
603
+ }
604
+ if (releases.length === 0)
605
+ console.log(c.dim("No releases yet."));
606
+ for (const r of releases) {
607
+ const locales = [...new Set(r.changes.map((ch) => ch.locale))];
608
+ console.log(`${c.bold(r.id)} ${storeLabel(r.store)} · ${r.target} · ${r.changes.length} changes in ${locales.join(", ")}`);
609
+ if (r.note)
610
+ console.log(` ${c.dim(r.note)}`);
611
+ }
612
+ });
613
+ program
614
+ .command("ads")
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)")
616
+ .option("--days <n>", "How many days, up to yesterday", "30")
617
+ .option("--save", "Write the numbers to appeeky/ads/<date>.yaml", false)
618
+ .action(async (opts) => {
619
+ const project = await load();
620
+ const a = api();
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);
625
+ const saved = opts.save ? await saveAdsSnapshot(project.root, snapshot) : undefined;
626
+ if (g().json) {
627
+ printJson({ ...snapshot, ...(saved ? { saved } : {}) });
628
+ return;
629
+ }
630
+ const money = (n) => (n === undefined ? "—" : `${snapshot.currency && snapshot.currency !== "USD" ? `${snapshot.currency} ` : "$"}${n.toFixed(2)}`);
631
+ console.log(c.dim(`${snapshot.from} → ${snapshot.to} (${snapshot.days} days, per day)`));
632
+ if (snapshot.channels.length === 0 && snapshot.errors.length === 0) {
633
+ console.log(c.dim("No ad data. Connect Apple Search Ads, Meta or TikTok in Appeeky, and list Meta and TikTok campaign ids under ads: in appeeky.spec.yaml."));
634
+ }
635
+ for (const ch of snapshot.channels) {
636
+ console.log(`${c.bold(CHANNEL_LABELS[ch.channel].padEnd(18))} spend ${money(ch.spendPerDay).padEnd(10)} ${ch.result} ${String(ch.resultsPerDay ?? "—").padEnd(8)} cost per ${ch.result === "installs" ? "install" : "conversion"} ${money(ch.costPerResult)}`);
637
+ }
638
+ for (const e of snapshot.errors)
639
+ console.log(`${c.red(CHANNEL_LABELS[e.channel].padEnd(18))} ${c.dim(e.message)}`);
640
+ if (saved)
641
+ console.log(c.dim(`\nSaved ${saved.replace(project.root + "/", "")}`));
642
+ });
643
+ const reviews = program.command("reviews").description("Customer reviews in the repo, and replies sent through a pull request (needs APPEEKY_API_KEY)");
644
+ reviews
645
+ .command("pull")
646
+ .description("Write App Store and Google Play reviews to appeeky/reviews/ and update summary.md")
647
+ .option("--since <date>", "Only reviews from this date on (default: everything the connection has)")
648
+ .option("--max <n>", "At most this many reviews per store", "2000")
649
+ .action(async (opts) => {
650
+ const project = await load();
651
+ const a = api();
652
+ if (!a)
653
+ throw new Error("Set APPEEKY_API_KEY: reviews come from the store connections in Appeeky.");
654
+ const ids = rankAppIds(project);
655
+ const results = {};
656
+ for (const store of Object.keys(ids)) {
657
+ try {
658
+ const got = await fetchReviews(a, store, ids[store], { since: opts.since, max: Number(opts.max) || 2000 });
659
+ results[store] = { fetched: got.length, ...(await saveReviews(project.root, got)) };
660
+ }
661
+ catch (e) {
662
+ results[store] = { error: e.message };
663
+ }
664
+ }
665
+ // Every listing's text, so the summary can show what users say that it does not.
666
+ const listingText = Object.values(project.listings)
667
+ .flatMap((byLocale) => Object.values(byLocale))
668
+ .flatMap((l) => Object.values(l).filter((v) => typeof v === "string"))
669
+ .join(" ");
670
+ const summary = await writeSummary(project.root, new Date(), listingText);
671
+ if (g().json) {
672
+ printJson({ results, summary });
673
+ return;
674
+ }
675
+ for (const [store, r] of Object.entries(results)) {
676
+ if ("error" in r)
677
+ console.log(`${c.red(storeLabel(store).padEnd(12))} ${c.dim(r.error)}`);
678
+ else
679
+ console.log(`${c.bold(storeLabel(store).padEnd(12))} ${r.fetched} reviews · ${c.green(`${r.added} new`)} · ${r.updated} changed`);
680
+ }
681
+ console.log(c.dim(`\nWrote ${summary.replace(project.root + "/", "")}`));
682
+ });
683
+ reviews
684
+ .command("list")
685
+ .description("Reviews from the repo, newest first")
686
+ .option("--store <store>", "app-store or google-play")
687
+ .option("--rating <n>", "Only this many stars, or 1-2 for a range")
688
+ .option("--unanswered", "Only reviews without a reply", false)
689
+ .option("-n, --limit <n>", "How many", "20")
690
+ .action(async (opts) => {
691
+ const project = await load();
692
+ const [lo, hi] = (opts.rating ?? "1-5").split("-").map(Number);
693
+ const rows = (await readReviews(project.root, opts.store))
694
+ .filter((r) => r.rating >= (lo ?? 1) && r.rating <= (hi ?? lo ?? 5))
695
+ .filter((r) => !opts.unanswered || !r.reply)
696
+ .slice(0, Number(opts.limit) || 20);
697
+ if (g().json) {
698
+ printJson({ reviews: rows });
699
+ return;
700
+ }
701
+ if (rows.length === 0)
702
+ console.log(c.dim("No reviews. Run appeeky-spec reviews pull."));
703
+ for (const r of rows) {
704
+ const stars = r.rating <= 2 ? c.red(`${r.rating}★`) : r.rating === 3 ? c.yellow(`${r.rating}★`) : c.green(`${r.rating}★`);
705
+ console.log(`${stars} ${c.dim(`${r.date.slice(0, 10)} ${r.store} ${r.where ?? ""} ${r.version ?? ""} ${r.id}`)}${r.reply ? c.dim(" · replied") : ""}`);
706
+ console.log(` ${(r.title ? `${c.bold(r.title)} ` : "") + r.body.replace(/\s+/g, " ").slice(0, 200)}`);
707
+ }
708
+ });
709
+ reviews
710
+ .command("reply")
711
+ .description("Draft a reply in appeeky/reviews/replies/ for review in a pull request; reviews push sends it")
712
+ .argument("<review>", "Review id from reviews list")
713
+ .requiredOption("--text <text>", "The reply")
714
+ .action(async (id, opts) => {
715
+ const project = await load();
716
+ const review = (await readReviews(project.root)).find((r) => r.id === id);
717
+ if (!review)
718
+ throw new Error(`No review ${id} in appeeky/reviews/. Run appeeky-spec reviews pull first.`);
719
+ const file = await writeReply(project.root, {
720
+ id,
721
+ store: review.store,
722
+ review: `${review.rating}★ ${review.date.slice(0, 10)}: ${(review.title ? `${review.title}. ` : "") + review.body.replace(/\s+/g, " ").slice(0, 200)}`,
723
+ text: opts.text,
724
+ });
725
+ const issues = replyIssues({ id, store: review.store, text: opts.text, status: "draft" });
726
+ if (g().json) {
727
+ printJson({ file, issues });
728
+ return;
729
+ }
730
+ console.log(`${c.green("Drafted")} ${file.replace(project.root + "/", "")}`);
731
+ for (const i of issues)
732
+ console.log(c.red(` ${i}`));
733
+ });
734
+ reviews
735
+ .command("push")
736
+ .description("Send the draft replies in appeeky/reviews/replies/ to the stores")
737
+ .option("--yes", "Send without asking", false)
738
+ .action(async (opts) => {
739
+ const project = await load();
740
+ const a = api();
741
+ if (!a)
742
+ throw new Error("Set APPEEKY_API_KEY: replies are sent through the store connections in Appeeky.");
743
+ const drafts = (await readReplies(project.root)).filter((r) => r.status === "draft");
744
+ const blocked = drafts.filter((r) => replyIssues(r).length);
745
+ if (blocked.length)
746
+ throw new Error(`Too long: ${blocked.map((r) => `${r.id} (${replyIssues(r).join(", ")})`).join("; ")}`);
747
+ if (drafts.length === 0) {
748
+ if (g().json)
749
+ printJson({ sent: [] });
750
+ else
751
+ console.log(c.dim("No draft replies."));
752
+ return;
753
+ }
754
+ if (!g().json)
755
+ for (const r of drafts)
756
+ console.log(`${c.bold(r.id)} ${c.dim(r.store)}\n ${r.text}`);
757
+ if (!opts.yes && !(await confirm(`Send ${drafts.length} repl${drafts.length === 1 ? "y" : "ies"}?`)))
758
+ return;
759
+ const sent = [];
760
+ const failed = [];
761
+ for (const r of drafts) {
762
+ try {
763
+ await sendReply(a, r, project.spec.stores.googlePlay?.packageName);
764
+ sent.push(r.id);
765
+ }
766
+ catch (e) {
767
+ failed.push({ id: r.id, message: e.message });
768
+ }
769
+ }
770
+ if (g().json) {
771
+ printJson({ sent, failed });
772
+ return;
773
+ }
774
+ console.log(c.green(`Sent ${sent.length}.`));
775
+ for (const f of failed)
776
+ console.log(`${c.red(f.id)} ${c.dim(f.message)}`);
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
+ }
994
+ const competitors = program.command("competitors").description("Competitors in the repo: their listing, ranks and reviews (needs APPEEKY_API_KEY)");
995
+ competitors
996
+ .command("pull")
997
+ .description("Write each competitor under competitors: to appeeky/competitors/<name>.yaml")
998
+ .action(async () => {
999
+ const project = await load();
1000
+ const a = api();
1001
+ if (!a)
1002
+ throw new Error("Set APPEEKY_API_KEY: competitor data comes from the Appeeky API.");
1003
+ if (project.spec.competitors.length === 0)
1004
+ throw new Error("No competitors in appeeky.spec.yaml. Find some with appeeky-spec competitors discover.");
1005
+ const results = await pullCompetitors(a, project.root, project.spec);
1006
+ if (g().json) {
1007
+ printJson({ results });
1008
+ return;
1009
+ }
1010
+ for (const r of results) {
1011
+ console.log(`${r.errors.length ? c.yellow("!") : c.green("✓")} ${r.file.replace(project.root + "/", "")}`);
1012
+ for (const e of r.errors)
1013
+ console.log(c.dim(` ${e}`));
1014
+ for (const ch of r.changes)
1015
+ console.log(` ${c.yellow("changed")} ${ch}`);
1016
+ }
1017
+ console.log(c.dim("\nThe git diff shows what each competitor changed since the last pull."));
1018
+ });
1019
+ competitors
1020
+ .command("discover")
1021
+ .description("Apps like yours, to add as competitors")
1022
+ .option("--store <store>", "app-store or google-play")
1023
+ .option("--add <n>", "Add the first n to competitors: in appeeky.spec.yaml")
1024
+ .action(async (opts) => {
1025
+ const project = await load();
1026
+ const a = api();
1027
+ if (!a)
1028
+ throw new Error("Set APPEEKY_API_KEY: similar apps come from the Appeeky API.");
1029
+ const ids = rankAppIds(project);
1030
+ const store = opts.store ?? Object.keys(ids)[0];
1031
+ if (!store || !ids[store])
1032
+ throw new Error("Set stores.appStore.appId or stores.googlePlay.packageName first.");
1033
+ const country = Object.keys(project.spec.targets)[0] ?? "us";
1034
+ const apps = await similarApps(a, store, ids[store], country);
1035
+ const n = Number(opts.add) || 0;
1036
+ if (n > 0) {
1037
+ const have = new Set(project.spec.competitors.map((x) => (store === "app-store" ? x.appStore : x.googlePlay)));
1038
+ const fresh = apps.filter((x) => !have.has(x.id)).slice(0, n);
1039
+ project.spec.competitors.push(...fresh.map((x) => (store === "app-store" ? { name: x.title, appStore: x.id } : { name: x.title, googlePlay: x.id })));
1040
+ await writeSpec(project.root, project.spec);
1041
+ }
1042
+ if (g().json) {
1043
+ printJson({ store, country, apps, added: n });
1044
+ return;
1045
+ }
1046
+ for (const x of apps)
1047
+ console.log(`${x.id.padEnd(14)} ${x.title}${x.rating ? c.dim(` ${x.rating}★ ${x.ratings ?? ""}`) : ""}`);
1048
+ if (n > 0)
1049
+ console.log(c.green(`\nAdded ${Math.min(n, apps.length)} to appeeky.spec.yaml. Run appeeky-spec competitors pull.`));
1050
+ else
1051
+ console.log(c.dim("\nAdd them with --add <n>, or by hand under competitors: in appeeky.spec.yaml."));
1052
+ });
1053
+ const visibility = program
1054
+ .command("visibility")
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");
1056
+ const needKey = () => {
1057
+ const a = api();
1058
+ if (!a)
1059
+ throw new Error("Set APPEEKY_API_KEY: AI visibility comes from Appeeky AI Visibility.");
1060
+ return a;
1061
+ };
1062
+ const watched = (project) => {
1063
+ const t = visibilityTarget(project);
1064
+ if (!t)
1065
+ throw new Error("Set stores.appStore.appId (numeric) or stores.googlePlay.packageName first.");
1066
+ return t;
1067
+ };
1068
+ visibility
1069
+ .command("init")
1070
+ .description("Write the intents and prompts Appeeky tracks into appeeky.spec.yaml; with none yet, have Appeeky write them from your listing")
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)
1073
+ .action(async (opts) => {
1074
+ if (opts.local)
1075
+ return initLocal(opts.country);
1076
+ const project = await load();
1077
+ const a = needKey();
1078
+ const t = watched(project);
1079
+ const countries = opts.country ? [opts.country.toLowerCase()] : t.countries;
1080
+ const spec = project.spec;
1081
+ spec.aiVisibility ??= { intents: {} };
1082
+ const report = [];
1083
+ for (const country of countries) {
1084
+ const remote = await readRemoteIntents(a, t.appId, country);
1085
+ if (remote.length > 0) {
1086
+ spec.aiVisibility.intents[country] = remote;
1087
+ report.push({ country, intents: remote.length });
1088
+ }
1089
+ else {
1090
+ const r = await bootstrapIntents(a, t.appId, country, languageFor(project, country));
1091
+ report.push({ country, intents: 0, bootstrapped: r.queued || r.already });
1092
+ }
1093
+ }
1094
+ // A storefront asked for by --country is watched from now on.
1095
+ if (opts.country && !t.countries.includes(countries[0]))
1096
+ spec.aiVisibility.countries = [...t.countries, countries[0]];
1097
+ if (report.some((r) => r.intents > 0))
1098
+ await writeSpec(project.root, spec);
1099
+ if (g().json) {
1100
+ printJson({ store: t.store, appId: t.appId, report });
1101
+ return;
1102
+ }
1103
+ for (const r of report) {
1104
+ if (r.intents > 0)
1105
+ console.log(`${c.green("✓")} ${r.country.toUpperCase()}: wrote ${r.intents} intents to aiVisibility.intents in appeeky.spec.yaml`);
1106
+ else
1107
+ console.log(`${c.yellow("…")} ${r.country.toUpperCase()}: Appeeky is writing intents and prompts from your listing. Run appeeky-spec visibility init again in a few minutes.`);
1108
+ }
1109
+ if (report.some((r) => r.intents > 0))
1110
+ console.log(c.dim("\nEdit them in the spec, then send the changes with appeeky-spec visibility sync."));
1111
+ });
1112
+ visibility
1113
+ .command("sync")
1114
+ .description("Send the intents and prompts in appeeky.spec.yaml to Appeeky AI Visibility")
1115
+ .option("--prune", "Also archive intents and prompts that are not in the spec", false)
1116
+ .option("--dry-run", "Only show what would change", false)
1117
+ .action(async (opts) => {
1118
+ const project = await load();
1119
+ const a = needKey();
1120
+ const t = watched(project);
1121
+ const intents = project.spec.aiVisibility?.intents ?? {};
1122
+ if (Object.keys(intents).length === 0)
1123
+ throw new Error("No aiVisibility.intents in appeeky.spec.yaml. Start with appeeky-spec visibility init.");
1124
+ const out = [];
1125
+ for (const [country, wanted] of Object.entries(intents)) {
1126
+ const plan = await planSync(a, t.appId, country, wanted);
1127
+ const applied = opts.dryRun ? null : await applySync(a, t.appId, plan, languageFor(project, country), opts.prune);
1128
+ out.push({ plan, applied });
1129
+ }
1130
+ if (g().json) {
1131
+ printJson(out);
1132
+ return;
1133
+ }
1134
+ for (const { plan, applied } of out) {
1135
+ const cc = plan.country.toUpperCase();
1136
+ for (const i of plan.addIntents)
1137
+ console.log(`${c.green("+")} ${cc} intent "${i.label}" (${i.prompts.length} prompts)`);
1138
+ for (const p of plan.addPrompts)
1139
+ console.log(`${c.green("+")} ${cc} prompt under "${p.label}": ${p.text}`);
1140
+ const drop = opts.prune ? c.red("-") : c.dim("·");
1141
+ for (const i of plan.extraIntents)
1142
+ console.log(`${drop} ${cc} intent "${i.label}"${opts.prune ? "" : c.dim(" (only in Appeeky; --prune archives it)")}`);
1143
+ for (const p of plan.extraPrompts)
1144
+ console.log(`${drop} ${cc} prompt under "${p.label}": ${p.text}${opts.prune ? "" : c.dim(" (only in Appeeky)")}`);
1145
+ const nothing = !plan.addIntents.length && !plan.addPrompts.length && !plan.extraIntents.length && !plan.extraPrompts.length;
1146
+ if (nothing)
1147
+ console.log(c.dim(`${cc}: in sync.`));
1148
+ else if (applied)
1149
+ console.log(c.dim(`${cc}: added ${applied.intents} intents and ${applied.prompts} prompts${opts.prune ? `, archived ${applied.archived}` : ""}.`));
1150
+ }
1151
+ if (opts.dryRun)
1152
+ console.log(c.dim("\nDry run: nothing was sent."));
1153
+ else
1154
+ console.log(c.dim("\nThe next scheduled scan asks the assistants. To ask now: appeeky-spec visibility scan (uses AI Visibility credits)."));
1155
+ });
1156
+ visibility
1157
+ .command("pull")
1158
+ .description("Write what the assistants answer to appeeky/visibility/<country>.yaml")
1159
+ .option("--country <cc>", "One storefront only")
1160
+ .action(async (opts) => {
1161
+ const project = await load();
1162
+ const a = needKey();
1163
+ const results = await pullVisibility(a, project, opts.country ? [opts.country.toLowerCase()] : undefined);
1164
+ if (g().json) {
1165
+ printJson(results.map(({ data, ...r }) => ({ ...r, summary: data?.summary })));
1166
+ return;
1167
+ }
1168
+ for (const r of results) {
1169
+ const cc = r.country.toUpperCase();
1170
+ if (r.error) {
1171
+ console.log(`${c.red("✗")} ${cc}: ${r.error}`);
1172
+ continue;
1173
+ }
1174
+ const d = r.data;
1175
+ if (d.intents.length === 0) {
1176
+ console.log(`${c.yellow("!")} ${cc}: no intents tracked yet. Start with appeeky-spec visibility init.`);
1177
+ continue;
1178
+ }
1179
+ const s = d.summary;
1180
+ console.log(`${c.green("✓")} ${r.file.replace(project.root + "/", "")} ${c.green(`${s.winning} named`)} · ${c.yellow(`${s.weak} weak`)} · ${c.red(`${s.missing} missing`)}${s.noData ? c.dim(` · ${s.noData} not scanned yet`) : ""}`);
1181
+ for (const i of d.intents.filter((x) => x.status === "missing").slice(0, 3))
1182
+ console.log(` ${c.red("missing")} "${i.label}"${i.namedInstead ? c.dim(`: they name ${i.namedInstead.slice(0, 3).join(", ")}`) : ""}`);
1183
+ for (const ch of r.changes)
1184
+ console.log(` ${c.yellow("changed")} ${ch}`);
1185
+ if (d.fixes.length)
1186
+ console.log(c.dim(` ${d.fixes.length} suggested fixes in the file.`));
1187
+ }
1188
+ });
1189
+ visibility
1190
+ .command("scan")
1191
+ .description("Ask the assistants now: in Appeeky (AI Visibility credits), or with --local on your own model keys")
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)
1197
+ .action(async (opts) => {
1198
+ if (opts.local)
1199
+ return scanLocalCmd(opts);
1200
+ const project = await load();
1201
+ const a = needKey();
1202
+ const t = watched(project);
1203
+ const countries = opts.country ? [opts.country.toLowerCase()] : t.countries;
1204
+ for (const country of countries)
1205
+ await scanVisibility(a, t.appId, country);
1206
+ if (g().json) {
1207
+ printJson({ scanned: countries });
1208
+ return;
1209
+ }
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"));
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
+ });
1518
+ const category = program.command("category").description("Your rank in the category chart, one row a day in appeeky/category.csv");
1519
+ category
1520
+ .command("pull")
1521
+ .description("Record today's category rank (App Store: no key needed; Google Play: APPEEKY_API_KEY)")
1522
+ .action(async () => {
1523
+ const project = await load();
1524
+ const { plans, notes } = await categoryPlans(project);
1525
+ const a = api();
1526
+ if (!a && plans.some((p) => p.store === "google-play"))
1527
+ notes.push("Google Play: set APPEEKY_API_KEY to read its chart.");
1528
+ const history = await readCategoryHistory(project.root);
1529
+ const { ranks, failed } = await readCategoryRanks(plans, chartSources(a));
1530
+ const file = ranks.length ? await saveCategoryRanks(project.root, ranks) : undefined;
1531
+ if (g().json) {
1532
+ printJson({ plans, ranks, failed, notes, file });
1533
+ return;
1534
+ }
1535
+ for (const r of ranks) {
1536
+ const prev = previousRank(history, r);
1537
+ const now = r.rank === null ? "not in the top 100" : `#${r.rank}`;
1538
+ let move = "";
1539
+ if (prev) {
1540
+ const d = (prev.rank ?? 101) - (r.rank ?? 101);
1541
+ move = d > 0 ? c.green(` ▲${d}`) : d < 0 ? c.red(` ▼${-d}`) : c.dim(" =");
1542
+ move += c.dim(` since ${prev.date}`);
1543
+ }
1544
+ console.log(`${storeLabel(r.store).padEnd(12)} ${r.country.toUpperCase()} ${r.chart} · ${r.name ?? r.category} ${c.bold(now)}${move}`);
1545
+ }
1546
+ for (const f of failed)
1547
+ console.log(c.yellow(`! ${f}`));
1548
+ for (const n of notes)
1549
+ console.log(c.dim(n));
1550
+ if (file)
1551
+ console.log(c.dim(`\nRecorded in ${categoryFile(project.root).replace(project.root + "/", "")}. verify reads it as category_rank.`));
1552
+ });
1553
+ const attribution = program.command("attribution").description("Install attribution from Appeeky Analytics");
1554
+ attribution
1555
+ .command("init")
1556
+ .description("Create the app in Appeeky Analytics for each store, write attribution: and print the SDK key")
1557
+ .option("--self [url]", "Your own Appeeky Analytics server (with ATTRIBUTION_TOKEN); without it, Appeeky's hosted service (with APPEEKY_API_KEY)")
1558
+ .action(async (opts) => {
1559
+ const project = await load();
1560
+ const via = opts.self ? "self" : "hosted";
1561
+ const url = typeof opts.self === "string" ? opts.self : project.spec.attribution?.url;
1562
+ if (specStores(project.spec).length === 0)
1563
+ throw new Error("Add the App Store app id or the Google Play package under stores: first.");
1564
+ let apps;
1565
+ if (via === "self") {
1566
+ if (!url)
1567
+ throw new Error("--self <url>: your Appeeky Analytics server, e.g. https://analytics.example.workers.dev");
1568
+ if (!process.env.ATTRIBUTION_TOKEN)
1569
+ throw new Error("Set ATTRIBUTION_TOKEN to your server's ADMIN_TOKEN.");
1570
+ apps = await initSelfHosted(project.spec, url, process.env.ATTRIBUTION_TOKEN);
1571
+ }
1572
+ else {
1573
+ const a = api();
1574
+ if (!a)
1575
+ throw new Error("Set APPEEKY_API_KEY to turn attribution on in Appeeky.");
1576
+ apps = await initHosted(project.spec, a);
1577
+ }
1578
+ project.spec.attribution = attributionBlock(via, apps, url);
1579
+ await writeSpec(project.root, project.spec);
1580
+ if (g().json) {
1581
+ printJson({ apps, attribution: project.spec.attribution });
1582
+ return;
1583
+ }
1584
+ for (const a of apps)
1585
+ console.log(`${c.green(a.created ? "Created" : "Found")} ${storeLabel(a.store)} app ${c.bold(a.id)} · key ${a.publishableKey}`);
1586
+ console.log(c.dim("Wrote attribution: to appeeky.spec.yaml.\n"));
1587
+ for (const line of nextSteps(via, apps, url))
1588
+ console.log(line);
1589
+ });
1590
+ const data = program.command("data").description("Your app's analytics, ads and revenue as CSV files an agent can read (needs APPEEKY_API_KEY)");
1591
+ data
1592
+ .command("pull")
1593
+ .description("App Store Connect, Google Play, Apple Search Ads, RevenueCat, Meta and TikTok, per day")
1594
+ .option("--days <n>", "How many days, up to yesterday", "90")
1595
+ .action(async (opts) => {
1596
+ const project = await load();
1597
+ const a = api();
1598
+ const attribution = attributionClients(project.spec, a);
1599
+ if (!a && !hasClients(attribution))
1600
+ throw new Error("Set APPEEKY_API_KEY (data from the connections in Appeeky), or attribution: with via: self and ATTRIBUTION_TOKEN.");
1601
+ const result = await pullData(a, project, Number(opts.days) || 90, new Date(), hasClients(attribution) ? attribution : undefined);
1602
+ if (g().json) {
1603
+ printJson(result);
1604
+ return;
1605
+ }
1606
+ for (const f of result.files)
1607
+ console.log(`${c.green("✓")} ${f.path.replace(project.root + "/", "").padEnd(52)} ${String(f.rows).padStart(5)} rows ${c.dim(f.from && f.to ? `${f.from} → ${f.to}` : "")}`);
1608
+ for (const e of result.errors)
1609
+ console.log(`${c.red("✗")} ${e.source.padEnd(42)} ${c.dim(e.message)}`);
1610
+ if (result.files.length === 0 && result.errors.length === 0)
1611
+ console.log(c.dim("Nothing to pull. Connect your stores, Search Ads or RevenueCat in Appeeky."));
1612
+ if (!project.spec.data.commit && result.files.length)
1613
+ console.log(c.dim("\nIn .appeeky/data/ (gitignored). Set data: { commit: true } in appeeky.spec.yaml to version it with the spec."));
1614
+ });
1615
+ program
1616
+ .command("revert")
1617
+ .description("Put the values from before a release back into the spec (then run push)")
1618
+ .argument("<release>", "Release id from `appeeky-spec log`")
1619
+ .action(async (id) => {
1620
+ const project = await load();
1621
+ const release = (await readReleases(project.root)).find((r) => r.id === id || r.id.startsWith(id));
1622
+ if (!release)
1623
+ throw new Error(`No release ${id}. See \`appeeky-spec log\`.`);
1624
+ const inverse = invertChanges(release.changes.map((ch) => ({ ...ch, store: release.store, newLocale: false })));
1625
+ const touched = new Set();
1626
+ for (const ch of inverse) {
1627
+ const listings = project.listings[release.store];
1628
+ listings[ch.locale] = { ...listings[ch.locale], [ch.field]: ch.after };
1629
+ touched.add(ch.locale);
1630
+ }
1631
+ for (const locale of touched) {
1632
+ await writeListing(project.root, release.store, locale, project.listings[release.store][locale]);
1633
+ }
1634
+ if (g().json)
1635
+ printJson({ release: release.id, reverted: inverse.length });
1636
+ else {
1637
+ console.log(`${c.green("Restored")} ${inverse.length} fields from before ${release.id} in the spec. Review with \`appeeky-spec diff\`, then \`appeeky-spec push\`.`);
1638
+ }
1639
+ });
1640
+ program
1641
+ .command("suggest")
1642
+ .description("Words to add to an App Store keyword field, from missing targets and Appeeky keyword opportunities")
1643
+ .option("--locale <locale>", "Listing locale (default: the primary locale)")
1644
+ .option("--country <cc>", "Storefront for opportunities", "us")
1645
+ .option("--apply <words...>", "Add these words to the keyword field in the spec")
1646
+ .action(async (opts) => {
1647
+ const project = await load();
1648
+ const locale = opts.locale ?? project.spec.app.primaryLocale;
1649
+ if (!project.listings["app-store"][locale])
1650
+ throw new Error(`No App Store listing for ${locale}. Run \`appeeky-spec pull\` first.`);
1651
+ if (opts.apply) {
1652
+ let field = project.listings["app-store"][locale].keywords ?? "";
1653
+ const skipped = [];
1654
+ for (const w of opts.apply) {
1655
+ const next = appendKeyword(field, w);
1656
+ if (next === null)
1657
+ skipped.push(w);
1658
+ else
1659
+ field = next;
1660
+ }
1661
+ await writeListing(project.root, "app-store", locale, { ...project.listings["app-store"][locale], keywords: field });
1662
+ if (g().json)
1663
+ printJson({ locale, keywords: field, skipped });
1664
+ else
1665
+ console.log(`${c.green("Updated")} ${locale} keywords → ${field}${skipped.length ? c.yellow(`\nDid not fit: ${skipped.join(", ")}`) : ""}`);
1666
+ return;
1667
+ }
1668
+ const a = api();
1669
+ const appId = rankAppIds(project)["app-store"];
1670
+ const candidates = a && appId ? await a.opportunities(appId, opts.country.toLowerCase(), "app-store").catch(() => []) : [];
1671
+ const suggestions = keywordSuggestions(project, locale, candidates);
1672
+ if (g().json) {
1673
+ printJson({ locale, suggestions });
1674
+ return;
1675
+ }
1676
+ if (suggestions.length === 0)
1677
+ console.log(c.dim("Nothing to add: every target word is already indexed."));
1678
+ for (const s of suggestions.slice(0, 25)) {
1679
+ const pop = s.popularity !== undefined ? c.dim(` pop ${s.popularity}`) : "";
1680
+ console.log(`${s.fits ? c.green("+") : c.dim("·")} ${s.word.padEnd(18)}${pop} ${c.dim(`helps: ${s.helps.slice(0, 3).join(", ")}`)}${s.fits ? "" : c.dim(" (does not fit)")}`);
1681
+ }
1682
+ if (!a)
1683
+ console.log(c.dim("\nSet APPEEKY_API_KEY to add Appeeky keyword opportunities."));
1684
+ else
1685
+ console.log(c.dim(`\nAdd with: appeeky-spec suggest --locale ${locale} --apply <words...>`));
1686
+ });
1687
+ program
1688
+ .command("verify")
1689
+ .description("Measure releases against the ranks recorded before each push (day 7 and day 14)")
1690
+ .argument("[releases...]", "Release ids (default: every release that reached a milestone)")
1691
+ .option("--now", "Measure now, even before day 7", false)
1692
+ .option("--playbook", "Add one line per result to appeeky/playbook.md", false)
1693
+ .action(async (ids, opts) => {
1694
+ const project = await load();
1695
+ const a = api();
1696
+ const attribution = attributionClients(project.spec, a);
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);
1702
+ const metrics = combineSources([
1703
+ ...(a ? [metricSourceFor(a, project.spec)] : []),
1704
+ ...(visibility ? [visibility] : []),
1705
+ ...(localVisibility ? [localVisibility] : []),
1706
+ ...(search ? [search] : []),
1707
+ ...(ownAds ? [ownAds] : []),
1708
+ ...(webRank ? [webRank] : []),
1709
+ categoryMetricSource(project.root),
1710
+ reviewMetricSource(project.root),
1711
+ ...(hasClients(attribution) ? [attributionMetricSource(attribution)] : []),
1712
+ ]);
1713
+ const results = await verifyReleases(project, rankSource(), { ids: ids.length ? ids : undefined, force: opts.now, metrics });
1714
+ const measured = metrics ? await measureExperiments(project, metrics) : [];
1715
+ const lines = [...results.map(playbookLine), ...measured.map(experimentPlaybookLine)];
1716
+ const added = opts.playbook ? await appendPlaybook(project.root, lines) : 0;
1717
+ const experiments = dueExperiments(await readExperiments(project.root));
1718
+ await writeLoops(project.root);
1719
+ if (g().json) {
1720
+ printJson({
1721
+ results: results.map((r) => ({ release: r.release.id, ...r.check })),
1722
+ closedExperiments: measured,
1723
+ playbookLines: added,
1724
+ dueExperiments: experiments.map((e) => e.id),
1725
+ });
1726
+ return;
1727
+ }
1728
+ if (results.length === 0)
1729
+ console.log(c.dim("No release is due. Releases are measured on day 7 and day 14 (or pass --now)."));
1730
+ for (const { release, check } of results) {
1731
+ const tone = check.verdict === "won" ? c.green : check.verdict === "lost" ? c.red : c.yellow;
1732
+ const by = check.decidedBy ? c.dim(` (ranks did not move; decided by ${check.decidedBy.map((m) => METRICS[m]?.label ?? m).join(", ")})`) : "";
1733
+ console.log(`${c.bold(release.id)} day ${check.day}: ${tone(check.verdict)}${by} ${check.improved.length} up · ${check.dropped.length} down · ${check.regressions.length} top-20 lost`);
1734
+ if (check.control) {
1735
+ const k = check.control;
1736
+ const sign = (n) => (n > 0 ? `+${n}` : `${n}`);
1737
+ console.log(` ${c.dim("control")} changed (${k.changed.join(", ")}) ${sign(k.changedMove)} places · unchanged (${k.control.join(", ")}) ${sign(k.controlMove)} · net ${sign(k.net)}`);
1738
+ }
1739
+ if (check.note)
1740
+ console.log(` ${c.yellow(check.note)}`);
1741
+ for (const m of check.regressions)
1742
+ console.log(` ${c.red("lost")} ${m.country.toUpperCase()} "${m.keyword}" ${m.before} → ${m.after ?? "not ranked"}`);
1743
+ for (const m of check.improved.slice(0, 5))
1744
+ console.log(` ${c.green("up")} ${m.country.toUpperCase()} "${m.keyword}" ${m.before ?? "not ranked"} → ${m.after}`);
1745
+ for (const m of check.metrics?.changes ?? []) {
1746
+ const tone = m.outcome === "better" ? c.green : m.outcome === "worse" ? c.red : c.dim;
1747
+ const usual = m.noise !== undefined && m.noise > 0.05 ? c.dim(` (moves ±${Math.round(m.noise * 100)}% on its own)`) : "";
1748
+ console.log(` ${tone(m.outcome.padEnd(6))} ${formatChange(m, check.metrics?.after.currency)}${usual}`);
1749
+ }
1750
+ if (check.verdict === "lost")
1751
+ console.log(c.yellow(` Consider: appeeky-spec revert ${release.id}`));
1752
+ }
1753
+ for (const e of measured) {
1754
+ const tone = e.status === "won" ? c.green : e.status === "lost" ? c.red : c.yellow;
1755
+ console.log(`${c.bold(e.id)}: ${tone(e.status)} ${e.metric} ${e.baseline !== undefined ? `${round(e.baseline)} → ` : ""}${round(e.result?.value)}`);
1756
+ }
1757
+ if (!a && results.length > 0)
1758
+ console.log(c.dim("\nSet APPEEKY_API_KEY to measure conversion, downloads, revenue and ads too."));
1759
+ if (added > 0)
1760
+ console.log(c.dim(`\nAdded ${added} line${added === 1 ? "" : "s"} to appeeky/playbook.md.`));
1761
+ for (const e of experiments)
1762
+ console.log(c.yellow(`Experiment ${e.id} is due: appeeky-spec experiment close ${e.id} --value <${e.metric}>`));
1763
+ });
1764
+ const experiment = program.command("experiment").description("Growth experiments outside the listing: screenshots, UGC, ads, pricing");
1765
+ experiment
1766
+ .command("add")
1767
+ .description("Start an experiment")
1768
+ .requiredOption("--hypothesis <text>", "What you expect and why")
1769
+ .requiredOption("--metric <name>", `The one number that decides it. Measured for you: ${Object.keys(METRICS).join(", ")}`)
1770
+ .option("--type <type>", "screenshots | ugc | ads | pricing | other", "other")
1771
+ .option("--persona <id>", "Persona id from appeeky.spec.yaml")
1772
+ .option("--store <store>", "app-store | google-play")
1773
+ .option("--country <cc>", "Storefront")
1774
+ .option("--baseline <n>", "Metric value before", parseFloat)
1775
+ .option("--target <n>", "Metric value that counts as a win", parseFloat)
1776
+ .option("--days <n>", "Check after this many days", "14")
1777
+ .option("--asset <paths...>", "Files, links or ad ids that ran")
1778
+ .option("--link [channel]", "Create a smart link for the experiment (needs attribution:); optional channel: tiktok, instagram, creator, tiktok_ads, meta_ads…")
1779
+ .action(async (o) => {
1780
+ const project = await load();
1781
+ if (o.persona && !project.spec.personas.some((p) => p.id === o.persona))
1782
+ throw new Error(`No persona "${o.persona}" in ${SPEC_FILE}.`);
1783
+ const now = new Date();
1784
+ const days = Number(o.days) || 14;
1785
+ let baseline = o.baseline;
1786
+ const a = api();
1787
+ if (baseline === undefined && a && isMetricName(o.metric)) {
1788
+ // The baseline is the metric over the same number of days before the start.
1789
+ const app = experimentApp(project, { store: o.store });
1790
+ if (app)
1791
+ baseline = await metricBefore(metricSourceFor(a, project.spec), app.store, app.appId, o.metric, now.toISOString(), days).catch(() => undefined);
1792
+ }
1793
+ const id = experimentId(now, o.hypothesis);
1794
+ let link;
1795
+ if (o.link) {
1796
+ const client = attributionClient(project.spec, a);
1797
+ if (!isClient(client))
1798
+ throw new Error(`--link needs attribution. ${client.missing}`);
1799
+ const made = await createExperimentLink(client, id, { appStoreId: project.spec.stores.appStore?.appId, packageName: project.spec.stores.googlePlay?.packageName }, typeof o.link === "string" ? o.link : undefined);
1800
+ const host = project.spec.attribution?.via === "self" ? project.spec.attribution.url?.replace(/\/$/, "") : "https://ingest.appeeky.com";
1801
+ link = { slug: made.slug, url: `${host}/l/${made.slug}`, store: linkStore(project.spec) };
1802
+ if (o.baseline === undefined && o.metric.startsWith("link_"))
1803
+ baseline = 0;
1804
+ }
1805
+ const e = await writeExperiment(project.root, {
1806
+ ...(link ? { link } : {}),
1807
+ id,
1808
+ type: o.type,
1809
+ hypothesis: o.hypothesis,
1810
+ persona: o.persona,
1811
+ store: o.store,
1812
+ country: o.country,
1813
+ metric: o.metric,
1814
+ baseline,
1815
+ target: o.target,
1816
+ startedAt: now.toISOString(),
1817
+ checkAfterDays: days,
1818
+ assets: o.asset ?? [],
1819
+ });
1820
+ await writeLoops(project.root);
1821
+ if (g().json)
1822
+ printJson({ experiment: e });
1823
+ else {
1824
+ console.log(`${c.green("Started")} ${e.id} · check on ${new Date(now.getTime() + e.checkAfterDays * 86_400_000).toISOString().slice(0, 10)}`);
1825
+ if (e.baseline !== undefined)
1826
+ console.log(c.dim(` baseline ${e.metric} ${round(e.baseline)}${o.baseline === undefined ? " (last " + days + " days)" : ""}`));
1827
+ else if (isMetricName(e.metric) && !a)
1828
+ console.log(c.dim(" Set APPEEKY_API_KEY to measure the baseline and the result for you."));
1829
+ if (e.link?.url)
1830
+ console.log(` ${c.bold("link")} ${e.link.url} ${c.dim("· post this link; its installs and revenue are counted to it")}`);
1831
+ if (e.link && iosOnlyLink(project.spec, e.metric))
1832
+ console.log(c.yellow(IOS_LINK_NOTE));
1833
+ }
1834
+ });
1835
+ experiment
1836
+ .command("list")
1837
+ .description("Experiments, newest first")
1838
+ .action(async () => {
1839
+ const project = await load();
1840
+ const all = await readExperiments(project.root);
1841
+ const due = new Set(dueExperiments(all).map((e) => e.id));
1842
+ if (g().json) {
1843
+ printJson({ experiments: all.map((e) => ({ ...e, due: due.has(e.id) })) });
1844
+ return;
1845
+ }
1846
+ if (all.length === 0)
1847
+ console.log(c.dim("No experiments yet."));
1848
+ for (const e of all) {
1849
+ const status = due.has(e.id) ? c.yellow("due") : e.status === "won" ? c.green("won") : e.status === "lost" ? c.red("lost") : c.dim(e.status);
1850
+ console.log(`${c.bold(e.id)} ${e.type} · ${status}${e.result?.value !== undefined ? ` · ${e.metric} ${e.result.value}` : ""}\n ${c.dim(e.hypothesis)}`);
1851
+ }
1852
+ });
1853
+ experiment
1854
+ .command("close")
1855
+ .description("Record the result of an experiment")
1856
+ .argument("<id>")
1857
+ .option("--value <n>", "Measured metric value", parseFloat)
1858
+ .option("--note <text>", "What happened")
1859
+ .option("--playbook", "Add the result to appeeky/playbook.md", false)
1860
+ .action(async (id, o) => {
1861
+ const project = await load();
1862
+ const e = (await readExperiments(project.root)).find((x) => x.id === id || x.id.startsWith(id));
1863
+ if (!e)
1864
+ throw new Error(`No experiment ${id}. See \`appeeky-spec experiment list\`.`);
1865
+ let value = o.value;
1866
+ const a = api();
1867
+ if (value === undefined && e.link && (e.metric === "link_clicks" || e.metric === "link_installs" || e.metric === "link_revenue")) {
1868
+ // The experiment's own smart link: its clicks, or the installs and revenue counted to it.
1869
+ const client = attributionClient(project.spec, a, process.env, e.link.store);
1870
+ const stats = isClient(client) ? await linkResult(client, e.link.slug).catch(() => null) : null;
1871
+ if (stats)
1872
+ value = e.metric === "link_clicks" ? stats.clicks.total : e.metric === "link_installs" ? stats.installs : stats.revenueUsd;
1873
+ }
1874
+ if (value === undefined && a && isMetricName(e.metric)) {
1875
+ const app = experimentApp(project, e);
1876
+ if (app)
1877
+ value = await metricAfter(metricSourceFor(a, project.spec), app.store, app.appId, e.metric, e.startedAt, e.checkAfterDays).catch(() => undefined);
1878
+ }
1879
+ const closed = await writeExperiment(project.root, closeExperiment(e, value, o.note));
1880
+ if (o.playbook)
1881
+ await appendPlaybook(project.root, [experimentPlaybookLine(closed)]);
1882
+ await writeLoops(project.root);
1883
+ if (g().json)
1884
+ printJson({ experiment: closed });
1885
+ else
1886
+ console.log(`${closed.id}: ${closed.status}`);
1887
+ });
1888
+ const screenshots = program.command("screenshots").description("App Store screenshots in appeeky/screenshots/");
1889
+ screenshots
1890
+ .command("pull")
1891
+ .description("Download the live screenshots of every locale")
1892
+ .option("--locale <locales...>", "Only these locales")
1893
+ .action(async (opts) => {
1894
+ const project = await load();
1895
+ const handle = storeHandle(project, "app-store", transportOverrides());
1896
+ const state = await handle.adapter.pull();
1897
+ const count = await pullScreenshots(project.root, handle.transport, state, { locales: opts.locale });
1898
+ if (g().json)
1899
+ printJson({ downloaded: count });
1900
+ else
1901
+ console.log(`${c.green("Downloaded")} ${count} screenshots from ${state.target}`);
1902
+ });
1903
+ screenshots
1904
+ .command("check")
1905
+ .description("Sizes and counts per display type")
1906
+ .action(async () => {
1907
+ const project = await load();
1908
+ const issues = await checkScreenshots(project.root);
1909
+ if (g().json)
1910
+ printJson({ issues });
1911
+ else
1912
+ console.log(formatIssues(issues));
1913
+ if (hasErrors(issues))
1914
+ process.exitCode = 1;
1915
+ });
1916
+ program
1917
+ .command("report")
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")
1919
+ .option("--base <ref>", "Git ref to compare with", "origin/main")
1920
+ .option("-o, --output <file>", "Write the report to a file")
1921
+ .action(async (opts) => {
1922
+ const project = await load();
1923
+ const base = await loadProjectAtRef(project.root, opts.base);
1924
+ const md = specReport(base, project) + (await outboxReport(project.root));
1925
+ if (opts.output)
1926
+ await writeFile(opts.output, `${md}\n`);
1927
+ else
1928
+ console.log(md);
1929
+ if (hasErrors(lintProject(project)))
1930
+ process.exitCode = 1;
1931
+ });
1932
+ return program;
1933
+ }
1934
+ export async function run(argv = process.argv) {
1935
+ const program = buildProgram();
1936
+ try {
1937
+ await program.parseAsync(argv);
1938
+ }
1939
+ catch (err) {
1940
+ const message = err.message;
1941
+ if (program.opts().json)
1942
+ printJson({ error: message });
1943
+ else
1944
+ console.error(c.red(message));
1945
+ process.exitCode = 1;
1946
+ }
1947
+ }
1948
+ const STATE_TONE = { late: c.red, due: c.yellow, measuring: c.dim, closed: c.dim };
1949
+ function printLoop(l) {
1950
+ const state = STATE_TONE[l.state](l.state.padEnd(9));
1951
+ const when = l.nextCheck ? `next ${l.nextCheck}` : "";
1952
+ console.log(`${state} ${c.bold(l.id)} ${c.dim(`${l.kind}${l.store ? ` · ${l.store}` : ""}${when ? ` · ${when}` : ""}`)}`);
1953
+ console.log(` ${l.what}`);
1954
+ console.log(` ${c.dim("decided by")} ${l.metric}${l.result ? ` ${c.dim("·")} ${l.result}` : ""}`);
1955
+ for (const d of l.detail ?? [])
1956
+ console.log(` ${c.dim(d)}`);
1957
+ for (const w of l.warnings)
1958
+ console.log(` ${c.yellow("! " + w)}`);
1959
+ }
1960
+ //# sourceMappingURL=cli.js.map