katman-mcp 0.1.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/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// All Katman state lives in <project>/.katman/. Writes never leave that folder.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
export const KATMAN_DIR = ".katman";
|
|
5
|
+
export function katmanPath(root, ...parts) {
|
|
6
|
+
const base = path.resolve(root, KATMAN_DIR);
|
|
7
|
+
const p = path.resolve(base, ...parts);
|
|
8
|
+
if (p !== base && !p.startsWith(base + path.sep)) {
|
|
9
|
+
throw new Error(`Refusing to access ${p}: outside ${base}`);
|
|
10
|
+
}
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
export async function ensureDir(root, rel = "") {
|
|
14
|
+
const dir = katmanPath(root, rel);
|
|
15
|
+
await fs.mkdir(dir, { recursive: true });
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
export async function readText(root, rel) {
|
|
19
|
+
try {
|
|
20
|
+
return await fs.readFile(katmanPath(root, rel), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function writeText(root, rel, text) {
|
|
27
|
+
const p = katmanPath(root, rel);
|
|
28
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
29
|
+
await fs.writeFile(p, text, "utf8");
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
export async function readJson(root, rel) {
|
|
33
|
+
const t = await readText(root, rel);
|
|
34
|
+
if (t === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(t);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function writeJson(root, rel, data) {
|
|
44
|
+
return writeText(root, rel, JSON.stringify(data, null, 2) + "\n");
|
|
45
|
+
}
|
|
46
|
+
/** List file names (not paths) in a .katman subfolder, sorted ascending. */
|
|
47
|
+
export async function listFiles(root, relDir) {
|
|
48
|
+
try {
|
|
49
|
+
const names = await fs.readdir(katmanPath(root, relDir));
|
|
50
|
+
return names.sort();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Filesystem-safe UTC timestamp: 2026-09-30T14-05-09Z */
|
|
57
|
+
export function stamp(d = new Date()) {
|
|
58
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
|
|
59
|
+
}
|
|
60
|
+
export function today(d = new Date()) {
|
|
61
|
+
return d.toISOString().slice(0, 10);
|
|
62
|
+
}
|
|
63
|
+
export async function loadResearch(root) {
|
|
64
|
+
return readJson(root, "research.json");
|
|
65
|
+
}
|
|
66
|
+
export async function saveResearch(root, r) {
|
|
67
|
+
r.updatedAt = new Date().toISOString();
|
|
68
|
+
return writeJson(root, "research.json", r);
|
|
69
|
+
}
|
|
70
|
+
export async function loadConfig(root) {
|
|
71
|
+
return (await readJson(root, "config.json")) ?? {};
|
|
72
|
+
}
|
|
73
|
+
export async function saveConfig(root, c) {
|
|
74
|
+
return writeJson(root, "config.json", c);
|
|
75
|
+
}
|
|
76
|
+
/** Append a line to .katman/log.md so the project keeps a dated history of what Katman did. */
|
|
77
|
+
export async function logEvent(root, line) {
|
|
78
|
+
const p = katmanPath(root, "log.md");
|
|
79
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
80
|
+
await fs.appendFile(p, `- ${new Date().toISOString()} ${line}\n`, "utf8");
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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";
|
|
6
|
+
export async function runAccount(provider, opts = {}) {
|
|
7
|
+
const now = opts.now ?? new Date();
|
|
8
|
+
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)})` : ""}`;
|
|
21
|
+
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
|
+
const rows = [
|
|
23
|
+
["Plan", planCell],
|
|
24
|
+
["Account", s.emailMasked ?? "–"],
|
|
25
|
+
["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"],
|
|
27
|
+
["Last check", s.checkedAt ? `${fmtWhen(s.checkedAt)} (${source}${s.nextCheckAt && s.source !== "offline-grace" ? `; next check after ${fmtWhen(s.nextCheckAt)}` : ""})` : `– (${source})`],
|
|
28
|
+
];
|
|
29
|
+
if (s.error)
|
|
30
|
+
rows.push(["Connection", `couldn't reach katman.pro: ${s.error}`]);
|
|
31
|
+
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(", ")}.`);
|
|
33
|
+
if (s.state === "valid" && s.plan === "pro") {
|
|
34
|
+
L.push(`Manage the subscription and invoices at ${BILLING_URL}.`);
|
|
35
|
+
}
|
|
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.`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (s.state !== "no_key" || s.hint)
|
|
41
|
+
L.push(`**Why Pro tools are off:** ${reasonText(s)}`);
|
|
42
|
+
L.push(howToText(s));
|
|
43
|
+
}
|
|
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}.`);
|
|
45
|
+
return {
|
|
46
|
+
text: L.join("\n\n"),
|
|
47
|
+
structured: {
|
|
48
|
+
state: s.state,
|
|
49
|
+
plan: s.plan,
|
|
50
|
+
trialEndsAt: s.trialEndsAt ?? null,
|
|
51
|
+
trialDaysLeft: left ?? null,
|
|
52
|
+
trialEnded: !!s.trialEnded,
|
|
53
|
+
features: s.features,
|
|
54
|
+
account: { emailMasked: s.emailMasked ?? null },
|
|
55
|
+
checkedAt: s.checkedAt ?? null,
|
|
56
|
+
nextCheckAt: s.nextCheckAt ?? null,
|
|
57
|
+
source: s.source,
|
|
58
|
+
graceUntil: s.graceUntil ?? null,
|
|
59
|
+
freeTools: [...FREE_TOOLS],
|
|
60
|
+
proTools,
|
|
61
|
+
proToolsOn: on,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseArgs } from "../../cli.js";
|
|
2
|
+
import { defaultLicense } from "../../license.js";
|
|
3
|
+
import { runAccount } from "./account.js";
|
|
4
|
+
export const commands = {
|
|
5
|
+
account: async (args, deps) => {
|
|
6
|
+
const { flags } = parseArgs(args);
|
|
7
|
+
try {
|
|
8
|
+
const out = await runAccount(deps?.license ?? defaultLicense(), { refresh: flags.refresh === true || flags.refresh === "true" });
|
|
9
|
+
console.log(out.text);
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
console.error(`katman account: ${e instanceof Error ? e.message : String(e)}`);
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
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",
|
|
21
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Account module: katman_account (free: plan, trial days, features, how to connect/upgrade) and katman_sync
|
|
2
|
+
// (Pro: sends aggregates to the katman.pro dashboard; dry run first).
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { resolveRoot, rootWarning } from "../../context.js";
|
|
5
|
+
import { PRO_NOTE, licenseOf, withPro } from "../../license.js";
|
|
6
|
+
import { fail, ok } from "../../util.js";
|
|
7
|
+
import { runAccount } from "./account.js";
|
|
8
|
+
import { runSync } from "./sync.js";
|
|
9
|
+
export function registerAccountTools(server, ctx) {
|
|
10
|
+
server.registerTool("katman_account", {
|
|
11
|
+
title: "Katman account and plan",
|
|
12
|
+
description: "Free. Shows the Katman plan behind KATMAN_KEY (free, trial with the days left, or Pro), the Pro features it unlocks, the account email (masked), when the key was last checked, and how to connect or upgrade (7-day free trial at katman.pro/app, with the exact MCP config snippet per editor). Checks the key with katman.pro at most once a day; refresh: true re-checks now (e.g. right after upgrading). Never shows the key.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
refresh: z.boolean().optional().describe("Re-check the key with katman.pro now instead of using today's cached answer (e.g. right after upgrading)."),
|
|
15
|
+
},
|
|
16
|
+
annotations: { title: "Katman account", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
17
|
+
}, async ({ refresh }) => {
|
|
18
|
+
try {
|
|
19
|
+
const out = await runAccount(licenseOf(ctx), { refresh });
|
|
20
|
+
return ok(out.text, out.structured);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return fail(`katman_account failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
server.registerTool("katman_sync", {
|
|
27
|
+
title: "Sync progress to the katman.pro dashboard",
|
|
28
|
+
description: "Sends this project's progress to the user's katman.pro dashboard: name and domain, gate pass counts from the latest audit, visibility rates by assistant and language from the latest run, plan progress, the top 10 issue titles, dates and the Katman version. Never code, file contents, AI answers, questions or prompts. dry_run (default true) shows the exact JSON and sends nothing; dry_run: false sends it and returns the dashboard link." +
|
|
29
|
+
PRO_NOTE,
|
|
30
|
+
inputSchema: {
|
|
31
|
+
project_root: z.string().optional().describe("Absolute path of the project (where .katman/ lives)."),
|
|
32
|
+
dry_run: z.boolean().optional().describe("Default true: show the exact payload, send nothing. false = send it to katman.pro."),
|
|
33
|
+
},
|
|
34
|
+
annotations: { title: "Sync to katman.pro", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
35
|
+
}, withPro(ctx, "sync", async ({ project_root, dry_run }) => {
|
|
36
|
+
try {
|
|
37
|
+
const root = await resolveRoot(ctx, project_root);
|
|
38
|
+
const out = await runSync(root, licenseOf(ctx), { dry_run }, { version: ctx.version });
|
|
39
|
+
if (!out.isError)
|
|
40
|
+
return ok(out.text, out.structured);
|
|
41
|
+
const warn = rootWarning(root);
|
|
42
|
+
return fail(warn ? `${warn}\n\n${out.text}` : out.text);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return fail(`katman_sync failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { logEvent } from "../../state.js";
|
|
2
|
+
import { domainOf, sortFindings, truncate } from "../../util.js";
|
|
3
|
+
import { katmanMtime, loadConfigSafe, loadLatestAudit, loadResearchSafe, loadRuns, loadScan, runDate } from "../method/lib/state.js";
|
|
4
|
+
import { loadPlanState, openTasks } from "../method/core/plan.js";
|
|
5
|
+
import { BILLING_URL, KEYS_URL } from "../../meta.js";
|
|
6
|
+
const REMEASURE_DAYS = 14;
|
|
7
|
+
const num = (x) => (typeof x === "number" && Number.isFinite(x) ? x : 0);
|
|
8
|
+
const rate = (x) => Math.round(Math.min(1, Math.max(0, num(x))) * 1000) / 1000;
|
|
9
|
+
const iso = (s) => {
|
|
10
|
+
const t = Date.parse(s ?? "");
|
|
11
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
|
|
12
|
+
};
|
|
13
|
+
const filled = (s) => !!s && !!s.trim() && !/\[(FILL|DOLDUR)\]/i.test(s);
|
|
14
|
+
function counts(rec) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [k, v] of Object.entries(rec ?? {}).slice(0, 20)) {
|
|
17
|
+
if (!v || typeof v !== "object")
|
|
18
|
+
continue;
|
|
19
|
+
const c = v;
|
|
20
|
+
out[truncate(String(k), 60)] = { answered: num(c.answered), mentioned: num(c.mentioned), recommendedFirst: num(c.recommendedFirst), cited: num(c.cited) };
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
const isManual = (r) => r.id.startsWith("manual") || (r.answers.length > 0 && r.answers.every((a) => a.source === "manual"));
|
|
25
|
+
/** Build the exact body for /api/web/mcp/sync (without the key). */
|
|
26
|
+
export async function buildSyncPayload(root, opts) {
|
|
27
|
+
const now = opts.now ?? new Date();
|
|
28
|
+
const [research, config, scan, loaded, runs, planState] = await Promise.all([
|
|
29
|
+
loadResearchSafe(root), loadConfigSafe(root), loadScan(root), loadLatestAudit(root), loadRuns(root), loadPlanState(root),
|
|
30
|
+
]);
|
|
31
|
+
let domain = research?.site.domain || "";
|
|
32
|
+
const url = config.siteUrl || research?.site.url;
|
|
33
|
+
if (!domain && url)
|
|
34
|
+
domain = domainOf(url);
|
|
35
|
+
if (!domain || !domain.includes(".")) {
|
|
36
|
+
return { problem: "No site domain yet: call katman_start with site_url (e.g. https://example.com) first, so the dashboard knows which site this is." };
|
|
37
|
+
}
|
|
38
|
+
const name = filled(research?.brand.name) ? truncate(research.brand.name.trim(), 80) : domain;
|
|
39
|
+
const audit = loaded?.audit;
|
|
40
|
+
const gate = (g) => ({ pass: num(g.pass), total: num(g.total) });
|
|
41
|
+
const measured = runs.filter((r) => num(r.summary.answered) > 0);
|
|
42
|
+
const api = measured.filter((r) => !isManual(r));
|
|
43
|
+
const last = api[api.length - 1] ?? measured[measured.length - 1];
|
|
44
|
+
const lastDate = last ? runDate(last) : undefined;
|
|
45
|
+
const plan = planState.plan;
|
|
46
|
+
const tasks = Array.isArray(plan?.tasks) ? plan.tasks : [];
|
|
47
|
+
const findings = new Map();
|
|
48
|
+
for (const f of scan?.findings ?? [])
|
|
49
|
+
findings.set(f.id, f);
|
|
50
|
+
for (const f of audit?.findings ?? [])
|
|
51
|
+
findings.set(f.id, f);
|
|
52
|
+
const topIssues = sortFindings([...findings.values()].filter((f) => f.severity !== "info"))
|
|
53
|
+
.slice(0, 10)
|
|
54
|
+
.map((f) => truncate(f.title.replace(/\s+/g, " ").trim(), 160));
|
|
55
|
+
const reportAt = await katmanMtime(root, "report.md");
|
|
56
|
+
const dates = {};
|
|
57
|
+
const setDate = (k, v) => {
|
|
58
|
+
const d = iso(v);
|
|
59
|
+
if (d)
|
|
60
|
+
dates[k] = d;
|
|
61
|
+
};
|
|
62
|
+
setDate("research", research?.updatedAt);
|
|
63
|
+
setDate("scan", scan?.ts);
|
|
64
|
+
setDate("audit", audit?.ts);
|
|
65
|
+
setDate("visibilityRun", lastDate?.toISOString());
|
|
66
|
+
setDate("plan", plan?.generatedAt);
|
|
67
|
+
setDate("report", reportAt?.toISOString());
|
|
68
|
+
if (lastDate)
|
|
69
|
+
setDate("nextMeasure", new Date(lastDate.getTime() + REMEASURE_DAYS * 86_400_000).toISOString());
|
|
70
|
+
const summary = {
|
|
71
|
+
katmanVersion: opts.version,
|
|
72
|
+
generatedAt: now.toISOString(),
|
|
73
|
+
gates: audit ? { read: gate(audit.gates.read), quote: gate(audit.gates.quote), fit: gate(audit.gates.fit) } : null,
|
|
74
|
+
visibility: last && lastDate
|
|
75
|
+
? {
|
|
76
|
+
runAt: lastDate.toISOString(),
|
|
77
|
+
answered: num(last.summary.answered),
|
|
78
|
+
mentionRate: rate(last.summary.mentionRate),
|
|
79
|
+
recommendedFirstRate: rate(last.summary.recommendedFirstRate),
|
|
80
|
+
citationRate: rate(last.summary.citationRate),
|
|
81
|
+
byFamily: counts(last.summary.byFamily),
|
|
82
|
+
byLanguage: counts(last.summary.byLanguage),
|
|
83
|
+
}
|
|
84
|
+
: null,
|
|
85
|
+
plan: plan && tasks.length ? { done: tasks.length - openTasks(plan).length, total: tasks.length } : null,
|
|
86
|
+
topIssues,
|
|
87
|
+
dates,
|
|
88
|
+
};
|
|
89
|
+
return { payload: { project: { name, domain }, summary } };
|
|
90
|
+
}
|
|
91
|
+
export async function runSync(root, provider, args, opts) {
|
|
92
|
+
const { payload, problem } = await buildSyncPayload(root, opts);
|
|
93
|
+
if (!payload)
|
|
94
|
+
return { text: problem ?? "Nothing to sync.", isError: true };
|
|
95
|
+
const endpoint = `${provider.baseUrl()}/api/web/mcp/sync`;
|
|
96
|
+
const shown = JSON.stringify({ key: "<KATMAN_KEY>", ...payload }, null, 2);
|
|
97
|
+
const s = payload.summary;
|
|
98
|
+
const contents = [
|
|
99
|
+
s.gates ? `gate pass counts from the audit of ${(s.dates.audit ?? "").slice(0, 10)}` : "no audit yet",
|
|
100
|
+
s.visibility ? `visibility rates from the measurement of ${s.visibility.runAt.slice(0, 10)} (${Object.keys(s.visibility.byFamily).length} assistant${Object.keys(s.visibility.byFamily).length === 1 ? "" : "s"})` : "no measurement yet",
|
|
101
|
+
s.plan ? `plan progress ${s.plan.done}/${s.plan.total}` : "no plan yet",
|
|
102
|
+
`${s.topIssues.length} issue title${s.topIssues.length === 1 ? "" : "s"}`,
|
|
103
|
+
].join(" · ");
|
|
104
|
+
if (args.dry_run !== false) {
|
|
105
|
+
return {
|
|
106
|
+
text: [
|
|
107
|
+
"# katman_sync: dry run (nothing was sent)",
|
|
108
|
+
`Katman would POST this JSON to ${endpoint}. \`key\` is your KATMAN_KEY (never shown or stored); everything else is exactly what would be sent:`,
|
|
109
|
+
"```json\n" + shown + "\n```",
|
|
110
|
+
`Contents: ${contents}. Only aggregates: never code, file contents, AI answers, questions or prompts.`,
|
|
111
|
+
`Show this to the user. To send it: \`katman_sync\` \`${JSON.stringify({ project_root: root, dry_run: false })}\`, then open ${provider.baseUrl()}/app/projects.`,
|
|
112
|
+
].join("\n\n"),
|
|
113
|
+
structured: { sent: false, dryRun: true, endpoint, payload },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const res = await provider.sync(payload);
|
|
117
|
+
if (res.ok) {
|
|
118
|
+
await logEvent(root, `katman_sync: sent the project summary for ${payload.project.domain} to ${provider.baseUrl()} (${contents})`);
|
|
119
|
+
return {
|
|
120
|
+
text: [
|
|
121
|
+
"# katman_sync: sent",
|
|
122
|
+
`Synced **${payload.project.name}** (${payload.project.domain}) to the Katman dashboard: ${res.dashboardUrl}`,
|
|
123
|
+
`Sent: ${contents}. Run katman_sync again after the next audit or measurement to add a point to the history.`,
|
|
124
|
+
].join("\n\n"),
|
|
125
|
+
structured: { sent: true, dashboardUrl: res.dashboardUrl, projectId: res.projectId ?? null, payload },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const why = res.error === "invalid_key" || res.error === "revoked" || res.error === "no_key"
|
|
129
|
+
? `katman.pro did not accept KATMAN_KEY (${res.error}). Create a key at ${KEYS_URL}, put it in the MCP config as KATMAN_KEY and restart the server.`
|
|
130
|
+
: res.error === "not_pro" || res.status === 402 || res.status === 403
|
|
131
|
+
? `katman.pro says this account has no active Katman Pro plan or trial (${res.error}). Upgrade at ${BILLING_URL}, then call katman_account with refresh: true.`
|
|
132
|
+
: res.status === 0
|
|
133
|
+
? `Couldn't reach katman.pro (${res.message ?? res.error ?? "network error"}). Nothing was sent; try again later.`
|
|
134
|
+
: res.status === 429
|
|
135
|
+
? "Too many syncs in the last hour (rate_limited). Nothing was saved; try again later."
|
|
136
|
+
: res.error === "too_many_projects"
|
|
137
|
+
? "This account already has the maximum number of projects on the dashboard (too_many_projects). Nothing was saved."
|
|
138
|
+
: res.error === "invalid_project"
|
|
139
|
+
? "katman.pro didn't accept the project's domain (invalid_project): check siteUrl in .katman/config.json (katman_start with site_url)."
|
|
140
|
+
: `katman.pro answered HTTP ${res.status}${res.error && !res.error.startsWith("HTTP") ? ` (${res.error})` : ""}${res.message ? `: ${res.message}` : ""}. Nothing was saved on the dashboard; try again later.`;
|
|
141
|
+
return { text: `katman_sync: not sent. ${why}`, isError: true, structured: { sent: false, error: res.error ?? null, status: res.status } };
|
|
142
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Katman Pro texts: the upsell a Pro tool returns without a license (MCP and CLI), the one-line plan status,
|
|
2
|
+
// the "needs Pro" notice for katman_start and the MCP config snippets with KATMAN_KEY.
|
|
3
|
+
// Only type imports from license.ts (license.ts imports this module).
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { APP_URL, BILLING_URL, KEYS_URL, PRO_PRICE_NOTE, TRIAL_DAYS } from "../../meta.js";
|
|
7
|
+
import { listFiles } from "../../state.js";
|
|
8
|
+
import { domainOf, sortFindings, truncate } from "../../util.js";
|
|
9
|
+
import { loadConfigSafe, loadLatestAudit, loadResearchSafe, loadScan } from "../method/lib/state.js";
|
|
10
|
+
const FREE_LIST = "katman_start, katman_research, katman_scan_code, katman_audit_site, katman_keywords";
|
|
11
|
+
const filled = (s) => !!s && !!s.trim() && !/\[(FILL|DOLDUR)\]/i.test(s);
|
|
12
|
+
export async function projectFacts(root) {
|
|
13
|
+
if (root === path.parse(root).root || root === os.homedir())
|
|
14
|
+
return undefined;
|
|
15
|
+
const [research, config, scan, loaded, runFiles] = await Promise.all([
|
|
16
|
+
loadResearchSafe(root), loadConfigSafe(root), loadScan(root), loadLatestAudit(root), listFiles(root, "runs"),
|
|
17
|
+
]);
|
|
18
|
+
const audit = loaded?.audit;
|
|
19
|
+
if (!research && !scan && !audit && !config.siteUrl)
|
|
20
|
+
return undefined;
|
|
21
|
+
let domain = research?.site.domain || undefined;
|
|
22
|
+
if (!domain && (config.siteUrl || research?.site.url))
|
|
23
|
+
domain = domainOf(config.siteUrl || research.site.url);
|
|
24
|
+
const findings = new Map();
|
|
25
|
+
for (const f of scan?.findings ?? [])
|
|
26
|
+
findings.set(f.id, f);
|
|
27
|
+
for (const f of audit?.findings ?? [])
|
|
28
|
+
findings.set(f.id, f);
|
|
29
|
+
const issues = sortFindings([...findings.values()].filter((f) => f.severity !== "info"));
|
|
30
|
+
return {
|
|
31
|
+
...(filled(research?.brand.name) ? { brand: research.brand.name } : {}),
|
|
32
|
+
...(domain ? { domain } : {}),
|
|
33
|
+
...(scan && scan.framework.name !== "unknown" ? { framework: scan.framework.name } : {}),
|
|
34
|
+
situations: research?.situations.length ?? 0,
|
|
35
|
+
questions: research?.questions.length ?? 0,
|
|
36
|
+
...(scan ? { scan: { findings: scan.findings.filter((f) => f.severity !== "info").length, critical: scan.findings.filter((f) => f.severity === "critical").length } } : {}),
|
|
37
|
+
...(audit
|
|
38
|
+
? { audit: { date: audit.ts.slice(0, 10), gates: { read: pick(audit.gates.read), quote: pick(audit.gates.quote), fit: pick(audit.gates.fit) } } }
|
|
39
|
+
: {}),
|
|
40
|
+
topIssues: issues.slice(0, 3).map((f) => truncate(f.title, 110)),
|
|
41
|
+
runs: runFiles.filter((n) => n.endsWith(".json")).length,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const pick = (g) => ({ pass: g.pass, total: g.total });
|
|
45
|
+
function gatesText(a) {
|
|
46
|
+
return `Read ${a.gates.read.pass}/${a.gates.read.total} · Quote ${a.gates.quote.pass}/${a.gates.quote.total} · Fit ${a.gates.fit.pass}/${a.gates.fit.total} pages pass`;
|
|
47
|
+
}
|
|
48
|
+
function foundText(f) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
if (f?.scan)
|
|
51
|
+
parts.push(`the code scan (${f.scan.findings} issue${f.scan.findings === 1 ? "" : "s"}${f.scan.critical ? `, ${f.scan.critical} critical` : ""})`);
|
|
52
|
+
if (f?.audit)
|
|
53
|
+
parts.push(`the live audit (${gatesText(f.audit)})`);
|
|
54
|
+
return parts.length ? ` in ${parts.join(" and ")}` : "";
|
|
55
|
+
}
|
|
56
|
+
/** One sentence: what this Pro tool would do for this project. */
|
|
57
|
+
export function pitch(tool, args, f) {
|
|
58
|
+
const brand = f?.brand ?? f?.domain ?? "this site";
|
|
59
|
+
const str = (k) => (typeof args[k] === "string" && args[k] ? String(args[k]) : undefined);
|
|
60
|
+
switch (tool) {
|
|
61
|
+
case "katman_plan":
|
|
62
|
+
return `turn what Katman found${foundText(f)} into ${brand}'s page plan (which situation, comparison, pricing and FAQ pages are missing) and a 30-day task list, each task with a prompt code and an acceptance check.`;
|
|
63
|
+
case "katman_prompt":
|
|
64
|
+
return `give your AI prompt ${str("id") ?? "P0–P14"} filled with ${brand}'s research (one sentence, not-X note, competitors${f?.situations ? `, ${f.situations} customer situations` : ""}), with the acceptance criterion and the exact Katman check that proves the task is done.`;
|
|
65
|
+
case "katman_page_brief":
|
|
66
|
+
return `write the brief for ${brand}'s ${str("type") ?? "next"} page: URL, title, first sentence, proof, not-for, alternatives, FAQ, the JSON-LD to include and where the file goes${f?.framework ? ` in this ${f.framework} project` : ""}.`;
|
|
67
|
+
case "katman_generate":
|
|
68
|
+
return `generate ${str("artifact") ?? "robots.txt, llms.txt, JSON-LD and the AI-channel analytics snippet"} for ${brand} from the research (one source of truth), ready to write into the project.`;
|
|
69
|
+
case "katman_lint_content":
|
|
70
|
+
return "check the page against the content rules before it ships: an answer in the first 1–3 sentences, the same one sentence as everywhere else, no hype or unsourced numbers, an FAQ, a not-for section and internal links.";
|
|
71
|
+
case "katman_visibility_run":
|
|
72
|
+
return `ask ${f?.questions ? `your ${f.questions} questions` : "your question set"} to ChatGPT, Claude, Gemini, Perplexity and Grok with live web search (on your own OpenRouter key, after a cost estimate) and record whether ${brand} is named, recommended first and cited, and who is recommended instead.`;
|
|
73
|
+
case "katman_manual_check":
|
|
74
|
+
return `turn answers pasted from the ChatGPT, Claude, Gemini or Perplexity apps into a measurement: whether ${brand} is named, in which position, who is recommended instead and which pages are cited.`;
|
|
75
|
+
case "katman_indexnow":
|
|
76
|
+
return `tell Bing (and through it Copilot and ChatGPT search), Yandex and the other IndexNow engines about only the new or changed pages of ${f?.domain ?? "your site"}, with the Search Console, Bing and Brave checklist.`;
|
|
77
|
+
case "katman_report":
|
|
78
|
+
return `write a shareable report for ${brand}: gate pass rates, AI-visibility changes, the competitors assistants name instead, the sites they cite, plan progress and the next 7 days.`;
|
|
79
|
+
case "katman_setup_monitoring":
|
|
80
|
+
return `set up a GitHub Actions workflow that re-measures ${brand}'s AI visibility on a schedule with your own OpenRouter key and opens an issue when mentions drop.`;
|
|
81
|
+
case "katman_sync":
|
|
82
|
+
return `send ${brand}'s progress (gate pass counts, visibility rates, plan progress, top issue titles; never code or content) to your katman.pro dashboard.`;
|
|
83
|
+
default:
|
|
84
|
+
return "run the Katman Pro step of the method for this project.";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function freeDoneText(f) {
|
|
88
|
+
if (f?.scan || f?.audit) {
|
|
89
|
+
const top = f.topIssues.length ? ` Top issues: ${f.topIssues.map((t) => `“${t}”`).join("; ")}.` : "";
|
|
90
|
+
return `**Already done for free:** the diagnosis${foundText(f)}.${top} The free tools (${FREE_LIST}) keep working without a key.`;
|
|
91
|
+
}
|
|
92
|
+
return `**Free, no key needed:** the diagnosis. katman_scan_code (your code) and katman_audit_site (your live site, as AI crawlers see it) find what keeps assistants from reading, quoting and recommending the site; katman_research and katman_keywords build the research.`;
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
95
|
+
// Status texts
|
|
96
|
+
const day = (iso) => (iso ? iso.slice(0, 10) : "?");
|
|
97
|
+
const when = (iso) => (iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : "–");
|
|
98
|
+
export function trialDaysLeft(s, now = new Date()) {
|
|
99
|
+
if (s.plan !== "trial" || !s.trialEndsAt)
|
|
100
|
+
return undefined;
|
|
101
|
+
return Math.max(0, Math.ceil((Date.parse(s.trialEndsAt) - now.getTime()) / 86_400_000));
|
|
102
|
+
}
|
|
103
|
+
function offlineText(s) {
|
|
104
|
+
return s.source === "offline-grace" ? ` · offline: using the check from ${day(s.checkedAt)}, works until ${day(s.graceUntil)}` : "";
|
|
105
|
+
}
|
|
106
|
+
/** Why a Pro tool didn't run (one or two sentences). */
|
|
107
|
+
export function reasonText(s, feature) {
|
|
108
|
+
switch (s.state) {
|
|
109
|
+
case "no_key":
|
|
110
|
+
return s.hint ?? "No KATMAN_KEY is set in this MCP server's configuration.";
|
|
111
|
+
case "invalid":
|
|
112
|
+
return s.source === "local" ? s.hint ?? "KATMAN_KEY doesn't look like a Katman key." : `katman.pro doesn't recognise this KATMAN_KEY (${s.keyHint ?? "km_…"}).`;
|
|
113
|
+
case "revoked":
|
|
114
|
+
return `This KATMAN_KEY (${s.keyHint ?? "km_…"}) was revoked on katman.pro.`;
|
|
115
|
+
case "unreachable":
|
|
116
|
+
return `Katman couldn't reach katman.pro to check KATMAN_KEY (${s.error ?? "network error"})${s.checkedAt ? `, and the last successful check (${day(s.checkedAt)}) is more than 7 days old` : ""}.`;
|
|
117
|
+
case "valid":
|
|
118
|
+
if (s.plan === "free")
|
|
119
|
+
return s.trialEnded ? `The ${TRIAL_DAYS}-day trial on this account ended on ${day(s.trialEndsAt)}.` : "This account is on the free plan (no active trial or Katman Pro subscription).";
|
|
120
|
+
return `Your ${s.plan} plan doesn't include "${feature ?? "this feature"}" according to katman.pro.`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** One line for katman_start / katman_account. */
|
|
124
|
+
export function statusLine(s, now = new Date()) {
|
|
125
|
+
switch (s.state) {
|
|
126
|
+
case "no_key":
|
|
127
|
+
return s.hint ? `**Katman plan:** free · ${s.hint}` : `**Katman plan:** free (no KATMAN_KEY) · Pro tools: ${TRIAL_DAYS}-day free trial at ${APP_URL}`;
|
|
128
|
+
case "invalid":
|
|
129
|
+
return `**Katman plan:** free · KATMAN_KEY ${s.source === "local" ? "doesn't look like a Katman key" : "isn't recognised by katman.pro"} · get a key at ${KEYS_URL}`;
|
|
130
|
+
case "revoked":
|
|
131
|
+
return `**Katman plan:** free · this KATMAN_KEY was revoked · create a new one at ${KEYS_URL}`;
|
|
132
|
+
case "unreachable":
|
|
133
|
+
return `**Katman plan:** not verified · couldn't reach katman.pro to check KATMAN_KEY (${s.error ?? "network error"}) · Pro tools are off until a check succeeds`;
|
|
134
|
+
case "valid": {
|
|
135
|
+
const who = s.emailMasked ? ` · ${s.emailMasked}` : "";
|
|
136
|
+
if (s.plan === "pro")
|
|
137
|
+
return `**Katman plan:** Pro · all Pro tools on${who}${offlineText(s)}`;
|
|
138
|
+
if (s.plan === "trial") {
|
|
139
|
+
const left = trialDaysLeft(s, now);
|
|
140
|
+
return `**Katman plan:** trial · ${left === undefined ? "" : `${left} day${left === 1 ? "" : "s"} left (until ${day(s.trialEndsAt)}) · `}all Pro tools on${who}${offlineText(s)}`;
|
|
141
|
+
}
|
|
142
|
+
return `**Katman plan:** free${s.trialEnded ? ` (trial ended ${day(s.trialEndsAt)})` : ""} · Pro tools are off: upgrade at ${BILLING_URL}${who}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** katman_start: the next step is a Pro tool and the license doesn't cover it. */
|
|
147
|
+
export function proNotice(tool, s) {
|
|
148
|
+
let why;
|
|
149
|
+
let action;
|
|
150
|
+
switch (s.state) {
|
|
151
|
+
case "no_key":
|
|
152
|
+
why = "this Katman server has no KATMAN_KEY";
|
|
153
|
+
action = `Start the ${TRIAL_DAYS}-day free trial (no card): ${APP_URL} → create a key → add it to the MCP config as KATMAN_KEY (katman_account shows the exact snippet for each editor)`;
|
|
154
|
+
break;
|
|
155
|
+
case "invalid":
|
|
156
|
+
case "revoked":
|
|
157
|
+
why = `KATMAN_KEY was ${s.state === "revoked" ? "revoked" : "not accepted"}`;
|
|
158
|
+
action = `Create a new key at ${KEYS_URL} and replace KATMAN_KEY in the MCP config`;
|
|
159
|
+
break;
|
|
160
|
+
case "unreachable":
|
|
161
|
+
why = "katman.pro couldn't be reached to check KATMAN_KEY";
|
|
162
|
+
action = "Check the internet connection, then call katman_account with refresh: true";
|
|
163
|
+
break;
|
|
164
|
+
case "valid":
|
|
165
|
+
why = s.plan === "free" ? (s.trialEnded ? "the trial on this account has ended" : "this account is on the free plan") : "this plan doesn't include it";
|
|
166
|
+
action = `Upgrade at ${BILLING_URL} (${PRO_PRICE_NOTE}), then call katman_account with refresh: true`;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
return `> **Needs Katman Pro:** \`${tool}\` is a Katman Pro tool and ${why}. ${action}. Tell the user; the free diagnosis (katman_research, katman_scan_code, katman_audit_site, katman_keywords) keeps working.`;
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
172
|
+
// Snippets
|
|
173
|
+
export function installSnippets() {
|
|
174
|
+
return [
|
|
175
|
+
"**Claude Code**",
|
|
176
|
+
"```bash",
|
|
177
|
+
"claude mcp remove katman --scope user # only if Katman is already added",
|
|
178
|
+
"claude mcp add katman --scope user -e KATMAN_KEY=km_live_… -e OPENROUTER_API_KEY=sk-or-… -- npx -y katman-mcp",
|
|
179
|
+
"```",
|
|
180
|
+
"OPENROUTER_API_KEY is optional (only for API visibility runs). With the Katman Claude Code plugin, set its `katman_key` option instead.",
|
|
181
|
+
"",
|
|
182
|
+
"**Cursor**: in `~/.cursor/mcp.json` (your user file, not a committed project file); keep any other env entries you have:",
|
|
183
|
+
"```json",
|
|
184
|
+
'{ "mcpServers": { "katman": { "command": "npx", "args": ["-y", "katman-mcp"], "env": { "KATMAN_KEY": "km_live_…" } } } }',
|
|
185
|
+
"```",
|
|
186
|
+
"",
|
|
187
|
+
"**VS Code**: in `.vscode/mcp.json`; VS Code asks for the key once and keeps it in its secret storage:",
|
|
188
|
+
"```json",
|
|
189
|
+
"{",
|
|
190
|
+
' "inputs": [{ "type": "promptString", "id": "katman_key", "description": "Katman key (katman.pro/app)", "password": true }],',
|
|
191
|
+
' "servers": { "katman": { "type": "stdio", "command": "npx", "args": ["-y", "katman-mcp"], "env": { "KATMAN_KEY": "${input:katman_key}" } } }',
|
|
192
|
+
"}",
|
|
193
|
+
"```",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
const RESTART = "Restart the Katman MCP server (or reload the editor window) so it reads the key, then call katman_account to confirm.";
|
|
197
|
+
/** What to do next, by license state. */
|
|
198
|
+
export function howToText(s, rerun) {
|
|
199
|
+
const again = rerun ? ` and run ${rerun} again` : "";
|
|
200
|
+
switch (s.state) {
|
|
201
|
+
case "no_key":
|
|
202
|
+
return [
|
|
203
|
+
`### Start the ${TRIAL_DAYS}-day free trial (no card)`,
|
|
204
|
+
`1. Open ${APP_URL}, sign in with your email, then **MCP → Create key** and copy the key (km_live_…; it's shown once).`,
|
|
205
|
+
"2. Add it to the MCP config as `KATMAN_KEY`:",
|
|
206
|
+
"",
|
|
207
|
+
installSnippets(),
|
|
208
|
+
"",
|
|
209
|
+
`3. ${RESTART.replace(/\.$/, "")}${again}.`,
|
|
210
|
+
"",
|
|
211
|
+
`After the trial: Katman Pro, ${PRO_PRICE_NOTE}.`,
|
|
212
|
+
].join("\n");
|
|
213
|
+
case "invalid":
|
|
214
|
+
case "revoked":
|
|
215
|
+
return [
|
|
216
|
+
"### Use a valid key",
|
|
217
|
+
`1. Open ${KEYS_URL}. ${s.state === "revoked" ? "Create a new key" : "Copy the key again (the whole km_live_… string, no quotes or spaces), or create a new one if you no longer have it"}.`,
|
|
218
|
+
"2. Replace `KATMAN_KEY` in the MCP config:",
|
|
219
|
+
"",
|
|
220
|
+
installSnippets(),
|
|
221
|
+
"",
|
|
222
|
+
`3. ${RESTART.replace(/\.$/, "")}${again}.`,
|
|
223
|
+
].join("\n");
|
|
224
|
+
case "unreachable":
|
|
225
|
+
return [
|
|
226
|
+
"### Check the connection",
|
|
227
|
+
"Pro tools keep working offline for 7 days after a successful check with katman.pro. Make sure katman.pro is reachable from this machine (internet connection, proxy or firewall" +
|
|
228
|
+
`${/KATMAN_API_URL/.test(s.error ?? "") ? ", KATMAN_API_URL" : ""}), then call katman_account with refresh: true${again}.`,
|
|
229
|
+
].join("\n");
|
|
230
|
+
case "valid":
|
|
231
|
+
if (s.plan === "free") {
|
|
232
|
+
return [
|
|
233
|
+
"### Upgrade to Katman Pro",
|
|
234
|
+
`Katman Pro is ${PRO_PRICE_NOTE}: ${BILLING_URL}. The key stays the same.`,
|
|
235
|
+
`Already upgraded? Call katman_account with refresh: true${again}.`,
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
return [
|
|
239
|
+
"### Update Katman",
|
|
240
|
+
`Run the latest version (npx -y katman-mcp@latest), call katman_account with refresh: true${again}. If it persists, write to hello@katman.pro.`,
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function renderUpsell(o) {
|
|
245
|
+
if (o.surface === "cli")
|
|
246
|
+
return renderCliUpsell(o);
|
|
247
|
+
const where = o.facts?.brand ?? o.facts?.domain;
|
|
248
|
+
return [
|
|
249
|
+
`## ${o.tool} is part of Katman Pro`,
|
|
250
|
+
`**What it would do${where ? ` for ${where}` : ""}:** ${pitch(o.tool, o.args ?? {}, o.facts).replace(/^./, (c) => c.toUpperCase())}`,
|
|
251
|
+
freeDoneText(o.facts),
|
|
252
|
+
`**Why it didn't run:** ${reasonText(o.status, o.feature)}`,
|
|
253
|
+
howToText(o.status, o.tool),
|
|
254
|
+
`Tell the user this in your own words. Don't try to work around it; the free tools (${FREE_LIST}) keep working.`,
|
|
255
|
+
].join("\n\n");
|
|
256
|
+
}
|
|
257
|
+
function renderCliUpsell(o) {
|
|
258
|
+
const cmd = o.command ?? o.tool;
|
|
259
|
+
const L = [
|
|
260
|
+
`${cmd} is part of Katman Pro: it would ${pitch(o.tool, o.args ?? {}, o.facts)}`,
|
|
261
|
+
`Why it didn't run: ${reasonText(o.status, o.feature)}`,
|
|
262
|
+
"",
|
|
263
|
+
];
|
|
264
|
+
if (o.status.state === "valid" && o.status.plan === "free") {
|
|
265
|
+
L.push(`Upgrade: ${BILLING_URL} (${PRO_PRICE_NOTE}); the key stays the same. Already upgraded? Run: katman-mcp account --refresh`);
|
|
266
|
+
}
|
|
267
|
+
else if (o.status.state === "unreachable") {
|
|
268
|
+
L.push("Pro commands keep working offline for 7 days after a successful check. Make sure katman.pro is reachable, then run: katman-mcp account --refresh");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
L.push(`1. ${o.status.state === "no_key" ? `Start the ${TRIAL_DAYS}-day free trial (no card): ${APP_URL} → MCP → Create key` : `Get a valid key: ${KEYS_URL}`}`, "2. Run with the key in the environment:", ` KATMAN_KEY=km_live_… npx -y ${cmd} --root .`, " GitHub Actions: add the repository secret KATMAN_KEY (gh secret set KATMAN_KEY) and pass it as env.");
|
|
272
|
+
if (o.status.state === "no_key")
|
|
273
|
+
L.push(`After the trial: Katman Pro, ${PRO_PRICE_NOTE}.`);
|
|
274
|
+
}
|
|
275
|
+
L.push("", "Free without a key: katman-mcp scan, audit, keywords, models, indexnow (dry run), account.");
|
|
276
|
+
return L.join("\n");
|
|
277
|
+
}
|
|
278
|
+
/** structuredContent of an upsell (no secrets). */
|
|
279
|
+
export function upsellStructured(tool, feature, s) {
|
|
280
|
+
return {
|
|
281
|
+
licensed: false,
|
|
282
|
+
tool,
|
|
283
|
+
feature,
|
|
284
|
+
license: { state: s.state, plan: s.plan, ...(s.trialEnded ? { trialEnded: true, trialEndsAt: s.trialEndsAt } : {}) },
|
|
285
|
+
trialUrl: APP_URL,
|
|
286
|
+
keysUrl: KEYS_URL,
|
|
287
|
+
upgradeUrl: BILLING_URL,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
export { when as fmtWhen, day as fmtDay };
|