@farming-labs/docs 0.1.68 → 0.1.69

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.
@@ -100,7 +100,7 @@ async function main() {
100
100
  printAgentCompactHelp();
101
101
  process.exit(1);
102
102
  } else if (parsedCommand.command === "doctor") {
103
- const { parseDoctorArgs, printDoctorHelp, runDoctor } = await import("../doctor-DrZM2dke.mjs");
103
+ const { parseDoctorArgs, printDoctorHelp, runDoctor } = await import("../doctor-B2uu7zsB.mjs");
104
104
  const doctorOptions = parseDoctorArgs(args.slice(1));
105
105
  if (doctorOptions.help) {
106
106
  printDoctorHelp();
@@ -9,6 +9,7 @@ import { t as detectFramework } from "./utils-DSMXVnEu.mjs";
9
9
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import pc from "picocolors";
12
+ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types";
12
13
 
13
14
  //#region src/cli/doctor.ts
14
15
  const NEXT_CONFIG_PATTERN = /^next\.config\.(?:[cm]?js|[cm]?ts)$/;
@@ -60,6 +61,19 @@ function parseDoctorArgs(argv) {
60
61
  parsed.configPath = value;
61
62
  continue;
62
63
  }
64
+ if (arg.startsWith("--url=")) {
65
+ const value = parseInlineFlag(arg).value;
66
+ if (!value) throw new Error("Missing value for --url.");
67
+ parsed.url = value;
68
+ continue;
69
+ }
70
+ if (arg === "--url") {
71
+ const value = argv[index + 1];
72
+ if (!value || value.startsWith("--")) throw new Error("Missing value for --url.");
73
+ parsed.url = value;
74
+ index += 1;
75
+ continue;
76
+ }
63
77
  if (arg === "--config") {
64
78
  const value = argv[index + 1];
65
79
  if (!value || value.startsWith("--")) throw new Error("Missing value for --config.");
@@ -89,6 +103,7 @@ ${pc.dim("Options:")}
89
103
  ${pc.cyan("--site")} Score reader-facing docs quality for the current docs app
90
104
  ${pc.cyan("--human")} Alias for ${pc.cyan("--site")}
91
105
  ${pc.cyan("--json")} Print the report as JSON for CI, scripts, and other agents
106
+ ${pc.cyan("--url <url>")} Probe hosted agent surfaces, e.g. ${pc.dim("https://docs.example.com")}
92
107
  ${pc.cyan("--config <path>")} Use a custom docs config path instead of ${pc.dim("docs.config.ts[x]")}
93
108
  ${pc.cyan("-h, --help")} Show this help message
94
109
  `);
@@ -466,6 +481,10 @@ function gradeForHumanScore(score) {
466
481
  if (score >= 60) return "Promising";
467
482
  return "Needs work";
468
483
  }
484
+ function percentageScore(score, maxScore) {
485
+ if (maxScore <= 0) return 0;
486
+ return Math.round(score / maxScore * 100);
487
+ }
469
488
  function formatStatus(status) {
470
489
  if (status === "pass") return pc.green("PASS");
471
490
  if (status === "warn") return pc.yellow("WARN");
@@ -611,6 +630,216 @@ function buildHumanCoverage(pages, navigationPages) {
611
630
  navigationPages
612
631
  };
613
632
  }
633
+ function normalizeDoctorBaseUrl(value) {
634
+ const url = new URL(value);
635
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http or https.");
636
+ url.hash = "";
637
+ url.search = "";
638
+ url.pathname = url.pathname.replace(/\/+$/, "");
639
+ return url.toString().replace(/\/+$/, "");
640
+ }
641
+ function joinDoctorUrl(baseUrl, route) {
642
+ const base = new URL(baseUrl);
643
+ const basePath = base.pathname.replace(/\/+$/, "");
644
+ const routePath = route.startsWith("/") ? route : `/${route}`;
645
+ return new URL(`${basePath}${routePath}`, base.origin).toString();
646
+ }
647
+ function toMarkdownRoute(pageUrl) {
648
+ if (!pageUrl) return void 0;
649
+ const normalized = pageUrl === "/" ? "/index" : pageUrl.replace(/\/+$/, "");
650
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
651
+ }
652
+ async function fetchWithTimeout(url, init = {}, timeoutMs = 8e3) {
653
+ const controller = new AbortController();
654
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
655
+ try {
656
+ return await fetch(url, {
657
+ ...init,
658
+ signal: controller.signal
659
+ });
660
+ } finally {
661
+ clearTimeout(timeout);
662
+ }
663
+ }
664
+ async function probeTextRoute(baseUrl, route) {
665
+ const url = joinDoctorUrl(baseUrl, route);
666
+ try {
667
+ const response = await fetchWithTimeout(url, { headers: { Accept: "text/plain, text/markdown, */*" } });
668
+ const body = await response.text().catch(() => "");
669
+ if (!response.ok) return {
670
+ ok: false,
671
+ status: response.status,
672
+ detail: `${route} returned HTTP ${response.status}.`
673
+ };
674
+ if (body.trim().length === 0) return {
675
+ ok: false,
676
+ status: response.status,
677
+ detail: `${route} returned an empty body.`
678
+ };
679
+ return {
680
+ ok: true,
681
+ status: response.status,
682
+ detail: `${route} returned HTTP ${response.status} with ${body.length} characters.`
683
+ };
684
+ } catch (error) {
685
+ return {
686
+ ok: false,
687
+ detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
688
+ };
689
+ }
690
+ }
691
+ async function probeJsonRoute(baseUrl, route) {
692
+ const url = joinDoctorUrl(baseUrl, route);
693
+ try {
694
+ const response = await fetchWithTimeout(url, { headers: { Accept: "application/json" } });
695
+ const text = await response.text().catch(() => "");
696
+ if (!response.ok) return {
697
+ ok: false,
698
+ status: response.status,
699
+ detail: `${route} returned HTTP ${response.status}.`
700
+ };
701
+ try {
702
+ const body = JSON.parse(text);
703
+ return {
704
+ ok: true,
705
+ status: response.status,
706
+ detail: `${route} returned valid JSON.`,
707
+ body
708
+ };
709
+ } catch {
710
+ return {
711
+ ok: false,
712
+ status: response.status,
713
+ detail: `${route} did not return valid JSON.`
714
+ };
715
+ }
716
+ } catch (error) {
717
+ return {
718
+ ok: false,
719
+ detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
720
+ };
721
+ }
722
+ }
723
+ async function parseMcpResponse(response) {
724
+ const contentType = response.headers.get("content-type") ?? "";
725
+ const body = await response.text();
726
+ if (contentType.includes("application/json")) return JSON.parse(body);
727
+ const data = body.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).filter(Boolean).at(-1);
728
+ if (!data) throw new Error(`Expected MCP JSON-RPC payload, got ${body.slice(0, 120) || "empty body"}.`);
729
+ return JSON.parse(data);
730
+ }
731
+ async function postMcpJson(baseUrl, route, body, sessionId) {
732
+ return fetchWithTimeout(joinDoctorUrl(baseUrl, route), {
733
+ method: "POST",
734
+ headers: {
735
+ "Content-Type": "application/json",
736
+ Accept: "application/json, text/event-stream",
737
+ "mcp-protocol-version": LATEST_PROTOCOL_VERSION,
738
+ ...sessionId ? { "mcp-session-id": sessionId } : {}
739
+ },
740
+ body: JSON.stringify(body)
741
+ });
742
+ }
743
+ async function probeMcpRoute(baseUrl, route) {
744
+ try {
745
+ const initializeResponse = await postMcpJson(baseUrl, route, {
746
+ jsonrpc: "2.0",
747
+ id: "doctor-initialize",
748
+ method: "initialize",
749
+ params: {
750
+ protocolVersion: LATEST_PROTOCOL_VERSION,
751
+ capabilities: {},
752
+ clientInfo: {
753
+ name: "@farming-labs/docs doctor",
754
+ version: "0.0.0"
755
+ }
756
+ }
757
+ });
758
+ const initializePayload = await parseMcpResponse(initializeResponse);
759
+ if (!initializeResponse.ok || initializePayload.error) return {
760
+ ok: false,
761
+ detail: `${route} initialize returned HTTP ${initializeResponse.status}: ${String(initializePayload.error?.message ?? "unknown MCP error")}.`
762
+ };
763
+ const sessionId = initializeResponse.headers.get("mcp-session-id");
764
+ if (!sessionId) return {
765
+ ok: false,
766
+ detail: `${route} initialize did not return mcp-session-id.`
767
+ };
768
+ await postMcpJson(baseUrl, route, {
769
+ jsonrpc: "2.0",
770
+ method: "notifications/initialized",
771
+ params: {}
772
+ }, sessionId).catch(() => void 0);
773
+ const toolsResponse = await postMcpJson(baseUrl, route, {
774
+ jsonrpc: "2.0",
775
+ id: "doctor-tools-list",
776
+ method: "tools/list",
777
+ params: {}
778
+ }, sessionId);
779
+ const toolsPayload = await parseMcpResponse(toolsResponse);
780
+ await fetchWithTimeout(joinDoctorUrl(baseUrl, route), {
781
+ method: "DELETE",
782
+ headers: {
783
+ "mcp-protocol-version": LATEST_PROTOCOL_VERSION,
784
+ "mcp-session-id": sessionId
785
+ }
786
+ }).catch(() => void 0);
787
+ if (!toolsResponse.ok || toolsPayload.error) return {
788
+ ok: false,
789
+ detail: `${route} tools/list returned HTTP ${toolsResponse.status}: ${String(toolsPayload.error?.message ?? "unknown MCP error")}.`
790
+ };
791
+ const tools = toolsPayload.result?.tools;
792
+ const toolNames = Array.isArray(tools) ? tools.map((tool) => tool.name).filter((name) => typeof name === "string") : [];
793
+ const missingTools = [
794
+ "list_pages",
795
+ "get_navigation",
796
+ "search_docs",
797
+ "read_page"
798
+ ].filter((tool) => !toolNames.includes(tool));
799
+ if (missingTools.length > 0) return {
800
+ ok: false,
801
+ detail: `${route} connected but is missing tools: ${missingTools.join(", ")}.`
802
+ };
803
+ return {
804
+ ok: true,
805
+ detail: `${route} initialized and exposed ${toolNames.length} MCP tool${toolNames.length === 1 ? "" : "s"}.`
806
+ };
807
+ } catch (error) {
808
+ return {
809
+ ok: false,
810
+ detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
811
+ };
812
+ }
813
+ }
814
+ async function buildHostedAgentChecks(url, pages) {
815
+ let baseUrl;
816
+ try {
817
+ baseUrl = normalizeDoctorBaseUrl(url);
818
+ } catch (error) {
819
+ return { checks: [makeCheck("hosted-url", "Hosted URL", "fail", 0, 5, `Could not parse --url "${url}": ${error instanceof Error ? error.message : String(error)}`, "Pass a full hosted URL such as https://docs.example.com.")] };
820
+ }
821
+ const checks = [];
822
+ const discovery = await probeJsonRoute(baseUrl, DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE);
823
+ checks.push(makeCheck("hosted-agent-discovery", "Hosted agent discovery", discovery.ok ? "pass" : "fail", discovery.ok ? 5 : 0, 5, `${baseUrl}: ${discovery.detail}`, discovery.ok ? void 0 : `Make sure ${DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE} is routed to the shared docs API on the deployed site.`));
824
+ const llms = await Promise.all([probeTextRoute(baseUrl, DEFAULT_LLMS_TXT_ROUTE), probeTextRoute(baseUrl, DEFAULT_LLMS_FULL_TXT_ROUTE)]);
825
+ const llmsPassed = llms.filter((result) => result.ok).length;
826
+ checks.push(makeCheck("hosted-llms", "Hosted llms.txt", llmsPassed === llms.length ? "pass" : llmsPassed > 0 ? "warn" : "fail", llmsPassed === llms.length ? 5 : llmsPassed > 0 ? 3 : 0, 5, llms.map((result) => result.detail).join(" "), llmsPassed === llms.length ? void 0 : "Verify deployed /llms.txt and /llms-full.txt routes return non-empty text."));
827
+ const skill = await Promise.all([probeTextRoute(baseUrl, DEFAULT_SKILL_MD_ROUTE), probeTextRoute(baseUrl, DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE)]);
828
+ const skillPassed = skill.filter((result) => result.ok).length;
829
+ checks.push(makeCheck("hosted-skill", "Hosted skill.md", skillPassed === skill.length ? "pass" : skillPassed > 0 ? "warn" : "fail", skillPassed === skill.length ? 5 : skillPassed > 0 ? 3 : 0, 5, skill.map((result) => result.detail).join(" "), skillPassed === skill.length ? void 0 : "Verify deployed /skill.md and /.well-known/skill.md routes return non-empty markdown."));
830
+ const markdownRoute = toMarkdownRoute(pages[0]?.url);
831
+ if (markdownRoute) {
832
+ const markdown = await probeTextRoute(baseUrl, markdownRoute);
833
+ checks.push(makeCheck("hosted-markdown", "Hosted markdown route", markdown.ok ? "pass" : "fail", markdown.ok ? 5 : 0, 5, markdown.detail, markdown.ok ? void 0 : `Verify deployed markdown routes are forwarded, starting with ${markdownRoute}.`));
834
+ } else checks.push(makeCheck("hosted-markdown", "Hosted markdown route", "warn", 0, 5, "No local docs page was available to choose a sample .md route.", "Add docs pages so the hosted doctor can probe a representative .md route."));
835
+ const mcp = await Promise.all([probeMcpRoute(baseUrl, DEFAULT_MCP_PUBLIC_ROUTE), probeMcpRoute(baseUrl, DEFAULT_MCP_WELL_KNOWN_ROUTE)]);
836
+ const mcpPassed = mcp.filter((result) => result.ok).length;
837
+ checks.push(makeCheck("hosted-mcp", "Hosted MCP handshake", mcpPassed === mcp.length ? "pass" : mcpPassed > 0 ? "warn" : "fail", mcpPassed === mcp.length ? 10 : mcpPassed > 0 ? 5 : 0, 10, mcp.map((result) => result.detail).join(" "), mcpPassed === mcp.length ? void 0 : `Verify deployed ${DEFAULT_MCP_PUBLIC_ROUTE} and ${DEFAULT_MCP_WELL_KNOWN_ROUTE} support Streamable HTTP initialize and tools/list.`));
838
+ return {
839
+ baseUrl,
840
+ checks
841
+ };
842
+ }
614
843
  async function loadDocsConfigModuleWithProjectEnv(rootDir, explicitPath) {
615
844
  const env = loadProjectEnv(rootDir);
616
845
  const injectedKeys = Object.entries(env).filter(([key]) => process.env[key] === void 0).map(([key, value]) => {
@@ -717,6 +946,8 @@ async function inspectAgentReadiness(options = {}) {
717
946
  const coverageResult = coverageScore(coverage.explicitCoverage);
718
947
  checks.push(makeCheck("coverage", "Explicit page optimization", coverageResult.status, coverageResult.score, 10, coverage.totalPages > 0 ? `${coverage.explicitPages}/${coverage.totalPages} pages define explicit machine-only context (${coverage.pagesWithAgentFiles} agent.md, ${coverage.pagesWithAgentBlocks} Agent blocks, ${coverage.explicitCoverage}% of pages).` : "No docs pages were available to score explicit page optimization.", coverage.explicitCoverage >= 50 ? void 0 : "Add agent.md files or <Agent> blocks to more pages, or run docs agent compact to create page-level machine docs."));
719
948
  checks.push(makeCheck("compact", "Agent compaction freshness", compactionResult.status, compactionResult.score, 5, `${compactionCoverage.freshGeneratedPages} fresh, ${compactionCoverage.staleGeneratedPages} stale, ${compactionCoverage.modifiedGeneratedPages} modified, ${compactionCoverage.unknownGeneratedPages} unknown, ${compactionCoverage.tokenBudgetMissingPages} token-budget missing, and ${compactionCoverage.otherMissingPages} other missing page${compactionCoverage.otherMissingPages === 1 ? "" : "s"} across compactable docs pages.` + (compactConfigured ? " agent.compact defaults are configured." : " No agent.compact defaults were found in docs config."), compactionResult.recommendation));
949
+ const hosted = options.url ? await buildHostedAgentChecks(options.url, pages) : void 0;
950
+ if (hosted) checks.push(...hosted.checks);
720
951
  const score = checks.reduce((total, check) => total + check.score, 0);
721
952
  const maxScore = checks.reduce((total, check) => total + check.maxScore, 0);
722
953
  return {
@@ -725,9 +956,10 @@ async function inspectAgentReadiness(options = {}) {
725
956
  configPath: path.relative(rootDir, configPath).replace(/\\/g, "/"),
726
957
  entry,
727
958
  contentDir,
959
+ url: hosted?.baseUrl,
728
960
  score,
729
961
  maxScore,
730
- grade: gradeForAgentScore(score),
962
+ grade: gradeForAgentScore(percentageScore(score, maxScore)),
731
963
  checks,
732
964
  coverage,
733
965
  recommendations: checks.map((check) => check.recommendation).filter((recommendation) => Boolean(recommendation)).slice(0, 3)
@@ -808,7 +1040,7 @@ async function inspectHumanReadiness(options = {}) {
808
1040
  contentDir,
809
1041
  score,
810
1042
  maxScore,
811
- grade: gradeForHumanScore(score),
1043
+ grade: gradeForHumanScore(percentageScore(score, maxScore)),
812
1044
  checks,
813
1045
  coverage,
814
1046
  recommendations: checks.map((check) => check.recommendation).filter((recommendation) => Boolean(recommendation)).slice(0, 3)
@@ -819,6 +1051,7 @@ function printAgentDoctorReport(report) {
819
1051
  console.log();
820
1052
  console.log(`${pc.bold("Score:")} ${pc.cyan(`${report.score}/${report.maxScore}`)} ${pc.dim(`(${report.grade})`)}`);
821
1053
  console.log(`${pc.bold("Framework:")} ${report.framework} ${pc.dim("•")} ${pc.bold("Entry:")} ${report.entry ?? "docs"} ${pc.dim("•")} ${pc.bold("Content:")} ${report.contentDir ?? "-"}`);
1054
+ if (report.url) console.log(`${pc.bold("Hosted URL:")} ${report.url}`);
822
1055
  console.log(`${pc.bold("Explicit agent-friendly pages:")} ${report.coverage.explicitPages}/${report.coverage.totalPages} pages ${pc.dim(`(${report.coverage.explicitCoverage}%)`)}`);
823
1056
  console.log(`${pc.bold("Generated agent.md freshness:")} ${report.coverage.compaction.freshGeneratedPages} fresh ${pc.dim("•")} ${report.coverage.compaction.staleGeneratedPages} stale ${pc.dim("•")} ${report.coverage.compaction.modifiedGeneratedPages} modified ${pc.dim("•")} ${report.coverage.compaction.tokenBudgetMissingPages} token-budget missing`);
824
1057
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/docs",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "description": "Modern, flexible MDX-based docs framework — core types, config, and CLI",
5
5
  "keywords": [
6
6
  "docs",