@analitikisler/ai-tag-debugger 0.9.0 → 0.11.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.
package/src/cli.js CHANGED
@@ -3,79 +3,18 @@ import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { loadConfig, runAudit } from "./audit.js";
6
- import { LANGUAGES, messages } from "./i18n.js";
7
- import { PROVIDERS } from "./providers.js";
8
- import { JOURNEY_PRESETS } from "./journeys.js";
9
- import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
6
+ import { messages } from "./i18n.js";
7
+ import { helpText } from "./help.js";
10
8
  import { replaySavedSite, writeSavedSteps } from "./replay.js";
9
+ import { savePlan } from "./plan.js";
10
+ import { DEFAULT_DATA_DIR } from "./workspace.js";
11
+ import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
11
12
 
12
13
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
14
 
14
- const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
15
-
16
- Usage:
17
- ai-tag-debugger run --site <url> --journey purchase [options] Audit ready-made journeys, no config needed.
18
- ai-tag-debugger run --site <url> --goal "<what to do>" Audit a journey you describe in plain language.
19
- ai-tag-debugger run --config <file> [options] Run an audit from a config file.
20
- ai-tag-debugger replay <dir>... [--update] Audit sites saved with --save-site again, offline and without AI,
21
- and list findings that changed. Exits with code 1 when any did.
22
- ai-tag-debugger mcp [--http] [--port <n>] Start the MCP server for Claude, ChatGPT, Gemini and other chat apps.
23
-
24
- Run options:
25
- --site <url> The site to audit (overrides "site" in the config).
26
- --config <file> Audit config (JSON). See examples/demo.config.json.
27
- --journey <id> Run this journey (repeatable): a journey id from the config, or a ready-made
28
- journey: ${Object.keys(JOURNEY_PRESETS).join(", ")}.
29
- --goal "<text>" Run a journey you describe in plain language (repeatable).
30
- --events <names> Only check these events, comma separated, e.g. purchase,add_to_cart.
31
- --max-steps <n> Most AI steps per journey (default 30). Lower it to cap API usage.
32
- --out <dir> Where to write report.html, report.md, timeline.csv and capture.json (default: reports/<timestamp>).
33
- --provider <name> AI provider: ${Object.keys(PROVIDERS).join(", ")} (default: whichever API key is set).
34
- --model <id> Model id (defaults: ${Object.entries(PROVIDERS).filter(([, p]) => p.defaultModel).map(([k, p]) => `${k} ${p.defaultModel}`).join(", ")}).
35
- --base-url <url> API address for --provider openai-compatible (Kimi, GLM, DeepSeek, OpenRouter, Ollama...).
36
- --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")} (default: en, or "language" in the config).
37
- --providers <list> Tracking providers to check, comma separated (default: ${DEFAULT_PROVIDERS.join(",")}).
38
- Choose from ${Object.keys(TRACKING_PROVIDERS).join(", ")}. Requests to the
39
- others are still answered locally, but only counted ("providers" in the config).
40
- --viewport <name> Only run this viewport: desktop or mobile (repeatable).
41
- --allow-host <host> Let journeys visit this host too, e.g. a hosted checkout (repeatable). By default the
42
- browser stays on the audited site and its subdomains ("allowed_hosts" in the config).
43
- --scripted Replay each journey's fixed "steps" instead of letting the AI browse.
44
- --no-ai Use no AI model at all (requires --scripted). The report then has no explanations.
45
- --fail-on <level> Exit with code 1 when findings reach this level: broken, risk, warning or never (default: broken).
46
- --headed Show the browser window.
47
- --keep-bodies Store tracking requests as sent. By default, emails, phone numbers and user ids sent
48
- for ad matching are redacted in the report and capture.json ("keep_bodies" in the config).
49
- --no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
50
- --save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
51
- the same pages again offline. Its findings become the expected ones.
52
- --save-steps <file> Write the steps each journey took into "journeys[].steps" of this config file (created
53
- if missing), so "run --config <file> --scripted --no-ai" repeats them with no AI model
54
- or API key. A journey run on several viewports keeps the steps of the first.
55
-
56
- Replay options:
57
- --update Make this replay's findings the expected ones (after checking that the changes are right).
58
- --out <dir> Where to write the replay's reports (default: <dir>/last-replay).
59
-
60
- MCP options:
61
- --http Serve over Streamable HTTP instead of stdio (for apps that connect to a URL).
62
- --port <n> HTTP port (default 8787).
63
- --host <addr> HTTP host (default 127.0.0.1).
64
- --token <secret> Secret URL path segment, at least 16 characters (default: random, printed at start).
65
- --allow-private-network Over HTTP, let audits open localhost and private network addresses (blocked by default).
66
- --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports).
67
- --public-url <url> The HTTPS address your tunnel or host gives this server, so audits can link to their reports.
68
-
69
- -h, --help Show this help.
70
- -v, --version Print the version.
71
-
72
- Short alias: aitd (aitd run ... is the same as ai-tag-debugger run ...).
73
-
74
- API keys: set one of ${Object.values(PROVIDERS).map((p) => p.env).join(", ")}.
75
- Any OpenAI-compatible API: --provider openai-compatible --base-url <url> --model <id>, with the key in OPENAI_COMPATIBLE_API_KEY.`;
76
-
77
- async function main() {
15
+ async function main(args = process.argv.slice(2)) {
78
16
  const { values, positionals } = parseArgs({
17
+ args,
79
18
  allowPositionals: true,
80
19
  options: {
81
20
  config: { type: "string" },
@@ -86,6 +25,7 @@ async function main() {
86
25
  out: { type: "string" },
87
26
  journey: { type: "string", multiple: true },
88
27
  viewport: { type: "string", multiple: true },
28
+ platforms: { type: "string", multiple: true },
89
29
  providers: { type: "string", multiple: true },
90
30
  "allow-host": { type: "string", multiple: true },
91
31
  "allow-private-network": { type: "boolean", default: false },
@@ -108,6 +48,13 @@ async function main() {
108
48
  "save-site": { type: "string" },
109
49
  "save-steps": { type: "string" },
110
50
  update: { type: "boolean", default: false },
51
+ "data-dir": { type: "string" },
52
+ against: { type: "string" },
53
+ from: { type: "string" },
54
+ topic: { type: "string" },
55
+ format: { type: "string" },
56
+ force: { type: "boolean", default: false },
57
+ yes: { type: "boolean", short: "y", default: false },
111
58
  help: { type: "boolean", short: "h", default: false },
112
59
  version: { type: "boolean", short: "v", default: false },
113
60
  },
@@ -118,6 +65,11 @@ async function main() {
118
65
  return;
119
66
  }
120
67
 
68
+ if (isSentence(positionals) && !values.help) {
69
+ const next = await fromSentence(positionals[0], values, args);
70
+ if (next) await main(next);
71
+ return;
72
+ }
121
73
  if (command === "mcp" && !values.help) {
122
74
  const { startMcp } = await import("./mcp.js");
123
75
  await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"] });
@@ -145,19 +97,76 @@ async function main() {
145
97
  if (changed && !values.update) process.exitCode = 1;
146
98
  return;
147
99
  }
148
- if (values.help || command !== "run" || (!values.config && !values.site)) {
149
- console.log(USAGE);
150
- process.exit(values.help ? 0 : 2);
100
+ if (command === "plan" && positionals[1] === "list" && !values.help) {
101
+ if (!values.site) throw new Error("plan list needs --site <url>: aitd plan list --site https://www.example.com");
102
+ const { domainOf, listPlanIds } = await import("./workspace.js");
103
+ const ids = await listPlanIds(values["data-dir"] ?? DEFAULT_DATA_DIR, values.site);
104
+ if (!ids.length) throw new Error(`No plan yet for ${domainOf(values.site)}. Draft one with: aitd plan --site ${values.site}`);
105
+ for (const id of ids) console.log(id);
106
+ return;
151
107
  }
152
- if (values["no-ai"] && !values.scripted) {
153
- throw new Error("--no-ai needs --scripted: without an AI model, journeys must have fixed steps.");
108
+ if (command === "plan" && !values.help && ["import", "export"].includes(positionals[1])) {
109
+ await planSheet(positionals[1], positionals[2], values);
110
+ return;
111
+ }
112
+ if (command === "plan" && !values.help) {
113
+ if (values.from) throw new Error("--from was removed. Draft a plan with: aitd plan --site <url>");
114
+ if (values.out) throw new Error("plan --out was removed. Plans are saved in aitd/<domain>/plans/<id>/; use --data-dir <dir> to keep them elsewhere.");
115
+ if (values.force) throw new Error("--force was removed. Each plan is saved in a new folder, so nothing is replaced.");
116
+ if (!values.site) throw new Error("plan needs --site <url>: aitd plan --site https://www.example.com");
117
+ if (values.topic !== undefined) (await import("./discover.js")).checkTopic(values.topic);
118
+ const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
119
+ const { domainOf } = await import("./workspace.js");
120
+ domainOf(values.site); // Checked before the browser starts.
121
+ const maxSteps = values["max-steps"] ? Number(values["max-steps"]) : undefined;
122
+ if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
123
+ const { createProvider } = await import("./providers.js");
124
+ const { discoverPlan } = await import("./discover.js");
125
+ const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] });
126
+ console.log(`Exploring ${values.site} to draft its measurement plan…`);
127
+ const { plan } = await discoverPlan({ site: values.site, topic: values.topic, provider, maxSteps, headed: values.headed, allowedHosts: values["allow-host"], log: console.log });
128
+ const { id, dir } = await savePlan(plan, { dataDir, site: values.site, branding: !values["no-branding"] });
129
+ const custom = plan.events.filter((e) => e.custom).length;
130
+ const rel = path.relative(process.cwd(), dir);
131
+ console.log(`✓ Saved plan ${id}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties in ${rel.startsWith("..") ? dir : rel}`);
132
+ return;
154
133
  }
134
+ if (values.help || command !== "run" || (!values.config && !values.site && !values.against)) {
135
+ console.log(helpText(command));
136
+ process.exit(values.help ? 0 : 2);
137
+ }
138
+ if (values["no-ai"]) values.scripted = true; // Without an AI model the journeys can only replay fixed steps.
139
+ if (values.providers?.length) console.log("--providers is now --platforms.");
140
+ const platforms = [...(values.platforms ?? []), ...(values.providers ?? [])];
155
141
  const levels = ["broken", "risk", "warning"];
156
142
  const failOn = values["fail-on"];
157
143
  if (failOn !== "never" && !levels.includes(failOn)) throw new Error(`--fail-on must be one of ${levels.join(", ")}, never.`);
158
144
 
159
145
  const loaded = values.config ? await loadConfig(values.config) : { config: {}, plan: null, gtmContainer: null };
160
146
  if (values.site) loaded.config = { ...loaded.config, site: values.site };
147
+ if (!values.against && !values.journey?.length && !values.goal?.length && !loaded.config.journeys?.length) {
148
+ throw new Error('Nothing to test. Add --against latest, --journey <id> or --goal "<text>".');
149
+ }
150
+ let against = null;
151
+ if (values.against) {
152
+ const { readJson } = await import("./audit.js");
153
+ const { resolvePlan } = await import("./workspace.js");
154
+ if (loaded.config.site) {
155
+ try {
156
+ new URL(loaded.config.site);
157
+ } catch {
158
+ throw new Error(`"${loaded.config.site}" is not a full URL. Use e.g. https://www.example.com`);
159
+ }
160
+ }
161
+ against = await resolvePlan(values.against, { dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR, site: loaded.config.site });
162
+ const plan = await readJson(against.file);
163
+ if (!Array.isArray(plan?.events)) throw new Error(`${against.file} is not a plan: it needs an "events" list.`);
164
+ if (!loaded.config.site) {
165
+ if (!plan.site) throw new Error(`${against.file} has no "site". Add --site <url>.`);
166
+ loaded.config = { ...loaded.config, site: plan.site };
167
+ }
168
+ loaded.plan = plan;
169
+ }
161
170
  if (values["keep-bodies"]) loaded.config = { ...loaded.config, keep_bodies: true };
162
171
  if (values["allow-host"]?.length) loaded.config = { ...loaded.config, allowed_hosts: [...(loaded.config.allowed_hosts ?? []), ...values["allow-host"]] };
163
172
  try {
@@ -167,6 +176,8 @@ async function main() {
167
176
  }
168
177
  const maxSteps = values["max-steps"] ? Number(values["max-steps"]) : undefined;
169
178
  if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
179
+ // run --out is a hidden alias kept for one minor version; --data-dir replaces it.
180
+ if (values.out) console.log("Note: run --out will be removed in a later version. Runs are saved in aitd/<domain>/runs/<id>/; use --data-dir <dir> to keep them elsewhere.");
170
181
  const result = await runAudit({
171
182
  ...loaded,
172
183
  journeys: values.journey,
@@ -174,7 +185,7 @@ async function main() {
174
185
  events: (values.events ?? []).flatMap((e) => e.split(",")).map((e) => e.trim()).filter(Boolean),
175
186
  maxSteps,
176
187
  viewports: values.viewport,
177
- providers: values.providers?.length ? values.providers.flatMap((p) => p.split(",")) : undefined,
188
+ providers: platforms.length ? platforms.flatMap((p) => p.split(",")) : undefined,
178
189
  scripted: values.scripted,
179
190
  ai: !values["no-ai"],
180
191
  provider: values.provider,
@@ -184,7 +195,9 @@ async function main() {
184
195
  headed: values.headed,
185
196
  branding: values["no-branding"] ? false : undefined,
186
197
  outDir: values.out,
198
+ dataDir: values["data-dir"],
187
199
  saveSite: values["save-site"],
200
+ against,
188
201
  log: (msg) => console.log(msg),
189
202
  });
190
203
 
@@ -209,6 +222,95 @@ async function main() {
209
222
  }
210
223
  }
211
224
 
225
+ // aitd "check my GA tags": shows the matching command and runs it once confirmed.
226
+ async function fromSentence(sentence, values, extra) {
227
+ const { createProvider } = await import("./providers.js");
228
+ const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] }).catch((err) => {
229
+ if (err.code === "NO_API_KEY") return null;
230
+ throw err;
231
+ });
232
+ let { intent } = await translate(sentence, { provider });
233
+ let r = intentToArgs(intent);
234
+ if (r.question) {
235
+ const answer = (await ask(`${r.question} `)).toLowerCase();
236
+ if (!["plan", "run"].includes(answer)) throw new Error('Answer "plan" or "run", or type the command, e.g. aitd run --help.');
237
+ r = intentToArgs({ ...intent, action: answer });
238
+ intent = { ...intent, action: answer };
239
+ }
240
+ if (r.unsupported) throw new Error(r.unsupported);
241
+ let args = r.args;
242
+ const needsSite = !values.site && !args.includes("--site") && (args[0] === "plan" || !args.includes("--against"));
243
+ if (needsSite) {
244
+ const site = await ask("Which site? (e.g. https://www.example.com) ");
245
+ if (!site) throw new Error("A site is needed. Add it to the sentence or pass --site <url>.");
246
+ r = intentToArgs({ ...intent, site });
247
+ args = r.args;
248
+ }
249
+ if (needsWhatToTest(args)) args = await whatToTest(args, values);
250
+ // Options typed next to the sentence (--site, --provider, --headed…) carry over.
251
+ args = [...args, ...extra.filter((a) => a !== sentence && a !== "-y" && a !== "--yes")];
252
+ console.log(commandText(args));
253
+ if (!values.yes) {
254
+ const answer = (await ask("Run this? (Y/n) ")).toLowerCase();
255
+ if (answer && !["y", "yes"].includes(answer)) return null;
256
+ }
257
+ return args;
258
+ }
259
+
260
+ // A run needs something to test: suggest the site's latest plan, else ask for a goal.
261
+ async function whatToTest(args, values) {
262
+ const site = values.site ?? args[args.indexOf("--site") + 1];
263
+ const { listPlanIds } = await import("./workspace.js");
264
+ const hasPlan = (await listPlanIds(values["data-dir"] ?? DEFAULT_DATA_DIR, site).catch(() => [])).length > 0;
265
+ if (hasPlan) {
266
+ const answer = values.yes ? "" : (await ask("Check against the latest plan? (Y/n) ")).toLowerCase();
267
+ if (!answer || ["y", "yes"].includes(answer)) return [...args, "--against", "latest"];
268
+ } else if (values.yes) {
269
+ throw new Error('Nothing to test yet. Say what to check, e.g. aitd "check my GA tags on the contact form", or draft a plan first.');
270
+ }
271
+ const goal = await ask("What should I check, e.g. the contact form? ");
272
+ if (!goal) throw new Error('Nothing to test. Say what to check, e.g. aitd "check my GA tags on the contact form".');
273
+ return [...args, "--goal", goal];
274
+ }
275
+
276
+ async function ask(question) {
277
+ const { createInterface } = await import("node:readline/promises");
278
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
279
+ try {
280
+ return (await rl.question(question)).trim();
281
+ } finally {
282
+ rl.close();
283
+ }
284
+ }
285
+
286
+ async function planSheet(action, arg, values) {
287
+ const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
288
+ const shown = (p) => {
289
+ const rel = path.relative(process.cwd(), p);
290
+ return rel.startsWith("..") ? p : rel;
291
+ };
292
+ const { readPlanSheet, writePlanSheet } = await import("./sheet.js");
293
+ if (action === "import") {
294
+ if (!arg) throw new Error("plan import needs a file: aitd plan import plan.xlsx --site https://www.example.com");
295
+ if (!values.site) throw new Error(`plan import needs --site <url>: aitd plan import ${arg} --site https://www.example.com`);
296
+ const { domainOf } = await import("./workspace.js");
297
+ domainOf(values.site);
298
+ const { plan, warnings } = await readPlanSheet(arg, { site: values.site });
299
+ for (const w of warnings) console.log(`Warning: ${w}`);
300
+ const { id, dir } = await savePlan(plan, { dataDir, site: values.site });
301
+ console.log(`✓ Imported plan ${id}: ${plan.events.length} events and ${plan.user_properties.length} user properties in ${shown(dir)}`);
302
+ return;
303
+ }
304
+ if (!arg) throw new Error("plan export needs a plan: aitd plan export latest --site https://www.example.com");
305
+ const format = (values.format ?? "xlsx").toLowerCase();
306
+ if (!["xlsx", "csv"].includes(format)) throw new Error(`--format must be xlsx or csv (found "${values.format}").`);
307
+ const { resolvePlan } = await import("./workspace.js");
308
+ const { file } = await resolvePlan(arg, { dataDir, site: values.site });
309
+ const plan = JSON.parse(readFileSync(file, "utf8"));
310
+ const written = await writePlanSheet(plan, path.dirname(file), { format });
311
+ console.log(`✓ Exported the plan to ${written.map(shown).join(" and ")}. Edit it, then save it as a new plan with: aitd plan import ${shown(written[0])} --site ${values.site ?? plan.site ?? "<url>"}`);
312
+ }
313
+
212
314
  main().catch((err) => {
213
315
  console.error(`Error: ${err.message}`);
214
316
  process.exit(2);
@@ -0,0 +1,185 @@
1
+ import { AuditSession } from "./browser.js";
2
+ import { runAgentJourney, StepTimeout } from "./agent.js";
3
+
4
+ /** The goal of the browsing part: one page of each type, nothing submitted. */
5
+ export const DISCOVER_GOAL = `Explore this site to draft its analytics measurement plan. You are not completing a purchase or any other task: visit ONE example of each kind of page that exists here and look at what a visitor can do on it, then call finish with success true.
6
+ Visit, where the site has them (a small site may have only a few; that is fine, don't look for pages that aren't linked): the home page, one category or listing page, one product, item or article page, the cart (add one item first if there is an add-to-cart button), the first checkout step, the search results (search for something the site sells), and the sign-up, login, contact or newsletter forms.
7
+ Rules: never pay, never submit a form with personal details, never open more than one page of the same kind, and accept the cookie banner when it appears. Stop as soon as every kind of page above has been seen once.`;
8
+
9
+ const PLAN_SYSTEM = `You are a senior digital analytics consultant writing a GA4 measurement plan for a website that may have no tracking yet. You get the pages an explorer visited (their type, address, visible text and interactive elements) and any tracking hits the site already sent.
10
+
11
+ Propose the events this site should track:
12
+ - Recommended GA4 events where they fit (page_view, view_item_list, view_item, select_item, add_to_cart, view_cart, begin_checkout, purchase, search, sign_up, login, generate_lead, ...), with GA4's recommended parameters.
13
+ - Custom events for interactions specific to this site that a business would want to measure (for example a size guide, a store locator, a video, a configurator, a filter, a download). Name them in snake_case and mark them custom.
14
+ - For each event: what the visitor does, the page type and element that triggers it (quote the element's visible label), its parameters (standard and custom) with one realistic example value taken from the pages when possible, and which parameters are required.
15
+ - Suggested user properties (for example customer_type, logged_in, membership_level), with a description and example.
16
+ Only propose what the pages show is possible. Prefer fewer, useful events over many trivial ones.`;
17
+
18
+ /** Kinds of events a plan can be limited to with --topic: what to explore, what to propose, and which events to keep. */
19
+ const ECOMMERCE = new Set(["view_item_list", "select_item", "view_item", "add_to_cart", "remove_from_cart", "view_cart", "add_to_wishlist", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "view_promotion", "select_promotion"]);
20
+ const FORMS = new Set(["sign_up", "login", "generate_lead"]);
21
+ export const TOPICS = {
22
+ "custom-events": {
23
+ explore: "the pages with interactions specific to this site (tools, filters, videos, downloads, configurators, locators)",
24
+ propose: "custom events for interactions specific to this site. Do not propose GA4 recommended events",
25
+ keep: (ev) => Boolean(ev.custom),
26
+ },
27
+ ecommerce: {
28
+ explore: "the home page, one listing page, one product page, the cart (add one item first) and the first checkout step",
29
+ propose: "the GA4 ecommerce events (view_item_list, select_item, view_item, add_to_cart, remove_from_cart, view_cart, begin_checkout, add_shipping_info, add_payment_info, purchase, and promotions) with their items parameters",
30
+ keep: (ev) => ECOMMERCE.has(ev.name),
31
+ },
32
+ forms: {
33
+ explore: "the sign-up, login, contact and newsletter forms",
34
+ propose: "form events: sign_up, login, generate_lead, and custom form events (for example form_start, form_submit, form_error, newsletter_signup). Never propose ecommerce events",
35
+ keep: (ev) => FORMS.has(ev.name) || /form|newsletter|subscribe|signup|sign_up|lead|contact/.test(ev.name),
36
+ },
37
+ consent: {
38
+ explore: "the cookie or consent banner and any privacy settings page. Open the banner's settings before accepting it",
39
+ propose: "consent events: the banner shown, accepted, rejected and customised (for example consent_banner_view, consent_update), with the chosen categories as parameters. Never propose ecommerce or form events",
40
+ keep: (ev) => /consent|cookie|privacy|gdpr/.test(ev.name),
41
+ },
42
+ };
43
+
44
+ /** Checks a --topic value; undefined means no topic (the whole plan). */
45
+ export function checkTopic(topic) {
46
+ if (topic === undefined) return undefined;
47
+ if (!Object.hasOwn(TOPICS, topic)) throw new Error(`Unknown --topic "${topic}". Use one of: ${Object.keys(TOPICS).join(", ")}`);
48
+ return topic;
49
+ }
50
+
51
+ const str = { type: "string" };
52
+ const PLAN_SCHEMA = {
53
+ type: "object",
54
+ properties: {
55
+ pages: { type: "array", items: { type: "object", properties: { type: str, url: str }, required: ["type", "url"], additionalProperties: false } },
56
+ events: {
57
+ type: "array",
58
+ items: {
59
+ type: "object",
60
+ properties: {
61
+ name: str,
62
+ custom: { type: "boolean" },
63
+ description: str,
64
+ trigger_page: str,
65
+ trigger_element: str,
66
+ parameters: {
67
+ type: "array",
68
+ items: {
69
+ type: "object",
70
+ properties: { name: str, custom: { type: "boolean" }, required: { type: "boolean" }, description: str, example: str },
71
+ required: ["name", "custom", "required", "description", "example"],
72
+ additionalProperties: false,
73
+ },
74
+ },
75
+ },
76
+ required: ["name", "custom", "description", "trigger_page", "trigger_element", "parameters"],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ user_properties: {
81
+ type: "array",
82
+ items: { type: "object", properties: { name: str, description: str, example: str }, required: ["name", "description", "example"], additionalProperties: false },
83
+ },
84
+ },
85
+ required: ["pages", "events", "user_properties"],
86
+ additionalProperties: false,
87
+ };
88
+
89
+ /** Page types the plan reports on; a site that has fewer of them (a two-page site) is normal, not a failure. */
90
+ export const PAGE_TYPES = ["home", "listing", "detail", "search", "cart", "checkout", "signup", "login", "contact", "newsletter"];
91
+
92
+ /** Which of PAGE_TYPES the plan's pages cover, and which are absent. Unrecognised type names are ignored. */
93
+ export function pageTypeCoverage(pages = []) {
94
+ const norm = (t) => String(t ?? "").toLowerCase().replace(/[\s_-]+/g, "");
95
+ const aliases = { homepage: "home", category: "listing", product: "detail", item: "detail", article: "detail", register: "signup", registration: "signup", basket: "cart", lead: "contact", form: "contact" };
96
+ const found = new Set(pages.map((p) => aliases[norm(p.type)] ?? norm(p.type)));
97
+ return { found: PAGE_TYPES.filter((t) => found.has(t)), absent: PAGE_TYPES.filter((t) => !found.has(t)) };
98
+ }
99
+
100
+ const MAX_PAGES = 15;
101
+ const cut = (s, n) => (s && s.length > n ? `${s.slice(0, n)}…` : s ?? "");
102
+
103
+ /** One entry per address the explorer saw, keeping the last (fullest) view of each. */
104
+ export function pagesSeen(snapshots) {
105
+ const byUrl = new Map();
106
+ for (const s of snapshots) {
107
+ if (!s?.url || s.url === "about:blank") continue;
108
+ const key = s.url.split("#")[0];
109
+ byUrl.delete(key);
110
+ byUrl.set(key, { url: key, title: s.title, elements: cut(s.elements, 4000), text: cut(s.text, 1500) });
111
+ }
112
+ return [...byUrl.values()].slice(-MAX_PAGES);
113
+ }
114
+
115
+ /** Turns the model's answer into the plan file: the audit's fields plus descriptions, triggers, parameters and user properties. */
116
+ export function toPlan(site, answer, hits = []) {
117
+ const seen = new Set(hits.filter((h) => h.platform === "ga4").map((h) => h.name));
118
+ return {
119
+ site,
120
+ consent_mode: true,
121
+ pages: answer.pages ?? [],
122
+ events: (answer.events ?? []).map((ev) => {
123
+ const params = ev.parameters ?? [];
124
+ const required = params.filter((p) => p.required).map((p) => p.name);
125
+ return {
126
+ platform: "ga4",
127
+ name: ev.name,
128
+ ...(ev.custom ? { custom: true } : {}),
129
+ description: ev.description,
130
+ trigger: { page: ev.trigger_page, element: ev.trigger_element },
131
+ ...(required.length ? { required_params: required } : {}),
132
+ parameters: Object.fromEntries(params.map((p) => [p.name, { ...(p.custom ? { custom: true } : {}), description: p.description, example: p.example }])),
133
+ ...(seen.has(ev.name) ? { seen: true } : {}),
134
+ };
135
+ }),
136
+ user_properties: answer.user_properties ?? [],
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Lets the AI explore the site (one page per type), then asks it for a measurement plan.
142
+ * With `topic`, exploring and the plan are limited to that kind of event (see TOPICS).
143
+ * @param {{ site: string, topic?: string, provider: any, maxSteps?: number, headed?: boolean, allowedHosts?: string[], viewport?: string, blockPrivate?: boolean, log?: (msg: string) => void }} opts
144
+ */
145
+ export async function discoverPlan(opts) {
146
+ const log = opts.log ?? (() => {});
147
+ const topic = TOPICS[checkTopic(opts.topic)];
148
+ const goal = topic ? `${DISCOVER_GOAL}\nThis plan covers only ${opts.topic}: visit just ${topic.explore}, and skip other kinds of page.` : DISCOVER_GOAL;
149
+ const session = new AuditSession({ viewport: opts.viewport ?? "desktop", site: opts.site, allowedHosts: opts.allowedHosts, headless: !opts.headed, blockPrivate: opts.blockPrivate });
150
+ const snapshots = [];
151
+ let outcome;
152
+ try {
153
+ await session.start();
154
+ const startError = await session.act({ action: "goto", url: opts.site, reason: `Open ${opts.site}` });
155
+ if (startError) throw new Error(`Could not open ${opts.site}: ${startError}`);
156
+ const snapshot = session.snapshot.bind(session);
157
+ session.snapshot = async () => {
158
+ const s = await snapshot();
159
+ snapshots.push(s);
160
+ return s;
161
+ };
162
+ outcome = await runAgentJourney(session, { id: "discover", goal }, { provider: opts.provider, maxSteps: opts.maxSteps ?? 25, log }).catch((err) => {
163
+ if (err instanceof StepTimeout) return { completed: false, note: err.message };
164
+ throw err;
165
+ });
166
+ } finally {
167
+ await session.close();
168
+ }
169
+ const pages = pagesSeen(snapshots);
170
+ if (!pages.length) throw new Error("The explorer saw no pages, so there is nothing to plan from.");
171
+ log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
172
+ const existing = [...new Set(session.hits.map((h) => `${h.platform}:${h.name}`))];
173
+ const result = await opts.provider.json({
174
+ system: topic ? `${PLAN_SYSTEM}\nThis plan covers only ${opts.topic}. Propose only ${topic.propose}.` : PLAN_SYSTEM,
175
+ texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
176
+ schema: PLAN_SCHEMA,
177
+ });
178
+ if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
179
+ const plan = toPlan(opts.site, result.value, session.hits);
180
+ if (topic) plan.events = plan.events.filter(topic.keep);
181
+ const coverage = pageTypeCoverage(plan.pages);
182
+ log(`Page types found: ${coverage.found.join(", ") || "none"}`);
183
+ if (coverage.absent.length) log(`Not present on this site: ${coverage.absent.join(", ")}`);
184
+ return { plan, outcome, coverage };
185
+ }
package/src/help.js ADDED
@@ -0,0 +1,107 @@
1
+ import { LANGUAGES } from "./i18n.js";
2
+ import { PROVIDERS } from "./providers.js";
3
+ import { JOURNEY_PRESETS } from "./journeys.js";
4
+
5
+ const journeys = Object.keys(JOURNEY_PRESETS).join(", ");
6
+
7
+ const MAIN = `aitd: an open-source AI agent that QA-tests your analytics and ad tracking.
8
+
9
+ Usage: aitd <command> [options]
10
+
11
+ Commands:
12
+ plan Draft a measurement plan: what should be tracked
13
+ run Audit what a site actually sends, and compare it to a plan
14
+ replay Re-check saved sites offline, without AI
15
+ mcp Start the MCP server for Claude, ChatGPT and other chat apps
16
+
17
+ Examples:
18
+ aitd plan --site https://example.com
19
+ aitd run --site https://example.com --against latest
20
+ aitd run --site https://example.com --journey purchase
21
+ aitd "check my GA tags on example.com" (shows the command first; -y skips the question)
22
+
23
+ Run "aitd <command> --help" for that command's options. -h, --help -v, --version`;
24
+
25
+ const RUN = `Usage: aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>")
26
+
27
+ Audit what a site actually sends while an AI agent walks its journeys. Needs an AI key
28
+ (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai. Each run is saved in
29
+ aitd/<domain>/runs/<id>/ and its report says what changed since the site's previous run.
30
+
31
+ What to test (at least one):
32
+ --against <plan> Check against a plan: latest, first, a plan ID or a plan file or folder
33
+ --journey <id> A ready-made journey: ${journeys} (repeatable)
34
+ --goal "<text>" A journey in plain language (repeatable)
35
+
36
+ Options:
37
+ --site <url> The site to audit
38
+ --config <file> Take the site and journeys from a config file
39
+ --events <names> Only check these events, e.g. purchase,add_to_cart
40
+ --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
41
+ --headed Show the browser window
42
+ --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
43
+
44
+ AI: --provider <name> --model <id> (default: whichever API key is set)
45
+
46
+ Advanced:
47
+ --max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
48
+ --scripted --no-ai --save-site <dir> --save-steps <file> (see the README)`;
49
+
50
+ const PLAN = `Usage: aitd plan --site <url>
51
+ aitd plan list --site <url>
52
+ aitd plan import <file> --site <url>
53
+ aitd plan export <plan> --site <url>
54
+
55
+ Draft a measurement plan: what should be tracked. The AI explores one page of each type
56
+ and proposes events, parameters and user properties. Plans are saved in
57
+ aitd/<domain>/plans/<id>/. Needs an AI key (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}).
58
+
59
+ Commands:
60
+ (none) Draft a new plan from the site
61
+ list List the site's plan IDs, oldest first
62
+ import <file> Save a plan from a spreadsheet (xlsx or csv) as a new plan
63
+ export <plan> Write a plan to a spreadsheet to edit; <plan> is latest, first, an ID or a path
64
+
65
+ Options:
66
+ --site <url> The site (required)
67
+ --topic <name> Only plan one kind of event: custom-events, ecommerce, forms or consent
68
+ --headed Show the browser window
69
+
70
+ AI: --provider <name> --model <id> (default: whichever API key is set)
71
+
72
+ Advanced:
73
+ --max-steps <n> --data-dir <dir> --format <xlsx|csv> (see the README)`;
74
+
75
+ const REPLAY = `Usage: aitd replay <dir>... [--update]
76
+
77
+ Audit sites saved with "aitd run --save-site <dir>" again, offline and without AI, and
78
+ list findings that changed. Exits with code 1 when any did.
79
+
80
+ Options:
81
+ --update Make this replay's findings the expected ones (check the changes first)
82
+
83
+ Advanced:
84
+ --out <dir> Where to write the reports (default: <dir>/last-replay)`;
85
+
86
+ const MCP = `Usage: aitd mcp [--http] [--port <n>]
87
+
88
+ Start the MCP server for Claude, ChatGPT, Gemini and other chat apps. It talks over
89
+ stdio unless you pass --http.
90
+
91
+ Options:
92
+ --http Serve over Streamable HTTP, for apps that connect to a URL
93
+ --port <n> HTTP port (default 8787)
94
+
95
+ Advanced:
96
+ --host <addr> HTTP host (default 127.0.0.1)
97
+ --token <secret> Secret URL path segment, at least 16 characters (default: random)
98
+ --allow-private-network Over HTTP, let audits open localhost and private addresses
99
+ --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
100
+ --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
101
+
102
+ const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP };
103
+
104
+ /** Help text for a command, or the top-level help when the command is unknown or missing. */
105
+ export function helpText(command) {
106
+ return HELP[command] ?? MAIN;
107
+ }