@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
@@ -0,0 +1,152 @@
1
+ import type { AppeekyApi } from "./appeeky-api.js";
2
+ import type { MetricSource } from "./metrics.js";
3
+ import type { Project, SpecFile, StoreId } from "./schema.js";
4
+ /**
5
+ * AI visibility in the repo: whether ChatGPT, Claude, Gemini and Perplexity name
6
+ * the app when people ask for an app like it, who they name instead, and which
7
+ * pages they cite. The intents and prompts live in appeeky.spec.yaml; Appeeky AI
8
+ * Visibility asks the assistants on a schedule, and `visibility pull` writes what
9
+ * they answered to appeeky/visibility/<country>.yaml.
10
+ */
11
+ export declare const MODELS: readonly ["chatgpt", "claude", "gemini", "perplexity"];
12
+ export type Model = (typeof MODELS)[number];
13
+ export type AiIntent = NonNullable<SpecFile["aiVisibility"]>["intents"][string][number];
14
+ export declare const visibilityDir: (root: string) => string;
15
+ export declare const visibilityChangesFile: (root: string) => string;
16
+ export interface VisibilityTarget {
17
+ store: StoreId;
18
+ /** The store id: numeric App Store id or Google Play package name. */
19
+ appId: string;
20
+ countries: string[];
21
+ }
22
+ /** Which app and storefronts to watch. */
23
+ export declare function visibilityTarget(project: Project): VisibilityTarget | null;
24
+ /** ISO 639-1 language for a storefront's prompts, from the listing locale it indexes first. */
25
+ export declare function languageFor(project: Project, country: string): string;
26
+ export interface RemoteIntentRow {
27
+ intentId: string;
28
+ label: string;
29
+ description: string | null;
30
+ status: "active" | "paused" | "archived";
31
+ visibilityPct: number;
32
+ promptsTotal: number;
33
+ }
34
+ export interface RemotePrompt {
35
+ promptId: string;
36
+ text: string;
37
+ status: "active" | "paused" | "archived";
38
+ }
39
+ interface Opportunity {
40
+ intentId: string;
41
+ label: string;
42
+ status: "missing" | "weak" | "winning" | "no_data";
43
+ ownerVisibilityPct: number | null;
44
+ byModel: Partial<Record<Model, {
45
+ visibilityPct: number;
46
+ positionAvg: number | null;
47
+ promptsTotal: number;
48
+ date: string;
49
+ }>>;
50
+ winners: Array<{
51
+ name: string;
52
+ sharePct: number;
53
+ models: Model[];
54
+ }>;
55
+ }
56
+ export declare function remoteIntents(api: AppeekyApi, appId: string, country: string): Promise<RemoteIntentRow[]>;
57
+ export declare function remotePrompts(api: AppeekyApi, appId: string, country: string, intentId: string): Promise<RemotePrompt[]>;
58
+ export interface VisibilityFile {
59
+ pulledAt: string;
60
+ /** Where the answers came from: Appeeky AI Visibility, or `visibility scan --local` on your own keys. */
61
+ source?: "appeeky" | "local";
62
+ country: string;
63
+ store: StoreId;
64
+ appId: string;
65
+ /** Intents by status: missing = no assistant names the app, weak = some do, rarely. */
66
+ summary: {
67
+ winning: number;
68
+ weak: number;
69
+ missing: number;
70
+ noData: number;
71
+ };
72
+ intents: Array<{
73
+ label: string;
74
+ status: Opportunity["status"];
75
+ /** Share of prompts whose answer names the app, averaged over the assistants (0–100). */
76
+ you?: number;
77
+ byModel?: Partial<Record<Model, number>>;
78
+ /** Who the assistants name instead. */
79
+ namedInstead?: string[];
80
+ }>;
81
+ /** Pages the assistants cite, most cited first. */
82
+ sources: Array<{
83
+ domain: string;
84
+ count: number;
85
+ yours?: true;
86
+ url: string;
87
+ }>;
88
+ /** What Appeeky suggests changing, still open. */
89
+ fixes: Array<{
90
+ intent: string;
91
+ title: string;
92
+ why: string;
93
+ page?: string;
94
+ }>;
95
+ }
96
+ export declare function fetchVisibility(api: AppeekyApi, target: VisibilityTarget, country: string, now?: Date): Promise<VisibilityFile>;
97
+ export declare const visibilityPath: (root: string, country: string) => string;
98
+ export declare function readVisibility(root: string, country: string): Promise<VisibilityFile | null>;
99
+ /** What moved since the last pull: an intent the assistants started or stopped naming the app for. */
100
+ export declare function visibilityChanges(prev: VisibilityFile, next: VisibilityFile): string[];
101
+ export declare function recordChanges(root: string, date: string, country: string, changes: string[]): Promise<void>;
102
+ /** Pull every watched storefront and write its file. */
103
+ export declare function pullVisibility(api: AppeekyApi, project: Project, countries?: string[], now?: Date): Promise<Array<{
104
+ country: string;
105
+ file?: string;
106
+ data?: VisibilityFile;
107
+ changes: string[];
108
+ error?: string;
109
+ }>>;
110
+ /** The intents and prompts Appeeky tracks for a storefront, in the spec's shape. */
111
+ export declare function readRemoteIntents(api: AppeekyApi, appId: string, country: string): Promise<AiIntent[]>;
112
+ /** Ask Appeeky to write intents and prompts from the store listing (runs in the background). */
113
+ export declare function bootstrapIntents(api: AppeekyApi, appId: string, country: string, language: string): Promise<{
114
+ queued: boolean;
115
+ already: boolean;
116
+ }>;
117
+ export interface SyncPlan {
118
+ country: string;
119
+ addIntents: AiIntent[];
120
+ addPrompts: Array<{
121
+ intentId: string;
122
+ label: string;
123
+ text: string;
124
+ }>;
125
+ /** Tracked in Appeeky but not in the spec: archived only with --prune. */
126
+ extraIntents: Array<{
127
+ intentId: string;
128
+ label: string;
129
+ }>;
130
+ extraPrompts: Array<{
131
+ promptId: string;
132
+ label: string;
133
+ text: string;
134
+ }>;
135
+ }
136
+ /** Compare the spec's intents for a storefront with what Appeeky tracks. */
137
+ export declare function planSync(api: AppeekyApi, appId: string, country: string, wanted: AiIntent[]): Promise<SyncPlan>;
138
+ /** Apply a plan: add what the spec has; archive what it does not only when `prune` is set. */
139
+ export declare function applySync(api: AppeekyApi, appId: string, plan: SyncPlan, language: string, prune: boolean): Promise<{
140
+ intents: number;
141
+ prompts: number;
142
+ archived: number;
143
+ }>;
144
+ /** Ask the assistants now instead of waiting for the schedule. Charged in AI Visibility credits. */
145
+ export declare function scanVisibility(api: AppeekyApi, appId: string, country: string): Promise<unknown>;
146
+ /**
147
+ * `ai_visibility` around a push: the share of prompts whose answer names the
148
+ * app, averaged over the scanned days in the window and the assistants (0–1).
149
+ * Reads Appeeky's daily series once per assistant per run.
150
+ */
151
+ export declare function visibilityMetricSource(api: AppeekyApi, project: Project): MetricSource | null;
152
+ export {};
@@ -0,0 +1,239 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { SPEC_DIR } from "./files.js";
6
+ import { indexedLocales } from "./storefronts.js";
7
+ import { normalize } from "./text.js";
8
+ import { rankAppIds } from "./verify.js";
9
+ /**
10
+ * AI visibility in the repo: whether ChatGPT, Claude, Gemini and Perplexity name
11
+ * the app when people ask for an app like it, who they name instead, and which
12
+ * pages they cite. The intents and prompts live in appeeky.spec.yaml; Appeeky AI
13
+ * Visibility asks the assistants on a schedule, and `visibility pull` writes what
14
+ * they answered to appeeky/visibility/<country>.yaml.
15
+ */
16
+ export const MODELS = ["chatgpt", "claude", "gemini", "perplexity"];
17
+ export const visibilityDir = (root) => join(root, SPEC_DIR, "visibility");
18
+ export const visibilityChangesFile = (root) => join(visibilityDir(root), "changes.md");
19
+ /** Which app and storefronts to watch. */
20
+ export function visibilityTarget(project) {
21
+ const cfg = project.spec.aiVisibility;
22
+ const ids = rankAppIds(project);
23
+ const store = cfg?.store ?? (ids["app-store"] ? "app-store" : ids["google-play"] ? "google-play" : undefined);
24
+ if (!store || !ids[store])
25
+ return null;
26
+ const countries = cfg?.countries ?? [Object.keys(project.spec.targets)[0] ?? "us"];
27
+ return { store, appId: ids[store], countries };
28
+ }
29
+ /** ISO 639-1 language for a storefront's prompts, from the listing locale it indexes first. */
30
+ export function languageFor(project, country) {
31
+ return (indexedLocales(project, country)[0] ?? project.spec.app.primaryLocale).slice(0, 2).toLowerCase();
32
+ }
33
+ const path = (appId, rest = "") => `/ai-visibility/${encodeURIComponent(appId)}${rest}`;
34
+ export async function remoteIntents(api, appId, country) {
35
+ const rows = await api.get(path(appId, "/intents"), { country });
36
+ return (rows ?? []).filter((r) => r.status !== "archived");
37
+ }
38
+ export async function remotePrompts(api, appId, country, intentId) {
39
+ const detail = await api.get(path(appId, `/intents/${encodeURIComponent(intentId)}`), { country });
40
+ return (detail?.prompts ?? []).filter((p) => p.status !== "archived");
41
+ }
42
+ const pct = (n) => (n === null || n === undefined ? undefined : Math.round(n));
43
+ export async function fetchVisibility(api, target, country, now = new Date()) {
44
+ const opp = await api.get(path(target.appId, "/opportunities"), { country, days: "30" });
45
+ const fixes = await api.get(path(target.appId, "/fixes"), { country }).catch(() => null);
46
+ const labels = new Map((opp?.intents ?? []).map((i) => [i.intentId, i.label]));
47
+ return {
48
+ pulledAt: now.toISOString().slice(0, 10),
49
+ country,
50
+ store: target.store,
51
+ appId: target.appId,
52
+ summary: opp?.summary ?? { winning: 0, weak: 0, missing: 0, noData: 0 },
53
+ intents: (opp?.intents ?? []).map((i) => {
54
+ const byModel = Object.fromEntries(Object.entries(i.byModel).map(([m, s]) => [m, pct(s?.visibilityPct)]));
55
+ return strip({
56
+ label: i.label,
57
+ status: i.status,
58
+ you: pct(i.ownerVisibilityPct),
59
+ byModel: Object.keys(byModel).length ? byModel : undefined,
60
+ namedInstead: i.winners.length ? i.winners.slice(0, 5).map((w) => `${w.name} (${Math.round(w.sharePct)}%)`) : undefined,
61
+ });
62
+ }),
63
+ sources: (opp?.sources.sources ?? []).slice(0, 15).map((s) => strip({ domain: s.domain, count: s.count, yours: s.citesOwner ? true : undefined, url: s.url })),
64
+ fixes: (fixes?.fixes ?? [])
65
+ .filter((f) => f.status === "open")
66
+ .map((f) => strip({ intent: labels.get(f.intentId) ?? f.intentId, title: f.title, why: f.rationale, page: f.targetUrl ?? undefined })),
67
+ };
68
+ }
69
+ const strip = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
70
+ export const visibilityPath = (root, country) => join(visibilityDir(root), `${country}.yaml`);
71
+ export async function readVisibility(root, country) {
72
+ const file = visibilityPath(root, country);
73
+ return existsSync(file) ? YAML.parse(await readFile(file, "utf8")) : null;
74
+ }
75
+ /** What moved since the last pull: an intent the assistants started or stopped naming the app for. */
76
+ export function visibilityChanges(prev, next) {
77
+ const out = [];
78
+ const before = new Map(prev.intents.map((i) => [normalize(i.label), i]));
79
+ const rank = { missing: 0, weak: 1, winning: 2, no_data: -1 };
80
+ for (const i of next.intents) {
81
+ const was = before.get(normalize(i.label));
82
+ if (!was || was.status === "no_data" || i.status === "no_data" || was.status === i.status)
83
+ continue;
84
+ const verb = rank[i.status] > rank[was.status] ? "up" : "down";
85
+ out.push(`"${i.label}": ${was.status} → ${i.status} (${was.you ?? 0}% → ${i.you ?? 0}%), ${verb}`);
86
+ }
87
+ const cited = (f) => f.sources.some((s) => s.yours);
88
+ if (!cited(prev) && cited(next))
89
+ out.push("the assistants now cite your own site");
90
+ if (cited(prev) && !cited(next))
91
+ out.push("the assistants stopped citing your own site");
92
+ return out;
93
+ }
94
+ export async function recordChanges(root, date, country, changes) {
95
+ if (changes.length === 0)
96
+ return;
97
+ const file = visibilityChangesFile(root);
98
+ const old = existsSync(file) ? await readFile(file, "utf8") : "# AI visibility changes\n\nWritten by `visibility pull`: intents the assistants started or stopped naming the app for.\n";
99
+ const [head, ...rest] = old.split(/\n(?=- )/);
100
+ const lines = changes.map((c) => `- ${date} · ${country.toUpperCase()} · ${c}`);
101
+ await writeFile(file, [head.trimEnd(), "", ...lines, ...rest.map((r) => r.trimEnd())].join("\n") + "\n");
102
+ }
103
+ /** Pull every watched storefront and write its file. */
104
+ export async function pullVisibility(api, project, countries, now = new Date()) {
105
+ const target = visibilityTarget(project);
106
+ if (!target)
107
+ throw new Error("Set stores.appStore.appId (numeric) or stores.googlePlay.packageName first.");
108
+ await mkdir(visibilityDir(project.root), { recursive: true });
109
+ const out = [];
110
+ for (const country of countries ?? target.countries) {
111
+ try {
112
+ const data = await fetchVisibility(api, target, country, now);
113
+ const file = visibilityPath(project.root, country);
114
+ const prev = await readVisibility(project.root, country);
115
+ const changes = prev ? visibilityChanges(prev, data) : [];
116
+ await recordChanges(project.root, data.pulledAt, country, changes);
117
+ await writeFile(file, YAML.stringify(data, { lineWidth: 0 }));
118
+ out.push({ country, file, data, changes });
119
+ }
120
+ catch (e) {
121
+ out.push({ country, changes: [], error: e.message });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ // ── Intents as code ─────────────────────────────────────────────────────────
127
+ /** The intents and prompts Appeeky tracks for a storefront, in the spec's shape. */
128
+ export async function readRemoteIntents(api, appId, country) {
129
+ const out = [];
130
+ for (const i of await remoteIntents(api, appId, country)) {
131
+ const prompts = await remotePrompts(api, appId, country, i.intentId);
132
+ out.push(strip({ label: i.label, description: i.description ?? undefined, prompts: prompts.map((p) => p.text) }));
133
+ }
134
+ return out;
135
+ }
136
+ /** Ask Appeeky to write intents and prompts from the store listing (runs in the background). */
137
+ export async function bootstrapIntents(api, appId, country, language) {
138
+ const r = await api.post(`${path(appId, "/bootstrap")}?country=${encodeURIComponent(country)}&language=${encodeURIComponent(language)}`, {});
139
+ return { queued: Boolean(r?.taskRunId), already: Boolean(r?.alreadyBootstrapped) };
140
+ }
141
+ const key = (s) => normalize(s).replace(/\s+/g, " ").trim();
142
+ /** Compare the spec's intents for a storefront with what Appeeky tracks. */
143
+ export async function planSync(api, appId, country, wanted) {
144
+ const remote = await remoteIntents(api, appId, country);
145
+ const byLabel = new Map(remote.map((r) => [key(r.label), r]));
146
+ const plan = { country, addIntents: [], addPrompts: [], extraIntents: [], extraPrompts: [] };
147
+ const seen = new Set();
148
+ for (const intent of wanted) {
149
+ const r = byLabel.get(key(intent.label));
150
+ if (!r) {
151
+ plan.addIntents.push(intent);
152
+ continue;
153
+ }
154
+ seen.add(r.intentId);
155
+ const prompts = await remotePrompts(api, appId, country, r.intentId);
156
+ const have = new Map(prompts.map((p) => [key(p.text), p]));
157
+ const want = new Set(intent.prompts.map(key));
158
+ for (const text of intent.prompts)
159
+ if (!have.has(key(text)))
160
+ plan.addPrompts.push({ intentId: r.intentId, label: intent.label, text });
161
+ for (const p of prompts)
162
+ if (!want.has(key(p.text)))
163
+ plan.extraPrompts.push({ promptId: p.promptId, label: intent.label, text: p.text });
164
+ }
165
+ for (const r of remote)
166
+ if (!seen.has(r.intentId))
167
+ plan.extraIntents.push({ intentId: r.intentId, label: r.label });
168
+ return plan;
169
+ }
170
+ /** Apply a plan: add what the spec has; archive what it does not only when `prune` is set. */
171
+ export async function applySync(api, appId, plan, language, prune) {
172
+ const q = `?country=${encodeURIComponent(plan.country)}&language=${encodeURIComponent(language)}`;
173
+ let intents = 0;
174
+ let prompts = 0;
175
+ let archived = 0;
176
+ for (const intent of plan.addIntents) {
177
+ const created = await api.post(`${path(appId, "/intents")}${q}`, { label: intent.label, description: intent.description ?? null });
178
+ intents++;
179
+ for (const text of intent.prompts) {
180
+ await api.post(`${path(appId, `/intents/${encodeURIComponent(created.id)}/prompts`)}${q}`, { text });
181
+ prompts++;
182
+ }
183
+ }
184
+ for (const p of plan.addPrompts) {
185
+ await api.post(`${path(appId, `/intents/${encodeURIComponent(p.intentId)}/prompts`)}${q}`, { text: p.text });
186
+ prompts++;
187
+ }
188
+ if (prune) {
189
+ for (const p of plan.extraPrompts) {
190
+ await api.request("DELETE", path(appId, `/prompts/${encodeURIComponent(p.promptId)}`));
191
+ archived++;
192
+ }
193
+ for (const i of plan.extraIntents) {
194
+ await api.request("DELETE", `${path(appId, `/intents/${encodeURIComponent(i.intentId)}`)}`);
195
+ archived++;
196
+ }
197
+ }
198
+ return { intents, prompts, archived };
199
+ }
200
+ /** Ask the assistants now instead of waiting for the schedule. Charged in AI Visibility credits. */
201
+ export async function scanVisibility(api, appId, country) {
202
+ return api.post(`${path(appId, "/scan")}?country=${encodeURIComponent(country)}`, {});
203
+ }
204
+ // ── Measuring ───────────────────────────────────────────────────────────────
205
+ /**
206
+ * `ai_visibility` around a push: the share of prompts whose answer names the
207
+ * app, averaged over the scanned days in the window and the assistants (0–1).
208
+ * Reads Appeeky's daily series once per assistant per run.
209
+ */
210
+ export function visibilityMetricSource(api, project) {
211
+ const target = visibilityTarget(project);
212
+ if (!target)
213
+ return null;
214
+ const country = target.countries[0];
215
+ let series;
216
+ return {
217
+ label: "Appeeky AI Visibility",
218
+ async window(store, _appId, from, to) {
219
+ if (store !== target.store)
220
+ return null;
221
+ series ??= Promise.all(MODELS.map((model) => api.get(path(target.appId, "/trend"), { country, model, windowDays: "90" }).then((s) => s ?? []).catch(() => [])));
222
+ const byDay = new Map();
223
+ for (const points of await series) {
224
+ for (const p of points) {
225
+ if (p.date < from || p.date >= to)
226
+ continue;
227
+ byDay.set(p.date, [...(byDay.get(p.date) ?? []), p.visibilityPct]);
228
+ }
229
+ }
230
+ if (byDay.size === 0)
231
+ return null;
232
+ const daily = [...byDay.values()].map((xs) => xs.reduce((a, b) => a + b, 0) / xs.length);
233
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
234
+ const values = { ai_visibility: daily.reduce((a, b) => a + b, 0) / daily.length / 100 };
235
+ return { from, to, days, values };
236
+ },
237
+ };
238
+ }
239
+ //# sourceMappingURL=visibility.js.map
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
2
  "name": "@appeeky/spec",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Your app's store listing as a spec: lint it, check keyword coverage, diff it against the live store and push it to App Store Connect and Google Play.",
5
+ "license": "FSL-1.1-MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "appeeky-spec": "dist/bin.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./core": {
18
+ "types": "./dist/core.d.ts",
19
+ "import": "./dist/core.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "!dist/**/*.map",
26
+ "skills",
27
+ "agents",
28
+ ".github/assets/cover.gif",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/appeeky/appeeky-spec.git"
39
+ },
40
+ "homepage": "https://spec.appeeky.com",
41
+ "bugs": {
42
+ "url": "https://github.com/appeeky/appeeky-spec/issues"
43
+ },
44
+ "keywords": [
45
+ "aso",
46
+ "app-store-optimization",
47
+ "app-store",
48
+ "app-store-connect",
49
+ "google-play",
50
+ "metadata",
51
+ "keywords",
52
+ "spec",
53
+ "cli",
54
+ "appeeky"
55
+ ],
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.build.json && node -e \"for (const f of ['ugc-core.js','visibility-core.js']) require('fs').copyFileSync('vendor/'+f,'dist/'+f)\"",
61
+ "dev": "tsc -p tsconfig.build.json --watch",
62
+ "typecheck": "tsc -p tsconfig.json --noEmit",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
65
+ "prepublishOnly": "npm run build",
66
+ "pack:check": "npm run build && npm pack --dry-run",
67
+ "release:dry": "node scripts/release.mjs --dry-run",
68
+ "vendor:ugc": "node scripts/vendor-ugc-core.mjs",
69
+ "vendor:visibility": "node scripts/vendor-visibility-core.mjs"
70
+ },
71
+ "dependencies": {
72
+ "commander": "^13.1.0",
73
+ "yaml": "^2.6.1",
74
+ "zod": "^3.24.3"
75
+ },
76
+ "devDependencies": {
77
+ "@types/node": "^22.15.3",
78
+ "esbuild": "^0.28.2",
79
+ "typescript": "^5.8.3",
80
+ "vitest": "^3.1.2"
81
+ }
82
+ }
@@ -0,0 +1,142 @@
1
+ ---
2
+ name: appeeky-spec
3
+ description: Improve and ship an app's App Store / Google Play listing with Appeeky Spec (appeeky.spec.yaml + appeeky/store). Use when asked to do ASO on a repo, change an app's name, subtitle, keywords, promotional text or descriptions, check keyword coverage, set up listing-as-code or its GitHub Action, handle App Store screenshots, or push listing changes to App Store Connect or Google Play, or run the weekly growth routine (the co-founder review with the specialist subagents). To choose target keywords see appeeky-spec-targets; for a new language or country see appeeky-spec-localize; to judge past releases see appeeky-spec-review; for AI assistants and the category chart see appeeky-spec-visibility; for screenshots, UGC, ads, pricing, attribution and data see appeeky-spec-experiments.
4
+ metadata:
5
+ version: 1.3.0
6
+ ---
7
+
8
+ # Appeeky Spec
9
+
10
+ The app's store listings live in files, and every push is recorded and measured.
11
+
12
+ | Path | What it holds |
13
+ | --- | --- |
14
+ | `appeeky.spec.yaml` | app, stores, `targets` (searches the app should be found for, per storefront), optional `storefronts` and `personas` |
15
+ | `appeeky/store/app-store/<locale>.yaml` | name, subtitle, keywords, promotionalText, description, whatsNew, supportUrl, marketingUrl |
16
+ | `appeeky/store/google-play/<language>.yaml` | title, shortDescription, fullDescription, video (Play codes like `tr-TR`) |
17
+ | `appeeky/releases/<id>.yaml` | every push: before/after values, note, rank baseline, day-7/14 checks |
18
+ | `appeeky/experiments/<id>.yaml` | screenshot, UGC, ads and pricing experiments |
19
+ | `appeeky/loops.md` | generated by `status`, `verify`, `push` and `experiment`: every change being measured, its metric, next check and result so far |
20
+ | `appeeky/playbook.md` | what worked and what did not for this app |
21
+ | `appeeky/reviews/` | customer reviews per month (JSONL), `summary.md`, and `replies/` drafted for review |
22
+ | `appeeky/competitors/<name>.yaml` | each competitor's listing, best ranks, ranks on your targets, recent low ratings |
23
+ | `appeeky/visibility/<country>.yaml` | whether ChatGPT, Claude, Gemini and Perplexity name the app per intent, who they name instead, the pages they cite, suggested fixes; `changes.md` beside it (`history.csv` and `answers/` with `--local` scans) |
24
+ | `appeeky/seo/` | Google Search Console for the app's website: `daily.csv`, `queries.csv`, `pages.csv`, `summary.md` (`seo pull`) |
25
+ | `appeeky/category.csv` | rank in the category chart (top 100), one row a day |
26
+ | `.appeeky/data/*.csv` (or `appeeky/data/`) | analytics, Search Ads, RevenueCat, Meta and TikTok per day; `README.md` says what each file holds |
27
+
28
+ Run commands as `npx @appeeky/spec <command> --json` (or `appeeky-spec` when installed). Add `-C <dir>` when the spec is not in the current folder.
29
+
30
+ ## No spec yet
31
+
32
+ In a terminal the user runs, `init` with no options is a guided setup. It finds the app, pulls both stores, suggests targets, offers the workflows and the skills, and shows the first coverage. As an agent, use the flags instead:
33
+
34
+ 1. Find the App Store app id and Google Play package name (Xcode project, `app.json`, `build.gradle`), or ask.
35
+ 2. Store access: `appstore` / `gps` CLIs with the user's keys (local), or `APPEEKY_API_KEY` with a store connected in Appeeky (hosted). Do not ask for or handle key files yourself.
36
+ - Local App Store: `appstore accounts add`, or `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY`.
37
+ - Local Google Play: `gps accounts add`, or `GPS_CREDENTIALS`.
38
+ - `--via auto` (default) uses a local CLI when installed, hosted when `APPEEKY_API_KEY` is set. `APPEEKY_API_KEY` also adds keyword popularity and ideas.
39
+ 3. `init --name "<App>" --app-store <id> --google-play <package> --pull`, then fill `targets` (see appeeky-spec-targets).
40
+ 4. `doctor --json` lists everything still missing (store access, targets, the key, attribution, workflows, skills, `.gitignore`), each with its fix. Exit code 1 means something must be fixed.
41
+
42
+ ### Automation
43
+
44
+ `init --ci` (also on an existing spec) writes three GitHub workflows:
45
+
46
+ | Workflow | When | What |
47
+ | --- | --- | --- |
48
+ | `listing-check.yml` | every pull request | comments what the listing changes do (`report`), fails on check errors |
49
+ | `listing-push.yml` | merge to main touching `appeeky/store/**` or the spec | `push --yes`, then commits the release file |
50
+ | `listing-daily.yml` | daily 06:00 UTC | `category pull`, `visibility pull`, `verify --playbook`, `reviews pull`, `competitors pull`, `social pull`, `ugc pull`; `seo pull` with `GSC_CREDENTIALS` and `visibility scan --local` every `VISIBILITY_EVERY` days; then commits |
51
+
52
+ The merge is the approval gate: nothing reaches the store unless a person merged it. The workflows need `APPEEKY_API_KEY` (or store keys) as repository secrets.
53
+
54
+ Without GitHub Actions the same commands run anywhere:
55
+
56
+ - **Other CI** (GitLab, Bitbucket, CircleCI): the same three triggers and commands.
57
+ - **cron or launchd** on the user's machine: `examples/weekly/` has a script.
58
+ - **The user's own server** (or Railway, Fly, Render): `docker/docker-compose.yml` with a `.env` from `docker/.env.example`, then `docker compose up -d`. It runs the routines on a schedule and commits back. Its push routine only pushes listing changes that reached `main` after it started, so the merge stays the gate. Suggest `PUSH_DRY_RUN=1` for the first days.
59
+ - **A scheduled agent:** it runs the daily commands and the weekly routine below.
60
+ - **By hand:** `status` flags checks that are late.
61
+
62
+ Without CI, a person runs `push`. Never take over that gate with `--yes`.
63
+
64
+ ## Workflow
65
+
66
+ 1. **Understand.** Run `status` (or read `appeeky/loops.md`; `status --json` lists the `free` stores): do not start a change on a store that already has a release being measured, or the two results mix. Read `appeeky/playbook.md` and recent releases (`log --json`). Do not repeat a change that already lost unless something changed. With `APPEEKY_API_KEY`, refresh and read what users and competitors say: `reviews pull` then `appeeky/reviews/summary.md`, `competitors pull` then `appeeky/competitors/`, `visibility pull` then `appeeky/visibility/`, and `data pull` for the numbers (`.appeeky/data/README.md`). `category pull` records today's category rank (the App Store needs no key). Words users use in reviews and searches competitors win are good candidates for targets.
67
+ 2. **Find gaps.** `coverage --json`, `suggest --json` and `check --json`. Missing or `partial` high-popularity targets are the opportunities. `crossLocalization` lists extra locales a storefront reads with spare keyword space. If listings are missing, run `pull` first.
68
+ 3. **Propose.** Edit the listing files. Keep every word that currently covers a target unless the user agrees to drop it. For each change say which targets it should gain and which it could lose. One clear hypothesis per release.
69
+ 4. **Verify.** `check --json` (no errors allowed) and `diff --json`. Changes marked `blocked` cannot go live yet; tell the user why (usually: the App Store needs a new version, `push --create-version <x.y.z>`).
70
+ 5. **Ship only with consent.** Pushing changes the live store; on Google Play it is public within minutes. Run `push --note "<hypothesis>"` only when the user asked; use `--yes` only when they said so explicitly. Commit the release file it writes.
71
+ 6. **Learn.** After day 7, `verify --playbook --json` (see appeeky-spec-review).
72
+
73
+ Never run `pull` over uncommitted listing edits: it overwrites local files.
74
+
75
+ ### Weekly routine: the co-founder review
76
+
77
+ For a scheduled or "what should we do this week" run, follow the co-founder review: `agents/cofounder.md` in this package, installed as the `/appeeky-cofounder` command by `agents install`.
78
+
79
+ 1. `status --json`: only the `free` stores may change. Read `appeeky/loops.md`, `appeeky/playbook.md` and the latest brief in `appeeky/briefs/`.
80
+ 2. Start the specialist subagents in parallel: `appeeky-aso`, `appeeky-reviews`, `appeeky-ads`, `appeeky-releases`, `appeeky-ugc`, `appeeky-social`. They only read, and each returns findings and up to two candidate changes. Without subagents, do their checklists yourself.
81
+ 3. Pick exactly one change for a free store, the one with the strongest evidence. Never repeat a change the playbook says lost.
82
+ 4. Edit, then `check` and `diff`.
83
+ 5. Write the brief to `appeeky/briefs/<date>.md`: where things stand, what each specialist found, the change (or why none), what is for a person (replies, ads, product requests, the next UGC experiment, posts to publish), and what was not picked.
84
+ 6. Write `.appeeky/proposal.md` for the pull request. Do not `push`. A person merges; the push runs on merge.
85
+
86
+ `npx @appeeky/spec agents install` writes the specialists and the command into `.claude/` (`--for opencode` or `all` for `.opencode/`). Commit them so scheduled runs have them too.
87
+
88
+ To run it every week without anyone starting it:
89
+
90
+ | Where | How |
91
+ | --- | --- |
92
+ | GitHub Actions | `examples/weekly/appeeky-spec-weekly.yml` |
93
+ | The user's machine | `examples/weekly/weekly.sh` with cron or launchd |
94
+ | The user's own server | `WEEKLY_AT=0 7 * * 1` in the docker `.env`, with the `latest-agent` image |
95
+
96
+ Each uses `.github/appeeky-propose.md` when it exists, otherwise the installed `.claude/commands/appeeky-cofounder.md`. The pull request carries the brief, and the listing change when there is one.
97
+
98
+ The agent needs one of two things:
99
+ - **`CLAUDE_CODE_OAUTH_TOKEN`:** the user runs `claude setup-token` once on their computer. Runs then count against their Claude Pro or Max plan, with no API key.
100
+ - **`ANTHROPIC_API_KEY`:** billed per use.
101
+
102
+ Set it as a repository secret or in the `.env`. Never ask the user to paste a token into chat or commit it.
103
+
104
+ ### Before and after a change
105
+
106
+ - `competitors discover --add <n>` finds apps like this one and adds them to `competitors:`.
107
+ - `report --base main` prints the Markdown a pull request needs: listing changes, check results and coverage.
108
+ - `log --markdown` summarizes past releases.
109
+
110
+ ## App Store rules
111
+
112
+ - Name 30, subtitle 30, keyword field 100, promotional text 170 characters. Counted in characters, not bytes (a Cyrillic or CJK letter is one).
113
+ - Name, subtitle and keyword field words are indexed once and combine into phrases across the three. Never repeat a word across them. Separate keywords with commas and no spaces; a field without commas is read as one phrase.
114
+ - Weight: name > subtitle > keyword field. The description is not indexed for search.
115
+ - Skip filler words ("app", "the", "and", "for"), the app's category name and plurals of words already present (English).
116
+ - Only promotional text changes without a new version.
117
+ - A storefront can index more than one listing locale (the US App Store also reads Spanish (Mexico)). Use the extra locale's keyword field for more keywords for that storefront; `coverage` counts it.
118
+ - Do not use competitor brand names or trademarks.
119
+
120
+ ## Google Play rules
121
+
122
+ - Title 30, short description 80, full description 4000 characters. All three are indexed; use the main keywords naturally in the full description, without stuffing.
123
+ - No ranking or promotional words ("best", "#1", "top", "free", "sale"), emoji or all caps in the title.
124
+ - Changes go live without an app update. A new language needs all three text fields.
125
+
126
+ ## Screenshots
127
+
128
+ `screenshots pull` downloads the live App Store screenshots of every locale into `appeeky/screenshots/app-store/`. `screenshots check` (also part of `check`) flags wrong sizes and counts per display type. Screenshots are not indexed for search; they change conversion, so treat a new set as an experiment.
129
+
130
+ ## Other experiments
131
+
132
+ Screenshots, UGC, ads, pricing, attribution, smart links and the app's numbers: see appeeky-spec-experiments.
133
+
134
+ Docs: https://docs.appeeky.com/docs/spec
135
+
136
+ ## AI visibility and category rank
137
+
138
+ Whether ChatGPT, Claude, Gemini and Perplexity name the app (`aiVisibility:` in the spec, `visibility init|sync|pull`), and the daily category chart rank (`category pull`): see appeeky-spec-visibility. Never run `visibility sync --prune` or `visibility scan` unless the user asked.
139
+
140
+ ## Reviews
141
+
142
+ `reviews list --rating 1-2 --unanswered --json` shows what needs an answer. Draft each reply with `reviews reply <id> --text "..."`: specific to what the user said, no promises the app does not keep, under 350 characters for Google Play. The drafts land in `appeeky/reviews/replies/`; open a pull request with them and send them with `reviews push` only after it is approved. Recurring complaints in reviews are listing input too: a feature people look for belongs in the description or the screenshots.