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 };
+ }
+}