@lpm-registry/mcp-server 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/auth.js CHANGED
@@ -1,36 +1,90 @@
1
+ import { createHash } from "node:crypto"
2
+ import { isIP } from "node:net"
1
3
  import {
2
4
  DEFAULT_REGISTRY_URL,
3
- KEYTAR_ACCOUNT_NAME,
5
+ KEYTAR_ACCOUNT_PREFIX,
4
6
  KEYTAR_SERVICE_NAME,
5
7
  } from "./constants.js"
6
8
 
9
+ const KEYCHAIN_READ_ERROR =
10
+ "Unable to read LPM credentials from the OS keychain. Set LPM_TOKEN or run `lpm login` and retry."
11
+
7
12
  /**
8
13
  * Resolve the LPM auth token.
9
14
  * Priority: LPM_TOKEN env var → OS keychain (via keytar) → null
10
15
  *
16
+ * @param {() => Promise<unknown>} [loadKeytar]
11
17
  * @returns {Promise<string | null>}
12
18
  */
13
- export async function getToken() {
14
- // 1. Environment variable (recommended for MCP configs)
19
+ export async function getToken(loadKeytar = () => import("keytar")) {
15
20
  if (process.env.LPM_TOKEN) {
16
21
  return process.env.LPM_TOKEN
17
22
  }
18
23
 
19
- // 2. OS keychain (reads from same store as `lpm login`)
24
+ let keytarModule
20
25
  try {
21
- const keytar = await import("keytar")
22
- const token = await keytar.default.getPassword(
23
- KEYTAR_SERVICE_NAME,
24
- KEYTAR_ACCOUNT_NAME,
25
- )
26
- if (token) return token
26
+ keytarModule = await loadKeytar()
27
+ } catch (error) {
28
+ if (
29
+ error &&
30
+ typeof error === "object" &&
31
+ "code" in error &&
32
+ (error.code === "ERR_MODULE_NOT_FOUND" ||
33
+ error.code === "MODULE_NOT_FOUND")
34
+ ) {
35
+ return null
36
+ }
37
+ throw new Error(KEYCHAIN_READ_ERROR)
38
+ }
39
+
40
+ try {
41
+ const keytar = keytarModule.default ?? keytarModule
42
+ for (const account of keychainAccountsToTry(getBaseUrl())) {
43
+ const token = await keytar.getPassword(KEYTAR_SERVICE_NAME, account)
44
+ if (token) return token
45
+ }
27
46
  } catch {
28
- // keytar not available — this is expected in many environments
47
+ throw new Error(KEYCHAIN_READ_ERROR)
29
48
  }
30
49
 
31
50
  return null
32
51
  }
33
52
 
53
+ /**
54
+ * The accounts that may hold the login for `registryUrl`, whichever way it
55
+ * ends: the one LPM CLI uses now, then the one CLI releases before 0.86 used
56
+ * for a login made with the registry spelled with a trailing slash.
57
+ *
58
+ * @param {string} registryUrl
59
+ * @returns {string[]}
60
+ */
61
+ function keychainAccountsToTry(registryUrl) {
62
+ const canonical = registryUrl.replace(/\/+$/, "")
63
+ return [accountForKey(canonical), accountForKey(`${canonical}/`)]
64
+ }
65
+
66
+ /**
67
+ * Apply the LPM CLI's default registry transport contract: HTTPS, or HTTP
68
+ * only for loopback. The MCP server has no `--insecure` remote-HTTP escape hatch.
69
+ *
70
+ * @param {string | undefined} raw
71
+ * @returns {string}
72
+ */
73
+ export function resolveRegistryUrl(raw = process.env.LPM_REGISTRY_URL) {
74
+ if (!raw) return DEFAULT_REGISTRY_URL
75
+
76
+ let parsed
77
+ try {
78
+ parsed = new URL(raw)
79
+ } catch {
80
+ return DEFAULT_REGISTRY_URL
81
+ }
82
+
83
+ if (parsed.protocol === "https:") return raw
84
+ if (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)) return raw
85
+ return DEFAULT_REGISTRY_URL
86
+ }
87
+
34
88
  /**
35
89
  * Resolve the LPM registry base URL.
36
90
  * Priority: LPM_REGISTRY_URL env var → default
@@ -38,5 +92,43 @@ export async function getToken() {
38
92
  * @returns {string}
39
93
  */
40
94
  export function getBaseUrl() {
41
- return process.env.LPM_REGISTRY_URL || DEFAULT_REGISTRY_URL
95
+ return resolveRegistryUrl()
96
+ }
97
+
98
+ /**
99
+ * Derive the keychain account used by LPM CLI for one effective registry URL.
100
+ * LPM CLI keys logins by the registry without trailing slashes, so both
101
+ * spellings name one account.
102
+ *
103
+ * @param {string} [registryUrl]
104
+ * @returns {string}
105
+ */
106
+ export function getKeychainAccount(registryUrl = getBaseUrl()) {
107
+ return accountForKey(registryUrl.replace(/\/+$/, ""))
108
+ }
109
+
110
+ /** @param {string} key */
111
+ function accountForKey(key) {
112
+ const digest = createHash("sha256").update(key, "utf8").digest("hex")
113
+ return `${KEYTAR_ACCOUNT_PREFIX}:${digest.slice(0, 16)}`
114
+ }
115
+
116
+ function isLoopbackHost(host) {
117
+ const unbracketed =
118
+ host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host
119
+ if (unbracketed.toLowerCase() === "localhost") return true
120
+
121
+ const version = isIP(unbracketed)
122
+ if (version === 4) return unbracketed.split(".", 1)[0] === "127"
123
+ if (version !== 6) return false
124
+
125
+ const normalized = new URL(`http://[${unbracketed}]/`).hostname
126
+ .slice(1, -1)
127
+ .toLowerCase()
128
+ if (normalized === "::1") return true
129
+
130
+ const mapped = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
131
+ if (!mapped) return false
132
+ const high = Number.parseInt(mapped[1], 16)
133
+ return high >> 8 === 127
42
134
  }
package/lib/cli.js CHANGED
@@ -1,41 +1,83 @@
1
- import { execFile, execSync } from "node:child_process"
1
+ import { execFile } from "node:child_process"
2
+ import { accessSync, constants, realpathSync, statSync } from "node:fs"
3
+ import { createRequire } from "node:module"
4
+ import path from "node:path"
2
5
 
3
6
  const CLI_TIMEOUT_MS = 60_000
4
7
 
5
- /**
6
- * Resolve the path to the `lpm` CLI binary.
7
- *
8
- * Resolution order:
9
- * 1. LPM_CLI_PATH env var (explicit override)
10
- * 2. `which lpm` (global install)
11
- * 3. null (caller should handle fallback)
12
- *
13
- * @returns {string|null}
14
- */
15
- export function resolveCli() {
16
- // 1. Explicit override
8
+ /** Resolve an explicit CLI path or a global installation outside the caller's project. */
9
+ export function resolveCli(platform = process.platform) {
17
10
  const envPath = process.env.LPM_CLI_PATH
18
- if (envPath) return envPath
11
+ if (envPath) {
12
+ const syntax = platform === "win32" ? path.win32 : path.posix
13
+ const qualified =
14
+ syntax.isAbsolute(envPath) &&
15
+ (platform !== "win32" || syntax.parse(envPath).root.length > 1)
16
+ return qualified ? envPath : null
17
+ }
18
+
19
+ const workspace = realpathSync(process.cwd())
20
+ for (const directory of (process.env.PATH || "").split(path.delimiter)) {
21
+ if (!path.isAbsolute(directory)) continue
22
+ const executable = globalExecutable(
23
+ path.join(directory, platform === "win32" ? "lpm.exe" : "lpm"),
24
+ workspace,
25
+ platform,
26
+ )
27
+ if (executable) return executable
28
+ if (platform !== "win32") continue
29
+
30
+ // npm exposes a .cmd shim. Resolve its native dependency without a shell.
31
+ try {
32
+ const wrapper = path.join(
33
+ directory,
34
+ "node_modules",
35
+ "@lpm-registry",
36
+ "cli",
37
+ "package.json",
38
+ )
39
+ if (!globalExecutable(wrapper, workspace, platform)) continue
40
+ const require = createRequire(wrapper)
41
+ const nativePackage = require.resolve(
42
+ "@lpm-registry/cli-win32-x64/package.json",
43
+ )
44
+ const native = globalExecutable(
45
+ path.join(path.dirname(nativePackage), "lpm.exe"),
46
+ workspace,
47
+ platform,
48
+ )
49
+ if (native) return native
50
+ } catch {
51
+ // Continue to the next PATH directory if optional native dependencies are absent.
52
+ }
53
+ }
54
+ return null
55
+ }
19
56
 
20
- // 2. Global install
57
+ function globalExecutable(candidate, workspace, platform) {
21
58
  try {
22
- const result = execSync("which lpm", {
23
- encoding: "utf-8",
24
- timeout: 5_000,
25
- }).trim()
26
- if (result) return result
59
+ const resolved = realpathSync(candidate)
60
+ const relative = path.relative(workspace, resolved)
61
+ if (
62
+ relative === "" ||
63
+ (!relative.startsWith(`..${path.sep}`) &&
64
+ relative !== ".." &&
65
+ !path.isAbsolute(relative))
66
+ )
67
+ return null
68
+ if (!statSync(resolved).isFile()) return null
69
+ accessSync(resolved, platform === "win32" ? constants.F_OK : constants.X_OK)
70
+ return resolved
27
71
  } catch {
28
- // not found
72
+ return null
29
73
  }
30
-
31
- return null
32
74
  }
33
75
 
34
76
  /**
35
77
  * Run an LPM CLI command and return parsed JSON output.
36
78
  *
37
79
  * @param {string[]} args - CLI arguments (e.g., ['add', '@lpm.dev/owner.pkg', '--json'])
38
- * @param {{ timeout?: number }} options
80
+ * @param {{ timeout?: number, cwd?: string }} options
39
81
  * @returns {Promise<{ success: boolean, data: object|null, error: string|null }>}
40
82
  */
41
83
  export async function runCli(args, options = {}) {
@@ -58,6 +100,7 @@ export async function runCli(args, options = {}) {
58
100
  args,
59
101
  {
60
102
  timeout,
103
+ cwd: options.cwd,
61
104
  maxBuffer: 10 * 1024 * 1024,
62
105
  env: { ...process.env },
63
106
  },
@@ -74,13 +117,23 @@ export async function runCli(args, options = {}) {
74
117
  // Try to parse JSON from stdout
75
118
  try {
76
119
  const data = JSON.parse(stdout)
120
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
121
+ throw new Error("CLI returned an invalid result")
122
+ }
123
+ const success = !err && data.success !== false
124
+ const reason =
125
+ typeof data.error === "string" ? data.error : data.error?.message
77
126
  resolve({
78
- success: data.success !== false,
127
+ success,
79
128
  data,
80
- error:
81
- data.success === false
82
- ? data.errors?.[0] || "Command failed"
83
- : null,
129
+ error: success
130
+ ? null
131
+ : reason ||
132
+ data.errors?.[0] ||
133
+ (err?.killed
134
+ ? `CLI command timed out after ${timeout / 1000}s`
135
+ : err?.message) ||
136
+ "Command failed",
84
137
  })
85
138
  } catch {
86
139
  // CLI produced output but not valid JSON
package/lib/constants.js CHANGED
@@ -3,7 +3,7 @@
3
3
  export const DEFAULT_REGISTRY_URL = "https://lpm.dev"
4
4
 
5
5
  export const KEYTAR_SERVICE_NAME = "lpm-cli"
6
- export const KEYTAR_ACCOUNT_NAME = "auth-token"
6
+ export const KEYTAR_ACCOUNT_PREFIX = "auth-token"
7
7
 
8
8
  export const REQUEST_TIMEOUT_MS = 30_000
9
9
  export const MAX_RETRIES = 2
@@ -31,5 +31,5 @@ export const ERROR_MESSAGES = {
31
31
  timeout: "Request timed out. Try again later.",
32
32
  invalidName:
33
33
  "Invalid package name format. Expected: owner.package-name or @lpm.dev/owner.package-name",
34
- searchNoParams: 'At least one of "query" or "category" is required.',
34
+ searchNoParams: "A search query or structured filter is required.",
35
35
  }
package/lib/format.js CHANGED
@@ -65,3 +65,11 @@ export function parseName(input) {
65
65
 
66
66
  return { owner, name }
67
67
  }
68
+
69
+ /** Preserve CLI reports and mark failed process results as MCP tool errors. */
70
+ export function cliResponse(result) {
71
+ if (!result.data) return errorResponse(result.error || "Command failed")
72
+ const response = jsonResponse(result.data)
73
+ if (!result.success) response.isError = true
74
+ return response
75
+ }
package/lib/server.js CHANGED
@@ -1,13 +1,16 @@
1
+ import { createRequire } from "node:module"
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
2
3
  import { z } from "zod"
3
4
  import { getBaseUrl, getToken } from "./auth.js"
4
5
  import { MemoryCache } from "./cache.js"
5
6
  import { add } from "./tools/add.js"
6
7
  import { apiDocs } from "./tools/api-docs.js"
8
+ import { audit } from "./tools/audit.js"
7
9
  import { browseSource } from "./tools/browse-source.js"
8
- import { getInstallCommand } from "./tools/get-install-command.js"
10
+ import { docs } from "./tools/docs.js"
9
11
  import { install } from "./tools/install.js"
10
12
  import { llmContext } from "./tools/llm-context.js"
13
+ import { marketplaceInfo } from "./tools/marketplace-info.js"
11
14
  import { packageContext } from "./tools/package-context.js"
12
15
  import { packageInfo } from "./tools/package-info.js"
13
16
  import { packageSkills } from "./tools/package-skills.js"
@@ -18,6 +21,8 @@ import { search } from "./tools/search.js"
18
21
  import { searchOwners } from "./tools/search-owners.js"
19
22
  import { userInfo } from "./tools/user-info.js"
20
23
 
24
+ const { version } = createRequire(import.meta.url)("../package.json")
25
+
21
26
  /**
22
27
  * Create and configure the MCP server with all LPM tools.
23
28
  * @returns {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer}
@@ -25,7 +30,7 @@ import { userInfo } from "./tools/user-info.js"
25
30
  export function createServer() {
26
31
  const server = new McpServer({
27
32
  name: "lpm-registry",
28
- version: "0.1.0",
33
+ version,
29
34
  })
30
35
 
31
36
  const cache = new MemoryCache()
@@ -46,7 +51,7 @@ export function createServer() {
46
51
 
47
52
  server.tool(
48
53
  "lpm_quality_report",
49
- "Get the quality score and detailed check breakdown for an LPM package (27 checks across documentation, code, testing, health)",
54
+ "Get the quality score and detailed, ecosystem-specific check breakdown for a package on the LPM.dev Registry across documentation, code quality, testing, and health.",
50
55
  {
51
56
  name: z.string().describe("Package name in owner.package-name format"),
52
57
  },
@@ -89,7 +94,7 @@ export function createServer() {
89
94
 
90
95
  server.tool(
91
96
  "lpm_package_context",
92
- "Get complete context for an LPM package in a single call — combines condensed package metadata (name, version, description, install method, dependencies), structured API docs (functions, classes, types), and LLM usage guide (quickStart, patterns, gotchas). Use this as the primary tool when you need to understand and use a package.",
97
+ "Get complete context for an LPM package in a single call — combines condensed package metadata (name, version, description, install method, dependencies), structured API docs (functions, classes, types), and LLM usage guide (quickStart, patterns, gotchas). Use this BEFORE installing to evaluate and understand a package. If the package is already installed locally, prefer reading local files directly and use lpm_package_skills for usage patterns instead.",
93
98
  {
94
99
  name: z
95
100
  .string()
@@ -106,7 +111,7 @@ export function createServer() {
106
111
 
107
112
  server.tool(
108
113
  "lpm_package_skills",
109
- "Get author-written Agent Skills for an LPM package - usage patterns, anti-patterns, gotchas, and best practices. Skills are version-specific and automatically resolve from local package.json if no version is specified.",
114
+ "Get author-written Agent Skills for an LPM package — usage patterns, anti-patterns, gotchas, and best practices for code generation. Use this when BUILDING with an already-installed package. Skills are version-specific and automatically resolve from local package.json if no version is specified.",
110
115
  {
111
116
  name: z
112
117
  .string()
@@ -170,25 +175,33 @@ export function createServer() {
170
175
  )
171
176
 
172
177
  server.tool(
173
- "lpm_get_install_command",
174
- "Get the correct CLI command to install an LPM package. Returns either `lpm add` (extracts source files for customization — components, blocks, Swift packages) or `lpm install` (installs to node_modules as a dependency — libraries, utilities).",
178
+ "lpm_audit",
179
+ "Run a security audit on the project's LPM dependencies. Returns behavioral tags (eval, childProcess, shell, dynamicRequire), AI security findings, quality scores, and lifecycle scripts. Requires LPM CLI installed; registry access follows CLI authentication and policy.",
180
+ {
181
+ path: z
182
+ .string()
183
+ .optional()
184
+ .describe("Project directory to audit (defaults to current directory)"),
185
+ },
186
+ params => audit(params, context),
187
+ )
188
+
189
+ server.tool(
190
+ "lpm_marketplace_info",
191
+ "Get marketplace information for an LPM package — pricing, licensing model, seat management, and purchase status. Use this before recommending a paid package to check cost and access.",
175
192
  {
176
193
  name: z
177
194
  .string()
178
195
  .describe(
179
196
  "Package name in owner.package-name or @lpm.dev/owner.package-name format",
180
197
  ),
181
- version: z
182
- .string()
183
- .optional()
184
- .describe("Specific version to install (defaults to latest)"),
185
198
  },
186
- params => getInstallCommand(params, context),
199
+ params => marketplaceInfo(params, context),
187
200
  )
188
201
 
189
202
  server.tool(
190
203
  "lpm_add",
191
- "Add an LPM package to the project by extracting source files for customization. Use for UI components, blocks, templates, Swift packages, and MCP servers. Requires LPM CLI installed.",
204
+ "Add an LPM package to the project by extracting source files for customization. Use for UI components, blocks, templates, and MCP servers. Requires LPM CLI installed.",
192
205
  {
193
206
  name: z
194
207
  .string()
@@ -235,7 +248,7 @@ export function createServer() {
235
248
 
236
249
  server.tool(
237
250
  "lpm_install",
238
- "Install an LPM package as a dependency to node_modules (like npm install). Use for JS libraries, utilities, and SDKs. Requires LPM CLI installed.",
251
+ "Install an LPM package as a managed dependency. JS packages go to node_modules (like npm install), Swift packages edit Package.swift via SE-0292. Use for libraries, utilities, and SDKs. Requires LPM CLI installed.",
239
252
  {
240
253
  name: z
241
254
  .string()
@@ -252,9 +265,11 @@ export function createServer() {
252
265
 
253
266
  server.tool(
254
267
  "lpm_browse_source",
255
- `Browse source code of an LPM package you have access to. Use without \`path\` to see the file tree first, then request specific files or directories with \`path\`. Requires authentication. Pool packages require Pool subscription, marketplace packages require a license.
268
+ `LAST RESORT: Browse source code of an LPM package remotely. Only use this when you cannot install the package (e.g., evaluating before purchase, checking capabilities without access). If you can install the package, prefer lpm_add or lpm_install first, then read the local files directly — it is faster.
269
+
270
+ Requires authentication. Pool packages require Pool subscription, marketplace packages require a license.
256
271
 
257
- IMPORTANT: Prefer fetching directory paths (e.g., "src", "components") to get all files under that directory in a single request, rather than requesting individual files one by one. To get ALL source files at once, pass an empty string "" as path.`,
272
+ If you must use this tool: fetch directory paths (e.g., "src") to get multiple files in one request rather than individual files.`,
258
273
  {
259
274
  name: z
260
275
  .string()
@@ -296,7 +311,7 @@ IMPORTANT: Prefer fetching directory paths (e.g., "src", "components") to get al
296
311
  'Filter by package ecosystem (e.g., "swift" for iOS/macOS packages)',
297
312
  ),
298
313
  distribution: z
299
- .enum(["marketplace", "pool", "private"])
314
+ .enum(["free", "marketplace", "pool", "private"])
300
315
  .optional()
301
316
  .describe("Filter by distribution mode"),
302
317
  packageType: z
@@ -353,5 +368,25 @@ IMPORTANT: Prefer fetching directory paths (e.g., "src", "components") to get al
353
368
  params => search(params, context),
354
369
  )
355
370
 
371
+ server.tool(
372
+ "lpm_docs",
373
+ "Search or read LPM documentation. Use this when the user asks how to use LPM itself — setup, CLI commands, publishing, CI/CD, Swift registry, organizations, billing, etc. Without parameters: returns the docs index. With `page`: returns that specific page. With `query`: searches page titles and returns matching content.",
374
+ {
375
+ page: z
376
+ .string()
377
+ .optional()
378
+ .describe(
379
+ 'Documentation page slug (e.g., "cli/commands", "packages/swift-registry", "getting-started/installation")',
380
+ ),
381
+ query: z
382
+ .string()
383
+ .optional()
384
+ .describe(
385
+ 'Search query to find relevant docs (e.g., "npmrc", "swift", "CI deployment")',
386
+ ),
387
+ },
388
+ params => docs(params, context),
389
+ )
390
+
356
391
  return server
357
392
  }
package/lib/tools/add.js CHANGED
@@ -1,17 +1,22 @@
1
1
  import { runCli } from "../cli.js"
2
- import { errorResponse, parseName, textResponse } from "../format.js"
2
+ import { cliResponse, errorResponse, parseName } from "../format.js"
3
3
 
4
4
  /**
5
5
  * Add an LPM package to the user's project via the CLI.
6
6
  * Extracts source files into the project for customization.
7
7
  *
8
8
  * @param {{ name: string, version?: string, path?: string, alias?: string, target?: string, force?: boolean, installDeps?: boolean, config?: Record<string, string> }} params
9
- * @param {{ getToken: () => Promise<string|null> }} ctx
10
9
  */
11
- export async function add(
12
- { name, version, path, alias, target, force, installDeps, config },
13
- ctx,
14
- ) {
10
+ export async function add({
11
+ name,
12
+ version,
13
+ path,
14
+ alias,
15
+ target,
16
+ force,
17
+ installDeps,
18
+ config,
19
+ }) {
15
20
  let owner, pkgName
16
21
  try {
17
22
  const parsed = parseName(name)
@@ -21,13 +26,6 @@ export async function add(
21
26
  return errorResponse(err.message)
22
27
  }
23
28
 
24
- const token = await ctx.getToken()
25
- if (!token) {
26
- return errorResponse(
27
- "Authentication required. Set LPM_TOKEN environment variable or run `lpm login`.",
28
- )
29
- }
30
-
31
29
  // Build package reference with optional config params as URL query string
32
30
  let pkgRef = `@lpm.dev/${owner}.${pkgName}`
33
31
  if (version) pkgRef += `@${version}`
@@ -49,31 +47,5 @@ export async function add(
49
47
 
50
48
  const result = await runCli(args)
51
49
 
52
- if (!result.success) {
53
- return errorResponse(result.error || "Failed to add package.")
54
- }
55
-
56
- // Format the result for the AI
57
- const data = result.data
58
- const summary = {
59
- success: true,
60
- package: data.package,
61
- installPath: data.installPath,
62
- alias: data.alias || null,
63
- files: (data.files || []).map(f => ({
64
- dest: f.dest,
65
- action: f.action,
66
- })),
67
- dependencies: data.dependencies || { npm: [], lpm: [] },
68
- }
69
-
70
- if (data.config && Object.keys(data.config).length > 0) {
71
- summary.config = data.config
72
- }
73
-
74
- if (data.warnings?.length > 0) {
75
- summary.warnings = data.warnings
76
- }
77
-
78
- return textResponse(JSON.stringify(summary, null, 2))
50
+ return cliResponse(result)
79
51
  }
@@ -0,0 +1,19 @@
1
+ import { runCli } from "../cli.js"
2
+ import { cliResponse } from "../format.js"
3
+
4
+ /**
5
+ * Run a security audit on the project's LPM dependencies.
6
+ * Returns behavioral tags, AI security findings, quality scores, and lifecycle scripts.
7
+ *
8
+ * @param {{ path?: string }} params
9
+ */
10
+ export async function audit({ path }) {
11
+ const args = ["audit", "--json"]
12
+
13
+ const result = await runCli(args, {
14
+ timeout: 30_000,
15
+ ...(path && { cwd: path }),
16
+ })
17
+
18
+ return cliResponse(result)
19
+ }
@@ -0,0 +1,101 @@
1
+ import { CACHE_TTL } from "../constants.js"
2
+ import { errorResponse, textResponse } from "../format.js"
3
+
4
+ const DOCS_BASE_URL = "https://lpm.dev/api/docs"
5
+
6
+ /**
7
+ * Search or read LPM documentation.
8
+ *
9
+ * - Without `page`: returns the full docs index (page titles and slugs).
10
+ * - With `page`: returns the content of that specific documentation page.
11
+ * - With `query`: searches page titles for matches and returns their content.
12
+ *
13
+ * @param {{ page?: string, query?: string }} params
14
+ * @param {{ cache: import('../cache.js').MemoryCache }} ctx
15
+ */
16
+ export async function docs({ page, query }, ctx) {
17
+ // Mode 1: Fetch a specific page
18
+ if (page) {
19
+ const slug = page.replace(/^\//, "").replace(/\/$/, "")
20
+ const cacheKey = `docs:page:${slug}`
21
+
22
+ const cached = ctx.cache.get(cacheKey)
23
+ if (cached) return cached
24
+
25
+ const response = await fetchText(`${DOCS_BASE_URL}/${slug}`)
26
+ if (!response.ok) {
27
+ if (response.status === 404) {
28
+ return errorResponse(
29
+ `Documentation page "${slug}" not found. Use this tool without a page parameter to see all available pages.`,
30
+ )
31
+ }
32
+ return errorResponse(`Failed to fetch docs (${response.status})`)
33
+ }
34
+
35
+ const content = await response.text()
36
+ const result = textResponse(content)
37
+ ctx.cache.set(cacheKey, result, CACHE_TTL.LONG)
38
+ return result
39
+ }
40
+
41
+ // Mode 2: Search or list index
42
+ const cacheKey = "docs:index"
43
+ let indexText = ctx.cache.get(cacheKey)
44
+
45
+ if (!indexText) {
46
+ const response = await fetchText(DOCS_BASE_URL)
47
+ if (!response.ok) {
48
+ return errorResponse(`Failed to fetch docs index (${response.status})`)
49
+ }
50
+ indexText = await response.text()
51
+ ctx.cache.set(cacheKey, indexText, CACHE_TTL.LONG)
52
+ } else {
53
+ // Cache stores the textResponse wrapper, extract the text
54
+ indexText =
55
+ typeof indexText === "string"
56
+ ? indexText
57
+ : indexText?.content?.[0]?.text || indexText
58
+ }
59
+
60
+ // If query is provided, filter the index and fetch matching pages
61
+ if (query) {
62
+ const queryLower = query.toLowerCase()
63
+ const lines =
64
+ typeof indexText === "string"
65
+ ? indexText.split("\n")
66
+ : String(indexText).split("\n")
67
+ const matches = lines.filter(
68
+ line => line.includes(" — ") && line.toLowerCase().includes(queryLower),
69
+ )
70
+
71
+ if (matches.length === 0) {
72
+ return textResponse(
73
+ `No documentation pages found matching "${query}".\n\nAvailable pages:\n${indexText}`,
74
+ )
75
+ }
76
+
77
+ // Fetch content of matching pages (up to 3)
78
+ const results = []
79
+ for (const match of matches.slice(0, 3)) {
80
+ const slug = match.split(" — ")[0].trim()
81
+ const response = await fetchText(`${DOCS_BASE_URL}/${slug}`)
82
+ if (response.ok) {
83
+ const content = await response.text()
84
+ results.push(`--- ${slug} ---\n${content}`)
85
+ }
86
+ }
87
+
88
+ return textResponse(results.join("\n\n"))
89
+ }
90
+
91
+ // No query, no page — return the full index
92
+ return textResponse(
93
+ typeof indexText === "string" ? indexText : String(indexText),
94
+ )
95
+ }
96
+
97
+ async function fetchText(url) {
98
+ return fetch(url, {
99
+ headers: { Accept: "text/plain" },
100
+ })
101
+ }