katman-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/license.js CHANGED
@@ -1,12 +1,16 @@
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, the Audit Week (audit and recommendations, 7 days) or Katman Pro (everything); the plans,
3
+ // 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
6
  // https://katman.pro), in the JSON body of POST /api/web/mcp/verify and /api/web/mcp/sync, and is never
5
7
  // 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.
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
+ // - Every tool goes through withPlan() at registration (katman_start through startLicense()); katman_account is the
13
+ // only open tool, and the CLI uses the same gate. Without a key nothing is sent to katman.pro.
10
14
  // - Tests inject a LicenseProvider (createServer({ license }) / runCli(args, { license })); there is no env
11
15
  // switch that turns the gate off.
12
16
  import { createHash } from "node:crypto";
@@ -15,31 +19,10 @@ import path from "node:path";
15
19
  import { promises as fs } from "node:fs";
16
20
  import { resolveRoot } from "./context.js";
17
21
  import { HOMEPAGE, VERSION } from "./meta.js";
22
+ import { AUDIT_FEATURES, PRO_FEATURES, TOOL_FEATURES, toolForFeature } from "./plans.js";
18
23
  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
- }
24
+ import { planNotice, projectFacts, renderUpsell, statusLine, upsellStructured } from "./tools/account/upsell.js";
25
+ export * from "./plans.js";
43
26
  export function allows(status, feature) {
44
27
  return status.state === "valid" && status.features.includes(feature);
45
28
  }
@@ -114,6 +97,11 @@ export const GRACE_MS = 7 * DAY_S * 1000;
114
97
  /** After a failed attempt, wait this long before trying the network again while the grace covers us. */
115
98
  const RETRY_AFTER_FAILURE_MS = 10 * 60 * 1000;
116
99
  const TIMEOUT_MS = 8_000;
100
+ /**
101
+ * Entries this version writes (verify API v2: plans none / audit / pro). Katman 0.1.x cached free / trial / pro with
102
+ * other feature names in the same file; its entries don't count here and are replaced at the next check.
103
+ */
104
+ const CACHE_CONTRACT = 2;
117
105
  export function defaultCacheFile() {
118
106
  return path.join(os.homedir(), ".katman", "license.json");
119
107
  }
@@ -150,9 +138,10 @@ function prune(entries, keep, now) {
150
138
  out.sort((a, b) => Date.parse(b[1].checkedAt ?? "") - Date.parse(a[1].checkedAt ?? ""));
151
139
  return Object.fromEntries(out.slice(0, 20));
152
140
  }
141
+ /** checkAgainAfter in seconds: at least a minute, at most a day (the server lowers it at the end of an Audit Week). */
153
142
  function ttlSeconds(x) {
154
143
  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;
144
+ return Number.isFinite(n) && n > 0 ? Math.min(Math.max(Math.round(n), 60), DAY_S) : DEFAULT_TTL_S;
156
145
  }
157
146
  function toIso(x) {
158
147
  if (typeof x === "string" && x.trim()) {
@@ -163,7 +152,11 @@ function toIso(x) {
163
152
  return new Date(x < 1e12 ? x * 1000 : x).toISOString();
164
153
  return undefined;
165
154
  }
166
- /** Parse a /api/web/mcp/verify response (spec §6). Only a 401 (or an explicit ok:false) is a rejection. */
155
+ /**
156
+ * Parse a /api/web/mcp/verify response (spec §6, API v2). Only a 401 (or an explicit ok:false) is a rejection.
157
+ * Tolerant of older servers: an unknown plan (trial, free…) is "none", `features` is read from the array whatever the
158
+ * plan says, and trialEndsAt counts as planEndsAt.
159
+ */
167
160
  export function parseVerify(res) {
168
161
  let j;
169
162
  try {
@@ -175,16 +168,28 @@ export function parseVerify(res) {
175
168
  j = undefined;
176
169
  }
177
170
  if (res.status === 200 && j?.ok === true) {
178
- const plan = j.plan === "trial" || j.plan === "pro" ? j.plan : "free";
171
+ const plan = j.plan === "audit" || j.plan === "pro" ? j.plan : "none";
179
172
  const features = Array.isArray(j.features)
180
173
  ? j.features.filter((f) => typeof f === "string").slice(0, 64)
181
- : plan === "free"
182
- ? []
183
- : [...PRO_FEATURES];
174
+ : plan === "pro"
175
+ ? [...PRO_FEATURES]
176
+ : plan === "audit"
177
+ ? [...AUDIT_FEATURES]
178
+ : [];
184
179
  const account = j.account && typeof j.account === "object" ? j.account : {};
185
180
  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 } : {}) } };
181
+ const planEndsAt = toIso(j.planEndsAt) ?? toIso(j.trialEndsAt);
182
+ return {
183
+ kind: "good",
184
+ good: {
185
+ plan,
186
+ features,
187
+ checkAgainAfter: ttlSeconds(j.checkAgainAfter),
188
+ ...(planEndsAt ? { planEndsAt } : {}),
189
+ ...(j.auditWeekUsed === true ? { auditWeekUsed: true } : {}),
190
+ ...(emailMasked ? { emailMasked } : {}),
191
+ },
192
+ };
188
193
  }
189
194
  if (res.status === 401 || (j?.ok === false && (j.error === "invalid_key" || j.error === "revoked"))) {
190
195
  return { kind: "rejected", error: j?.error === "revoked" ? "revoked" : "invalid_key" };
@@ -195,6 +200,30 @@ export function parseVerify(res) {
195
200
  return { kind: "failed", error: `unexpected redirect (HTTP ${res.status}); check KATMAN_API_URL` };
196
201
  return { kind: "failed", error: `HTTP ${res.status}${res.status === 200 ? " (unexpected answer)" : ""}` };
197
202
  }
203
+ /** The end date of a plan that grants features and still ran when it was checked; once it passes, check again. */
204
+ function endsAfterCheck(g, checkedAt) {
205
+ const ends = Date.parse(g.planEndsAt ?? "");
206
+ return g.features.length && Number.isFinite(ends) && ends > checkedAt ? ends : undefined;
207
+ }
208
+ function nextCheck(g) {
209
+ const checked = Date.parse(g.checkedAt);
210
+ return new Date(Math.min(checked + g.checkAgainAfter * 1000, endsAfterCheck(g, checked) ?? Infinity)).toISOString();
211
+ }
212
+ /** The offline grace: 7 days after the last good check, never past the end of an Audit Week. */
213
+ function graceUntil(g) {
214
+ const grace = Date.parse(g.checkedAt) + GRACE_MS;
215
+ const ends = g.plan === "pro" ? NaN : Date.parse(g.planEndsAt ?? "");
216
+ return new Date(Number.isFinite(ends) ? Math.min(grace, ends) : grace).toISOString();
217
+ }
218
+ /** A "none" answer right after a paid plan remembers which plan ended and, when the old end date has passed, when. */
219
+ function withEnded(next, prev, at) {
220
+ if (next.plan !== "none" || next.features.length || !prev)
221
+ return next;
222
+ if (prev.plan === "none")
223
+ return prev.ended ? { ...next, ended: prev.ended } : next;
224
+ const ends = Date.parse(prev.planEndsAt ?? "");
225
+ return { ...next, ended: { plan: prev.plan, ...(Number.isFinite(ends) && ends <= at ? { at: prev.planEndsAt } : {}) } };
226
+ }
198
227
  export function createLicenseProvider(deps = {}) {
199
228
  const env = () => deps.env ?? process.env;
200
229
  const post = deps.post ?? postJson;
@@ -206,34 +235,40 @@ export function createLicenseProvider(deps = {}) {
206
235
  /** Never let the key appear in an error string (belt and braces: it's only ever in a request body). */
207
236
  const scrub = (s, key) => (key ? s.split(key).join(keyHint(key)) : s);
208
237
  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";
238
+ // A plan past its end date gives nothing, even if the cached or offline answer still lists features (no offline
239
+ // extension of an Audit Week). Pro renews into a new period, so its end date only triggers a new check (fresh()).
240
+ const expired = g.plan !== "pro" && !!g.planEndsAt && Date.parse(g.planEndsAt) <= at;
241
+ const ended = expired ? { plan: g.plan === "audit" ? "audit" : g.ended?.plan, at: g.planEndsAt } : g.plan === "none" ? g.ended : undefined;
242
+ const endsAt = g.planEndsAt ?? ended?.at;
212
243
  return {
213
244
  state: "valid",
214
- plan: cutOff ? "free" : g.plan,
215
- features: cutOff ? [] : [...g.features],
216
- ...(g.trialEndsAt ? { trialEndsAt: g.trialEndsAt } : {}),
217
- ...(ended ? { trialEnded: true } : {}),
245
+ plan: expired ? "none" : g.plan,
246
+ features: expired ? [] : [...g.features],
247
+ ...(endsAt ? { planEndsAt: endsAt } : {}),
248
+ ...(ended ? { planEnded: true, ...(ended.plan ? { endedPlan: ended.plan } : {}) } : {}),
249
+ ...(g.auditWeekUsed || (expired && g.plan === "audit") ? { auditWeekUsed: true } : {}),
218
250
  ...(g.emailMasked ? { emailMasked: g.emailMasked } : {}),
219
251
  checkedAt: g.checkedAt,
220
- nextCheckAt: new Date(Date.parse(g.checkedAt) + g.checkAgainAfter * 1000).toISOString(),
252
+ nextCheckAt: nextCheck(g),
221
253
  source,
222
254
  ...extra,
223
255
  };
224
256
  }
225
257
  function rejected(error, source, key) {
226
- return { state: error === "revoked" ? "revoked" : "invalid", plan: "free", features: [], source, keyHint: keyHint(key) };
258
+ return { state: error === "revoked" ? "revoked" : "invalid", plan: "none", features: [], source, keyHint: keyHint(key) };
227
259
  }
228
260
  function fresh(e, at) {
229
261
  if (!e.result || !e.checkedAt)
230
262
  return false;
231
- const age = at - Date.parse(e.checkedAt);
263
+ const checked = Date.parse(e.checkedAt);
264
+ const age = at - checked;
232
265
  if (!(age >= 0))
233
266
  return false; // unparsable or in the future (clock change): check again
234
267
  if (!e.result.ok)
235
268
  return age < REJECTED_TTL_S * 1000;
236
- if (e.result.plan === "trial" && e.result.trialEndsAt && Date.parse(e.result.trialEndsAt) <= at)
269
+ // Once the plan's end date has passed, check again at once: an Audit Week ends, Pro renews into a new period.
270
+ const ends = endsAfterCheck(e.result, checked);
271
+ if (ends !== undefined && ends <= at)
237
272
  return false;
238
273
  return age < e.result.checkAgainAfter * 1000;
239
274
  }
@@ -249,8 +284,12 @@ export function createLicenseProvider(deps = {}) {
249
284
  const server = base.url ?? HOMEPAGE;
250
285
  const cache = await readCache(cacheFile);
251
286
  const cached = cache.entries[hash];
252
- const entry = cached && (cached.base ?? HOMEPAGE) === server ? cached : {};
287
+ const entry = cached && cached.contract === CACHE_CONTRACT && (cached.base ?? HOMEPAGE) === server ? cached : {};
253
288
  const hint = keyHint(key);
289
+ const offline = (g, error) => ({
290
+ ...fromGood(g, "offline-grace", at, { graceUntil: graceUntil(g), ...(error ? { error } : {}) }),
291
+ keyHint: hint,
292
+ });
254
293
  if (!refresh && fresh(entry, at)) {
255
294
  const r = entry.result;
256
295
  return r.ok ? { ...fromGood({ ...r, checkedAt: entry.checkedAt }, "cache", at), keyHint: hint } : rejected(r.error, "cache", key);
@@ -258,7 +297,7 @@ export function createLicenseProvider(deps = {}) {
258
297
  // Offline and recently failed: don't wait for another timeout on every tool call.
259
298
  const failedAt = Date.parse(entry.failedAt ?? "");
260
299
  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 };
300
+ return offline(entry.good, entry.failure);
262
301
  }
263
302
  const res = base.url
264
303
  ? await post(`${base.url}/api/web/mcp/verify`, { key, mcpVersion: version }, { timeoutMs })
@@ -267,21 +306,22 @@ export function createLicenseProvider(deps = {}) {
267
306
  const checkedAt = new Date(at).toISOString();
268
307
  let status;
269
308
  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 };
309
+ const good = withEnded(parsed.good, entry.good, at);
310
+ cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: true, ...good }, good: { ...good, checkedAt } };
311
+ status = { ...fromGood({ ...good, checkedAt }, "network", at), keyHint: hint };
272
312
  }
273
313
  else if (parsed.kind === "rejected") {
274
- cache.entries[hash] = { base: server, checkedAt, result: { ok: false, error: parsed.error } };
314
+ cache.entries[hash] = { contract: CACHE_CONTRACT, base: server, checkedAt, result: { ok: false, error: parsed.error } };
275
315
  status = rejected(parsed.error, "network", key);
276
316
  }
277
317
  else {
278
318
  const error = scrub(parsed.error, key);
279
- cache.entries[hash] = { ...entry, base: server, failedAt: checkedAt, failure: error };
319
+ cache.entries[hash] = { ...entry, contract: CACHE_CONTRACT, base: server, failedAt: checkedAt, failure: error };
280
320
  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 }
321
+ ? offline(entry.good, error)
282
322
  : {
283
323
  state: "unreachable",
284
- plan: "free",
324
+ plan: "none",
285
325
  features: [],
286
326
  source: "network",
287
327
  error,
@@ -299,11 +339,11 @@ export function createLicenseProvider(deps = {}) {
299
339
  async status(opts = {}) {
300
340
  const { key, hint } = readKey(env());
301
341
  if (!key)
302
- return { state: "no_key", plan: "free", features: [], source: "none", ...(hint ? { hint } : {}) };
342
+ return { state: "no_key", plan: "none", features: [], source: "none", ...(hint ? { hint } : {}) };
303
343
  if (!KEY_RE.test(key)) {
304
344
  return {
305
345
  state: "invalid",
306
- plan: "free",
346
+ plan: "none",
307
347
  features: [],
308
348
  source: "local",
309
349
  keyHint: keyHint(key),
@@ -345,7 +385,7 @@ export function createLicenseProvider(deps = {}) {
345
385
  return { ok: true, status: res.status, dashboardUrl: dashboardUrl(base.url, j.url ?? j.dashboardUrl, projectId), ...(projectId ? { projectId } : {}), ...(message ? { message } : {}) };
346
386
  }
347
387
  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}`;
388
+ 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
389
  return { ok: false, status: res.status, error, ...(res.status === 0 && res.error ? { message: scrub(res.error, key) } : message ? { message } : {}) };
350
390
  },
351
391
  };
@@ -375,24 +415,26 @@ export function licenseOf(ctx) {
375
415
  }
376
416
  // ---------------------------------------------------------------------------------------------------------------
377
417
  // Gate
378
- export async function requirePro(feature, provider) {
379
- let status;
418
+ async function statusOf(provider) {
380
419
  try {
381
- status = await provider.status();
420
+ return await provider.status();
382
421
  }
383
422
  catch (e) {
384
- status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
423
+ return { state: "unreachable", plan: "none", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
385
424
  }
425
+ }
426
+ export async function requireFeature(feature, provider) {
427
+ const status = await statusOf(provider);
386
428
  return { allowed: allows(status, feature), status };
387
429
  }
388
430
  /**
389
- * Wrap a Pro tool's handler at registration. Without a Pro/trial license it returns a friendly upsell
431
+ * Wrap a tool's handler at registration. Without a plan that includes the feature it returns a short explanation
390
432
  * (not isError, so the agent relays it) instead of running the tool.
391
433
  */
392
- export function withPro(ctx, feature, handler) {
434
+ export function withPlan(ctx, feature, handler) {
393
435
  const tool = toolForFeature(feature);
394
436
  return async (args, extra) => {
395
- const gate = await requirePro(feature, licenseOf(ctx));
437
+ const gate = await requireFeature(feature, licenseOf(ctx));
396
438
  if (gate.allowed)
397
439
  return handler(args, extra);
398
440
  let facts;
@@ -407,42 +449,44 @@ export function withPro(ctx, feature, handler) {
407
449
  return ok(renderUpsell({ tool, feature, status: gate.status, facts, args: a, surface: "mcp" }), upsellStructured(tool, feature, gate.status));
408
450
  };
409
451
  }
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
- */
452
+ /** For katman_start: the gate, the plan line, and a notice when the recommended next tool isn't in the plan. */
414
453
  export async function startLicense(ctx) {
415
454
  const provider = licenseOf(ctx);
416
- let status;
417
- try {
418
- status = await provider.status();
419
- }
420
- catch (e) {
421
- status = { state: "unreachable", plan: "free", features: [], source: "network", error: e instanceof Error ? e.message : String(e) };
422
- }
423
- const keySet = provider.hasKey();
455
+ const status = await statusOf(provider);
424
456
  return {
425
- ...(keySet || status.hint ? { line: statusLine(status) } : {}),
457
+ allowed: allows(status, "start"),
458
+ status,
459
+ line: statusLine(status),
426
460
  notice: (tool) => {
427
- const feature = PRO_TOOLS[tool];
428
- return feature && !allows(status, feature) ? proNotice(tool, status) : undefined;
461
+ const feature = Object.hasOwn(TOOL_FEATURES, tool) ? TOOL_FEATURES[tool] : undefined;
462
+ return feature && !allows(status, feature) ? planNotice(tool, feature, status) : undefined;
463
+ },
464
+ structured: {
465
+ keySet: provider.hasKey(),
466
+ state: status.state,
467
+ plan: status.plan,
468
+ ...(status.planEndsAt ? { planEndsAt: status.planEndsAt } : {}),
469
+ ...(status.planEnded ? { planEnded: true } : {}),
429
470
  },
430
- structured: { keySet, state: status.state, plan: status.plan, ...(status.trialEndsAt ? { trialEndsAt: status.trialEndsAt } : {}) },
431
471
  };
432
472
  }
433
473
  // ---------------------------------------------------------------------------------------------------------------
434
474
  // 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
- }
475
+ /** Exit code of a CLI command without the plan it needs. */
476
+ export const EXIT_NEEDS_PLAN = 4;
477
+ /** CLI command → feature. Every command needs its feature except the OPEN_COMMANDS (and --help / --version). */
478
+ export const CLI_FEATURES = {
479
+ scan: "scan",
480
+ audit: "audit",
481
+ keywords: "keywords",
482
+ plan: "plan",
483
+ report: "report",
484
+ check: "visibility",
485
+ models: "visibility",
486
+ indexnow: "indexnow",
487
+ };
488
+ /** Commands that always work, even without a key. */
489
+ export const OPEN_COMMANDS = ["account"];
490
+ export function cliFeature(cmd) {
491
+ return cmd !== undefined && Object.hasOwn(CLI_FEATURES, cmd) ? CLI_FEATURES[cmd] : undefined;
448
492
  }
package/dist/meta.js CHANGED
@@ -1,12 +1,19 @@
1
- export const VERSION = "0.1.0";
1
+ export const VERSION = "0.2.0";
2
2
  export const HOMEPAGE = "https://katman.pro";
3
- /** Katman Pro (docs/07-APP-SPEC.md §1): 7-day trial without a card, then the founding price. */
3
+ /** Plans (docs/07-APP-SPEC.md §1): nothing is free. Audit Week = $3 once for 7 days; Katman Pro = the founding price. */
4
4
  export const APP_URL = `${HOMEPAGE}/app`;
5
5
  export const KEYS_URL = `${APP_URL}/mcp`;
6
6
  export const BILLING_URL = `${APP_URL}/billing`;
7
- export const TRIAL_DAYS = 7;
7
+ export const PRICING_URL = `${HOMEPAGE}/pricing`;
8
+ /** How to add KATMAN_KEY to each editor's MCP config. */
9
+ export const KEY_DOCS_URL = `${HOMEPAGE}/docs/getting-started`;
10
+ export const AUDIT_WEEK_DAYS = 7;
11
+ export const AUDIT_WEEK_PRICE = "$3";
8
12
  export const PRO_PRICE = "$29/month";
9
- export const PRO_PRICE_NOTE = "$29/month founding price, locked for the first 100 customers";
13
+ /** Audit Week in one phrase. */
14
+ export const AUDIT_PRICE_NOTE = "$3 once for 7 days, no auto-renewal, one per account";
15
+ /** Katman Pro in one phrase. */
16
+ export const PRO_PRICE_NOTE = "$29/month founding price for the first 100 customers, locked while you stay subscribed (later $49), or $290/year";
10
17
  /** Sent to the client model at initialization. Keep it short and imperative. */
11
18
  export const SERVER_INSTRUCTIONS = `Katman makes this project's public website readable, quotable and recommendable by AI assistants (ChatGPT, Claude, Gemini, Perplexity, Copilot) and findable in Google, Bing and Brave.
12
19
 
@@ -15,8 +22,11 @@ It follows the Katman method: 4 steps (1 Research → 2 Plan → 3 Build → 4 V
15
22
  - Quote: the first 1–3 sentences answer the question on their own, with the brand's one-sentence description.
16
23
  - Fit: there is a page written for the situation the customer describes to the assistant.
17
24
 
18
- Free vs Pro: katman_start, katman_research, katman_scan_code, katman_audit_site, katman_keywords and katman_account are free (the diagnosis). Every other tool is Katman Pro and needs KATMAN_KEY in the MCP config (7-day free trial, no card: katman.pro/app); without it the tool returns a short explanation instead of running — pass it on to the user.
19
- Never try to bypass or work around the license (no editing Katman's code or cache, no made-up keys); the free tools keep working either way.
25
+ Plans: every tool except katman_account needs KATMAN_KEY with an active plan in the MCP config.
26
+ - Audit Week ($3 for 7 days): katman_start, katman_research, katman_scan_code, katman_audit_site, katman_keywords, katman_plan, katman_lint_content, katman_visibility_run, katman_manual_check, katman_report. Audit and recommendations only: it never builds pages or writes site files.
27
+ - Katman Pro ($29/month founding price, or $290/year): all of the above plus katman_prompt (P0–P14), katman_page_brief, katman_generate, katman_indexnow, katman_setup_monitoring and katman_sync.
28
+ Without the right plan a tool returns a short explanation instead of running: pass it on to the user. katman_account always works and shows the plan, the days left and how to get a key.
29
+ Never try to bypass or work around the licence (no editing Katman's code or cache, no made-up keys).
20
30
 
21
31
  How to work:
22
32
  1. Always call katman_start first; it tells you the next step. Pass project_root (absolute path of the project) and site_url when you know them.
package/dist/plans.js ADDED
@@ -0,0 +1,47 @@
1
+ // Katman plans and license features (docs/07-APP-SPEC.md §1 and §6). Nothing is free: every tool except
2
+ // katman_account needs KATMAN_KEY with an active plan. Shared by the gate (license.ts) and its texts
3
+ // (tools/account/upsell.ts); no imports, so both sides can use it.
4
+ /** Audit Week ($3 for 7 days): audit and recommendations. It never builds pages or writes site files. */
5
+ export const AUDIT_FEATURES = ["start", "research", "scan", "audit", "keywords", "plan", "lint", "visibility", "manual_check", "report"];
6
+ /** Katman Pro: the Audit Week set plus the tools that build pages and keep things running. */
7
+ export const PRO_FEATURES = [...AUDIT_FEATURES, "prompts", "briefs", "generate", "indexnow", "monitoring", "sync"];
8
+ /** Tool → license feature. Every tool needs its feature, except the OPEN_TOOLS. */
9
+ export const TOOL_FEATURES = {
10
+ katman_start: "start",
11
+ katman_research: "research",
12
+ katman_scan_code: "scan",
13
+ katman_audit_site: "audit",
14
+ katman_keywords: "keywords",
15
+ katman_plan: "plan",
16
+ katman_lint_content: "lint",
17
+ katman_visibility_run: "visibility",
18
+ katman_manual_check: "manual_check",
19
+ katman_report: "report",
20
+ katman_prompt: "prompts",
21
+ katman_page_brief: "briefs",
22
+ katman_generate: "generate",
23
+ katman_indexnow: "indexnow",
24
+ katman_setup_monitoring: "monitoring",
25
+ katman_sync: "sync",
26
+ };
27
+ /** Tools that always work, even without a key. */
28
+ export const OPEN_TOOLS = ["katman_account"];
29
+ /** Appended to the description of every Audit Week tool. */
30
+ export const AUDIT_NOTE = " (Audit Week or Pro — needs KATMAN_KEY)";
31
+ /** Appended to the description of every Pro-only tool. */
32
+ export const PRO_NOTE = " (Katman Pro — needs KATMAN_KEY)";
33
+ /** True for the features only Katman Pro unlocks (not in the Audit Week). */
34
+ export function isProOnly(feature) {
35
+ return !AUDIT_FEATURES.includes(feature);
36
+ }
37
+ /** The description note for a tool's feature. */
38
+ export function tierNote(feature) {
39
+ return isProOnly(feature) ? PRO_NOTE : AUDIT_NOTE;
40
+ }
41
+ export function toolForFeature(feature) {
42
+ return Object.keys(TOOL_FEATURES).find((t) => TOOL_FEATURES[t] === feature) ?? `katman_${feature}`;
43
+ }
44
+ /** The tools of one tier: "audit" = the Audit Week tools (also in Pro), "pro" = the Pro-only tools. */
45
+ export function toolsOf(tier) {
46
+ return Object.keys(TOOL_FEATURES).filter((t) => isProOnly(TOOL_FEATURES[t]) === (tier === "pro"));
47
+ }
@@ -1,64 +1,85 @@
1
- // katman_account (free): the Katman plan behind KATMAN_KEY, trial days left, Pro features, masked account
2
- // email, last check, and how to connect or upgrade. Never shows the key.
3
- import { FREE_TOOLS, PRO_TOOLS, allows } from "../../license.js";
4
- import { APP_URL, BILLING_URL, PRO_PRICE_NOTE } from "../../meta.js";
5
- import { fmtDay, fmtWhen, howToText, reasonText, trialDaysLeft } from "./upsell.js";
1
+ // katman_account (always open, even without a key): the Katman plan behind KATMAN_KEY (none, Audit Week with the
2
+ // days left, or Katman Pro), the tools it unlocks, the masked account email, the last check, and how to get a key
3
+ // or upgrade. Never shows the key.
4
+ import { OPEN_TOOLS, TOOL_FEATURES, allows, toolsOf } from "../../license.js";
5
+ import { APP_URL, BILLING_URL, KEYS_URL, PRICING_URL, PRO_PRICE_NOTE } from "../../meta.js";
6
+ import { NO_KEY_TEXT, endedPhrase, fmtDay, fmtWhen, howToText, planDaysLeft, reasonText } from "./upsell.js";
7
+ function planCell(s, left) {
8
+ if (s.state === "no_key")
9
+ return s.hint ? "None (KATMAN_KEY placeholder not filled)" : "None (no KATMAN_KEY)";
10
+ if (s.state !== "valid")
11
+ return `None (key ${s.state === "unreachable" ? "not verified" : s.state})`;
12
+ if (s.plan === "audit")
13
+ return `Audit Week: ${left ?? "?"} day${left === 1 ? "" : "s"} left (ends ${fmtDay(s.planEndsAt)}; no auto-renewal)`;
14
+ if (s.plan === "pro")
15
+ return "Katman Pro";
16
+ return `No active plan${s.planEnded ? ` (${endedPhrase(s)})` : s.auditWeekUsed ? " (Audit Week used)" : ""}`;
17
+ }
18
+ /** "on", "3 of 10 on" or "off". */
19
+ function onText(tools, s) {
20
+ const on = tools.filter((t) => allows(s, TOOL_FEATURES[t])).length;
21
+ return on === tools.length ? "on" : on ? `${on} of ${tools.length} on` : "off";
22
+ }
6
23
  export async function runAccount(provider, opts = {}) {
7
24
  const now = opts.now ?? new Date();
8
25
  const s = await provider.status({ refresh: opts.refresh });
9
- const left = trialDaysLeft(s, now);
10
- const proTools = Object.keys(PRO_TOOLS);
11
- const on = proTools.filter((t) => allows(s, PRO_TOOLS[t]));
12
- const planCell = s.state === "no_key"
13
- ? "Free (no KATMAN_KEY)"
14
- : s.state !== "valid"
15
- ? `Free (key ${s.state === "unreachable" ? "not verified" : s.state})`
16
- : s.plan === "trial"
17
- ? `Trial: ${left ?? "?"} day${left === 1 ? "" : "s"} left (ends ${fmtDay(s.trialEndsAt)})`
18
- : s.plan === "pro"
19
- ? "Katman Pro"
20
- : `Free${s.trialEnded ? ` (trial ended ${fmtDay(s.trialEndsAt)})` : ""}`;
26
+ const left = planDaysLeft(s, now);
27
+ const auditTools = toolsOf("audit");
28
+ const proTools = toolsOf("pro");
29
+ const on = Object.keys(TOOL_FEATURES).filter((t) => allows(s, TOOL_FEATURES[t]));
21
30
  const source = s.source === "cache" ? "cached answer" : s.source === "network" ? "just checked" : s.source === "offline-grace" ? `offline grace until ${fmtWhen(s.graceUntil)}` : s.source === "local" ? "not sent: malformed key" : "no key";
22
31
  const rows = [
23
- ["Plan", planCell],
32
+ ["Plan", planCell(s, left)],
24
33
  ["Account", s.emailMasked ?? "–"],
25
34
  ["Key", s.state === "no_key" ? (s.hint ? "placeholder, not filled" : "not set (KATMAN_KEY)") : `${s.keyHint ?? "km_…"} (from KATMAN_KEY)`],
26
- ["Pro features", s.features.length ? s.features.join(", ") : "none"],
35
+ ["Features", s.features.length ? s.features.join(", ") : "none"],
27
36
  ["Last check", s.checkedAt ? `${fmtWhen(s.checkedAt)} (${source}${s.nextCheckAt && s.source !== "offline-grace" ? `; next check after ${fmtWhen(s.nextCheckAt)}` : ""})` : `– (${source})`],
28
37
  ];
29
38
  if (s.error)
30
39
  rows.push(["Connection", `couldn't reach katman.pro: ${s.error}`]);
31
40
  const L = ["# Katman account", rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n")];
32
- L.push(`**Free tools** (no key): ${FREE_TOOLS.join(", ")}.`, `**Pro tools** (${on.length === proTools.length ? "on" : on.length ? `${on.length} of ${proTools.length} on` : "off"}): ${proTools.join(", ")}.`);
41
+ L.push([
42
+ `**Audit Week tools** (${onText(auditTools, s)}; Audit Week or Pro): ${auditTools.join(", ")}.`,
43
+ `**Katman Pro tools** (${onText(proTools, s)}; Pro only): ${proTools.join(", ")}.`,
44
+ `**Always open:** ${OPEN_TOOLS.join(", ")}.`,
45
+ ].join("\n"));
33
46
  if (s.state === "valid" && s.plan === "pro") {
34
47
  L.push(`Manage the subscription and invoices at ${BILLING_URL}.`);
35
48
  }
36
- else if (s.state === "valid" && s.plan === "trial") {
37
- L.push(`Keep Pro after the trial: ${BILLING_URL} (${PRO_PRICE_NOTE}). The key stays the same.`);
49
+ else if (s.state === "valid" && s.plan === "audit") {
50
+ L.push(`Your Audit Week ends on ${fmtDay(s.planEndsAt)} and doesn't renew. Katman Pro builds the pages (the P0–P14 prompts, page briefs, file generation, IndexNow, monitoring and the dashboard sync): ${PRO_PRICE_NOTE}, at ${BILLING_URL}. The key stays the same.`);
38
51
  }
39
52
  else {
40
- if (s.state !== "no_key" || s.hint)
41
- L.push(`**Why Pro tools are off:** ${reasonText(s)}`);
53
+ if (s.state === "no_key" && !s.hint)
54
+ L.push(NO_KEY_TEXT);
55
+ else
56
+ L.push(`**Why the tools are off:** ${reasonText(s)}`);
42
57
  L.push(howToText(s));
43
58
  }
44
- L.push(`Network: Katman contacts katman.pro only to check this key (at most once a day, only when KATMAN_KEY is set) and when you call katman_sync. The key is never shown or written to disk; the cache (~/.katman/license.json) stores its SHA-256 hash. Account and keys: ${APP_URL}.`);
59
+ L.push(`Network: Katman contacts katman.pro only to check this key (at most once a day and when a plan ends, only when KATMAN_KEY is set) and when you call katman_sync. The key is never shown or written to disk; the cache (~/.katman/license.json) stores its SHA-256 hash. Plans: ${PRICING_URL} · account and keys: ${APP_URL}.`);
45
60
  return {
46
61
  text: L.join("\n\n"),
47
62
  structured: {
48
63
  state: s.state,
49
64
  plan: s.plan,
50
- trialEndsAt: s.trialEndsAt ?? null,
51
- trialDaysLeft: left ?? null,
52
- trialEnded: !!s.trialEnded,
65
+ planEndsAt: s.planEndsAt ?? null,
66
+ planDaysLeft: left ?? null,
67
+ planEnded: !!s.planEnded,
68
+ endedPlan: s.endedPlan ?? null,
69
+ auditWeekUsed: !!s.auditWeekUsed,
53
70
  features: s.features,
54
71
  account: { emailMasked: s.emailMasked ?? null },
55
72
  checkedAt: s.checkedAt ?? null,
56
73
  nextCheckAt: s.nextCheckAt ?? null,
57
74
  source: s.source,
58
75
  graceUntil: s.graceUntil ?? null,
59
- freeTools: [...FREE_TOOLS],
76
+ auditTools,
60
77
  proTools,
61
- proToolsOn: on,
78
+ openTools: [...OPEN_TOOLS],
79
+ toolsOn: on,
80
+ pricingUrl: PRICING_URL,
81
+ upgradeUrl: BILLING_URL,
82
+ keysUrl: KEYS_URL,
62
83
  },
63
84
  };
64
85
  }
@@ -16,6 +16,8 @@ export const commands = {
16
16
  },
17
17
  };
18
18
  export const help = [
19
- " katman-mcp account [--refresh] Katman plan behind KATMAN_KEY (free / trial / Pro) and how to connect or upgrade",
20
- " Pro commands (plan, report, check, indexnow --submit) need KATMAN_KEY; exit code 4 without it",
19
+ " katman-mcp account [--refresh] your Katman plan (none / Audit Week / Pro), days left, and how to get a key or upgrade",
20
+ "",
21
+ "Every command except account, --help and --version needs KATMAN_KEY with an active plan: Audit Week or Katman Pro",
22
+ "(indexnow: Katman Pro only). Without it a command explains the plans and exits with code 4. Plans: https://katman.pro/pricing",
21
23
  ];