katman-mcp 0.1.0 → 0.3.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 (43) hide show
  1. package/README.md +56 -44
  2. package/dist/cli.js +27 -14
  3. package/dist/license.js +331 -98
  4. package/dist/meta.js +29 -7
  5. package/dist/plans.js +54 -0
  6. package/dist/tools/account/account.js +60 -30
  7. package/dist/tools/account/cli.js +5 -2
  8. package/dist/tools/account/index.js +7 -7
  9. package/dist/tools/account/sync.js +2 -2
  10. package/dist/tools/account/upsell.js +423 -118
  11. package/dist/tools/crawl/audit/report.js +2 -2
  12. package/dist/tools/crawl/cli.js +17 -5
  13. package/dist/tools/crawl/index.js +52 -18
  14. package/dist/tools/crawl/scan/framework.js +4 -1
  15. package/dist/tools/method/core/lint.js +15 -1
  16. package/dist/tools/method/core/prompt.js +2 -1
  17. package/dist/tools/method/core/start.js +1 -1
  18. package/dist/tools/method/data/_store.js +17 -0
  19. package/dist/tools/method/data/anatomy.js +12 -141
  20. package/dist/tools/method/data/appendices.js +22 -202
  21. package/dist/tools/method/data/cloudflare.js +28 -192
  22. package/dist/tools/method/data/evidence.js +18 -137
  23. package/dist/tools/method/data/geo-jobs.js +8 -128
  24. package/dist/tools/method/data/landing.js +14 -67
  25. package/dist/tools/method/data/lessons.js +16 -127
  26. package/dist/tools/method/data/measurement.js +38 -142
  27. package/dist/tools/method/data/offsite.js +16 -148
  28. package/dist/tools/method/data/pages.js +16 -167
  29. package/dist/tools/method/data/prompts.js +8 -633
  30. package/dist/tools/method/data/publish.js +14 -113
  31. package/dist/tools/method/data/research.js +14 -131
  32. package/dist/tools/method/data/roadmap.js +12 -105
  33. package/dist/tools/method/data/traps.js +6 -74
  34. package/dist/tools/method/data/verify.js +12 -88
  35. package/dist/tools/method/data/viral-list.js +12 -39
  36. package/dist/tools/method/index.js +27 -17
  37. package/dist/tools/method/lib/prompt-lookup.js +18 -0
  38. package/dist/tools/method/mcp/prompts.js +6 -6
  39. package/dist/tools/method/mcp/resources.js +21 -17
  40. package/dist/tools/visibility/index.js +10 -10
  41. package/dist/tools/visibility/monitoring.js +6 -3
  42. package/package.json +5 -3
  43. package/server.json +4 -4
package/dist/license.js CHANGED
@@ -1,12 +1,20 @@
1
- // Katman Pro licensing (docs/07-APP-SPEC.md §6).
1
+ // Katman licensing (docs/07-APP-SPEC.md §6). Nothing is free: every tool except katman_account needs KATMAN_KEY
2
+ // with an active plan: Katman Pro, 1 site or 3 sites (every tool; 5 or 30 audits a month), or an Audit Week bought
3
+ // earlier (audit and recommendations, 7 days); the plans, features and the tool → feature map live in plans.ts.
2
4
  //
3
5
  // - KATMAN_KEY comes from the environment (the MCP config). It is sent only to KATMAN_API_URL (default
4
- // https://katman.pro), in the JSON body of POST /api/web/mcp/verify and /api/web/mcp/sync, and is never
5
- // printed, logged or written anywhere.
6
- // - Verification runs at most once per `checkAgainAfter` (default 24 h). The answer is cached in
7
- // ~/.katman/license.json under the key's SHA-256 hash (never the key).
8
- // - Offline grace: when katman.pro can't be reached, the last good answer keeps working for 7 days.
9
- // - Free tools never call into this module's network path; Pro tools go through withPro() at registration.
6
+ // https://katman.pro), in the JSON body of POST /api/web/mcp/verify, /api/web/mcp/usage and /api/web/mcp/sync,
7
+ // and is never printed, logged or written anywhere.
8
+ // - Verification runs at most once per `checkAgainAfter` (≤ 24 h), and again as soon as the plan's end date
9
+ // (planEndsAt) has passed. The answer is cached in ~/.katman/license.json under the key's SHA-256 hash (never the key).
10
+ // - Offline grace: when katman.pro can't be reached, the last good answer keeps working for 7 days, never past the
11
+ // end of an Audit Week.
12
+ // - Audits are counted by katman.pro, not here: the audit runs on this machine, so katman_audit_site and the CLI
13
+ // `audit` ask first (useAudit → POST /api/web/mcp/usage, once the plan check has passed). A refusal (audit or site
14
+ // limit) explains and runs nothing; when katman.pro can't be reached or answers 5xx the audit runs anyway (fail
15
+ // open) and the result says the count wasn't checked.
16
+ // - Every tool goes through withPlan() at registration (katman_start through startLicense()); katman_account is the
17
+ // only open tool, and the CLI uses the same gate. Without a key nothing is sent to katman.pro.
10
18
  // - Tests inject a LicenseProvider (createServer({ license }) / runCli(args, { license })); there is no env
11
19
  // switch that turns the gate off.
12
20
  import { createHash } from "node:crypto";
@@ -15,31 +23,11 @@ import path from "node:path";
15
23
  import { promises as fs } from "node:fs";
16
24
  import { resolveRoot } from "./context.js";
17
25
  import { HOMEPAGE, VERSION } from "./meta.js";
26
+ import { AUDIT_FEATURES, PRO_FEATURES, TOOL_FEATURES, toolForFeature } from "./plans.js";
18
27
  import { ok } from "./util.js";
19
- import { proNotice, projectFacts, renderUpsell, statusLine, upsellStructured } from "./tools/account/upsell.js";
20
- /** License features (spec §6). Trial = Pro. */
21
- export const PRO_FEATURES = ["plan", "prompts", "briefs", "generate", "lint", "visibility", "manual_check", "indexnow", "report", "monitoring", "sync"];
22
- /** Tools that never need a key (the free diagnosis). */
23
- export const FREE_TOOLS = ["katman_start", "katman_research", "katman_scan_code", "katman_audit_site", "katman_keywords", "katman_account"];
24
- /** Pro tool → license feature. */
25
- export const PRO_TOOLS = {
26
- katman_plan: "plan",
27
- katman_prompt: "prompts",
28
- katman_page_brief: "briefs",
29
- katman_generate: "generate",
30
- katman_lint_content: "lint",
31
- katman_visibility_run: "visibility",
32
- katman_manual_check: "manual_check",
33
- katman_indexnow: "indexnow",
34
- katman_report: "report",
35
- katman_setup_monitoring: "monitoring",
36
- katman_sync: "sync",
37
- };
38
- /** Appended to every Pro tool's description. */
39
- export const PRO_NOTE = " (Katman Pro — needs KATMAN_KEY; 7-day free trial at katman.pro/app)";
40
- export function toolForFeature(feature) {
41
- return Object.keys(PRO_TOOLS).find((t) => PRO_TOOLS[t] === feature) ?? `katman_${feature}`;
42
- }
28
+ import { METHOD_INLINE, applyMethod } from "./tools/method/data/_store.js";
29
+ import { planNotice, projectFacts, renderUpsell, statusLine, upsellStructured } from "./tools/account/upsell.js";
30
+ export * from "./plans.js";
43
31
  export function allows(status, feature) {
44
32
  return status.state === "valid" && status.features.includes(feature);
45
33
  }
@@ -79,7 +67,7 @@ export function apiBase(env) {
79
67
  return { url: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` };
80
68
  }
81
69
  /** POST JSON. Redirects are not followed (a 3xx must never carry the key to another host). Never throws. */
82
- export const postJson = async (url, body, { timeoutMs }) => {
70
+ export const postJson = async (url, body, { timeoutMs, maxChars = 100_000 }) => {
83
71
  const ctrl = new AbortController();
84
72
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
85
73
  try {
@@ -94,7 +82,7 @@ export const postJson = async (url, body, { timeoutMs }) => {
94
82
  },
95
83
  body: JSON.stringify(body),
96
84
  });
97
- const text = (await res.text()).slice(0, 100_000);
85
+ const text = (await res.text()).slice(0, maxChars);
98
86
  return { status: res.status, body: text };
99
87
  }
100
88
  catch (e) {
@@ -114,6 +102,17 @@ export const GRACE_MS = 7 * DAY_S * 1000;
114
102
  /** After a failed attempt, wait this long before trying the network again while the grace covers us. */
115
103
  const RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
116
104
  const TIMEOUT_MS = 8_000;
105
+ /** The audit check sits in front of every audit: short, and the audit runs anyway when it times out. */
106
+ const USAGE_TIMEOUT_MS = 5_000;
107
+ /** The method data is a few hundred kB, fetched once per process. */
108
+ const METHOD_TIMEOUT_MS = 20_000;
109
+ const METHOD_MAX_CHARS = 8_000_000;
110
+ /**
111
+ * Entries this version writes (verify API v2: plans none / audit / pro, now with limits and usage). Katman 0.1.x
112
+ * cached free / trial / pro with other feature names in the same file, and 0.2.x entries have no limits; they don't
113
+ * count here and are replaced at the next check.
114
+ */
115
+ const CACHE_CONTRACT = 3;
117
116
  export function defaultCacheFile() {
118
117
  return path.join(os.homedir(), ".katman", "license.json");
119
118
  }
@@ -150,9 +149,10 @@ function prune(entries, keep, now) {
150
149
  out.sort((a, b) => Date.parse(b[1].checkedAt ?? "") - Date.parse(a[1].checkedAt ?? ""));
151
150
  return Object.fromEntries(out.slice(0, 20));
152
151
  }
152
+ /** checkAgainAfter in seconds: at least a minute, at most a day (the server lowers it at the end of an Audit Week). */
153
153
  function ttlSeconds(x) {
154
154
  const n = Number(x);
155
- return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60), 7 * DAY_S) : DEFAULT_TTL_S;
155
+ return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60), DAY_S) : DEFAULT_TTL_S;
156
156
  }
157
157
  function toIso(x) {
158
158
  if (typeof x === "string" && x.trim()) {
@@ -163,7 +163,43 @@ function toIso(x) {
163
163
  return new Date(x < 1e12 ? x * 1000 : x).toISOString();
164
164
  return undefined;
165
165
  }
166
- /** Parse a /api/web/mcp/verify response (spec §6). Only a 401 (or an explicit ok:false) is a rejection. */
166
+ const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
167
+ const wholeNumber = (x, min, max = 1_000_000) => typeof x === "number" && Number.isInteger(x) && x >= min && x <= max ? x : undefined;
168
+ /** {sites, auditsPerMonth} from katman.pro; anything that isn't two positive whole numbers is no limit. */
169
+ export function parseLimits(x) {
170
+ if (!isObj(x))
171
+ return undefined;
172
+ const sites = wholeNumber(x.sites, 1);
173
+ const auditsPerMonth = wholeNumber(x.auditsPerMonth, 1);
174
+ return sites !== undefined && auditsPerMonth !== undefined ? { sites, auditsPerMonth } : undefined;
175
+ }
176
+ /** {month, audits, sites, resetsAt} from katman.pro; malformed counts are dropped. */
177
+ export function parseUsage(x) {
178
+ if (!isObj(x))
179
+ return undefined;
180
+ const audits = wholeNumber(x.audits, 0);
181
+ const sites = wholeNumber(x.sites, 0);
182
+ const resetsAt = toIso(x.resetsAt);
183
+ const month = typeof x.month === "string" && /^\d{4}-\d{2}$/.test(x.month) ? x.month : undefined;
184
+ return audits !== undefined && sites !== undefined && resetsAt && month ? { month, audits, sites, resetsAt } : undefined;
185
+ }
186
+ /** "0.3.0" >= "0.3.0"; anything that isn't a plain x.y.z version counts as older. */
187
+ export function versionAtLeast(version, min) {
188
+ const parse = (v) => (/^\d+\.\d+\.\d+/.test(v) ? v.split("-")[0].split(".").map(Number) : undefined);
189
+ const a = parse(version);
190
+ const b = parse(min);
191
+ if (!a || !b)
192
+ return false;
193
+ for (let i = 0; i < 3; i++)
194
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
195
+ return (a[i] ?? 0) > (b[i] ?? 0);
196
+ return true;
197
+ }
198
+ /**
199
+ * Parse a /api/web/mcp/verify response (spec §6, API v2). Only a 401 (or an explicit ok:false) is a rejection.
200
+ * Tolerant of older servers: an unknown plan (trial, free…) is "none", `features` is read from the array whatever the
201
+ * plan says, and trialEndsAt counts as planEndsAt.
202
+ */
167
203
  export function parseVerify(res) {
168
204
  let j;
169
205
  try {
@@ -175,16 +211,36 @@ export function parseVerify(res) {
175
211
  j = undefined;
176
212
  }
177
213
  if (res.status === 200 && j?.ok === true) {
178
- const plan = j.plan === "trial" || j.plan === "pro" ? j.plan : "free";
214
+ const plan = j.plan === "audit" || j.plan === "pro" ? j.plan : "none";
179
215
  const features = Array.isArray(j.features)
180
216
  ? j.features.filter((f) => typeof f === "string").slice(0, 64)
181
- : plan === "free"
182
- ? []
183
- : [...PRO_FEATURES];
217
+ : plan === "pro"
218
+ ? [...PRO_FEATURES]
219
+ : plan === "audit"
220
+ ? [...AUDIT_FEATURES]
221
+ : [];
184
222
  const account = j.account && typeof j.account === "object" ? j.account : {};
185
223
  const emailMasked = typeof account.emailMasked === "string" ? account.emailMasked.slice(0, 120) : undefined;
186
- const trialEndsAt = toIso(j.trialEndsAt);
187
- return { kind: "good", good: { plan, features, checkAgainAfter: ttlSeconds(j.checkAgainAfter), ...(trialEndsAt ? { trialEndsAt } : {}), ...(emailMasked ? { emailMasked } : {}) } };
224
+ const planEndsAt = toIso(j.planEndsAt) ?? toIso(j.trialEndsAt);
225
+ // Only a plan with limits (Katman Pro, 1 site / 3 sites) carries limits, usage, enforced and updateMcpTo.
226
+ const limits = parseLimits(j.limits);
227
+ const usage = limits ? parseUsage(j.usage) : undefined;
228
+ const updateMcpTo = limits && typeof j.updateMcpTo === "string" && /^\d{1,4}\.\d{1,5}\.\d{1,6}$/.test(j.updateMcpTo) ? j.updateMcpTo : undefined;
229
+ return {
230
+ kind: "good",
231
+ good: {
232
+ plan,
233
+ features,
234
+ checkAgainAfter: ttlSeconds(j.checkAgainAfter),
235
+ ...(planEndsAt ? { planEndsAt } : {}),
236
+ ...(j.auditWeekUsed === true ? { auditWeekUsed: true } : {}),
237
+ ...(limits ? { limits } : {}),
238
+ ...(usage ? { usage } : {}),
239
+ ...(limits && typeof j.enforced === "boolean" ? { enforced: j.enforced } : {}),
240
+ ...(updateMcpTo ? { updateMcpTo } : {}),
241
+ ...(emailMasked ? { emailMasked } : {}),
242
+ },
243
+ };
188
244
  }
189
245
  if (res.status === 401 || (j?.ok === false && (j.error === "invalid_key" || j.error === "revoked"))) {
190
246
  return { kind: "rejected", error: j?.error === "revoked" ? "revoked" : "invalid_key" };
@@ -195,6 +251,30 @@ export function parseVerify(res) {
195
251
  return { kind: "failed", error: `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` };
196
252
  return { kind: "failed", error: `HTTP ${res.status}${res.status === 200 ? " (unexpected answer)" : ""}` };
197
253
  }
254
+ /** The end date of a plan that grants features and still ran when it was checked; once it passes, check again. */
255
+ function endsAfterCheck(g, checkedAt) {
256
+ const ends = Date.parse(g.planEndsAt ?? "");
257
+ return g.features.length && Number.isFinite(ends) && ends > checkedAt ? ends : undefined;
258
+ }
259
+ function nextCheck(g) {
260
+ const checked = Date.parse(g.checkedAt);
261
+ return new Date(Math.min(checked + g.checkAgainAfter * 1000, endsAfterCheck(g, checked) ?? Infinity)).toISOString();
262
+ }
263
+ /** The offline grace: 7 days after the last good check, never past the end of an Audit Week. */
264
+ function graceUntil(g) {
265
+ const grace = Date.parse(g.checkedAt) + GRACE_MS;
266
+ const ends = g.plan === "pro" ? NaN : Date.parse(g.planEndsAt ?? "");
267
+ return new Date(Number.isFinite(ends) ? Math.min(grace, ends) : grace).toISOString();
268
+ }
269
+ /** A "none" answer right after a paid plan remembers which plan ended and, when the old end date has passed, when. */
270
+ function withEnded(next, prev, at) {
271
+ if (next.plan !== "none" || next.features.length || !prev)
272
+ return next;
273
+ if (prev.plan === "none")
274
+ return prev.ended ? { ...next, ended: prev.ended } : next;
275
+ const ends = Date.parse(prev.planEndsAt ?? "");
276
+ return { ...next, ended: { plan: prev.plan, ...(Number.isFinite(ends) && ends <= at ? { at: prev.planEndsAt } : {}) } };
277
+ }
198
278
  export function createLicenseProvider(deps = {}) {
199
279
  const env = () => deps.env ?? process.env;
200
280
  const post = deps.post ?? postJson;
@@ -206,34 +286,48 @@ export function createLicenseProvider(deps = {}) {
206
286
  /** Never let the key appear in an error string (belt and braces: it's only ever in a request body). */
207
287
  const scrub = (s, key) => (key ? s.split(key).join(keyHint(key)) : s);
208
288
  function fromGood(g, source, at, extra = {}) {
209
- // A trial past its end date is free, even if the cached answer still says trial (no offline extension).
210
- const ended = g.plan !== "pro" && !!g.trialEndsAt && Date.parse(g.trialEndsAt) <= at;
211
- const cutOff = ended && g.plan === "trial";
289
+ // A plan past its end date gives nothing, even if the cached or offline answer still lists features (no offline
290
+ // extension of an Audit Week). Pro renews into a new period, so its end date only triggers a new check (fresh()).
291
+ const expired = g.plan !== "pro" && !!g.planEndsAt && Date.parse(g.planEndsAt) <= at;
292
+ const ended = expired ? { plan: g.plan === "audit" ? "audit" : g.ended?.plan, at: g.planEndsAt } : g.plan === "none" ? g.ended : undefined;
293
+ const endsAt = g.planEndsAt ?? ended?.at;
212
294
  return {
213
295
  state: "valid",
214
- plan: cutOff ? "free" : g.plan,
215
- features: cutOff ? [] : [...g.features],
216
- ...(g.trialEndsAt ? { trialEndsAt: g.trialEndsAt } : {}),
217
- ...(ended ? { trialEnded: true } : {}),
296
+ plan: expired ? "none" : g.plan,
297
+ features: expired ? [] : [...g.features],
298
+ ...(endsAt ? { planEndsAt: endsAt } : {}),
299
+ ...(ended ? { planEnded: true, ...(ended.plan ? { endedPlan: ended.plan } : {}) } : {}),
300
+ ...(g.auditWeekUsed || (expired && g.plan === "audit") ? { auditWeekUsed: true } : {}),
301
+ ...(!expired && g.limits ? { limits: g.limits } : {}),
302
+ // The counts start again on the 1st: a count from last month is not shown.
303
+ ...(!expired && g.limits && g.usage && Date.parse(g.usage.resetsAt) > at ? { usage: g.usage } : {}),
304
+ ...(!expired && g.limits && g.enforced !== undefined ? { enforced: g.enforced } : {}),
305
+ ...(!expired && g.updateMcpTo && !versionAtLeast(version, g.updateMcpTo) ? { updateMcpTo: g.updateMcpTo } : {}),
218
306
  ...(g.emailMasked ? { emailMasked: g.emailMasked } : {}),
219
307
  checkedAt: g.checkedAt,
220
- nextCheckAt: new Date(Date.parse(g.checkedAt) + g.checkAgainAfter * 1000).toISOString(),
308
+ nextCheckAt: nextCheck(g),
221
309
  source,
222
310
  ...extra,
223
311
  };
224
312
  }
225
313
  function rejected(error, source, key) {
226
- return { state: error === "revoked" ? "revoked" : "invalid", plan: "free", features: [], source, keyHint: keyHint(key) };
314
+ return { state: error === "revoked" ? "revoked" : "invalid", plan: "none", features: [], source, keyHint: keyHint(key) };
227
315
  }
228
316
  function fresh(e, at) {
229
317
  if (!e.result || !e.checkedAt)
230
318
  return false;
231
- const age = at - Date.parse(e.checkedAt);
319
+ const checked = Date.parse(e.checkedAt);
320
+ const age = at - checked;
232
321
  if (!(age >= 0))
233
322
  return false; // unparsable or in the future (clock change): check again
234
323
  if (!e.result.ok)
235
324
  return age < REJECTED_TTL_S * 1000;
236
- if (e.result.plan === "trial" && e.result.trialEndsAt && Date.parse(e.result.trialEndsAt) <= at)
325
+ // "Update katman-mcp" was the answer for an older version: after the update, ask again at once.
326
+ if (e.result.updateMcpTo && versionAtLeast(version, e.result.updateMcpTo))
327
+ return false;
328
+ // Once the plan's end date has passed, check again at once: an Audit Week ends, Pro renews into a new period.
329
+ const ends = endsAfterCheck(e.result, checked);
330
+ if (ends !== undefined && ends <= at)
237
331
  return false;
238
332
  return age < e.result.checkAgainAfter * 1000;
239
333
  }
@@ -249,8 +343,12 @@ export function createLicenseProvider(deps = {}) {
249
343
  const server = base.url ?? HOMEPAGE;
250
344
  const cache = await readCache(cacheFile);
251
345
  const cached = cache.entries[hash];
252
- const entry = cached && (cached.base ?? HOMEPAGE) === server ? cached : {};
346
+ const entry = cached && cached.contract === CACHE_CONTRACT && (cached.base ?? HOMEPAGE) === server ? cached : {};
253
347
  const hint = keyHint(key);
348
+ const offline = (g, error) => ({
349
+ ...fromGood(g, "offline-grace", at, { graceUntil: graceUntil(g), ...(error ? { error } : {}) }),
350
+ keyHint: hint,
351
+ });
254
352
  if (!refresh && fresh(entry, at)) {
255
353
  const r = entry.result;
256
354
  return r.ok ? { ...fromGood({ ...r, checkedAt: entry.checkedAt }, "cache", at), keyHint: hint } : rejected(r.error, "cache", key);
@@ -258,7 +356,7 @@ export function createLicenseProvider(deps = {}) {
258
356
  // Offline and recently failed: don't wait for another timeout on every tool call.
259
357
  const failedAt = Date.parse(entry.failedAt ?? "");
260
358
  if (!refresh && Number.isFinite(failedAt) && at - failedAt >= 0 && at - failedAt < RETRY_AFTER_FAILURE_MS && withinGrace(entry, at)) {
261
- return { ...fromGood(entry.good, "offline-grace", at, { graceUntil: new Date(Date.parse(entry.good.checkedAt) + GRACE_MS).toISOString(), error: entry.failure }), keyHint: hint };
359
+ return offline(entry.good, entry.failure);
262
360
  }
263
361
  const res = base.url
264
362
  ? await post(`${base.url}/api/web/mcp/verify`, { key, mcpVersion: version }, { timeoutMs })
@@ -267,21 +365,22 @@ export function createLicenseProvider(deps = {}) {
267
365
  const checkedAt = new Date(at).toISOString();
268
366
  let status;
269
367
  if (parsed.kind === "good") {
270
- cache.entries[hash] = { base: server, checkedAt, result: { ok: true, ...parsed.good }, good: { ...parsed.good, checkedAt } };
271
- status = { ...fromGood({ ...parsed.good, checkedAt }, "network", at), keyHint: hint };
368
+ const good = withEnded(parsed.good, entry.good, at);
369
+ cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: true, ...good }, good: { ...good, checkedAt } };
370
+ status = { ...fromGood({ ...good, checkedAt }, "network", at), keyHint: hint };
272
371
  }
273
372
  else if (parsed.kind === "rejected") {
274
- cache.entries[hash] = { base: server, checkedAt, result: { ok: false, error: parsed.error } };
373
+ cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: false, error: parsed.error } };
275
374
  status = rejected(parsed.error, "network", key);
276
375
  }
277
376
  else {
278
377
  const error = scrub(parsed.error, key);
279
- cache.entries[hash] = { ...entry, base: server, failedAt: checkedAt, failure: error };
378
+ cache.entries[hash] = { ...entry, contract: CACHE_CONTRACT, base: server, failedAt: checkedAt, failure: error };
280
379
  status = withinGrace(entry, at)
281
- ? { ...fromGood(entry.good, "offline-grace", at, { graceUntil: new Date(Date.parse(entry.good.checkedAt) + GRACE_MS).toISOString(), error }), keyHint: hint }
380
+ ? offline(entry.good, error)
282
381
  : {
283
382
  state: "unreachable",
284
- plan: "free",
383
+ plan: "none",
285
384
  features: [],
286
385
  source: "network",
287
386
  error,
@@ -293,17 +392,29 @@ export function createLicenseProvider(deps = {}) {
293
392
  await writeCache(cacheFile, cache);
294
393
  return status;
295
394
  }
395
+ /** Keep the newest counts in the cached answer, so katman_account shows this month's audits without another check. */
396
+ async function remember(key, limits, usage) {
397
+ const server = apiBase(env()).url ?? HOMEPAGE;
398
+ const cache = await readCache(cacheFile);
399
+ const e = cache.entries[hashKey(key)];
400
+ if (!e || e.contract !== CACHE_CONTRACT || (e.base ?? HOMEPAGE) !== server || !e.result?.ok)
401
+ return;
402
+ e.result = { ...e.result, limits, usage };
403
+ if (e.good)
404
+ e.good = { ...e.good, limits, usage };
405
+ await writeCache(cacheFile, cache);
406
+ }
296
407
  return {
297
408
  hasKey: () => !!readKey(env()).key,
298
409
  baseUrl: () => apiBase(env()).url ?? HOMEPAGE,
299
410
  async status(opts = {}) {
300
411
  const { key, hint } = readKey(env());
301
412
  if (!key)
302
- return { state: "no_key", plan: "free", features: [], source: "none", ...(hint ? { hint } : {}) };
413
+ return { state: "no_key", plan: "none", features: [], source: "none", ...(hint ? { hint } : {}) };
303
414
  if (!KEY_RE.test(key)) {
304
415
  return {
305
416
  state: "invalid",
306
- plan: "free",
417
+ plan: "none",
307
418
  features: [],
308
419
  source: "local",
309
420
  keyHint: keyHint(key),
@@ -320,6 +431,74 @@ export function createLicenseProvider(deps = {}) {
320
431
  inflight.set(flightKey, p);
321
432
  return p;
322
433
  },
434
+ async loadMethod() {
435
+ const { key } = readKey(env());
436
+ if (!key || !KEY_RE.test(key))
437
+ return { ok: false, error: "KATMAN_KEY is not set" };
438
+ const base = apiBase(env());
439
+ if (!base.url)
440
+ return { ok: false, error: base.error ?? "no API URL" };
441
+ const res = await post(`${base.url}/api/web/mcp/method`, { key, mcpVersion: version }, { timeoutMs: METHOD_TIMEOUT_MS, maxChars: METHOD_MAX_CHARS });
442
+ let j;
443
+ try {
444
+ const x = JSON.parse(res.body);
445
+ if (isObj(x))
446
+ j = x;
447
+ }
448
+ catch {
449
+ j = undefined;
450
+ }
451
+ if (res.status === 200 && j?.ok === true && isObj(j.data))
452
+ return { ok: true, data: j.data };
453
+ if (res.status === 401)
454
+ return { ok: false, error: "katman.pro did not accept this KATMAN_KEY" };
455
+ if (res.status === 403)
456
+ return { ok: false, error: "this key has no active Katman plan" };
457
+ const code = typeof j?.error === "string" && /^[a-z_]{1,40}$/.test(j.error) ? ` ${j.error}` : "";
458
+ const why = res.status === 0 ? res.error || "network error" : `HTTP ${res.status}${code}`;
459
+ return { ok: false, error: scrub(why, key).slice(0, 120) };
460
+ },
461
+ async useAudit(domain) {
462
+ const { key } = readKey(env());
463
+ if (!key || !KEY_RE.test(key))
464
+ return { allowed: false, reason: "no_plan" };
465
+ const unchecked = (error) => ({ allowed: true, unchecked: true, error: scrub(error, key).slice(0, 120) });
466
+ const base = apiBase(env());
467
+ if (!base.url)
468
+ return unchecked(base.error ?? "no API URL");
469
+ const res = await post(`${base.url}/api/web/mcp/usage`, { key, kind: "audit", domain, mcpVersion: version }, { timeoutMs: USAGE_TIMEOUT_MS });
470
+ let j;
471
+ try {
472
+ const x = JSON.parse(res.body);
473
+ if (isObj(x))
474
+ j = x;
475
+ }
476
+ catch {
477
+ j = undefined;
478
+ }
479
+ if (res.status === 401)
480
+ return { allowed: false, reason: j?.error === "revoked" ? "revoked" : "invalid_key" };
481
+ if (res.status === 200 && j?.ok === true && typeof j.allowed === "boolean") {
482
+ const limits = parseLimits(j.limits);
483
+ const usage = parseUsage(j.usage);
484
+ const reason = j.reason === "audit_limit" || j.reason === "site_limit" || j.reason === "no_plan" ? j.reason : undefined;
485
+ const plan = j.plan === "audit" || j.plan === "pro" || j.plan === "none" ? j.plan : undefined;
486
+ if (limits && usage)
487
+ await remember(key, limits, usage).catch(() => undefined);
488
+ return {
489
+ allowed: j.allowed,
490
+ ...(j.allowed ? {} : { reason: reason ?? "no_plan" }),
491
+ ...(plan ? { plan } : {}),
492
+ ...(typeof j.enforced === "boolean" ? { enforced: j.enforced } : {}),
493
+ limits: limits ?? null,
494
+ ...(usage ? { usage } : {}),
495
+ };
496
+ }
497
+ // Anything else (no network, a timeout, 5xx, a rate limit, a host without this endpoint, an answer this version
498
+ // doesn't understand): the audit runs, and the result says the count wasn't checked.
499
+ const code = typeof j?.error === "string" && /^[a-z_]{1,40}$/.test(j.error) ? j.error : undefined;
500
+ return unchecked(res.status === 0 ? res.error || "network error" : res.status >= 300 && res.status < 400 ? `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` : `HTTP ${res.status}${code ? ` ${code}` : ""}`);
501
+ },
323
502
  async sync(body) {
324
503
  const { key } = readKey(env());
325
504
  if (!key || !KEY_RE.test(key))
@@ -345,7 +524,7 @@ export function createLicenseProvider(deps = {}) {
345
524
  return { ok: true, status: res.status, dashboardUrl: dashboardUrl(base.url, j.url ?? j.dashboardUrl, projectId), ...(projectId ? { projectId } : {}), ...(message ? { message } : {}) };
346
525
  }
347
526
  const code = typeof j.error === "string" ? j.error.slice(0, 60) : undefined;
348
- const error = res.status === 0 ? "network" : res.status === 401 ? (code === "revoked" ? "revoked" : "invalid_key") : res.status === 402 || res.status === 403 ? code ?? "not_pro" : code ?? `HTTP ${res.status}`;
527
+ const error = res.status === 0 ? "network" : res.status === 401 ? (code === "revoked" ? "revoked" : "invalid_key") : res.status === 402 || res.status === 403 ? code ?? "pro_required" : code ?? `HTTP ${res.status}`;
349
528
  return { ok: false, status: res.status, error, ...(res.status === 0 && res.error ? { message: scrub(res.error, key) } : message ? { message } : {}) };
350
529
  },
351
530
  };
@@ -375,24 +554,61 @@ export function licenseOf(ctx) {
375
554
  }
376
555
  // ---------------------------------------------------------------------------------------------------------------
377
556
  // Gate
378
- export async function requirePro(feature, provider) {
379
- let status;
557
+ /** The provider's status; a provider that throws counts as unreachable. */
558
+ export async function statusOf(provider) {
380
559
  try {
381
- status = await provider.status();
560
+ return await provider.status();
382
561
  }
383
562
  catch (e) {
384
- status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
563
+ return { state: "unreachable", plan: "none", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
385
564
  }
386
- return { allowed: allows(status, feature), status };
565
+ }
566
+ export async function requireFeature(feature, provider) {
567
+ const status = await statusOf(provider);
568
+ const allowed = allows(status, feature);
569
+ if (allowed)
570
+ await ensureMethod(provider);
571
+ return { allowed, status };
572
+ }
573
+ // ---------------------------------------------------------------------------------------------------------------
574
+ // Method data (the published package carries none; see src/tools/method/data/_store.ts)
575
+ let methodLoaded = METHOD_INLINE;
576
+ let methodLoading;
577
+ /** Thrown when a tool needs the method and katman.pro couldn't send it; the message is shown to the user. */
578
+ export class MethodUnavailableError extends Error {
387
579
  }
388
580
  /**
389
- * Wrap a Pro tool's handler at registration. Without a Pro/trial license it returns a friendly upsell
581
+ * Make sure the method data is in memory before a paid tool runs: fetched from katman.pro once per process with the
582
+ * key, never written to disk. Throws MethodUnavailableError with a readable reason when it can't be loaded.
583
+ */
584
+ export async function ensureMethod(provider) {
585
+ if (methodLoaded)
586
+ return;
587
+ if (!provider.loadMethod)
588
+ throw new MethodUnavailableError("Katman could not load its method data (this license provider can't fetch it).");
589
+ methodLoading ??= provider.loadMethod().then((r) => {
590
+ if (r.ok) {
591
+ applyMethod(r.data);
592
+ methodLoaded = true;
593
+ }
594
+ else {
595
+ methodLoading = undefined;
596
+ }
597
+ return r;
598
+ });
599
+ const r = await methodLoading;
600
+ if (!r.ok) {
601
+ throw new MethodUnavailableError(`Katman needs to reach ${provider.baseUrl()} once per session to load its method for your plan, and couldn't (${r.error}). Check the connection and KATMAN_KEY, then try again.`);
602
+ }
603
+ }
604
+ /**
605
+ * Wrap a tool's handler at registration. Without a plan that includes the feature it returns a short explanation
390
606
  * (not isError, so the agent relays it) instead of running the tool.
391
607
  */
392
- export function withPro(ctx, feature, handler) {
608
+ export function withPlan(ctx, feature, handler) {
393
609
  const tool = toolForFeature(feature);
394
610
  return async (args, extra) => {
395
- const gate = await requirePro(feature, licenseOf(ctx));
611
+ const gate = await requireFeature(feature, licenseOf(ctx));
396
612
  if (gate.allowed)
397
613
  return handler(args, extra);
398
614
  let facts;
@@ -407,42 +623,59 @@ export function withPro(ctx, feature, handler) {
407
623
  return ok(renderUpsell({ tool, feature, status: gate.status, facts, args: a, surface: "mcp" }), upsellStructured(tool, feature, gate.status));
408
624
  };
409
625
  }
410
- /**
411
- * For katman_start: a one-line plan status when KATMAN_KEY is set (no line and no network without it), and a
412
- * notice when the recommended next tool is a Pro tool the license doesn't cover.
413
- */
414
- export async function startLicense(ctx) {
415
- const provider = licenseOf(ctx);
416
- let status;
626
+ /** Ask whether an audit of this domain may run (and have it counted). Never throws: a failing provider counts as unchecked, the audit runs. */
627
+ export async function meterAudit(provider, domain) {
417
628
  try {
418
- status = await provider.status();
629
+ return await provider.useAudit(domain);
419
630
  }
420
631
  catch (e) {
421
- status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
632
+ return { allowed: true, unchecked: true, error: e instanceof Error ? e.message.slice(0, 120) : "error" };
422
633
  }
423
- const keySet = provider.hasKey();
634
+ }
635
+ /** For katman_start: the gate, the plan line, and a notice when the recommended next tool isn't in the plan. */
636
+ export async function startLicense(ctx) {
637
+ const provider = licenseOf(ctx);
638
+ const status = await statusOf(provider);
639
+ const allowed = allows(status, "start");
640
+ if (allowed)
641
+ await ensureMethod(provider);
424
642
  return {
425
- ...(keySet || status.hint ? { line: statusLine(status) } : {}),
643
+ allowed,
644
+ status,
645
+ line: statusLine(status),
426
646
  notice: (tool) => {
427
- const feature = PRO_TOOLS[tool];
428
- return feature && !allows(status, feature) ? proNotice(tool, status) : undefined;
647
+ const feature = Object.hasOwn(TOOL_FEATURES, tool) ? TOOL_FEATURES[tool] : undefined;
648
+ return feature && !allows(status, feature) ? planNotice(tool, feature, status) : undefined;
649
+ },
650
+ structured: {
651
+ keySet: provider.hasKey(),
652
+ state: status.state,
653
+ plan: status.plan,
654
+ ...(status.planEndsAt ? { planEndsAt: status.planEndsAt } : {}),
655
+ ...(status.planEnded ? { planEnded: true } : {}),
656
+ ...(status.limits ? { limits: status.limits } : {}),
657
+ ...(status.usage ? { usage: status.usage } : {}),
658
+ ...(status.updateMcpTo ? { updateMcpTo: status.updateMcpTo } : {}),
429
659
  },
430
- structured: { keySet, state: status.state, plan: status.plan, ...(status.trialEndsAt ? { trialEndsAt: status.trialEndsAt } : {}) },
431
660
  };
432
661
  }
433
662
  // ---------------------------------------------------------------------------------------------------------------
434
663
  // CLI
435
- /** Exit code of a Pro CLI command without a license. */
436
- export const EXIT_NEEDS_PRO = 4;
437
- /** CLI command → feature: plan, report, check, and indexnow only with --submit. */
438
- export function cliFeature(cmd, rest) {
439
- if (rest.includes("--help") || rest.includes("-h"))
440
- return undefined;
441
- switch (cmd) {
442
- case "plan": return "plan";
443
- case "report": return "report";
444
- case "check": return "visibility";
445
- case "indexnow": return rest.includes("--submit") ? "indexnow" : undefined;
446
- default: return undefined;
447
- }
664
+ /** Exit code of a CLI command without the plan it needs. */
665
+ export const EXIT_NEEDS_PLAN = 4;
666
+ /** CLI command → feature. Every command needs its feature except the OPEN_COMMANDS (and --help / --version). */
667
+ export const CLI_FEATURES = {
668
+ scan: "scan",
669
+ audit: "audit",
670
+ keywords: "keywords",
671
+ plan: "plan",
672
+ report: "report",
673
+ check: "visibility",
674
+ models: "visibility",
675
+ indexnow: "indexnow",
676
+ };
677
+ /** Commands that always work, even without a key. */
678
+ export const OPEN_COMMANDS = ["account"];
679
+ export function cliFeature(cmd) {
680
+ return cmd !== undefined && Object.hasOwn(CLI_FEATURES, cmd) ? CLI_FEATURES[cmd] : undefined;
448
681
  }