From edb797187565d344b972d0fa14907636580b8a0f Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 28 Jul 2026 02:57:20 -0500 Subject: [PATCH] feat(cli): context7 alias, add/submit, and WinGet packaging Expose a memorable context7 bin next to ctx7, add authenticated library submission for agents (add/submit + MCP add-library), and ship Windows portable/WinGet scaffolding for parity with Homebrew. Co-authored-by: Cursor --- .changeset/cli-context7-alias-add-winget.md | 6 + .github/workflows/cli-portable-release.yml | 107 ++++++++++ .gitignore | 3 + docs/clients/cli.mdx | 47 ++++- packages/cli/README.md | 20 +- packages/cli/package.json | 4 +- packages/cli/packaging/README.md | 27 +++ .../winget/Upstash.Context7.installer.yaml | 27 +++ .../winget/Upstash.Context7.locale.en-US.yaml | 29 +++ .../packaging/winget/Upstash.Context7.yaml | 9 + packages/cli/scripts/build-win-portable.mjs | 119 +++++++++++ .../cli/src/__tests__/add-library.test.ts | 99 +++++++++ packages/cli/src/commands/add.ts | 197 ++++++++++++++++++ packages/cli/src/index.ts | 42 +++- packages/cli/src/utils/add-library.ts | 136 ++++++++++++ packages/cli/src/utils/api.ts | 53 +++++ packages/mcp/src/index.ts | 85 +++++++- packages/mcp/src/lib/add-library.ts | 92 ++++++++ packages/mcp/src/lib/api.ts | 80 +++++++ 19 files changed, 1163 insertions(+), 19 deletions(-) create mode 100644 .changeset/cli-context7-alias-add-winget.md create mode 100644 .github/workflows/cli-portable-release.yml create mode 100644 packages/cli/packaging/README.md create mode 100644 packages/cli/packaging/winget/Upstash.Context7.installer.yaml create mode 100644 packages/cli/packaging/winget/Upstash.Context7.locale.en-US.yaml create mode 100644 packages/cli/packaging/winget/Upstash.Context7.yaml create mode 100644 packages/cli/scripts/build-win-portable.mjs create mode 100644 packages/cli/src/__tests__/add-library.test.ts create mode 100644 packages/cli/src/commands/add.ts create mode 100644 packages/cli/src/utils/add-library.ts create mode 100644 packages/mcp/src/lib/add-library.ts diff --git a/.changeset/cli-context7-alias-add-winget.md b/.changeset/cli-context7-alias-add-winget.md new file mode 100644 index 000000000..d4295fa2b --- /dev/null +++ b/.changeset/cli-context7-alias-add-winget.md @@ -0,0 +1,6 @@ +--- +"ctx7": minor +"@upstash/context7-mcp": minor +--- + +Add memorable `context7` CLI bin alias, `ctx7 add` / `submit` for library indexing, MCP `add-library` tool, and Windows portable/WinGet packaging scaffolding. diff --git a/.github/workflows/cli-portable-release.yml b/.github/workflows/cli-portable-release.yml new file mode 100644 index 000000000..d57138ed8 --- /dev/null +++ b/.github/workflows/cli-portable-release.yml @@ -0,0 +1,107 @@ +name: CLI portable release (Windows / winget) + +# Builds a portable Windows zip for the Context7 CLI and attaches it to a GitHub +# Release. Optionally submits/updates the WinGet package when WINGET_TOKEN is set. +# +# Trigger manually after a ctx7 npm publish, or on published GitHub Releases. +# Maintainers: set repository secret WINGET_TOKEN (PAT that can open PRs against +# a fork of microsoft/winget-pkgs) to enable auto-submit. + +on: + workflow_dispatch: + inputs: + version: + description: "CLI version to package (defaults to packages/cli/package.json)" + required: false + type: string + release: + types: [published] + +permissions: + contents: write + +jobs: + portable-windows: + name: Build Windows portable zip + runs-on: windows-latest + outputs: + version: ${{ steps.meta.outputs.version }} + asset_name: ${{ steps.meta.outputs.asset_name }} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve version + id: meta + shell: pwsh + run: | + $pkg = Get-Content packages/cli/package.json | ConvertFrom-Json + $version = if ("${{ inputs.version }}") { "${{ inputs.version }}" } else { $pkg.version } + $asset = "context7-$version-win-portable.zip" + "version=$version" >> $env:GITHUB_OUTPUT + "asset_name=$asset" >> $env:GITHUB_OUTPUT + Write-Host "Packaging CLI version $version" + + - name: Build portable zip + shell: pwsh + run: node packages/cli/scripts/build-win-portable.mjs + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: context7-win-portable + path: packages/cli/dist-portable/${{ steps.meta.outputs.asset_name }} + + - name: Attach to GitHub Release (if release event) + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.event.release.tag_name }}" ` + "packages/cli/dist-portable/${{ steps.meta.outputs.asset_name }}" ` + --clobber + + winget: + name: Submit WinGet package + needs: portable-windows + runs-on: ubuntu-latest + if: github.event_name == 'release' + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Skip when WINGET_TOKEN is unset + id: gate + env: + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + if [ -z "$WINGET_TOKEN" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "WINGET_TOKEN not configured; skipping WinGet submission." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Submit to winget-pkgs + if: steps.gate.outputs.skip == 'false' + uses: vedantmgoyal9/winget-releaser@v2 + with: + identifier: Upstash.Context7 + version: ${{ needs.portable-windows.outputs.version }} + release-tag: ${{ github.event.release.tag_name }} + installers-regex: 'context7-.*-win-portable\.zip$' + token: ${{ secrets.WINGET_TOKEN }} diff --git a/.gitignore b/.gitignore index cec4734d0..7ce3dc578 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,6 @@ prompt.txt reports reports-old src/test/questions* + +dist-portable + diff --git a/docs/clients/cli.mdx b/docs/clients/cli.mdx index 6124f651a..cec63be45 100644 --- a/docs/clients/cli.mdx +++ b/docs/clients/cli.mdx @@ -3,9 +3,10 @@ title: CLI description: The ctx7 CLI — fetch library documentation and configure Context7 MCP from your terminal --- -The `ctx7` CLI is the command-line interface for Context7. It does two things: +The `ctx7` CLI (also installed as `context7`) is the command-line interface for Context7. It does three things: - **Fetch library documentation** — resolve any library by name and query its up-to-date docs directly in your terminal, without opening a browser +- **Submit documentation sources** — add public Git repos, websites, OpenAPI specs, or `llms.txt` files to the Context7 index (`ctx7 add`) - **Configure your AI coding agent** — set up the Context7 MCP server (or a CLI-based `docs` skill) for Claude Code, Cursor, OpenCode, and more with a single command The CLI is useful both as a standalone tool (fetching docs while you code) and as a setup utility (wiring up Context7 for your AI coding agent). @@ -30,11 +31,29 @@ Requires Node.js 18 or later. ```bash npm install -g ctx7 - # Verify installation + # Verify installation (both names work) ctx7 --version + context7 --version ``` + + ```bash + brew install ctx7 + ``` + + + + After the WinGet package is published: + + ```powershell + winget install Upstash.Context7 + context7 --version + ``` + + Until then, use `npm install -g ctx7`. See `packages/cli/packaging/README.md` in the repo for packaging details. + + --- @@ -103,6 +122,30 @@ ctx7 docs /vercel/next.js "How to add middleware for route protection" | grep -A --- +## Submit a library — ctx7 add + +Submit a public documentation source for indexing. Useful for agents that discover missing libraries while researching. + +Requires authentication (`CONTEXT7_API_KEY` or `ctx7 login`). + +```bash +# GitHub / GitLab / Bitbucket (auto-detected) +ctx7 add https://github.com/owner/repo +context7 add https://gitlab.com/owner/repo --json + +# Website / OpenAPI / llms.txt +ctx7 add https://docs.example.com --type website +ctx7 add https://api.example.com/openapi.json --type openapi +ctx7 add https://docs.example.com/llms.txt + +# Private repo (Pro/Enterprise) +ctx7 add https://github.com/owner/private-repo --private --git-token "$GIT_TOKEN" +``` + +`--json` prints a stable payload (`libraryName`, `message`, `alreadyExists`, `status`) for scripting. Duplicate submissions (`409`) are treated as idempotent success so agents can safely retry. + +--- + ## Setup Configure Context7 for your AI coding agent. On first run, prompts you to choose between two modes: diff --git a/packages/cli/README.md b/packages/cli/README.md index 731da4faa..15ebac947 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ -# ctx7 +# ctx7 / context7 -CLI for [Context7](https://context7.com) - query up-to-date library documentation and configure Context7 for AI coding agents. +CLI for [Context7](https://context7.com) - query up-to-date library documentation, submit sources for indexing, and configure Context7 for AI coding agents. ## Installation @@ -8,8 +8,14 @@ CLI for [Context7](https://context7.com) - query up-to-date library documentatio # Run directly with npx (no install needed) npx ctx7 -# Or install globally +# Or install globally (exposes both `ctx7` and `context7`) npm install -g ctx7 + +# Homebrew +brew install ctx7 + +# WinGet (when published) +winget install Upstash.Context7 ``` ## Quick Start @@ -39,6 +45,14 @@ ctx7 docs /facebook/react "useEffect cleanup" ctx7 docs /vercel/next.js "middleware" ``` +### Submit a library + +```bash +# Requires CONTEXT7_API_KEY or `ctx7 login` +context7 add https://github.com/owner/repo +ctx7 add https://docs.example.com --type website --json +``` + ## Usage ### Find a library diff --git a/packages/cli/package.json b/packages/cli/package.json index 19ffff408..dde88234a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,13 +4,15 @@ "description": "Context7 CLI - Fetch documentation context and configure Context7", "type": "module", "bin": { - "ctx7": "./dist/index.js" + "ctx7": "./dist/index.js", + "context7": "./dist/index.js" }, "files": [ "dist" ], "scripts": { "build": "tsup", + "build:portable": "node ./scripts/build-win-portable.mjs", "dev": "tsup --watch", "typecheck": "tsc --noEmit", "lint": "eslint src --fix", diff --git a/packages/cli/packaging/README.md b/packages/cli/packaging/README.md new file mode 100644 index 000000000..188fab979 --- /dev/null +++ b/packages/cli/packaging/README.md @@ -0,0 +1,27 @@ +# Windows packaging (winget) + +Context7 CLI is published to npm as [`ctx7`](https://www.npmjs.com/package/ctx7) and already available via Homebrew (`brew install ctx7`). Windows users historically had to use Node/`npx`. + +## Portable zip + WinGet + +This package ships: + +- `packages/cli/scripts/build-win-portable.mjs` — builds `context7--win-portable.zip` +- `packages/cli/packaging/winget/` — WinGet manifest templates (`Upstash.Context7`) +- `.github/workflows/cli-portable-release.yml` — builds the zip and optionally submits to [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) + +The portable package depends on **Node.js LTS** (`OpenJS.NodeJS.LTS`) and exposes both `context7` and `ctx7` commands. + +### Maintainer checklist + +1. Publish the CLI via the existing changesets/npm release flow. +2. Create a GitHub Release (or run **CLI portable release** via `workflow_dispatch`) so the portable zip is attached. +3. Set repository secret `WINGET_TOKEN` (classic PAT that can open PRs against a fork of `microsoft/winget-pkgs`) to enable automatic WinGet submission. +4. Verify: `winget install Upstash.Context7` then `context7 --version` / `ctx7 --version`. + +Until the first WinGet package lands, Windows users can still: + +```powershell +npm install -g ctx7 +context7 --help +``` diff --git a/packages/cli/packaging/winget/Upstash.Context7.installer.yaml b/packages/cli/packaging/winget/Upstash.Context7.installer.yaml new file mode 100644 index 000000000..f5ab2db98 --- /dev/null +++ b/packages/cli/packaging/winget/Upstash.Context7.installer.yaml @@ -0,0 +1,27 @@ +# Upstash.Context7.installer +# Placeholders {{PACKAGE_VERSION}}, {{INSTALLER_URL}}, {{INSTALLER_SHA256}} are filled by CI. +PackageIdentifier: Upstash.Context7 +PackageVersion: "{{PACKAGE_VERSION}}" +Platform: + - Windows.Desktop +MinimumOSVersion: 10.0.17763.0 +InstallerType: zip +NestedInstallerType: portable +NestedInstallerFiles: + - RelativeFilePath: context7-win-portable\context7.cmd + PortableCommandAlias: context7 + - RelativeFilePath: context7-win-portable\ctx7.cmd + PortableCommandAlias: ctx7 +Dependencies: + PackageDependencies: + - PackageIdentifier: OpenJS.NodeJS.LTS +InstallModes: + - silent +UpgradeBehavior: uninstallPrevious +ReleaseDate: "{{RELEASE_DATE}}" +Installers: + - Architecture: neutral + InstallerUrl: "{{INSTALLER_URL}}" + InstallerSha256: "{{INSTALLER_SHA256}}" +ManifestType: installer +ManifestVersion: 1.9.0 diff --git a/packages/cli/packaging/winget/Upstash.Context7.locale.en-US.yaml b/packages/cli/packaging/winget/Upstash.Context7.locale.en-US.yaml new file mode 100644 index 000000000..77c60b646 --- /dev/null +++ b/packages/cli/packaging/winget/Upstash.Context7.locale.en-US.yaml @@ -0,0 +1,29 @@ +# Upstash.Context7.locale.en-US +PackageIdentifier: Upstash.Context7 +PackageVersion: "{{PACKAGE_VERSION}}" +PackageLocale: en-US +Publisher: Upstash +PublisherUrl: https://upstash.com +PublisherSupportUrl: https://github.com/upstash/context7/issues +Author: Upstash +PackageName: Context7 CLI +PackageUrl: https://context7.com +License: MIT +LicenseUrl: https://github.com/upstash/context7/blob/master/LICENSE +Copyright: Copyright (c) Upstash +ShortDescription: Context7 CLI — fetch library docs and submit sources for AI coding agents +Description: > + Official Context7 command-line interface. Query up-to-date library documentation, + configure Context7 MCP/skills for AI coding agents, and submit documentation sources + for indexing. Provides both `context7` and `ctx7` commands. +Moniker: context7 +Tags: + - ai + - cli + - context7 + - documentation + - mcp + - ctx7 +ReleaseNotesUrl: https://github.com/upstash/context7/releases +ManifestType: defaultLocale +ManifestVersion: 1.9.0 diff --git a/packages/cli/packaging/winget/Upstash.Context7.yaml b/packages/cli/packaging/winget/Upstash.Context7.yaml new file mode 100644 index 000000000..d9e9f0fcc --- /dev/null +++ b/packages/cli/packaging/winget/Upstash.Context7.yaml @@ -0,0 +1,9 @@ +# Upstash.Context7 +# Version: {{PACKAGE_VERSION}} +# Publisher: Upstash +# PackageVersion: {{PACKAGE_VERSION}} +PackageIdentifier: Upstash.Context7 +PackageVersion: "{{PACKAGE_VERSION}}" +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.9.0 diff --git a/packages/cli/scripts/build-win-portable.mjs b/packages/cli/scripts/build-win-portable.mjs new file mode 100644 index 000000000..62059a91d --- /dev/null +++ b/packages/cli/scripts/build-win-portable.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Build a portable Windows zip for winget (Node dependency via OpenJS.NodeJS.LTS). + * + * Layout: + * context7-win-portable/ + * context7.cmd + * ctx7.cmd + * package.json + * dist/ + * node_modules/ (production deps only) + */ +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(pkgRoot, "../.."); +const outDir = path.resolve(pkgRoot, "dist-portable"); +const stagingName = "context7-win-portable"; + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function writeLauncher(dir, name) { + const content = `@echo off\r +setlocal\r +where node >nul 2>nul\r +if errorlevel 1 (\r + echo Node.js is required. Install with: winget install OpenJS.NodeJS.LTS\r + exit /b 1\r +)\r +node "%~dp0dist\\index.js" %*\r +`; + writeFileSync(path.join(dir, `${name}.cmd`), content, "utf8"); +} + +const pkg = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8")); +const version = pkg.version; + +console.log(`Building CLI...`); +run("pnpm", ["--filter", "ctx7", "build"], repoRoot); + +const stagingRoot = mkdtempSync(path.join(tmpdir(), "ctx7-portable-")); +const staging = path.join(stagingRoot, stagingName); +mkdirSync(staging, { recursive: true }); + +cpSync(path.join(pkgRoot, "dist"), path.join(staging, "dist"), { recursive: true }); +writeFileSync( + path.join(staging, "package.json"), + JSON.stringify( + { + name: pkg.name, + version: pkg.version, + type: "module", + private: true, + dependencies: pkg.dependencies, + }, + null, + 2 + ) +); + +console.log("Installing production dependencies into portable package..."); +run("npm", ["install", "--omit=dev", "--ignore-scripts"], staging); + +writeLauncher(staging, "context7"); +writeLauncher(staging, "ctx7"); +writeFileSync( + path.join(staging, "README.txt"), + `Context7 CLI ${version} (portable)\r +\r +Requires Node.js 18+ on PATH (winget install OpenJS.NodeJS.LTS).\r +Commands: context7.cmd and ctx7.cmd\r +` +); + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const zipPath = path.join(outDir, `context7-${version}-win-portable.zip`); +if (existsSync(zipPath)) rmSync(zipPath); + +console.log(`Creating ${zipPath}...`); +if (process.platform === "win32") { + run( + "powershell", + [ + "-NoProfile", + "-Command", + `Compress-Archive -Path '${staging.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`, + ], + stagingRoot + ); +} else { + run("zip", ["-r", zipPath, stagingName], stagingRoot); +} + +rmSync(stagingRoot, { recursive: true, force: true }); +console.log(`Wrote ${zipPath}`); diff --git a/packages/cli/src/__tests__/add-library.test.ts b/packages/cli/src/__tests__/add-library.test.ts new file mode 100644 index 000000000..ffd2d6752 --- /dev/null +++ b/packages/cli/src/__tests__/add-library.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; + +import { detectKind, parseAddKind, resolveAddTarget } from "../utils/add-library.js"; + +describe("detectKind", () => { + test("detects GitHub", () => { + expect(detectKind(new URL("https://github.com/vercel/next.js"))).toBe("github"); + }); + + test("detects GitLab", () => { + expect(detectKind(new URL("https://gitlab.com/owner/repo"))).toBe("gitlab"); + }); + + test("detects Bitbucket", () => { + expect(detectKind(new URL("https://bitbucket.org/owner/repo"))).toBe("bitbucket"); + }); + + test("detects llms.txt", () => { + expect(detectKind(new URL("https://docs.example.com/llms.txt"))).toBe("llmstxt"); + }); + + test("detects openapi specs", () => { + expect(detectKind(new URL("https://api.example.com/openapi.json"))).toBe("openapi"); + }); + + test("falls back to website for forge-like paths without .git", () => { + expect(detectKind(new URL("https://codeberg.org/owner/repo"))).toBe("website"); + }); + + test("detects explicit .git remotes as git", () => { + expect(detectKind(new URL("https://codeberg.org/owner/repo.git"))).toBe("git"); + }); + + test("falls back to website for shallow URLs", () => { + expect(detectKind(new URL("https://docs.example.com/"))).toBe("website"); + }); + + test("does not treat multi-segment docs URLs as git", () => { + expect(detectKind(new URL("https://docs.example.com/guide/intro"))).toBe("website"); + }); +}); + +describe("resolveAddTarget", () => { + test("builds a GitHub add payload", () => { + const target = resolveAddTarget("https://github.com/vercel/next.js", undefined, { + private: true, + gitToken: "tok", + }); + expect(target).toEqual({ + kind: "github", + endpointPath: "/api/v2/add/repo/github", + body: { + docsRepoUrl: "https://github.com/vercel/next.js", + private: true, + gitToken: "tok", + }, + }); + }); + + test("honors explicit website type", () => { + const target = resolveAddTarget("https://github.com/vercel/next.js", "website"); + expect(target.kind).toBe("website"); + expect(target.endpointPath).toBe("/api/v2/add/website"); + expect(target.body).toEqual({ websiteUrl: "https://github.com/vercel/next.js" }); + }); + + test("builds openapi payload", () => { + const target = resolveAddTarget("https://api.example.com/openapi.yaml", "openapi"); + expect(target).toEqual({ + kind: "openapi", + endpointPath: "/api/v2/add/openapi", + body: { openApiUrl: "https://api.example.com/openapi.yaml" }, + }); + }); + + test("builds llmstxt payload", () => { + const target = resolveAddTarget("https://docs.example.com/llms.txt", undefined); + expect(target).toEqual({ + kind: "llmstxt", + endpointPath: "/api/v2/add/llmstxt", + body: { llmstxtUrl: "https://docs.example.com/llms.txt" }, + }); + }); + + test("rejects invalid URLs", () => { + expect(() => resolveAddTarget("not-a-url", undefined)).toThrow(/Invalid URL/); + }); +}); + +describe("parseAddKind", () => { + test("parses valid kinds", () => { + expect(parseAddKind("GitHub")).toBe("github"); + expect(parseAddKind(undefined)).toBeUndefined(); + }); + + test("rejects unknown kinds", () => { + expect(() => parseAddKind("notion")).toThrow(/Invalid --type/); + }); +}); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts new file mode 100644 index 000000000..b2c0e3e6a --- /dev/null +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,197 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import ora from "ora"; + +import { addLibrary } from "../utils/api.js"; +import { parseAddKind, resolveAddTarget } from "../utils/add-library.js"; +import { log } from "../utils/logger.js"; +import { trackEvent } from "../utils/tracking.js"; +import { loadTokens, isTokenExpired } from "../utils/auth.js"; + +const isTTY = process.stdout.isTTY; + +function getAccessToken(): string | undefined { + const tokens = loadTokens(); + if (!tokens || isTokenExpired(tokens)) return undefined; + return tokens.access_token; +} + +function hasAuth(accessToken?: string): boolean { + return Boolean(process.env.CONTEXT7_API_KEY || accessToken); +} + +async function addCommand( + url: string, + options: { + json?: boolean; + type?: string; + private?: boolean; + gitToken?: string; + skipVersionFiltering?: boolean; + generateDocs?: boolean; + } +): Promise { + trackEvent("command", { name: "add" }); + + const accessToken = getAccessToken(); + if (!hasAuth(accessToken)) { + const message = + "Authentication required. Set CONTEXT7_API_KEY or run `ctx7 login` (API keys: https://context7.com/dashboard)."; + if (options.json) { + console.log(JSON.stringify({ error: "unauthorized", message }, null, 2)); + } else { + log.error(message); + } + process.exitCode = 2; + return; + } + + let target; + try { + target = resolveAddTarget(url, parseAddKind(options.type), { + private: options.private, + gitToken: options.gitToken, + skipVersionFiltering: options.skipVersionFiltering, + generateDocs: options.generateDocs, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (options.json) { + console.log(JSON.stringify({ error: "validation_error", message }, null, 2)); + } else { + log.error(message); + } + process.exitCode = 1; + return; + } + + const spinner = isTTY + ? ora(`Submitting ${target.kind} source to Context7...`).start() + : null; + + let result; + try { + result = await addLibrary(target, accessToken); + } catch (err) { + spinner?.fail(`Error: ${err instanceof Error ? err.message : String(err)}`); + if (!spinner) log.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + if (result.error) { + const message = result.message || result.error; + if (result.status === 401) { + spinner?.fail(message); + if (!spinner) log.error(message); + if (options.json) { + console.log( + JSON.stringify( + { error: result.error, message, status: result.status }, + null, + 2 + ) + ); + } + process.exitCode = 2; + return; + } + + if (result.status === 409) { + // Idempotent success for agents: source is already indexed / queued. + spinner?.succeed(message); + if (options.json) { + console.log( + JSON.stringify( + { + libraryName: result.libraryName, + message, + alreadyExists: true, + status: 409, + kind: target.kind, + }, + null, + 2 + ) + ); + } else if (!spinner) { + log.warn(message); + } + process.exitCode = 0; + return; + } + + spinner?.fail(message); + if (!spinner) log.error(message); + if (options.json) { + console.log( + JSON.stringify( + { error: result.error, message, status: result.status }, + null, + 2 + ) + ); + } + process.exitCode = 1; + return; + } + + spinner?.succeed(result.message || "Library submitted"); + if (options.json) { + console.log( + JSON.stringify( + { + libraryName: result.libraryName, + message: result.message, + kind: target.kind, + alreadyExists: false, + status: result.status ?? 200, + }, + null, + 2 + ) + ); + return; + } + + if (!spinner) { + log.success(result.message || "Library submitted"); + } + if (result.libraryName) { + log.plain(` ${pc.cyan(result.libraryName)}`); + log.dim(` Query docs with: ctx7 docs ${result.libraryName} ""`); + } + log.blank(); +} + +export function registerAddCommand(program: Command): void { + program + .command("add") + .alias("submit") + .argument("", "Git repo, docs site, OpenAPI, or llms.txt URL to submit") + .option( + "--type ", + "Source kind: github, gitlab, bitbucket, git, website, openapi, llmstxt (auto-detected when omitted)" + ) + .option("--private", "Mark the repository as private") + .option("--git-token ", "Git access token for private repositories") + .option("--skip-version-filtering", "Skip filtering version-specific documentation pages") + .option("--generate-docs", "Generate docs from source (private repos)") + .option("--json", "Output as JSON") + .description("Submit a library or docs source to Context7 for indexing") + .action( + async ( + url: string, + options: { + json?: boolean; + type?: string; + private?: boolean; + gitToken?: string; + skipVersionFiltering?: boolean; + generateDocs?: boolean; + } + ) => { + await addCommand(url, options); + } + ); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2544b9ffb..270be90ca 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import path from "node:path"; import pc from "picocolors"; import figlet from "figlet"; import { registerSkillCommands, registerSkillAliases } from "./commands/skill.js"; @@ -6,6 +7,7 @@ import { registerAuthCommands, setAuthBaseUrl } from "./commands/auth.js"; import { registerSetupCommand } from "./commands/setup.js"; import { registerRemoveCommand } from "./commands/remove.js"; import { registerDocsCommands } from "./commands/docs.js"; +import { registerAddCommand } from "./commands/add.js"; import { maybeShowUpgradeNotice, registerUpgradeCommand } from "./commands/upgrade.js"; import { setBaseUrl } from "./utils/api.js"; import { VERSION } from "./constants.js"; @@ -15,10 +17,18 @@ const brand = { dim: pc.dim, }; +function resolveInvokedName(): string { + const raw = process.argv[1] ? path.basename(process.argv[1]) : "ctx7"; + const base = raw.replace(/\.(js|mjs|cjs|exe|cmd|ps1)$/i, ""); + return base === "context7" ? "context7" : "ctx7"; +} + +const cliName = resolveInvokedName(); + const program = new Command(); program - .name("ctx7") + .name(cliName) .description("Context7 CLI - Fetch documentation context and configure Context7") .version(VERSION, "-v, --version") .option("--base-url ") @@ -40,19 +50,25 @@ program ` Examples: ${brand.dim("# Configure Context7 for your coding agent")} - ${brand.primary("npx ctx7 setup")} - ${brand.primary("npx ctx7 setup --mcp")} - ${brand.primary("npx ctx7 setup --cli")} + ${brand.primary(`npx ctx7 setup`)} + ${brand.primary(`npx ctx7 setup --mcp`)} + ${brand.primary(`npx ctx7 setup --cli`)} ${brand.dim("# Remove Context7 setup")} - ${brand.primary("npx ctx7 remove --cursor")} - ${brand.primary("npx ctx7 remove --cursor --all")} - ${brand.primary("npx ctx7 remove --cursor --cli")} - ${brand.primary("npx ctx7 remove --claude --mcp")} + ${brand.primary(`npx ctx7 remove --cursor`)} + ${brand.primary(`npx ctx7 remove --cursor --all`)} + ${brand.primary(`npx ctx7 remove --cursor --cli`)} + ${brand.primary(`npx ctx7 remove --claude --mcp`)} ${brand.dim("# Query library documentation")} - ${brand.primary('npx ctx7 library react "how to use hooks"')} - ${brand.primary('npx ctx7 docs /facebook/react "useEffect examples"')} + ${brand.primary(`npx ctx7 library react "how to use hooks"`)} + ${brand.primary(`npx ctx7 docs /facebook/react "useEffect examples"`)} + + ${brand.dim("# Submit a library for indexing (requires API key / login)")} + ${brand.primary(`npx ctx7 add https://github.com/owner/repo`)} + ${brand.primary(`npx context7 add https://docs.example.com --type website --json`)} + +Note: \`context7\` is an alias for \`ctx7\` after \`npm install -g ctx7\`. ` ); @@ -62,6 +78,7 @@ registerAuthCommands(program); registerSetupCommand(program); registerRemoveCommand(program); registerDocsCommands(program); +registerAddCommand(program); registerUpgradeCommand(program); program.action(() => { @@ -74,9 +91,12 @@ program.action(() => { console.log(" Quick start:"); console.log(` ${brand.primary("npx ctx7 setup")}`); console.log(` ${brand.primary('npx ctx7 docs /facebook/react "useEffect examples"')}`); + console.log(` ${brand.primary("npx ctx7 add https://github.com/owner/repo")}`); console.log(""); - console.log(` Run ${brand.primary("npx ctx7 --help")} for all commands and options`); + console.log( + ` Run ${brand.primary("npx ctx7 --help")} (or ${brand.primary("context7 --help")}) for all commands and options` + ); console.log(""); }); diff --git a/packages/cli/src/utils/add-library.ts b/packages/cli/src/utils/add-library.ts new file mode 100644 index 000000000..959a9feff --- /dev/null +++ b/packages/cli/src/utils/add-library.ts @@ -0,0 +1,136 @@ +export type AddLibraryKind = + | "github" + | "gitlab" + | "bitbucket" + | "git" + | "website" + | "openapi" + | "llmstxt"; + +export interface ResolvedAddTarget { + kind: AddLibraryKind; + endpointPath: string; + body: Record; +} + +export interface AddRepoOptions { + private?: boolean; + gitToken?: string; + skipVersionFiltering?: boolean; + generateDocs?: boolean; +} + +function normalizeUrl(raw: string): URL { + const trimmed = raw.trim(); + try { + return new URL(trimmed); + } catch { + throw new Error(`Invalid URL: "${raw}"`); + } +} + +function hostMatches(hostname: string, domain: string): boolean { + const host = hostname.toLowerCase(); + return host === domain || host.endsWith(`.${domain}`); +} + +/** + * Infer the Add Library API target from a URL and optional explicit kind. + */ +export function resolveAddTarget( + url: string, + kind: AddLibraryKind | undefined, + repoOptions: AddRepoOptions = {} +): ResolvedAddTarget { + const parsed = normalizeUrl(url); + const resolvedKind = kind ?? detectKind(parsed); + + if (resolvedKind === "website") { + return { + kind: "website", + endpointPath: "/api/v2/add/website", + body: { websiteUrl: parsed.toString() }, + }; + } + + if (resolvedKind === "openapi") { + return { + kind: "openapi", + endpointPath: "/api/v2/add/openapi", + body: { openApiUrl: parsed.toString() }, + }; + } + + if (resolvedKind === "llmstxt") { + return { + kind: "llmstxt", + endpointPath: "/api/v2/add/llmstxt", + body: { llmstxtUrl: parsed.toString() }, + }; + } + + const body: Record = { + docsRepoUrl: parsed.toString(), + }; + if (repoOptions.private !== undefined) body.private = repoOptions.private; + if (repoOptions.gitToken) body.gitToken = repoOptions.gitToken; + if (repoOptions.skipVersionFiltering !== undefined) { + body.skipVersionFiltering = repoOptions.skipVersionFiltering; + } + if (repoOptions.generateDocs !== undefined) body.generateDocs = repoOptions.generateDocs; + + const endpointPath = + resolvedKind === "github" + ? "/api/v2/add/repo/github" + : resolvedKind === "gitlab" + ? "/api/v2/add/repo/gitlab" + : resolvedKind === "bitbucket" + ? "/api/v2/add/repo/bitbucket" + : "/api/v2/add/repo/git"; + + return { kind: resolvedKind, endpointPath, body }; +} + +export function detectKind(parsed: URL): AddLibraryKind { + const path = parsed.pathname.toLowerCase(); + if (path.endsWith("llms.txt") || path.endsWith("/llms-full.txt")) { + return "llmstxt"; + } + if ( + path.includes("openapi") && + (path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml")) + ) { + return "openapi"; + } + + if (hostMatches(parsed.hostname, "github.com")) return "github"; + if (hostMatches(parsed.hostname, "gitlab.com")) return "gitlab"; + if (hostMatches(parsed.hostname, "bitbucket.org")) return "bitbucket"; + + // Explicit git remotes only — do not treat multi-segment docs URLs as git repos. + if (parsed.protocol === "git:" || parsed.pathname.toLowerCase().endsWith(".git")) { + return "git"; + } + + return "website"; +} + +export function parseAddKind(value: string | undefined): AddLibraryKind | undefined { + if (!value) return undefined; + const normalized = value.toLowerCase(); + const allowed: AddLibraryKind[] = [ + "github", + "gitlab", + "bitbucket", + "git", + "website", + "openapi", + "llmstxt", + ]; + if (!allowed.includes(normalized as AddLibraryKind)) { + throw new Error( + `Invalid --type "${value}". Expected one of: ${allowed.join(", ")}` + ); + } + return normalized as AddLibraryKind; +} diff --git a/packages/cli/src/utils/api.ts b/packages/cli/src/utils/api.ts index 50b039b57..e62f41f1a 100644 --- a/packages/cli/src/utils/api.ts +++ b/packages/cli/src/utils/api.ts @@ -359,3 +359,56 @@ export async function getLibraryContext( return (await response.json()) as ContextResponse; } + +export interface AddLibraryResult { + libraryName?: string; + message?: string; + error?: string; + status?: number; +} + +export async function addLibrary( + target: { endpointPath: string; body: Record }, + accessToken?: string +): Promise { + const headers = { + ...getAuthHeaders(accessToken), + "Content-Type": "application/json", + }; + + if (!headers.Authorization) { + return { + error: "unauthorized", + message: + "Authentication required. Set CONTEXT7_API_KEY or run `ctx7 login`.", + status: 401, + }; + } + + const response = await fetch(`${baseUrl}${target.endpointPath}`, { + method: "POST", + headers, + body: JSON.stringify(target.body), + }); + + const payload = (await response.json().catch(() => ({}))) as { + libraryName?: string; + message?: string; + error?: string; + }; + + if (!response.ok) { + return { + libraryName: payload.libraryName, + error: payload.error || `HTTP error ${response.status}`, + message: payload.message || payload.error || `HTTP error ${response.status}`, + status: response.status, + }; + } + + return { + libraryName: payload.libraryName, + message: payload.message || "Repository submitted successfully", + status: response.status, + }; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 78cc7c978..9991d8c90 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -9,7 +9,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { z } from "zod"; -import { searchLibraries, fetchLibraryContext } from "./lib/api.js"; +import { searchLibraries, fetchLibraryContext, addLibrarySource } from "./lib/api.js"; import type { ClientContext } from "./lib/types.js"; import { formatSearchResults, extractClientInfoFromUserAgent } from "./lib/utils.js"; import { isJWT, validateJWT } from "./lib/jwt.js"; @@ -127,7 +127,9 @@ function createMcpServer() { { instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs. -Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`, +Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts. + +When documentation is missing from Context7 and the user wants the source indexed, use the add-library tool with a public GitHub/GitLab/Bitbucket/docs URL (requires an API key).`, } ); @@ -261,6 +263,85 @@ Do not call this tool more than 3 times per question.`, } ); + server.registerTool( + "add-library", + { + title: "Add Library to Context7", + description: `Submit a public documentation source to Context7 for indexing so future queries can use it. + +Use when resolve-library-id finds no good match and you have a public GitHub/GitLab/Bitbucket repo URL, docs website, OpenAPI spec URL, or llms.txt URL. Requires a Context7 API key. + +Prefer public Git repository URLs. For websites/OpenAPI/llms.txt, pass type explicitly when auto-detection is ambiguous. + +Do not submit private or credentialed sources unless the user explicitly requests it and provides a gitToken.`, + inputSchema: { + url: z + .string() + .describe( + "Public URL to submit — typically a GitHub/GitLab/Bitbucket repository, docs site, OpenAPI spec, or llms.txt file." + ), + type: z + .enum(["github", "gitlab", "bitbucket", "git", "website", "openapi", "llmstxt"]) + .optional() + .describe( + "Optional source kind override. Auto-detected from the URL when omitted." + ), + private: z + .boolean() + .optional() + .describe("Whether the repository is private (requires a suitable plan and gitToken)."), + gitToken: z + .string() + .optional() + .describe( + "Optional git access token for private repository submissions. Do not invent tokens." + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + idempotentHint: true, + }, + }, + async ({ + url, + type, + private: isPrivate, + gitToken, + }: { + url: string; + type?: "github" | "gitlab" | "bitbucket" | "git" | "website" | "openapi" | "llmstxt"; + private?: boolean; + gitToken?: string; + }) => { + const ctx = getClientContext(); + const response = await addLibrarySource( + { url, type, private: isPrivate, gitToken }, + ctx + ); + maybeElicitAuthSignIn(server, ctx); + const lines = [response.data]; + if (response.libraryName) { + lines.push(`Library ID: ${response.libraryName}`); + lines.push( + `Next: call query-docs with libraryId "${response.libraryName}" after indexing completes.` + ); + } + if (response.alreadyExists) { + lines.push("Status: already exists (idempotent)."); + } + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } + ); + server.server.registerCapabilities({ prompts: {}, resources: {} }); server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ diff --git a/packages/mcp/src/lib/add-library.ts b/packages/mcp/src/lib/add-library.ts new file mode 100644 index 000000000..0caa7f13f --- /dev/null +++ b/packages/mcp/src/lib/add-library.ts @@ -0,0 +1,92 @@ +export type AddLibraryKind = + | "github" + | "gitlab" + | "bitbucket" + | "git" + | "website" + | "openapi" + | "llmstxt"; + +export interface ResolvedAddTarget { + kind: AddLibraryKind; + endpointPath: string; + body: Record; +} + +function normalizeUrl(raw: string): URL { + try { + return new URL(raw.trim()); + } catch { + throw new Error(`Invalid URL: "${raw}"`); + } +} + +function hostMatches(hostname: string, domain: string): boolean { + const host = hostname.toLowerCase(); + return host === domain || host.endsWith(`.${domain}`); +} + +export function detectKind(parsed: URL): AddLibraryKind { + const path = parsed.pathname.toLowerCase(); + if (path.endsWith("llms.txt") || path.endsWith("/llms-full.txt")) { + return "llmstxt"; + } + if ( + path.includes("openapi") && + (path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml")) + ) { + return "openapi"; + } + + if (hostMatches(parsed.hostname, "github.com")) return "github"; + if (hostMatches(parsed.hostname, "gitlab.com")) return "gitlab"; + if (hostMatches(parsed.hostname, "bitbucket.org")) return "bitbucket"; + if (parsed.protocol === "git:" || path.endsWith(".git")) return "git"; + return "website"; +} + +export function resolveAddTarget( + url: string, + kind?: AddLibraryKind, + options: { private?: boolean; gitToken?: string } = {} +): ResolvedAddTarget { + const parsed = normalizeUrl(url); + const resolvedKind = kind ?? detectKind(parsed); + + if (resolvedKind === "website") { + return { + kind: "website", + endpointPath: "/v2/add/website", + body: { websiteUrl: parsed.toString() }, + }; + } + if (resolvedKind === "openapi") { + return { + kind: "openapi", + endpointPath: "/v2/add/openapi", + body: { openApiUrl: parsed.toString() }, + }; + } + if (resolvedKind === "llmstxt") { + return { + kind: "llmstxt", + endpointPath: "/v2/add/llmstxt", + body: { llmstxtUrl: parsed.toString() }, + }; + } + + const body: Record = { docsRepoUrl: parsed.toString() }; + if (options.private !== undefined) body.private = options.private; + if (options.gitToken) body.gitToken = options.gitToken; + + const endpointPath = + resolvedKind === "github" + ? "/v2/add/repo/github" + : resolvedKind === "gitlab" + ? "/v2/add/repo/gitlab" + : resolvedKind === "bitbucket" + ? "/v2/add/repo/bitbucket" + : "/v2/add/repo/git"; + + return { kind: resolvedKind, endpointPath, body }; +} diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 6bcd4502d..46f3d1586 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -4,6 +4,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; +import { resolveAddTarget } from "./add-library.js"; /** * Parses error response from the Context7 API @@ -173,3 +174,82 @@ export async function fetchLibraryContext( return { data: errorMessage }; } } + +export interface AddLibraryRequest { + url: string; + type?: "github" | "gitlab" | "bitbucket" | "git" | "website" | "openapi" | "llmstxt"; + private?: boolean; + gitToken?: string; +} + +export interface AddLibraryResponse { + data: string; + libraryName?: string; + alreadyExists?: boolean; +} + +/** + * Submit a library / docs source to Context7 for indexing. + */ +export async function addLibrarySource( + request: AddLibraryRequest, + context: ClientContext = {} +): Promise { + try { + if (!context.apiKey) { + return { + data: "Authentication required to submit libraries. Set CONTEXT7_API_KEY or configure the MCP server with an API key from https://context7.com/dashboard.", + }; + } + + const target = resolveAddTarget(request.url, request.type, { + private: request.private, + gitToken: request.gitToken, + }); + + const url = new URL(`${CONTEXT7_API_BASE_URL}${target.endpointPath}`); + const headers = { + ...generateHeaders(context), + "Content-Type": "application/json", + }; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(target.body), + }); + readPromptSignal(response, context); + + const payload = (await response.json().catch(() => ({}))) as { + libraryName?: string; + message?: string; + error?: string; + }; + + if (response.status === 409) { + return { + data: payload.message || "This source is already submitted to Context7.", + libraryName: payload.libraryName, + alreadyExists: true, + }; + } + + if (!response.ok) { + const errorMessage = payload.message || (await parseErrorResponse(response, context.apiKey)); + console.error(errorMessage); + return { data: errorMessage }; + } + + const libraryName = payload.libraryName; + const message = + payload.message || + (libraryName + ? `Submitted successfully as ${libraryName}` + : "Submitted successfully for processing"); + return { data: message, libraryName, alreadyExists: false }; + } catch (error) { + const errorMessage = `Error submitting library: ${error}`; + console.error(errorMessage); + return { data: errorMessage }; + } +}