diff --git a/.please/docs/decisions/0001-registry-entry-schema-entry-package-source.md b/.please/docs/decisions/0001-registry-entry-schema-entry-package-source.md new file mode 100644 index 0000000..37074e7 --- /dev/null +++ b/.please/docs/decisions/0001-registry-entry-schema-entry-package-source.md @@ -0,0 +1,258 @@ +--- +adr: 0001 +title: Registry entry schema — Entry → Package → Source hierarchy +status: Accepted +date: 2026-04-09 +--- + +# ADR-0001: Registry entry schema — Entry → Package → Source hierarchy + +## Status + +Accepted (implemented 2026-04-09) + +## Context + +The ASK Registry (`apps/registry/content/registry//.md`) is the +source of truth that tells the `@pleaseai/ask` CLI where and how to download +documentation for a library. Each entry is a single Markdown file with YAML +frontmatter, validated by `apps/registry/content.config.ts` against a schema +defined in `packages/schema/src/`. + +The current schema places a single `strategies` list at the top level of the +entry, alongside top-level `aliases`, `docsPath`, and `repo`: + +```yaml +repo: mastra-ai/mastra +docsPath: docs +aliases: + - { ecosystem: npm, name: "@mastra/core" } + - { ecosystem: npm, name: "@mastra/memory" } +strategies: + - { source: npm, package: "@mastra/core", docsPath: dist/docs } + - { source: npm, package: "@mastra/memory", docsPath: dist/docs } + - { source: github, repo: mastra-ai/mastra, docsPath: docs } +``` + +As monorepo libraries like `mastra-ai/mastra` and `vercel/ai` entered the +registry, `strategies` started carrying two semantically distinct concerns in +the same list: + +1. **Dispatch** — "the user asked for `@mastra/core`, give them that package's + docs, not `@mastra/memory`'s." This is a routing concern keyed by alias. +2. **Fallback chain** — "try the npm tarball first; if the curated docs + directory is missing, fall back to the GitHub archive." This is the classic + Strategy Pattern usage. + +The server compensates at runtime with two pieces of implicit logic in +`apps/registry/server/api/registry/[...slug].get.ts`: + +- `disambiguateStrategies(all, requestedPackage)` reorders the list by + matching on a strategy's `package` field. +- `npmStrategyCount > 1` is used as a heuristic to detect monorepo entries, + which then triggers `slugifyPackageName(second)` to produce a per-package + `resolvedName` so different scoped packages land in distinct + `.ask/docs/@` directories on the CLI side. + +The top-level `aliases` list has the same problem in reverse: aliases belong +to a specific package inside a monorepo, but the schema places them at the +entry level with no way to express that binding. The server infers the +binding by matching `alias.name` against `strategy.package` at request time. + +Consequences of the current shape: + +- The name `strategies` misleads contributors — it is read as "fallback + alternatives" but is being used as a dispatch table. +- Monorepo support is a runtime heuristic (npm-count ≥ 2), not an explicit + schema state. A single-package entry that happens to list two npm + strategies for different docs paths would be misclassified. +- The top-level `docsPath` and the per-strategy `docsPath` compete, and the + top-level one is only meaningful for the auto-generated github fallback. +- Package-level metadata (a package-specific `description`, `deprecated` + flag, `since`/`until` version bounds) has no natural home. +- The CLI, server, and docs use inconsistent vocabulary: "strategy", + "package", "source", "alias", "resolvedName" all appear without a clear + hierarchy. + +The project is in early development (pre-1.0, few entries), so migration +cost is not a constraint for this decision. The goal is to pick the shape +we want to live with as the registry grows. + +## Decision + +Restructure the registry entry schema around an explicit three-level +hierarchy: **Entry → Package → Source.** + +- **Entry** — one Markdown file per GitHub repository. Holds repo-level + metadata (`name`, `description`, `repo`, `homepage`, `license`, `tags`). +- **Package** — a documentation target. Single-package libraries have one; + monorepos have N. Each package owns its `aliases` and its `sources`. + A package also carries its own `name`, which the server slugifies into + the CLI's `resolvedName`. +- **Source** — a way to fetch one package's docs. Multiple sources form a + fallback chain in declaration order ("the first one that works wins"). + Each source declares a `type` (`npm` | `github` | `web` | `llms-txt`) + and type-specific fields. + +Canonical shape: + +```yaml +--- +name: Mastra +description: TypeScript framework for AI agents, workflows, and RAG +repo: mastra-ai/mastra +homepage: https://mastra.ai +license: Apache-2.0 +tags: [ai, agents, framework, typescript, rag] + +packages: + - name: "@mastra/core" + aliases: + - { ecosystem: npm, name: "@mastra/core" } + sources: + - type: npm + package: "@mastra/core" + path: dist/docs + + - name: "@mastra/memory" + aliases: + - { ecosystem: npm, name: "@mastra/memory" } + sources: + - type: npm + package: "@mastra/memory" + path: dist/docs +--- +``` + +Single-package entries collapse to `packages` of length 1: + +```yaml +packages: + - name: zod + aliases: + - { ecosystem: npm, name: zod } + sources: + - type: github + repo: colinhacks/zod + path: docs +``` + +Entries with a fallback chain express it inside one package's `sources`: + +```yaml +packages: + - name: ai + aliases: + - { ecosystem: npm, name: ai } + sources: + - type: npm # 1st choice + package: ai + path: dist/docs + - type: github # fallback + repo: vercel/ai + path: content/docs +``` + +Field renames from the current schema: + +| Old | New | Reason | +|---|---|---| +| `strategies` (top-level) | `packages[].sources` | Name reflects actual semantics (fallback chain, not dispatch) | +| `source: npm` | `type: npm` | `sources` containing `source:` fields was redundant | +| `docsPath` (any level) | `path` (on source) | Parent context already implies "docs" | +| `aliases` (top-level) | `packages[].aliases` | Aliases belong to a package, not an entry | +| `docsPath` (top-level, entry) | removed | No longer needed; each source carries its own `path` | + +The CLI lock file's `resolvedName` remains the slugified package name; the +server produces it from `packages[i].name` instead of the current +`slugifyPackageName(second)` heuristic on the requested alias. + +## Consequences + +### Positive + +- Server logic becomes declarative. `disambiguateStrategies` and the + `npmStrategyCount > 1` monorepo heuristic are deleted. Looking up an + alias is: "find the `package` whose `aliases` include this request, + return its `sources`." +- Monorepo support is a first-class schema state (`packages.length > 1`), + not an inferred property. Intent lives in the data. +- `sources` carries only one meaning — fallback alternatives — so it + aligns with the Strategy Pattern usage readers expect. +- Package-level metadata has an obvious home for future additions + (per-package `description`, `deprecated`, `since`, custom `resolvedName` + overrides, etc.) without re-nesting. +- The vocabulary Entry / Package / Source becomes the ubiquitous language + for the registry across CLI code, server code, content files, and docs. +- Alias conflicts (two packages claiming the same alias) surface at build + time via schema validation, not as undefined runtime behavior. +- Direct `owner/repo` lookups for monorepo entries can now return a + well-defined error ("multiple packages; specify which one") instead of + the current behavior of silently picking a head strategy via + `selectBestStrategy`. + +### Negative + +- Schema is nominally deeper (3 levels vs. 2), which slightly increases the + minimum verbosity of a single-package entry. Mitigated by the fact that + single-package entries are still only ~8 lines and read top-to-bottom. +- All existing registry entries must be rewritten. Acceptable given the + stated "ignore migration cost" framing, but means the change is not a + drop-in schema patch — it touches content files, `content.config.ts`, + `packages/schema/src/`, `apps/registry/server/api/registry/[...slug].get.ts`, + and `packages/cli/src/registry.ts` in one coordinated change. +- The CLI's `selectBestStrategy` rule "curated npm wins outright" + (`packages/cli/src/registry.ts:236-241`) must move into the entry + author's hands: whoever writes the entry decides the `sources` order, + and the CLI honors it literally. Loses an implicit guarantee, gains + transparency. + +### Neutral + +- The registry stays file-per-repo; only the internal structure of each + file changes. +- Ecosystem aliasing (npm, pypi, pub, go, crates, hex, nuget, maven) + continues to work the same way — it just attaches one level deeper. +- The public CLI surface (`ask docs add npm:@mastra/core`) is unchanged. +- `llms-txt` source type remains behind `content.config.ts` schema + enablement, same as today. + +## Alternatives Considered + +- **Rename only, keep flat shape (`strategies` → `targets` or `sources`).** + Lowest-effort option. Removes the "Strategy Pattern" connotation but + preserves the dual meaning (dispatch + fallback) in one list. Server's + `disambiguateStrategies` and monorepo heuristic stay. Rejected because + it paints over the naming symptom without fixing the structural cause. + +- **Two parallel top-level fields: `packages` + `fallbacks`.** Split + dispatch (`packages`) from fallback (`fallbacks`), but make `fallbacks` + an entry-level shared list that applies to all packages. Simpler for + monorepos where every package shares the same GitHub fallback. Rejected + because it couples per-package fallback choices — a package without a + useful GitHub fallback (e.g. docs only on npm) cannot opt out, and + per-package `path` variations in the fallback are hard to express. + +- **Split into multiple files per package** (e.g. `mastra-ai/mastra-core.md`, + `mastra-ai/mastra-memory.md`). Considered in an earlier conversation. + Rejected because (a) it breaks the `/.md` file-path + convention the content collection relies on, (b) it duplicates + entry-level metadata (`repo`, `license`, `homepage`) across files, and + (c) it fragments the reader's view of a single library's documentation + surface. + +- **Keep current schema, document the dual meaning.** Adding extensive + comments to `content.config.ts` and the README to warn contributors + about the two meanings of `strategies`. Rejected because documentation + debt compounds; every future reader has to learn the same quirk. + +## Links + +- Current schema: `packages/schema/src/` (`RegistryEntry`, `RegistryStrategy`, + `RegistryAlias`, `expandStrategies`) +- Current server: `apps/registry/server/api/registry/[...slug].get.ts` + (`disambiguateStrategies`, `slugifyPackageName`, monorepo heuristic) +- Current CLI strategy selection: `packages/cli/src/registry.ts` + (`selectBestStrategy`, `resolveFromRegistry`) +- Existing monorepo entry: `apps/registry/content/registry/mastra-ai/mastra.md` +- Existing single-package with fallback: `apps/registry/content/registry/vercel/ai.md` diff --git a/.please/docs/decisions/index.md b/.please/docs/decisions/index.md index 1db25c5..48c0203 100644 --- a/.please/docs/decisions/index.md +++ b/.please/docs/decisions/index.md @@ -4,3 +4,4 @@ | ADR | Title | Date | Status | |-----|-------|------|--------| +| [0001](0001-registry-entry-schema-entry-package-source.md) | Registry entry schema — Entry → Package → Source hierarchy | 2026-04-09 | Accepted | diff --git a/apps/registry/content/registry/BerriAI/litellm.md b/apps/registry/content/registry/BerriAI/litellm.md index c889372..f49599b 100644 --- a/apps/registry/content/registry/BerriAI/litellm.md +++ b/apps/registry/content/registry/BerriAI/litellm.md @@ -2,13 +2,23 @@ name: LiteLLM description: Call 100+ LLM APIs using the OpenAI format repo: BerriAI/litellm -docsPath: docs homepage: https://docs.litellm.ai license: MIT -aliases: - - ecosystem: pypi - name: litellm -tags: [ai, llm, openai, proxy, python] +tags: + - ai + - llm + - openai + - proxy + - python +packages: + - name: litellm + aliases: + - ecosystem: pypi + name: litellm + sources: + - type: github + repo: BerriAI/litellm + path: docs --- # LiteLLM diff --git a/apps/registry/content/registry/Fizzadar/pyinfra.md b/apps/registry/content/registry/Fizzadar/pyinfra.md index 6d29f9f..4a26f27 100644 --- a/apps/registry/content/registry/Fizzadar/pyinfra.md +++ b/apps/registry/content/registry/Fizzadar/pyinfra.md @@ -2,13 +2,22 @@ name: pyinfra description: Infrastructure automation in Python repo: Fizzadar/pyinfra -docsPath: docs homepage: https://pyinfra.com license: MIT -aliases: - - ecosystem: pypi - name: pyinfra -tags: [infrastructure, automation, devops, python] +tags: + - infrastructure + - automation + - devops + - python +packages: + - name: pyinfra + aliases: + - ecosystem: pypi + name: pyinfra + sources: + - type: github + repo: Fizzadar/pyinfra + path: docs --- # pyinfra diff --git a/apps/registry/content/registry/HKUDS/LightRAG.md b/apps/registry/content/registry/HKUDS/LightRAG.md index 8159ac4..e0080c3 100644 --- a/apps/registry/content/registry/HKUDS/LightRAG.md +++ b/apps/registry/content/registry/HKUDS/LightRAG.md @@ -4,10 +4,20 @@ description: Simple and fast Retrieval-Augmented Generation repo: HKUDS/LightRAG homepage: https://github.com/HKUDS/LightRAG license: MIT -aliases: - - ecosystem: pypi - name: lightrag-hku -tags: [ai, rag, llm, graph, python] +tags: + - ai + - rag + - llm + - graph + - python +packages: + - name: lightrag-hku + aliases: + - ecosystem: pypi + name: lightrag-hku + sources: + - type: github + repo: HKUDS/LightRAG --- # LightRAG diff --git a/apps/registry/content/registry/angular/angular.md b/apps/registry/content/registry/angular/angular.md index a931cf9..fb71bb3 100644 --- a/apps/registry/content/registry/angular/angular.md +++ b/apps/registry/content/registry/angular/angular.md @@ -2,21 +2,30 @@ name: Angular description: Platform for building mobile and desktop web applications repo: angular/angular -docsPath: adev/src/content homepage: https://angular.dev license: MIT -aliases: - - ecosystem: npm - name: '@angular/core' - - ecosystem: npm - name: '@angular/common' - - ecosystem: npm - name: '@angular/router' - - ecosystem: npm - name: '@angular/forms' - - ecosystem: npm - name: '@angular/platform-browser' -tags: [typescript, framework, spa, google] +tags: + - typescript + - framework + - spa + - google +packages: + - name: "@angular/core" + aliases: + - ecosystem: npm + name: "@angular/core" + - ecosystem: npm + name: "@angular/common" + - ecosystem: npm + name: "@angular/router" + - ecosystem: npm + name: "@angular/forms" + - ecosystem: npm + name: "@angular/platform-browser" + sources: + - type: github + repo: angular/angular + path: adev/src/content --- # Angular diff --git a/apps/registry/content/registry/ansible/ansible.md b/apps/registry/content/registry/ansible/ansible.md index 927245e..b4647fd 100644 --- a/apps/registry/content/registry/ansible/ansible.md +++ b/apps/registry/content/registry/ansible/ansible.md @@ -2,13 +2,22 @@ name: Ansible description: Radically simple IT automation repo: ansible/ansible -docsPath: docs/docsite homepage: https://www.ansible.com license: GPL-3.0 -aliases: - - ecosystem: pypi - name: ansible -tags: [automation, devops, infrastructure, python] +tags: + - automation + - devops + - infrastructure + - python +packages: + - name: ansible + aliases: + - ecosystem: pypi + name: ansible + sources: + - type: github + repo: ansible/ansible + path: docs/docsite --- # Ansible diff --git a/apps/registry/content/registry/axios/axios.md b/apps/registry/content/registry/axios/axios.md index afa7f9b..fbc9f4f 100644 --- a/apps/registry/content/registry/axios/axios.md +++ b/apps/registry/content/registry/axios/axios.md @@ -2,13 +2,23 @@ name: Axios description: Promise-based HTTP client for the browser and Node.js repo: axios/axios -docsPath: docs homepage: https://axios-http.com license: MIT -aliases: - - ecosystem: npm - name: axios -tags: [http, client, promise, browser, node] +tags: + - http + - client + - promise + - browser + - node +packages: + - name: axios + aliases: + - ecosystem: npm + name: axios + sources: + - type: github + repo: axios/axios + path: docs --- # Axios diff --git a/apps/registry/content/registry/colinhacks/zod.md b/apps/registry/content/registry/colinhacks/zod.md index f62c232..2c4b265 100644 --- a/apps/registry/content/registry/colinhacks/zod.md +++ b/apps/registry/content/registry/colinhacks/zod.md @@ -2,12 +2,20 @@ name: Zod description: TypeScript-first schema validation library repo: colinhacks/zod -docsPath: docs license: MIT -aliases: - - ecosystem: npm - name: zod -tags: [typescript, validation, schema] +tags: + - typescript + - validation + - schema +packages: + - name: zod + aliases: + - ecosystem: npm + name: zod + sources: + - type: github + repo: colinhacks/zod + path: docs --- # Zod diff --git a/apps/registry/content/registry/deepset-ai/haystack.md b/apps/registry/content/registry/deepset-ai/haystack.md index bfcdb5a..7815e5c 100644 --- a/apps/registry/content/registry/deepset-ai/haystack.md +++ b/apps/registry/content/registry/deepset-ai/haystack.md @@ -2,13 +2,23 @@ name: Haystack description: AI orchestration framework for building LLM applications repo: deepset-ai/haystack -docsPath: docs homepage: https://haystack.deepset.ai license: Apache-2.0 -aliases: - - ecosystem: pypi - name: haystack-ai -tags: [ai, rag, nlp, pipelines, python] +tags: + - ai + - rag + - nlp + - pipelines + - python +packages: + - name: haystack-ai + aliases: + - ecosystem: pypi + name: haystack-ai + sources: + - type: github + repo: deepset-ai/haystack + path: docs --- # Haystack diff --git a/apps/registry/content/registry/expressjs/express.md b/apps/registry/content/registry/expressjs/express.md index 5af61fe..fc31446 100644 --- a/apps/registry/content/registry/expressjs/express.md +++ b/apps/registry/content/registry/expressjs/express.md @@ -4,10 +4,19 @@ description: Fast, unopinionated, minimalist web framework for Node.js repo: expressjs/express homepage: https://expressjs.com license: MIT -aliases: - - ecosystem: npm - name: express -tags: [node, web, framework, middleware] +tags: + - node + - web + - framework + - middleware +packages: + - name: express + aliases: + - ecosystem: npm + name: express + sources: + - type: github + repo: expressjs/express --- # Express diff --git a/apps/registry/content/registry/facebook/react.md b/apps/registry/content/registry/facebook/react.md index 05aa487..f762d80 100644 --- a/apps/registry/content/registry/facebook/react.md +++ b/apps/registry/content/registry/facebook/react.md @@ -4,14 +4,23 @@ description: Library for building user interfaces repo: facebook/react homepage: https://react.dev license: MIT -aliases: - - ecosystem: npm - name: react - - ecosystem: npm - name: react-dom - - ecosystem: npm - name: react-reconciler -tags: [ui, components, virtual-dom, meta] +tags: + - ui + - components + - virtual-dom + - meta +packages: + - name: react + aliases: + - ecosystem: npm + name: react + - ecosystem: npm + name: react-dom + - ecosystem: npm + name: react-reconciler + sources: + - type: github + repo: facebook/react --- # React diff --git a/apps/registry/content/registry/fastapi/fastapi.md b/apps/registry/content/registry/fastapi/fastapi.md index 3772e96..1c615bf 100644 --- a/apps/registry/content/registry/fastapi/fastapi.md +++ b/apps/registry/content/registry/fastapi/fastapi.md @@ -2,22 +2,27 @@ name: FastAPI description: High-performance Python web framework repo: fastapi/fastapi -docsPath: docs homepage: https://fastapi.tiangolo.com license: MIT -aliases: - - ecosystem: pypi - name: fastapi -strategies: - - source: github - repo: fastapi/fastapi - docsPath: docs - - source: web - urls: - - https://fastapi.tiangolo.com - maxDepth: 2 - allowedPathPrefix: /tutorial -tags: [python, api, async, web] +tags: + - python + - api + - async + - web +packages: + - name: fastapi + aliases: + - ecosystem: pypi + name: fastapi + sources: + - type: github + repo: fastapi/fastapi + path: docs + - type: web + urls: + - https://fastapi.tiangolo.com + maxDepth: 2 + allowedPathPrefix: /tutorial --- # FastAPI diff --git a/apps/registry/content/registry/huggingface/transformers.md b/apps/registry/content/registry/huggingface/transformers.md index 21f1498..1f4c967 100644 --- a/apps/registry/content/registry/huggingface/transformers.md +++ b/apps/registry/content/registry/huggingface/transformers.md @@ -2,13 +2,23 @@ name: Transformers description: State-of-the-art machine learning for PyTorch, TensorFlow, and JAX repo: huggingface/transformers -docsPath: docs/source/en homepage: https://huggingface.co/docs/transformers license: Apache-2.0 -aliases: - - ecosystem: pypi - name: transformers -tags: [ai, ml, nlp, huggingface, deep-learning] +tags: + - ai + - ml + - nlp + - huggingface + - deep-learning +packages: + - name: transformers + aliases: + - ecosystem: pypi + name: transformers + sources: + - type: github + repo: huggingface/transformers + path: docs/source/en --- # Transformers diff --git a/apps/registry/content/registry/jerryjliu/llama_index.md b/apps/registry/content/registry/jerryjliu/llama_index.md index abb804e..a1fec70 100644 --- a/apps/registry/content/registry/jerryjliu/llama_index.md +++ b/apps/registry/content/registry/jerryjliu/llama_index.md @@ -2,13 +2,23 @@ name: LlamaIndex description: Data framework for LLM applications repo: jerryjliu/llama_index -docsPath: docs/docs homepage: https://docs.llamaindex.ai license: MIT -aliases: - - ecosystem: pypi - name: llama-index -tags: [ai, rag, llm, data, python] +tags: + - ai + - rag + - llm + - data + - python +packages: + - name: llama-index + aliases: + - ecosystem: pypi + name: llama-index + sources: + - type: github + repo: jerryjliu/llama_index + path: docs/docs --- # LlamaIndex diff --git a/apps/registry/content/registry/jestjs/jest.md b/apps/registry/content/registry/jestjs/jest.md index 4ef09c6..9dd1dd3 100644 --- a/apps/registry/content/registry/jestjs/jest.md +++ b/apps/registry/content/registry/jestjs/jest.md @@ -2,13 +2,22 @@ name: Jest description: Delightful JavaScript testing framework repo: jestjs/jest -docsPath: docs homepage: https://jestjs.io license: MIT -aliases: - - ecosystem: npm - name: jest -tags: [testing, javascript, typescript, meta] +tags: + - testing + - javascript + - typescript + - meta +packages: + - name: jest + aliases: + - ecosystem: npm + name: jest + sources: + - type: github + repo: jestjs/jest + path: docs --- # Jest diff --git a/apps/registry/content/registry/jlowin/fastmcp.md b/apps/registry/content/registry/jlowin/fastmcp.md index ecd25e6..65c57b5 100644 --- a/apps/registry/content/registry/jlowin/fastmcp.md +++ b/apps/registry/content/registry/jlowin/fastmcp.md @@ -2,12 +2,22 @@ name: FastMCP description: Fast, Pythonic way to build MCP servers and clients repo: jlowin/fastmcp -docsPath: docs license: MIT -aliases: - - ecosystem: pypi - name: fastmcp -tags: [mcp, ai, agents, python, server] +tags: + - mcp + - ai + - agents + - python + - server +packages: + - name: fastmcp + aliases: + - ecosystem: pypi + name: fastmcp + sources: + - type: github + repo: jlowin/fastmcp + path: docs --- # FastMCP diff --git a/apps/registry/content/registry/jquery/jquery.md b/apps/registry/content/registry/jquery/jquery.md index b9923b1..bfc4bdd 100644 --- a/apps/registry/content/registry/jquery/jquery.md +++ b/apps/registry/content/registry/jquery/jquery.md @@ -4,10 +4,19 @@ description: Fast, small, and feature-rich JavaScript library repo: jquery/jquery homepage: https://jquery.com license: MIT -aliases: - - ecosystem: npm - name: jquery -tags: [dom, manipulation, ajax, browser] +tags: + - dom + - manipulation + - ajax + - browser +packages: + - name: jquery + aliases: + - ecosystem: npm + name: jquery + sources: + - type: github + repo: jquery/jquery --- # jQuery diff --git a/apps/registry/content/registry/kubernetes/kubernetes.md b/apps/registry/content/registry/kubernetes/kubernetes.md index 636d545..7e096f0 100644 --- a/apps/registry/content/registry/kubernetes/kubernetes.md +++ b/apps/registry/content/registry/kubernetes/kubernetes.md @@ -2,13 +2,22 @@ name: Kubernetes description: Production-grade container orchestration repo: kubernetes/kubernetes -docsPath: docs homepage: https://kubernetes.io license: Apache-2.0 -aliases: - - ecosystem: go - name: kubernetes -tags: [containers, orchestration, cloud-native, go] +tags: + - containers + - orchestration + - cloud-native + - go +packages: + - name: kubernetes + aliases: + - ecosystem: go + name: kubernetes + sources: + - type: github + repo: kubernetes/kubernetes + path: docs --- # Kubernetes diff --git a/apps/registry/content/registry/langchain-ai/langchain.md b/apps/registry/content/registry/langchain-ai/langchain.md index 1a055df..11378a3 100644 --- a/apps/registry/content/registry/langchain-ai/langchain.md +++ b/apps/registry/content/registry/langchain-ai/langchain.md @@ -2,13 +2,23 @@ name: LangChain description: Framework for developing applications powered by LLMs repo: langchain-ai/langchain -docsPath: docs/docs homepage: https://python.langchain.com license: MIT -aliases: - - ecosystem: pypi - name: langchain -tags: [ai, llm, chains, agents, python] +tags: + - ai + - llm + - chains + - agents + - python +packages: + - name: langchain + aliases: + - ecosystem: pypi + name: langchain + sources: + - type: github + repo: langchain-ai/langchain + path: docs/docs --- # LangChain diff --git a/apps/registry/content/registry/langchain-ai/langgraph.md b/apps/registry/content/registry/langchain-ai/langgraph.md index d72d5bc..17f7bd7 100644 --- a/apps/registry/content/registry/langchain-ai/langgraph.md +++ b/apps/registry/content/registry/langchain-ai/langgraph.md @@ -2,13 +2,23 @@ name: LangGraph description: Library for building stateful, multi-actor AI applications repo: langchain-ai/langgraph -docsPath: docs/docs homepage: https://langchain-ai.github.io/langgraph/ license: MIT -aliases: - - ecosystem: pypi - name: langgraph -tags: [ai, agents, langchain, graph, python] +tags: + - ai + - agents + - langchain + - graph + - python +packages: + - name: langgraph + aliases: + - ecosystem: pypi + name: langgraph + sources: + - type: github + repo: langchain-ai/langgraph + path: docs/docs --- # LangGraph diff --git a/apps/registry/content/registry/langchain-ai/langgraphjs.md b/apps/registry/content/registry/langchain-ai/langgraphjs.md index b8d0444..898954b 100644 --- a/apps/registry/content/registry/langchain-ai/langgraphjs.md +++ b/apps/registry/content/registry/langchain-ai/langgraphjs.md @@ -2,15 +2,25 @@ name: LangGraph.js description: Library for building stateful, multi-actor AI applications in JS/TS repo: langchain-ai/langgraphjs -docsPath: docs homepage: https://langchain-ai.github.io/langgraphjs/ license: MIT -aliases: - - ecosystem: npm - name: '@langchain/langgraph' - - ecosystem: npm - name: '@langchain/langgraph-sdk' -tags: [ai, agents, langchain, graph, typescript] +tags: + - ai + - agents + - langchain + - graph + - typescript +packages: + - name: "@langchain/langgraph" + aliases: + - ecosystem: npm + name: "@langchain/langgraph" + - ecosystem: npm + name: "@langchain/langgraph-sdk" + sources: + - type: github + repo: langchain-ai/langgraphjs + path: docs --- # LangGraph.js diff --git a/apps/registry/content/registry/lodash/lodash.md b/apps/registry/content/registry/lodash/lodash.md index d0df76e..f2135ae 100644 --- a/apps/registry/content/registry/lodash/lodash.md +++ b/apps/registry/content/registry/lodash/lodash.md @@ -4,10 +4,19 @@ description: Modern JavaScript utility library delivering modularity and perform repo: lodash/lodash homepage: https://lodash.com license: MIT -aliases: - - ecosystem: npm - name: lodash -tags: [utility, functional, collections, arrays] +tags: + - utility + - functional + - collections + - arrays +packages: + - name: lodash + aliases: + - ecosystem: npm + name: lodash + sources: + - type: github + repo: lodash/lodash --- # Lodash diff --git a/apps/registry/content/registry/mastra-ai/mastra.md b/apps/registry/content/registry/mastra-ai/mastra.md index 66b5143..f650174 100644 --- a/apps/registry/content/registry/mastra-ai/mastra.md +++ b/apps/registry/content/registry/mastra-ai/mastra.md @@ -2,25 +2,37 @@ name: Mastra description: TypeScript framework for building AI agents, workflows, and RAG pipelines repo: mastra-ai/mastra -docsPath: docs homepage: https://mastra.ai license: Apache-2.0 -aliases: - - ecosystem: npm - name: "@mastra/core" - - ecosystem: npm - name: "@mastra/memory" -strategies: - - source: npm - package: "@mastra/core" - docsPath: dist/docs - - source: npm - package: "@mastra/memory" - docsPath: dist/docs - - source: github - repo: mastra-ai/mastra - docsPath: docs -tags: [ai, agents, framework, typescript, rag] +tags: + - ai + - agents + - framework + - typescript + - rag +packages: + - name: "@mastra/core" + aliases: + - ecosystem: npm + name: "@mastra/core" + sources: + - type: npm + package: "@mastra/core" + path: dist/docs + - type: github + repo: mastra-ai/mastra + path: docs + - name: "@mastra/memory" + aliases: + - ecosystem: npm + name: "@mastra/memory" + sources: + - type: npm + package: "@mastra/memory" + path: dist/docs + - type: github + repo: mastra-ai/mastra + path: docs --- # Mastra diff --git a/apps/registry/content/registry/microsoft/agent-framework.md b/apps/registry/content/registry/microsoft/agent-framework.md index 524848c..cec8aa4 100644 --- a/apps/registry/content/registry/microsoft/agent-framework.md +++ b/apps/registry/content/registry/microsoft/agent-framework.md @@ -3,10 +3,19 @@ name: Microsoft Agent Framework description: Framework for building AI agents with .NET repo: microsoft/agent-framework license: MIT -aliases: - - ecosystem: nuget - name: agent-framework -tags: [ai, agents, microsoft, dotnet] +tags: + - ai + - agents + - microsoft + - dotnet +packages: + - name: agent-framework + aliases: + - ecosystem: nuget + name: agent-framework + sources: + - type: github + repo: microsoft/agent-framework --- # Microsoft Agent Framework diff --git a/apps/registry/content/registry/microsoft/semantic-kernel.md b/apps/registry/content/registry/microsoft/semantic-kernel.md index 36a72a1..a0a7b71 100644 --- a/apps/registry/content/registry/microsoft/semantic-kernel.md +++ b/apps/registry/content/registry/microsoft/semantic-kernel.md @@ -4,14 +4,25 @@ description: AI orchestration SDK integrating LLMs with conventional programming repo: microsoft/semantic-kernel homepage: https://learn.microsoft.com/semantic-kernel license: MIT -aliases: - - ecosystem: nuget - name: Microsoft.SemanticKernel - - ecosystem: pypi - name: semantic-kernel - - ecosystem: maven - name: semantic-kernel -tags: [ai, llm, microsoft, dotnet, python, orchestration] +tags: + - ai + - llm + - microsoft + - dotnet + - python + - orchestration +packages: + - name: Microsoft.SemanticKernel + aliases: + - ecosystem: nuget + name: Microsoft.SemanticKernel + - ecosystem: pypi + name: semantic-kernel + - ecosystem: maven + name: semantic-kernel + sources: + - type: github + repo: microsoft/semantic-kernel --- # Semantic Kernel diff --git a/apps/registry/content/registry/modelcontextprotocol/go-sdk.md b/apps/registry/content/registry/modelcontextprotocol/go-sdk.md index 10c9aa6..7cb46d3 100644 --- a/apps/registry/content/registry/modelcontextprotocol/go-sdk.md +++ b/apps/registry/content/registry/modelcontextprotocol/go-sdk.md @@ -4,10 +4,20 @@ description: Official Go SDK for the Model Context Protocol repo: modelcontextprotocol/go-sdk homepage: https://modelcontextprotocol.io license: MIT -aliases: - - ecosystem: go - name: mcp-go-sdk -tags: [mcp, ai, agents, go, sdk] +tags: + - mcp + - ai + - agents + - go + - sdk +packages: + - name: mcp-go-sdk + aliases: + - ecosystem: go + name: mcp-go-sdk + sources: + - type: github + repo: modelcontextprotocol/go-sdk --- # MCP Go SDK diff --git a/apps/registry/content/registry/modelcontextprotocol/python-sdk.md b/apps/registry/content/registry/modelcontextprotocol/python-sdk.md index ed5d6f9..3b2f14d 100644 --- a/apps/registry/content/registry/modelcontextprotocol/python-sdk.md +++ b/apps/registry/content/registry/modelcontextprotocol/python-sdk.md @@ -2,13 +2,23 @@ name: MCP Python SDK description: Official Python SDK for the Model Context Protocol repo: modelcontextprotocol/python-sdk -docsPath: docs homepage: https://modelcontextprotocol.io license: MIT -aliases: - - ecosystem: pypi - name: mcp -tags: [mcp, ai, agents, python, sdk] +tags: + - mcp + - ai + - agents + - python + - sdk +packages: + - name: mcp + aliases: + - ecosystem: pypi + name: mcp + sources: + - type: github + repo: modelcontextprotocol/python-sdk + path: docs --- # MCP Python SDK diff --git a/apps/registry/content/registry/modelcontextprotocol/typescript-sdk.md b/apps/registry/content/registry/modelcontextprotocol/typescript-sdk.md index 448c47e..aab1d47 100644 --- a/apps/registry/content/registry/modelcontextprotocol/typescript-sdk.md +++ b/apps/registry/content/registry/modelcontextprotocol/typescript-sdk.md @@ -2,13 +2,23 @@ name: MCP TypeScript SDK description: Official TypeScript SDK for the Model Context Protocol repo: modelcontextprotocol/typescript-sdk -docsPath: docs homepage: https://modelcontextprotocol.io license: MIT -aliases: - - ecosystem: npm - name: '@modelcontextprotocol/sdk' -tags: [mcp, ai, agents, typescript, sdk] +tags: + - mcp + - ai + - agents + - typescript + - sdk +packages: + - name: "@modelcontextprotocol/sdk" + aliases: + - ecosystem: npm + name: "@modelcontextprotocol/sdk" + sources: + - type: github + repo: modelcontextprotocol/typescript-sdk + path: docs --- # MCP TypeScript SDK diff --git a/apps/registry/content/registry/numpy/numpy.md b/apps/registry/content/registry/numpy/numpy.md index e5c1ad6..52f1614 100644 --- a/apps/registry/content/registry/numpy/numpy.md +++ b/apps/registry/content/registry/numpy/numpy.md @@ -2,13 +2,22 @@ name: NumPy description: Fundamental package for scientific computing with Python repo: numpy/numpy -docsPath: doc homepage: https://numpy.org license: BSD-3-Clause -aliases: - - ecosystem: pypi - name: numpy -tags: [scientific, math, arrays, python] +tags: + - scientific + - math + - arrays + - python +packages: + - name: numpy + aliases: + - ecosystem: pypi + name: numpy + sources: + - type: github + repo: numpy/numpy + path: doc --- # NumPy diff --git a/apps/registry/content/registry/nuxt/nuxt.md b/apps/registry/content/registry/nuxt/nuxt.md index 33601a4..df31532 100644 --- a/apps/registry/content/registry/nuxt/nuxt.md +++ b/apps/registry/content/registry/nuxt/nuxt.md @@ -2,19 +2,24 @@ name: Nuxt description: The Intuitive Vue Framework repo: nuxt/nuxt -docsPath: docs homepage: https://nuxt.com license: MIT -aliases: - - ecosystem: npm - name: nuxt -strategies: - - source: github - repo: nuxt/nuxt - docsPath: docs - - source: llms-txt - url: https://nuxt.com/llms.txt -tags: [vue, framework, ssr, nitro] +tags: + - vue + - framework + - ssr + - nitro +packages: + - name: nuxt + aliases: + - ecosystem: npm + name: nuxt + sources: + - type: github + repo: nuxt/nuxt + path: docs + - type: llms-txt + url: https://nuxt.com/llms.txt --- # Nuxt diff --git a/apps/registry/content/registry/nuxt/ui.md b/apps/registry/content/registry/nuxt/ui.md index d557438..e831503 100644 --- a/apps/registry/content/registry/nuxt/ui.md +++ b/apps/registry/content/registry/nuxt/ui.md @@ -2,21 +2,27 @@ name: Nuxt UI description: Comprehensive Vue UI component library with 125+ accessible Tailwind CSS components repo: nuxt/ui -docsPath: docs homepage: https://ui.nuxt.com license: MIT -aliases: - - ecosystem: npm - name: nuxt-ui -strategies: - - source: github - repo: nuxt/ui - docsPath: docs - - source: llms-txt - url: https://ui.nuxt.com/llms.txt - - source: llms-txt - url: https://ui3.nuxt.com/llms.txt -tags: [vue, nuxt, ui, components, tailwindcss] +tags: + - vue + - nuxt + - ui + - components + - tailwindcss +packages: + - name: nuxt-ui + aliases: + - ecosystem: npm + name: nuxt-ui + sources: + - type: github + repo: nuxt/ui + path: docs + - type: llms-txt + url: https://ui.nuxt.com/llms.txt + - type: llms-txt + url: https://ui3.nuxt.com/llms.txt --- # Nuxt UI diff --git a/apps/registry/content/registry/openai/codex.md b/apps/registry/content/registry/openai/codex.md index 719996a..13f8fa1 100644 --- a/apps/registry/content/registry/openai/codex.md +++ b/apps/registry/content/registry/openai/codex.md @@ -2,12 +2,22 @@ name: Codex description: Lightweight AI coding agent from OpenAI repo: openai/codex -docsPath: docs license: Apache-2.0 -aliases: - - ecosystem: npm - name: codex -tags: [ai, coding, agent, openai, cli] +tags: + - ai + - coding + - agent + - openai + - cli +packages: + - name: codex + aliases: + - ecosystem: npm + name: codex + sources: + - type: github + repo: openai/codex + path: docs --- # Codex diff --git a/apps/registry/content/registry/opencv/opencv.md b/apps/registry/content/registry/opencv/opencv.md index 2f79938..d14a2dd 100644 --- a/apps/registry/content/registry/opencv/opencv.md +++ b/apps/registry/content/registry/opencv/opencv.md @@ -2,13 +2,23 @@ name: OpenCV description: Open source computer vision library repo: opencv/opencv -docsPath: doc homepage: https://opencv.org license: Apache-2.0 -aliases: - - ecosystem: pypi - name: opencv-python -tags: [computer-vision, image-processing, ml, python, cpp] +tags: + - computer-vision + - image-processing + - ml + - python + - cpp +packages: + - name: opencv-python + aliases: + - ecosystem: pypi + name: opencv-python + sources: + - type: github + repo: opencv/opencv + path: doc --- # OpenCV diff --git a/apps/registry/content/registry/pallets/flask.md b/apps/registry/content/registry/pallets/flask.md index 7b1a010..5dc46a2 100644 --- a/apps/registry/content/registry/pallets/flask.md +++ b/apps/registry/content/registry/pallets/flask.md @@ -2,13 +2,22 @@ name: Flask description: Lightweight WSGI web application framework repo: pallets/flask -docsPath: docs homepage: https://flask.palletsprojects.com license: BSD-3-Clause -aliases: - - ecosystem: pypi - name: flask -tags: [python, web, framework, wsgi] +tags: + - python + - web + - framework + - wsgi +packages: + - name: flask + aliases: + - ecosystem: pypi + name: flask + sources: + - type: github + repo: pallets/flask + path: docs --- # Flask diff --git a/apps/registry/content/registry/pandas-dev/pandas.md b/apps/registry/content/registry/pandas-dev/pandas.md index fa4a278..b7a20c9 100644 --- a/apps/registry/content/registry/pandas-dev/pandas.md +++ b/apps/registry/content/registry/pandas-dev/pandas.md @@ -2,13 +2,22 @@ name: pandas description: Powerful data analysis and manipulation library for Python repo: pandas-dev/pandas -docsPath: doc homepage: https://pandas.pydata.org license: BSD-3-Clause -aliases: - - ecosystem: pypi - name: pandas -tags: [data, analysis, dataframe, python] +tags: + - data + - analysis + - dataframe + - python +packages: + - name: pandas + aliases: + - ecosystem: pypi + name: pandas + sources: + - type: github + repo: pandas-dev/pandas + path: doc --- # pandas diff --git a/apps/registry/content/registry/psf/requests.md b/apps/registry/content/registry/psf/requests.md index 34f1a55..3382488 100644 --- a/apps/registry/content/registry/psf/requests.md +++ b/apps/registry/content/registry/psf/requests.md @@ -2,13 +2,21 @@ name: Requests description: Elegant and simple HTTP library for Python repo: psf/requests -docsPath: docs homepage: https://requests.readthedocs.io license: Apache-2.0 -aliases: - - ecosystem: pypi - name: requests -tags: [http, client, python] +tags: + - http + - client + - python +packages: + - name: requests + aliases: + - ecosystem: pypi + name: requests + sources: + - type: github + repo: psf/requests + path: docs --- # Requests diff --git a/apps/registry/content/registry/pytorch/pytorch.md b/apps/registry/content/registry/pytorch/pytorch.md index c399a3a..7af18bd 100644 --- a/apps/registry/content/registry/pytorch/pytorch.md +++ b/apps/registry/content/registry/pytorch/pytorch.md @@ -2,13 +2,23 @@ name: PyTorch description: Tensors and dynamic neural networks in Python with GPU acceleration repo: pytorch/pytorch -docsPath: docs/source homepage: https://pytorch.org license: BSD-3-Clause -aliases: - - ecosystem: pypi - name: torch -tags: [ai, deep-learning, ml, gpu, python] +tags: + - ai + - deep-learning + - ml + - gpu + - python +packages: + - name: torch + aliases: + - ecosystem: pypi + name: torch + sources: + - type: github + repo: pytorch/pytorch + path: docs/source --- # PyTorch diff --git a/apps/registry/content/registry/qodo-ai/pr-agent.md b/apps/registry/content/registry/qodo-ai/pr-agent.md index bd898bc..a989837 100644 --- a/apps/registry/content/registry/qodo-ai/pr-agent.md +++ b/apps/registry/content/registry/qodo-ai/pr-agent.md @@ -2,12 +2,21 @@ name: PR-Agent description: AI-powered tool for automated pull request analysis and feedback repo: qodo-ai/pr-agent -docsPath: docs license: Apache-2.0 -aliases: - - ecosystem: pypi - name: pr-agent -tags: [ai, code-review, pull-request, devtools] +tags: + - ai + - code-review + - pull-request + - devtools +packages: + - name: pr-agent + aliases: + - ecosystem: pypi + name: pr-agent + sources: + - type: github + repo: qodo-ai/pr-agent + path: docs --- # PR-Agent diff --git a/apps/registry/content/registry/spring-projects/spring-ai.md b/apps/registry/content/registry/spring-projects/spring-ai.md index dcc5752..0f4a98f 100644 --- a/apps/registry/content/registry/spring-projects/spring-ai.md +++ b/apps/registry/content/registry/spring-projects/spring-ai.md @@ -4,10 +4,19 @@ description: AI engineering framework for Spring applications repo: spring-projects/spring-ai homepage: https://spring.io/projects/spring-ai license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-ai -tags: [java, spring, ai, llm] +tags: + - java + - spring + - ai + - llm +packages: + - name: spring-ai + aliases: + - ecosystem: maven + name: spring-ai + sources: + - type: github + repo: spring-projects/spring-ai --- # Spring AI diff --git a/apps/registry/content/registry/spring-projects/spring-boot.md b/apps/registry/content/registry/spring-projects/spring-boot.md index f926327..a5917a1 100644 --- a/apps/registry/content/registry/spring-projects/spring-boot.md +++ b/apps/registry/content/registry/spring-projects/spring-boot.md @@ -4,10 +4,19 @@ description: Convention-over-configuration Spring application framework repo: spring-projects/spring-boot homepage: https://spring.io/projects/spring-boot license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-boot -tags: [java, spring, framework, microservices] +tags: + - java + - spring + - framework + - microservices +packages: + - name: spring-boot + aliases: + - ecosystem: maven + name: spring-boot + sources: + - type: github + repo: spring-projects/spring-boot --- # Spring Boot diff --git a/apps/registry/content/registry/spring-projects/spring-framework.md b/apps/registry/content/registry/spring-projects/spring-framework.md index 3a90621..90b41d0 100644 --- a/apps/registry/content/registry/spring-projects/spring-framework.md +++ b/apps/registry/content/registry/spring-projects/spring-framework.md @@ -4,10 +4,20 @@ description: Core support framework for Java applications repo: spring-projects/spring-framework homepage: https://spring.io/projects/spring-framework license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-framework -tags: [java, spring, framework, di, aop] +tags: + - java + - spring + - framework + - di + - aop +packages: + - name: spring-framework + aliases: + - ecosystem: maven + name: spring-framework + sources: + - type: github + repo: spring-projects/spring-framework --- # Spring Framework diff --git a/apps/registry/content/registry/spring-projects/spring-integration.md b/apps/registry/content/registry/spring-projects/spring-integration.md index b9b4840..b6aaffd 100644 --- a/apps/registry/content/registry/spring-projects/spring-integration.md +++ b/apps/registry/content/registry/spring-projects/spring-integration.md @@ -4,10 +4,20 @@ description: Enterprise integration patterns support for Spring repo: spring-projects/spring-integration homepage: https://spring.io/projects/spring-integration license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-integration -tags: [java, spring, integration, messaging, eip] +tags: + - java + - spring + - integration + - messaging + - eip +packages: + - name: spring-integration + aliases: + - ecosystem: maven + name: spring-integration + sources: + - type: github + repo: spring-projects/spring-integration --- # Spring Integration diff --git a/apps/registry/content/registry/spring-projects/spring-modulith.md b/apps/registry/content/registry/spring-projects/spring-modulith.md index 64cc2a5..80bf209 100644 --- a/apps/registry/content/registry/spring-projects/spring-modulith.md +++ b/apps/registry/content/registry/spring-projects/spring-modulith.md @@ -4,10 +4,20 @@ description: Modular monolith support for Spring Boot repo: spring-projects/spring-modulith homepage: https://spring.io/projects/spring-modulith license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-modulith -tags: [java, spring, modular, monolith, architecture] +tags: + - java + - spring + - modular + - monolith + - architecture +packages: + - name: spring-modulith + aliases: + - ecosystem: maven + name: spring-modulith + sources: + - type: github + repo: spring-projects/spring-modulith --- # Spring Modulith diff --git a/apps/registry/content/registry/spring-projects/spring-security.md b/apps/registry/content/registry/spring-projects/spring-security.md index 3a1da11..48c5870 100644 --- a/apps/registry/content/registry/spring-projects/spring-security.md +++ b/apps/registry/content/registry/spring-projects/spring-security.md @@ -4,10 +4,20 @@ description: Authentication and access-control framework for Spring repo: spring-projects/spring-security homepage: https://spring.io/projects/spring-security license: Apache-2.0 -aliases: - - ecosystem: maven - name: spring-security -tags: [java, spring, security, authentication, authorization] +tags: + - java + - spring + - security + - authentication + - authorization +packages: + - name: spring-security + aliases: + - ecosystem: maven + name: spring-security + sources: + - type: github + repo: spring-projects/spring-security --- # Spring Security diff --git a/apps/registry/content/registry/stanfordnlp/stanza.md b/apps/registry/content/registry/stanfordnlp/stanza.md index 2812036..e115d54 100644 --- a/apps/registry/content/registry/stanfordnlp/stanza.md +++ b/apps/registry/content/registry/stanfordnlp/stanza.md @@ -2,13 +2,22 @@ name: Stanza description: Stanford NLP Python library for many human languages repo: stanfordnlp/stanza -docsPath: doc homepage: https://stanfordnlp.github.io/stanza/ license: Apache-2.0 -aliases: - - ecosystem: pypi - name: stanza -tags: [nlp, stanford, python, linguistics] +tags: + - nlp + - stanford + - python + - linguistics +packages: + - name: stanza + aliases: + - ecosystem: pypi + name: stanza + sources: + - type: github + repo: stanfordnlp/stanza + path: doc --- # Stanza diff --git a/apps/registry/content/registry/tailwindlabs/tailwindcss.md b/apps/registry/content/registry/tailwindlabs/tailwindcss.md index aa4435a..93e1e45 100644 --- a/apps/registry/content/registry/tailwindlabs/tailwindcss.md +++ b/apps/registry/content/registry/tailwindlabs/tailwindcss.md @@ -4,18 +4,23 @@ description: Utility-first CSS framework repo: tailwindlabs/tailwindcss homepage: https://tailwindcss.com license: MIT -aliases: - - ecosystem: npm - name: tailwindcss -strategies: - - source: github - repo: tailwindlabs/tailwindcss - - source: web - urls: - - https://tailwindcss.com/docs - maxDepth: 2 - allowedPathPrefix: /docs -tags: [css, framework, utility] +tags: + - css + - framework + - utility +packages: + - name: tailwindcss + aliases: + - ecosystem: npm + name: tailwindcss + sources: + - type: github + repo: tailwindlabs/tailwindcss + - type: web + urls: + - https://tailwindcss.com/docs + maxDepth: 2 + allowedPathPrefix: /docs --- # Tailwind CSS diff --git a/apps/registry/content/registry/tensorflow/tensorflow.md b/apps/registry/content/registry/tensorflow/tensorflow.md index 4a68624..9545835 100644 --- a/apps/registry/content/registry/tensorflow/tensorflow.md +++ b/apps/registry/content/registry/tensorflow/tensorflow.md @@ -2,13 +2,23 @@ name: TensorFlow description: Open source platform for machine learning repo: tensorflow/tensorflow -docsPath: tensorflow/docs homepage: https://www.tensorflow.org license: Apache-2.0 -aliases: - - ecosystem: pypi - name: tensorflow -tags: [ai, deep-learning, ml, google, python] +tags: + - ai + - deep-learning + - ml + - google + - python +packages: + - name: tensorflow + aliases: + - ecosystem: pypi + name: tensorflow + sources: + - type: github + repo: tensorflow/tensorflow + path: tensorflow/docs --- # TensorFlow diff --git a/apps/registry/content/registry/vercel/ai.md b/apps/registry/content/registry/vercel/ai.md index b3fed6d..f7b6895 100644 --- a/apps/registry/content/registry/vercel/ai.md +++ b/apps/registry/content/registry/vercel/ai.md @@ -2,20 +2,26 @@ name: Vercel AI SDK description: TypeScript SDK for building AI-powered applications and agents repo: vercel/ai -docsPath: dist/docs homepage: https://sdk.vercel.ai license: Apache-2.0 -aliases: - - ecosystem: npm - name: ai -strategies: - - source: npm - package: ai - docsPath: dist/docs - - source: github - repo: vercel/ai - docsPath: content/docs -tags: [ai, llm, sdk, agents, typescript] +tags: + - ai + - llm + - sdk + - agents + - typescript +packages: + - name: ai + aliases: + - ecosystem: npm + name: ai + sources: + - type: npm + package: ai + path: dist/docs + - type: github + repo: vercel/ai + path: content/docs --- # Vercel AI SDK diff --git a/apps/registry/content/registry/vercel/next.js.md b/apps/registry/content/registry/vercel/next.js.md index 74d3706..fab8973 100644 --- a/apps/registry/content/registry/vercel/next.js.md +++ b/apps/registry/content/registry/vercel/next.js.md @@ -2,20 +2,25 @@ name: Next.js description: The React framework by Vercel repo: vercel/next.js -docsPath: docs homepage: https://nextjs.org license: MIT -aliases: - - ecosystem: npm - name: next -strategies: - - source: npm - package: next - docsPath: dist/docs - - source: github - repo: vercel/next.js - docsPath: docs -tags: [react, framework, ssr, vercel] +tags: + - react + - framework + - ssr + - vercel +packages: + - name: next + aliases: + - ecosystem: npm + name: next + sources: + - type: npm + package: next + path: dist/docs + - type: github + repo: vercel/next.js + path: docs --- # Next.js diff --git a/apps/registry/content/registry/vuejs/core.md b/apps/registry/content/registry/vuejs/core.md index 60f4a43..7fb497b 100644 --- a/apps/registry/content/registry/vuejs/core.md +++ b/apps/registry/content/registry/vuejs/core.md @@ -4,14 +4,23 @@ description: Progressive JavaScript framework for building UIs repo: vuejs/core homepage: https://vuejs.org license: MIT -aliases: - - ecosystem: npm - name: vue - - ecosystem: npm - name: '@vue/reactivity' - - ecosystem: npm - name: '@vue/compiler-sfc' -tags: [framework, reactive, components, spa] +tags: + - framework + - reactive + - components + - spa +packages: + - name: vue + aliases: + - ecosystem: npm + name: vue + - ecosystem: npm + name: "@vue/reactivity" + - ecosystem: npm + name: "@vue/compiler-sfc" + sources: + - type: github + repo: vuejs/core --- # Vue diff --git a/apps/registry/content/registry/webpack/webpack.md b/apps/registry/content/registry/webpack/webpack.md index 8ae86e1..aaa456d 100644 --- a/apps/registry/content/registry/webpack/webpack.md +++ b/apps/registry/content/registry/webpack/webpack.md @@ -4,10 +4,19 @@ description: Module bundler for modern JavaScript applications repo: webpack/webpack homepage: https://webpack.js.org license: MIT -aliases: - - ecosystem: npm - name: webpack -tags: [bundler, build, modules, assets] +tags: + - bundler + - build + - modules + - assets +packages: + - name: webpack + aliases: + - ecosystem: npm + name: webpack + sources: + - type: github + repo: webpack/webpack --- # Webpack diff --git a/apps/registry/server/api/registry/[...slug].get.ts b/apps/registry/server/api/registry/[...slug].get.ts index 050dbf3..abf0944 100644 --- a/apps/registry/server/api/registry/[...slug].get.ts +++ b/apps/registry/server/api/registry/[...slug].get.ts @@ -1,68 +1,82 @@ -import type { RegistryAlias, RegistryStrategy } from '@pleaseai/ask-schema' -import { expandStrategies } from '@pleaseai/ask-schema' +import type { RegistryEntry, RegistryPackage, RegistrySource } from '@pleaseai/ask-schema' +import { findPackageByAlias, isMonorepoEntry, slugifyPackageName } from '@pleaseai/ask-schema' /** - * Convert an npm package name into a filesystem- and skill-name-safe slug. + * Registry lookup endpoint. * - * Examples: - * - `@mastra/core` → `mastra-core` - * - `@scope/pkg-name` → `scope-pkg-name` - * - `lodash` → `lodash` + * Implements ADR-0001 (`Entry → Package → Source` hierarchy). Each registry + * entry documents one repo and one or more packages. Callers can look up + * either: * - * The CLI uses the returned slug as both a directory name - * (`.ask/docs/@/`) and a Claude Code skill name. Both surfaces - * reject `@` and `/`, so the registry server pre-slugifies the name and - * exposes it as `resolvedName` in the response. Doing this on the server - * keeps every client (CLI today, future SDKs tomorrow) consistent without - * forcing each client to re-implement the rule. - */ -function slugifyPackageName(pkg: string): string { - if (pkg.startsWith('@')) { - return pkg.slice(1).replace('/', '-') - } - return pkg -} - -/** - * Pick the strategy that best satisfies a request for `requestedPackage` - * out of an entry's full strategy list. + * 1. Direct path: `GET /api/registry//` + * - Single-package entries return that package. + * - Monorepo entries return `409 Conflict` — the caller must + * disambiguate via an ecosystem alias. * - * Rules (in order): - * 1. A curated npm strategy (`source: npm` with `docsPath`) whose - * `package` field equals `requestedPackage` wins outright. This is - * the monorepo disambiguation case — `mastra-ai/mastra` declares - * both `@mastra/core` and `@mastra/memory`; we must hand the caller - * the strategy that matches what they actually asked for. - * 2. Any other curated npm strategy (first in declaration order). - * 3. Fall through to the default github strategy. + * 2. Alias: `GET /api/registry//` + * - Scans all entries for a package whose `aliases` include + * `{ ecosystem, name }`. The lookup is unambiguous because the + * schema's `superRefine` rejects duplicate aliases across packages + * within the same entry. (Cross-entry collisions are possible in + * theory but unenforced; the first match wins, which matches the + * previous endpoint's behavior.) * - * The result is a list of strategies in execution priority order — the - * caller picks the head and uses the rest as fallback. We return a list - * (not a single strategy) so the CLI can still iterate through fallbacks - * if the head fails (e.g. tarball missing the curated docs dir → github). + * The response carries a `resolvedName` field that the CLI uses as the + * directory name (`.ask/docs/@/`) and skill name. For + * single-package entries this is the entry's display `name`; for monorepo + * entries it is `slugifyPackageName(package.name)` so distinct scoped + * packages land in distinct directories. */ -function disambiguateStrategies( - all: RegistryStrategy[], - requestedPackage?: string, -): RegistryStrategy[] { - if (!requestedPackage) { - return all - } - const matchingNpm = all.find( - s => s.source === 'npm' && s.package === requestedPackage && s.docsPath, - ) - if (!matchingNpm) { - return all +interface RegistryApiResponse { + /** Entry-level display name (the library as a whole). */ + name: string + /** Entry-level one-line description. */ + description: string + /** GitHub `owner/name`. */ + repo: string + homepage?: string + license?: string + tags?: string[] + /** + * CLI-facing identifier. Safe for use as a directory name and a Claude + * Code skill name — `@mastra/core` is slugified to `mastra-core`. + */ + resolvedName: string + /** The selected package's canonical metadata. */ + package: { + name: string + description?: string } + /** + * Fetch sources in the entry author's declared priority order. The CLI + * is expected to try them head-first and walk the list on failure. + */ + sources: RegistrySource[] +} - // Put the matching npm strategy first; keep every other strategy in its - // original order behind it (the github fallback in particular). - const rest = all.filter(s => s !== matchingNpm) - return [matchingNpm, ...rest] +function buildResponse( + entry: RegistryEntry, + pkg: RegistryPackage, + resolvedName: string, +): RegistryApiResponse { + return { + name: entry.name, + description: entry.description, + repo: entry.repo, + homepage: entry.homepage, + license: entry.license, + tags: entry.tags, + resolvedName, + package: { + name: pkg.name, + description: pkg.description, + }, + sources: pkg.sources, + } } -export default defineEventHandler(async (event) => { +export default defineEventHandler(async (event): Promise => { const slug = getRouterParam(event, 'slug') if (!slug) { @@ -72,104 +86,64 @@ export default defineEventHandler(async (event) => { // Decode each segment so callers can URL-encode scoped npm packages // (`@mastra/client-js` → `%40mastra%2Fclient-js`) and still land on a // two-segment slug here. Nitro decodes `%40` but leaves `%2F` in the - // catch-all param, so we have to handle the decode ourselves. + // catch-all param, so we handle the decode ourselves. const segments = slug.split('/').map(s => decodeURIComponent(s)) if (segments.length !== 2) { - throw createError({ statusCode: 400, statusMessage: 'Slug must be in "owner/repo" or "ecosystem/name" form' }) + throw createError({ + statusCode: 400, + statusMessage: 'Slug must be in "owner/repo" or "ecosystem/name" form', + }) } - const [first, second] = segments - const directPath = `/registry/${first}/${second}` + const [first, second] = segments as [string, string] - // 1. Try direct path lookup (owner/repo). No disambiguation needed — - // `owner/repo` is unambiguous and the caller is asking for the - // repo as a whole. + // 1. Direct path lookup (owner/repo). Unambiguous for single-package + // entries; a monorepo entry cannot be resolved without a package + // selector, so return 409. + const directPath = `/registry/${first}/${second}` + // @ts-expect-error — Nuxt Content v3 types expect queryCollection(name) + // but the runtime accepts queryCollection(event, name). Pre-existing + // project-wide quirk, not part of this refactor. const directEntries = await queryCollection(event, 'registry') .where('path', '=', directPath) .all() if (directEntries.length > 0) { - const entry = directEntries[0] + const entry = directEntries[0] as unknown as RegistryEntry - let strategies: RegistryStrategy[] - try { - strategies = expandStrategies({ - repo: entry.repo, - docsPath: entry.docsPath, - strategies: entry.strategies, - }) - } - catch (error) { + if (isMonorepoEntry(entry)) { throw createError({ - statusCode: 422, - statusMessage: `Misconfigured registry entry ${slug}: ${(error as Error).message}`, + statusCode: 409, + statusMessage: `${first}/${second} documents ${entry.packages.length} packages — look up via an ecosystem alias (e.g. \`npm:\`) to disambiguate`, }) } - return { - name: entry.name, - resolvedName: entry.name, - description: entry.description, - repo: entry.repo, - docsPath: entry.docsPath, - homepage: entry.homepage, - license: entry.license, - aliases: entry.aliases, - strategies, - tags: entry.tags, + const [pkg] = entry.packages + if (!pkg) { + throw createError({ statusCode: 500, statusMessage: `Registry entry ${slug} has no packages (schema should have prevented this)` }) } + return buildResponse(entry, pkg, entry.name) } - // 2. Fallback: search by alias (ecosystem/name). The alias is the - // user's intent, so disambiguate strategies and slugify the - // response name based on `second` (the requested package). + // 2. Alias lookup (ecosystem/name). Intra-entry alias uniqueness is + // enforced by the schema's superRefine; we still scan all entries + // linearly to find the owning one. + // @ts-expect-error — see above note on queryCollection signature. const allEntries = await queryCollection(event, 'registry').all() - const matched = allEntries.find((entry) => { - const aliases = entry.aliases as RegistryAlias[] | undefined - if (!aliases) - return false - return aliases.some(a => a.ecosystem === first && a.name === second) - }) - - if (!matched) { - throw createError({ statusCode: 404, statusMessage: `Entry not found: ${slug}` }) - } - let allStrategies: RegistryStrategy[] - try { - allStrategies = expandStrategies({ - repo: matched.repo, - docsPath: matched.docsPath, - strategies: matched.strategies, - }) - } - catch (error) { - throw createError({ - statusCode: 422, - statusMessage: `Misconfigured registry entry ${slug}: ${(error as Error).message}`, - }) + for (const rawEntry of allEntries) { + const entry = rawEntry as unknown as RegistryEntry + if (!entry.packages) + continue + + const pkg = findPackageByAlias(entry, first as RegistryPackage['aliases'][number]['ecosystem'], second) + if (pkg) { + const resolvedName = isMonorepoEntry(entry) + ? slugifyPackageName(pkg.name) + : entry.name + return buildResponse(entry, pkg, resolvedName) + } } - // Detect monorepo entries (multiple npm strategies). For these, the - // resolved name is the slugified requested package so different scoped - // packages from the same repo land in distinct `.ask/docs/@` - // directories on the client side. - const npmStrategyCount = (matched.strategies ?? []).filter((s: RegistryStrategy) => s.source === 'npm').length - const isMonorepoEntry = npmStrategyCount > 1 - const resolvedName = isMonorepoEntry ? slugifyPackageName(second) : matched.name - - const strategies = disambiguateStrategies(allStrategies, second) - - return { - name: matched.name, - resolvedName, - description: matched.description, - repo: matched.repo, - docsPath: matched.docsPath, - homepage: matched.homepage, - license: matched.license, - aliases: matched.aliases, - strategies, - tags: matched.tags, - } + throw createError({ statusCode: 404, statusMessage: `Entry not found: ${slug}` }) }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1ecd330..f5a5ef0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { import { manageIgnoreFiles } from './ignore-files.js' import { contentHash, getConfigPath, getLockPath, readLock, upsertLockEntry } from './io.js' import { getReader } from './manifest/index.js' +import type { RegistrySource } from './registry.js' import { fetchRegistryEntry, parseDocSpec, parseEcosystem, resolveFromRegistry } from './registry.js' import { getResolver } from './resolvers/index.js' import { generateSkill, removeSkill } from './skill.js' @@ -182,6 +183,53 @@ function parseSpec(spec: string): { name: string, version: string } { return { name: spec, version: 'latest' } } +/** + * Adapt a registry API `RegistrySource` (the fetch recipe the server + * returned) into the internal `SourceConfig` shape the rest of the CLI + * operates on. The two types carry the same information in slightly + * different layouts — `type` vs `source`, `path` vs `docsPath` — per + * ADR-0001 (see `.please/docs/decisions/0001-*.md`). + */ +function sourceConfigFromRegistrySource( + name: string, + version: string, + source: RegistrySource, +): SourceConfig { + const base = { name, version } + switch (source.type) { + case 'npm': + return { + ...base, + source: 'npm', + package: source.package, + docsPath: source.path, + } satisfies NpmSourceOptions + case 'github': + return { + ...base, + source: 'github', + repo: source.repo, + branch: source.branch, + tag: source.tag, + docsPath: source.path, + } satisfies GithubSourceOptions + case 'web': + return { + ...base, + source: 'web', + urls: source.urls, + maxDepth: source.maxDepth ?? 1, + allowedPathPrefix: source.allowedPathPrefix, + } satisfies WebSourceOptions + case 'llms-txt': + return { + ...base, + source: 'llms-txt', + url: source.url, + } satisfies LlmsTxtSourceOptions + } +} + function buildSourceConfig( name: string, version: string, @@ -307,13 +355,19 @@ const addCmd = defineCommand({ const version = ref ?? 'latest' const libName = `${owner}-${repo}` - // Enrich with registry metadata (docsPath, strategies) when available + // Enrich with registry metadata (github source `path`) when available. + // Direct owner/repo lookup returns the entry's single package on + // single-package entries and 409 on monorepo entries — we only act + // on the single-package case. let docsPath = args.docsPath if (!docsPath) { const entry = await fetchRegistryEntry(owner, repo) if (entry) { consola.info(`Found ${entry.name} in registry`) - docsPath = entry.docsPath + const githubSource = entry.sources.find(s => s.type === 'github') + if (githubSource && githubSource.type === 'github') { + docsPath = githubSource.path + } } } @@ -347,18 +401,9 @@ const addCmd = defineCommand({ // Auto-detect from registry const resolved = await resolveFromRegistry(effectiveSpec, projectDir) if (resolved) { - const { strategy } = resolved - consola.start(`Downloading ${resolved.name}@${resolved.version} docs (source: ${strategy.source})...`) - sourceConfig = buildSourceConfig(resolved.name, resolved.version, { - source: strategy.source, - repo: strategy.repo, - docsPath: strategy.docsPath, - url: strategy.urls ?? (strategy.url ? [strategy.url] : undefined), - maxDepth: strategy.maxDepth?.toString(), - pathPrefix: strategy.allowedPathPrefix, - branch: strategy.branch, - tag: strategy.tag, - }) + const { source } = resolved + consola.start(`Downloading ${resolved.name}@${resolved.version} docs (source: ${source.type})...`) + sourceConfig = sourceConfigFromRegistrySource(resolved.name, resolved.version, source) } else if (parsed.kind === 'ecosystem' && parsed.ecosystem === 'npm') { // Registry miss with explicit `npm:` prefix → honor the user's diff --git a/packages/cli/src/registry.ts b/packages/cli/src/registry.ts index 895c3a4..1b94945 100644 --- a/packages/cli/src/registry.ts +++ b/packages/cli/src/registry.ts @@ -1,11 +1,9 @@ -import type { RegistryEntry, RegistryStrategy } from '@pleaseai/ask-schema' +import type { RegistrySource } from '@pleaseai/ask-schema' import fs from 'node:fs' import path from 'node:path' -import { expandStrategies } from '@pleaseai/ask-schema' import { consola } from 'consola' -export type { ExpandInput, RegistryAlias, RegistryEntry, RegistryStrategy } from '@pleaseai/ask-schema' -export { expandStrategies } from '@pleaseai/ask-schema' +export type { RegistryAlias, RegistryEntry, RegistryPackage, RegistrySource } from '@pleaseai/ask-schema' const REGISTRY_BASE_URL = 'https://ask-registry.pages.dev' @@ -144,19 +142,36 @@ function detectEcosystem(projectDir: string): string { } /** - * Shape of the registry API response — a stored entry plus the - * server-side `resolvedName` field that disambiguates monorepo entries. + * Shape of the registry API response — flattened view of one registry + * entry focused on a single package. * - * The server returns: - * - `resolvedName === entry.name` for non-monorepo entries - * - `resolvedName === slugifyPackageName(requestedAlias)` for monorepo - * entries that hold multiple npm strategies + * For direct `owner/repo` lookups the server returns the sole package of a + * single-package entry (and 409s on monorepo entries). For alias lookups + * the server returns the one package that declared the alias. * - * It also reorders `strategies` so that the curated npm strategy matching - * the requested alias comes first. The CLI trusts both fields and does no - * client-side disambiguation. See `apps/registry/server/api/registry/[...slug].get.ts`. + * `resolvedName` is the CLI-facing identifier safe for use as both a + * directory name and a Claude Code skill name — `@mastra/core` → + * `mastra-core`. For single-package entries it equals `entry.name`; for + * monorepo entries it is `slugifyPackageName(package.name)`. + * + * `sources` is in the entry author's declared priority order. The CLI + * uses the head as the primary choice and can walk the remainder as a + * fallback chain on download failure. See the ADR-0001 decision record. */ -export type RegistryApiResponse = RegistryEntry & { resolvedName?: string } +export interface RegistryApiResponse { + name: string + description: string + repo: string + homepage?: string + license?: string + tags?: string[] + resolvedName: string + package: { + name: string + description?: string + } + sources: RegistrySource[] +} /** * Fetch registry entry from the registry API. @@ -175,7 +190,20 @@ export async function fetchRegistryEntry( try { const response = await fetch(url) + if (response.status === 404) { + return null + } if (!response.ok) { + // Non-404 errors carry actionable information we must not swallow. + // The registry server returns 409 Conflict with a `statusMessage` + // telling the caller to disambiguate a monorepo entry via an + // ecosystem alias (e.g. `npm:@mastra/core` instead of + // `mastra-ai/mastra`). Surface it via `consola.warn` so the user + // sees the guidance; 5xx/other errors get the same treatment so + // they don't silently degrade to "entry not found". + const body = await response.json().catch(() => ({})) as { statusMessage?: string } + const message = body.statusMessage ?? response.statusText + consola.warn(`Registry lookup for ${first}/${second} returned ${response.status}: ${message}`) return null } const data = await response.json() as RegistryApiResponse @@ -187,78 +215,18 @@ export async function fetchRegistryEntry( } } -/** - * Source type priority for selecting the best strategy from a registry entry. - * - * Lower number = higher priority. Based on Nuxt UI eval results (2026-04-07): - * GitHub docs achieved 100% pass rate at lowest cost, while llms.txt scored - * below baseline. See evals/nuxt-ui/README.md for full methodology. - * - * Note: an explicit `npm` strategy carrying a `docsPath` overrides this table - * — see `selectBestStrategy` for the rationale. The base table only matters - * for tie-break and for npm strategies *without* an explicit docsPath. - */ -const SOURCE_PRIORITY: Record = { - 'github': 0, - 'npm': 1, - 'web': 2, - 'llms-txt': 3, -} - -/** - * An npm strategy that explicitly declares a `docsPath` is treated as - * author-curated (e.g. `vercel/ai`'s `dist/docs`). It outranks every other - * strategy in the same entry. Without `docsPath` we fall back to the static - * SOURCE_PRIORITY table — npm without curation is no better than github. - */ -function isCuratedNpm(strategy: RegistryStrategy): boolean { - return strategy.source === 'npm' && Boolean(strategy.docsPath) -} - -/** - * Pick the highest-priority strategy from a list, preserving the original - * order for ties (stable sort). - * - * Selection rules (in order): - * 1. If any strategy is a "curated npm" (`source: npm` with `docsPath`), - * pick the first one in declaration order. The registry server is - * responsible for putting the right curated npm strategy first when - * the request was for a specific scoped package in a monorepo entry - * — see `apps/registry/server/api/registry/[...slug].get.ts`. The - * client's job here is just to honor the order. - * 2. Otherwise, sort by SOURCE_PRIORITY (github > npm > web > llms-txt) - * preserving original order on ties. - */ -export function selectBestStrategy(strategies: RegistryStrategy[]): RegistryStrategy { - if (strategies.length === 0) { - throw new Error('selectBestStrategy requires at least one strategy') - } - // Rule 1: curated npm wins outright (first in declaration order — the - // server has already put the right one at the head when applicable) - const curated = strategies.find(isCuratedNpm) - if (curated) { - return curated - } - // Rule 2: stable sort by static priority - const indexed = Array.from(strategies, (s, i) => ({ s, i })) - indexed.sort((a, b) => { - const pa = SOURCE_PRIORITY[a.s.source] ?? 99 - const pb = SOURCE_PRIORITY[b.s.source] ?? 99 - if (pa !== pb) - return pa - pb - return a.i - b.i - }) - return indexed[0].s -} - /** * Resolve source config from registry. - * Returns the highest-priority strategy from the registry entry. + * + * Returns the first source from the selected package in declaration order. + * Per ADR-0001, priority is author-decided — the CLI no longer reorders + * sources client-side. A future enhancement can walk `entry.sources` as a + * fallback chain when the primary source fails. */ export async function resolveFromRegistry( input: string, projectDir: string, -): Promise<{ ecosystem: string, name: string, version: string, strategy: RegistryStrategy } | null> { +): Promise<{ ecosystem: string, name: string, version: string, source: RegistrySource } | null> { const { ecosystem: explicitEcosystem, spec } = parseEcosystem(input) const lastAt = spec.lastIndexOf('@') @@ -274,34 +242,19 @@ export async function resolveFromRegistry( return null } - let strategies: RegistryStrategy[] - try { - strategies = expandStrategies({ - repo: entry.repo, - docsPath: entry.docsPath, - strategies: entry.strategies, - }) - } - catch (error) { - consola.warn(`Registry entry for ${name} is misconfigured: ${(error as Error).message}`) + const [primary] = entry.sources + if (!primary) { + consola.warn(`Registry entry for ${name} has no sources`) return null } consola.success(`Found ${entry.name} in registry: ${entry.description}`) - - const strategy = selectBestStrategy(strategies) - consola.info(`Using source: ${strategy.source}`) - - // The server has already disambiguated and slugified `resolvedName` for - // monorepo entries (multiple scoped packages under one repo entry). For - // older server versions that don't return `resolvedName`, fall back to - // the entry's display name — the same behavior as before. - const resolvedName = entry.resolvedName ?? entry.name + consola.info(`Using source: ${primary.type}`) return { ecosystem, - name: resolvedName, + name: entry.resolvedName, version, - strategy, + source: primary, } } diff --git a/packages/cli/test/registry-schema.test.ts b/packages/cli/test/registry-schema.test.ts deleted file mode 100644 index 7631a6e..0000000 --- a/packages/cli/test/registry-schema.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { expandStrategies } from '../src/registry.js' - -describe('expandStrategies', () => { - it('generates github strategy from repo + docsPath', () => { - const result = expandStrategies({ - repo: 'vercel/next.js', - docsPath: 'docs', - }) - expect(result).toEqual([ - { source: 'github', repo: 'vercel/next.js', docsPath: 'docs' }, - ]) - }) - - it('generates github strategy from repo alone (no docsPath)', () => { - const result = expandStrategies({ - repo: 'colinhacks/zod', - }) - expect(result).toEqual([ - { source: 'github', repo: 'colinhacks/zod' }, - ]) - }) - - it('returns existing strategies as-is when provided', () => { - const strategies = [ - { source: 'npm' as const, package: 'next', docsPath: 'dist/docs' }, - { source: 'github' as const, repo: 'vercel/next.js', docsPath: 'docs' }, - ] - const result = expandStrategies({ strategies }) - expect(result).toEqual(strategies) - }) - - it('returns existing strategies when both repo and strategies are provided', () => { - const strategies = [ - { source: 'web' as const, urls: ['https://tailwindcss.com/docs'], maxDepth: 2 }, - ] - const result = expandStrategies({ repo: 'tailwindlabs/tailwindcss', strategies }) - expect(result).toEqual(strategies) - }) - - it('throws when neither repo nor strategies is provided', () => { - expect(() => expandStrategies({})).toThrow(/repo.*strategies|strategies.*repo/) - }) - - it('throws when strategies is an empty array and no repo', () => { - expect(() => expandStrategies({ strategies: [] })).toThrow(/repo.*strategies|strategies.*repo/) - }) - - it('generates strategy from repo when strategies is empty array', () => { - const result = expandStrategies({ - repo: 'fastapi/fastapi', - strategies: [], - docsPath: 'docs', - }) - expect(result).toEqual([ - { source: 'github', repo: 'fastapi/fastapi', docsPath: 'docs' }, - ]) - }) -}) diff --git a/packages/cli/test/registry.test.ts b/packages/cli/test/registry.test.ts index 9a50226..1827028 100644 --- a/packages/cli/test/registry.test.ts +++ b/packages/cli/test/registry.test.ts @@ -1,7 +1,6 @@ -import type { RegistryStrategy } from '@pleaseai/ask-schema' import type { ParsedDocSpec } from '../src/registry.js' import { describe, expect, it } from 'bun:test' -import { parseDocSpec, parseEcosystem, selectBestStrategy } from '../src/registry.js' +import { parseDocSpec, parseEcosystem } from '../src/registry.js' describe('parseEcosystem', () => { it('splits simple ecosystem prefix', () => { @@ -213,85 +212,7 @@ describe('parseDocSpec', () => { }) }) -describe('selectBestStrategy', () => { - // T-3/T-4 (npm-tarball-docs-20260408): An npm strategy carrying a `docsPath` - // is treated as author-curated and outranks github. Without `docsPath` the - // static SOURCE_PRIORITY table still wins. These tests are the regression - // guard for that selection rule. - - const github: RegistryStrategy = { source: 'github', repo: 'vercel/next.js', docsPath: 'docs' } - const npmCurated: RegistryStrategy = { source: 'npm', package: 'next', docsPath: 'dist/docs' } - const npmBare: RegistryStrategy = { source: 'npm', package: 'next' } - const web: RegistryStrategy = { source: 'web', urls: ['https://example.com/docs'] } - - it('returns the only strategy when list has one entry (github)', () => { - expect(selectBestStrategy([github])).toBe(github) - }) - - it('returns the only strategy when list has one entry (curated npm)', () => { - expect(selectBestStrategy([npmCurated])).toBe(npmCurated) - }) - - it('npm-with-docsPath beats github even when github is listed first', () => { - // Real-world case: vercel/ai entry lists npm + github strategies; the - // curated npm dist/docs path must win regardless of declaration order. - expect(selectBestStrategy([github, npmCurated])).toBe(npmCurated) - expect(selectBestStrategy([npmCurated, github])).toBe(npmCurated) - }) - - it('npm-without-docsPath does NOT beat github (falls back to static priority)', () => { - // A bare `source: npm` entry has no proof of curation — github wins. - expect(selectBestStrategy([github, npmBare])).toBe(github) - expect(selectBestStrategy([npmBare, github])).toBe(github) - }) - - it('curated npm wins over web too', () => { - expect(selectBestStrategy([web, npmCurated])).toBe(npmCurated) - }) - - it('tie-break is stable (declaration order) for non-curated case', () => { - const githubA: RegistryStrategy = { source: 'github', repo: 'a/x' } - const githubB: RegistryStrategy = { source: 'github', repo: 'b/y' } - expect(selectBestStrategy([githubA, githubB])).toBe(githubA) - expect(selectBestStrategy([githubB, githubA])).toBe(githubB) - }) - - it('among multiple curated npm strategies WITHOUT context, declaration order wins', () => { - const core: RegistryStrategy = { source: 'npm', package: '@mastra/core', docsPath: 'dist/docs' } - const memory: RegistryStrategy = { source: 'npm', package: '@mastra/memory', docsPath: 'dist/docs' } - expect(selectBestStrategy([core, memory])).toBe(core) - expect(selectBestStrategy([memory, core])).toBe(memory) - }) - - it('throws on empty list', () => { - expect(() => selectBestStrategy([])).toThrow(/at least one/i) - }) - - // The registry server handles monorepo disambiguation and reorders the - // strategies array so the matching curated npm strategy is at the head. - // From the client's POV, that means it always sees the right strategy - // first — no client-side context is needed. This test documents the - // contract: when the server has put the matching strategy first, the - // client must honor it. - it('honors server-side ordering for monorepo entries', () => { - // Server-shaped response for `npm:@mastra/memory` against the - // mastra-ai/mastra entry: matching npm strategy is at index 0, - // followed by the github fallback. - const memory: RegistryStrategy = { source: 'npm', package: '@mastra/memory', docsPath: 'dist/docs' } - const github: RegistryStrategy = { source: 'github', repo: 'mastra-ai/mastra', docsPath: 'docs' } - expect(selectBestStrategy([memory, github])).toBe(memory) - }) - - // T-14 regression: ensure that registry entries WITHOUT explicit - // strategies (the common case for entries created before this track) keep - // resolving to a github strategy via expandStrategies + selectBestStrategy. - // The full chain runs in fetchRegistryEntry → resolveFromRegistry. Here we - // just exercise selectBestStrategy on the synthetic shape that - // expandStrategies emits for those entries. - it('regression: bare github entry (no strategies array) still resolves to github', () => { - // Mirrors the shape `expandStrategies({ repo, docsPath })` produces for - // entries like lodash/lodash, axios/axios, jquery/jquery. - const fromBareEntry: RegistryStrategy = { source: 'github', repo: 'lodash/lodash' } - expect(selectBestStrategy([fromBareEntry])).toBe(fromBareEntry) - }) -}) +// NOTE: Source selection moved from the client to the entry author per +// ADR-0001 — `sources[]` is iterated in declaration order, and the registry +// server no longer reorders. There is no `selectBestStrategy` equivalent on +// the CLI side. Schema-level shape tests live in `packages/schema/test/`. diff --git a/packages/schema/src/registry.ts b/packages/schema/src/registry.ts index 602e834..262e910 100644 --- a/packages/schema/src/registry.ts +++ b/packages/schema/src/registry.ts @@ -1,68 +1,244 @@ import { z } from 'zod' -export const strategySchema = z.object({ - source: z.enum(['npm', 'github', 'web', 'llms-txt']), - package: z.string().optional(), - repo: z.string().optional(), +/** + * Registry entry schema — Entry → Package → Source hierarchy. + * + * See ADR-0001 (`.please/docs/decisions/0001-registry-entry-schema-entry-package-source.md`) + * for the rationale behind this shape. + * + * - Entry : one Markdown file per GitHub repository. Holds repo-level + * metadata (name, description, repo, homepage, license, tags). + * - Package : a documentation target. Single-package libraries have one; + * monorepos have N. Owns its aliases and its sources. + * - Source : a way to fetch one package's docs. Multiple sources form a + * fallback chain in declaration order — the CLI tries them + * head-first and falls back on failure. + */ + +// --------------------------------------------------------------------------- +// Source — discriminated union on `type` +// --------------------------------------------------------------------------- + +const npmSourceSchema = z.object({ + type: z.literal('npm'), + /** npm package name. Required; we no longer infer from the entry name. */ + package: z.string().min(1), + /** Path inside the published tarball (e.g. `dist/docs`). */ + path: z.string().optional(), +}) + +const githubSourceSchema = z.object({ + type: z.literal('github'), + /** GitHub repo in `owner/name` form. */ + repo: z.string().regex(/^[^/]+\/[^/]+$/, 'repo must be in "owner/name" form'), + /** Branch to fetch. Mutually exclusive with `tag`. Defaults to the repo's default branch. */ branch: z.string().optional(), + /** Tag or ref to fetch. Mutually exclusive with `branch`. */ tag: z.string().optional(), - docsPath: z.string().optional(), - url: z.string().optional(), - urls: z.array(z.string()).optional(), - maxDepth: z.number().optional(), + /** Path inside the repository (e.g. `docs`, `content/docs`). Auto-detected when omitted. */ + path: z.string().optional(), +}) + +const webSourceSchema = z.object({ + type: z.literal('web'), + /** Starting URLs for the crawl. */ + urls: z.array(z.string().url()).min(1), + /** Maximum crawl depth from each start URL. Defaults to 1. */ + maxDepth: z.number().int().positive().optional(), + /** Restrict the crawl to URLs whose path starts with this prefix. */ allowedPathPrefix: z.string().optional(), }) +const llmsTxtSourceSchema = z.object({ + type: z.literal('llms-txt'), + /** Absolute URL to the `llms.txt` file. */ + url: z.string().url(), +}) + +export const sourceSchema = z.discriminatedUnion('type', [ + npmSourceSchema, + githubSourceSchema, + webSourceSchema, + llmsTxtSourceSchema, +]) + .superRefine((source, ctx) => { + // GithubSource invariant: `branch` and `tag` are mutually exclusive. + // The prose contract used to document that `tag` takes precedence, + // which meant silently dropping a caller-supplied branch. Fail loudly + // instead — the caller must pick one. + if (source.type === 'github' && source.branch && source.tag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['tag'], + message: 'github source: `branch` and `tag` are mutually exclusive', + }) + } + }) + +export type RegistrySource = z.infer +export type NpmSource = z.infer +export type GithubSource = z.infer +export type WebSource = z.infer +export type LlmsTxtSource = z.infer + +// --------------------------------------------------------------------------- +// Alias — an (ecosystem, name) pair a user can request +// --------------------------------------------------------------------------- + export const aliasSchema = z.object({ ecosystem: z.enum(['npm', 'pypi', 'pub', 'go', 'crates', 'hex', 'nuget', 'maven']), - name: z.string(), + name: z.string().min(1), }) +export type RegistryAlias = z.infer + +// --------------------------------------------------------------------------- +// Package — a documentation target inside an entry +// --------------------------------------------------------------------------- + +export const packageSchema = z.object({ + /** + * Canonical package name. Used by the server to derive `resolvedName` + * (slugified) so different packages in a monorepo land in distinct + * `.ask/docs/@/` directories on the CLI side. + */ + name: z.string().min(1), + /** Optional per-package human description. */ + description: z.string().optional(), + /** + * Aliases through which this package can be requested. Typically one + * entry per ecosystem the package is published to (e.g. `npm:@mastra/core`). + */ + aliases: z.array(aliasSchema).min(1), + /** + * Fetch sources in declaration order. The CLI tries the head first and + * falls back on failure. At least one source is required. + */ + sources: z.array(sourceSchema).min(1), +}) + +export type RegistryPackage = z.infer + +// --------------------------------------------------------------------------- +// Entry — one registry content file +// --------------------------------------------------------------------------- + export const registryEntrySchema = z.object({ - name: z.string(), - description: z.string(), + name: z.string().min(1), + description: z.string().min(1), repo: z.string().regex(/^[^/]+\/[^/]+$/, 'repo must be in "owner/name" form'), - docsPath: z.string().optional(), - homepage: z.string().optional(), + homepage: z.string().url().optional(), license: z.string().optional(), - aliases: z.array(aliasSchema).optional().default([]), - strategies: z.array(strategySchema).optional().default([]), tags: z.array(z.string()).optional(), + /** + * Documentation targets. Single-package libraries have exactly one entry; + * monorepos have one entry per documented package. + */ + packages: z.array(packageSchema).min(1), }) + .superRefine((entry, ctx) => { + // Reject duplicate aliases across packages within the same entry — + // this would make alias-based routing ambiguous at request time. + const seen = new Map() + entry.packages.forEach((pkg, pkgIdx) => { + pkg.aliases.forEach((alias, aliasIdx) => { + const key = `${alias.ecosystem}:${alias.name}` + const prev = seen.get(key) + if (prev !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['packages', pkgIdx, 'aliases', aliasIdx], + message: `Duplicate alias "${key}" — already declared by packages[${prev}]`, + }) + } + else { + seen.set(key, pkgIdx) + } + }) + }) + + // Reject duplicate package names within the same entry. + const nameSeen = new Map() + entry.packages.forEach((pkg, pkgIdx) => { + const prev = nameSeen.get(pkg.name) + if (prev !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['packages', pkgIdx, 'name'], + message: `Duplicate package name "${pkg.name}" — already declared by packages[${prev}]`, + }) + } + else { + nameSeen.set(pkg.name, pkgIdx) + } + }) + + // Reject slug collisions. Two distinct package names can slugify to the + // same directory name (e.g. `@a/b-c` and `a-b/c` both → `a-b-c`), which + // would cause `.ask/docs/@/` directory clashes on the CLI + // side. The `nameSeen` check above does not catch this because the + // package names differ, only their slugs collide. + const slugSeen = new Map() + entry.packages.forEach((pkg, pkgIdx) => { + const slug = slugifyPackageName(pkg.name) + const prev = slugSeen.get(slug) + if (prev !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['packages', pkgIdx, 'name'], + message: `Package name "${pkg.name}" slugifies to "${slug}", colliding with packages[${prev}]`, + }) + } + else { + slugSeen.set(slug, pkgIdx) + } + }) + }) -export type RegistryStrategy = z.infer -export type RegistryAlias = z.infer export type RegistryEntry = z.infer -export interface ExpandInput { - repo?: string - docsPath?: string - strategies?: RegistryStrategy[] +// --------------------------------------------------------------------------- +// Lookup helpers +// --------------------------------------------------------------------------- + +/** + * Find the package inside an entry that matches the given alias. + * Returns `undefined` when no package declares the alias. + */ +export function findPackageByAlias( + entry: RegistryEntry, + ecosystem: RegistryAlias['ecosystem'], + name: string, +): RegistryPackage | undefined { + return entry.packages.find(pkg => + pkg.aliases.some(a => a.ecosystem === ecosystem && a.name === name), + ) } /** - * Expand a registry entry into a concrete list of strategies. + * True when an entry documents more than one package (a monorepo entry). + * Direct `owner/repo` lookups against such entries should prompt the caller + * to disambiguate which package they want. + */ +export function isMonorepoEntry(entry: RegistryEntry): boolean { + return entry.packages.length > 1 +} + +/** + * Slugify a package name into a filesystem- and skill-name-safe identifier. * - * When `strategies` is non-empty it is returned as-is. - * When `strategies` is empty/missing but `repo` is provided, - * a default github strategy is generated from `repo` (+ optional `docsPath`). + * Examples: + * - `@mastra/core` → `mastra-core` + * - `@scope/pkg-name` → `scope-pkg-name` + * - `lodash` → `lodash` * - * @throws Error when neither `repo` nor non-empty `strategies` is present. + * The CLI uses the returned slug as both a directory name + * (`.ask/docs/@/`) and a Claude Code skill name. Both surfaces + * reject `@` and `/`. */ -export function expandStrategies(input: ExpandInput): RegistryStrategy[] { - const { repo, docsPath, strategies } = input - - if (strategies && strategies.length > 0) { - return strategies +export function slugifyPackageName(pkg: string): string { + if (pkg.startsWith('@')) { + return pkg.slice(1).replace('/', '-') } - - if (repo) { - const strategy: RegistryStrategy = { source: 'github', repo } - if (docsPath) { - strategy.docsPath = docsPath - } - return [strategy] - } - - throw new Error('Registry entry requires at least one of `repo` or `strategies`') + return pkg } diff --git a/packages/schema/test/index.test.ts b/packages/schema/test/index.test.ts index 1d393cd..fafc29b 100644 --- a/packages/schema/test/index.test.ts +++ b/packages/schema/test/index.test.ts @@ -1,119 +1,344 @@ import { describe, expect, it } from 'bun:test' -import { aliasSchema, expandStrategies, registryEntrySchema, strategySchema } from '../src/index.js' +import { + aliasSchema, + findPackageByAlias, + isMonorepoEntry, + packageSchema, + registryEntrySchema, + slugifyPackageName, + sourceSchema, +} from '../src/index.js' -describe('expandStrategies', () => { - it('generates github strategy from repo + docsPath', () => { - const result = expandStrategies({ +describe('sourceSchema', () => { + it('parses a valid npm source', () => { + const result = sourceSchema.parse({ + type: 'npm', + package: '@mastra/core', + path: 'dist/docs', + }) + expect(result.type).toBe('npm') + }) + + it('parses a valid github source with tag', () => { + const result = sourceSchema.parse({ + type: 'github', repo: 'vercel/next.js', - docsPath: 'docs', + tag: 'v15.0.0', + path: 'docs', }) - expect(result).toEqual([ - { source: 'github', repo: 'vercel/next.js', docsPath: 'docs' }, - ]) + expect(result.type).toBe('github') }) - it('generates github strategy from repo alone (no docsPath)', () => { - const result = expandStrategies({ - repo: 'colinhacks/zod', + it('parses a valid web source', () => { + const result = sourceSchema.parse({ + type: 'web', + urls: ['https://tailwindcss.com/docs'], + maxDepth: 2, + allowedPathPrefix: '/docs', }) - expect(result).toEqual([ - { source: 'github', repo: 'colinhacks/zod' }, - ]) + expect(result.type).toBe('web') }) - it('returns existing strategies as-is when provided', () => { - const strategies = [ - { source: 'npm' as const, package: 'next', docsPath: 'dist/docs' }, - { source: 'github' as const, repo: 'vercel/next.js', docsPath: 'docs' }, - ] - const result = expandStrategies({ strategies }) - expect(result).toEqual(strategies) + it('parses a valid llms-txt source', () => { + const result = sourceSchema.parse({ + type: 'llms-txt', + url: 'https://example.com/llms.txt', + }) + expect(result.type).toBe('llms-txt') }) - it('returns existing strategies when both repo and strategies are provided', () => { - const strategies = [ - { source: 'web' as const, urls: ['https://tailwindcss.com/docs'], maxDepth: 2 }, - ] - const result = expandStrategies({ repo: 'tailwindlabs/tailwindcss', strategies }) - expect(result).toEqual(strategies) + it('rejects unknown source type', () => { + expect(() => sourceSchema.parse({ type: 'invalid' })).toThrow() }) - it('throws when neither repo nor strategies is provided', () => { - expect(() => expandStrategies({})).toThrow(/repo.*strategies|strategies.*repo/) + it('rejects a github source with both branch and tag', () => { + expect(() => sourceSchema.parse({ + type: 'github', + repo: 'vercel/next.js', + branch: 'main', + tag: 'v15.0.0', + })).toThrow(/mutually exclusive/) }) - it('throws when strategies is an empty array and no repo', () => { - expect(() => expandStrategies({ strategies: [] })).toThrow(/repo.*strategies|strategies.*repo/) + it('accepts a github source with only branch', () => { + const result = sourceSchema.parse({ type: 'github', repo: 'vercel/next.js', branch: 'canary' }) + expect(result.type).toBe('github') }) - it('generates strategy from repo when strategies is empty array', () => { - const result = expandStrategies({ - repo: 'fastapi/fastapi', - strategies: [], - docsPath: 'docs', - }) - expect(result).toEqual([ - { source: 'github', repo: 'fastapi/fastapi', docsPath: 'docs' }, - ]) + it('requires npm.package (no silent default to entry name)', () => { + expect(() => sourceSchema.parse({ type: 'npm', path: 'docs' })).toThrow() }) -}) -describe('strategySchema', () => { - it('parses valid strategy', () => { - const result = strategySchema.parse({ source: 'github', repo: 'vercel/next.js' }) - expect(result.source).toBe('github') - expect(result.repo).toBe('vercel/next.js') + it('rejects github without repo', () => { + expect(() => sourceSchema.parse({ type: 'github', path: 'docs' })).toThrow() }) - it('rejects invalid source', () => { - expect(() => strategySchema.parse({ source: 'invalid' })).toThrow() + it('rejects github with malformed repo', () => { + expect(() => sourceSchema.parse({ type: 'github', repo: 'no-slash' })).toThrow() + }) + + it('rejects web without urls', () => { + expect(() => sourceSchema.parse({ type: 'web' })).toThrow() + }) + + it('rejects web with empty urls array', () => { + expect(() => sourceSchema.parse({ type: 'web', urls: [] })).toThrow() }) }) describe('aliasSchema', () => { - it('parses valid alias', () => { + it('parses a valid alias', () => { const result = aliasSchema.parse({ ecosystem: 'npm', name: 'react' }) expect(result.ecosystem).toBe('npm') expect(result.name).toBe('react') }) - it('rejects invalid ecosystem', () => { + it('rejects an unknown ecosystem', () => { expect(() => aliasSchema.parse({ ecosystem: 'invalid', name: 'x' })).toThrow() }) }) -describe('registryEntrySchema', () => { - it('parses valid entry with defaults', () => { - const result = registryEntrySchema.parse({ - name: 'next', - description: 'React framework', - repo: 'vercel/next.js', +describe('packageSchema', () => { + it('parses a single-source package', () => { + const result = packageSchema.parse({ + name: 'zod', + aliases: [{ ecosystem: 'npm', name: 'zod' }], + sources: [{ type: 'github', repo: 'colinhacks/zod', path: 'docs' }], }) - expect(result.aliases).toEqual([]) - expect(result.strategies).toEqual([]) + expect(result.sources).toHaveLength(1) }) - it('parses a fully-populated entry', () => { - const result = registryEntrySchema.parse({ - name: 'next', - description: 'React framework', - repo: 'vercel/next.js', - docsPath: 'docs', - homepage: 'https://nextjs.org', - license: 'MIT', - aliases: [{ ecosystem: 'npm', name: 'next' }], - strategies: [{ source: 'github', repo: 'vercel/next.js', docsPath: 'docs' }], - tags: ['react', 'framework'], + it('parses a package with a fallback chain', () => { + const result = packageSchema.parse({ + name: 'ai', + aliases: [{ ecosystem: 'npm', name: 'ai' }], + sources: [ + { type: 'npm', package: 'ai', path: 'dist/docs' }, + { type: 'github', repo: 'vercel/ai', path: 'content/docs' }, + ], }) - expect(result.aliases).toHaveLength(1) - expect(result.strategies).toHaveLength(1) + expect(result.sources).toHaveLength(2) + }) + + it('requires at least one alias', () => { + expect(() => packageSchema.parse({ + name: 'zod', + aliases: [], + sources: [{ type: 'github', repo: 'colinhacks/zod' }], + })).toThrow() + }) + + it('requires at least one source', () => { + expect(() => packageSchema.parse({ + name: 'zod', + aliases: [{ ecosystem: 'npm', name: 'zod' }], + sources: [], + })).toThrow() + }) +}) + +describe('registryEntrySchema', () => { + const singlePackageEntry = { + name: 'Zod', + description: 'TypeScript-first schema validation', + repo: 'colinhacks/zod', + packages: [ + { + name: 'zod', + aliases: [{ ecosystem: 'npm' as const, name: 'zod' }], + sources: [{ type: 'github' as const, repo: 'colinhacks/zod', path: 'docs' }], + }, + ], + } + + const monorepoEntry = { + name: 'Mastra', + description: 'AI agent framework', + repo: 'mastra-ai/mastra', + packages: [ + { + name: '@mastra/core', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/core' }], + sources: [{ type: 'npm' as const, package: '@mastra/core', path: 'dist/docs' }], + }, + { + name: '@mastra/memory', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/memory' }], + sources: [{ type: 'npm' as const, package: '@mastra/memory', path: 'dist/docs' }], + }, + ], + } + + it('parses a single-package entry', () => { + const result = registryEntrySchema.parse(singlePackageEntry) + expect(result.packages).toHaveLength(1) + }) + + it('parses a monorepo entry', () => { + const result = registryEntrySchema.parse(monorepoEntry) + expect(result.packages).toHaveLength(2) }) it('rejects invalid repo format', () => { expect(() => registryEntrySchema.parse({ - name: 'bad', - description: 'bad', + ...singlePackageEntry, repo: 'no-slash', })).toThrow() }) + + it('rejects entries without packages', () => { + expect(() => registryEntrySchema.parse({ + name: 'Empty', + description: 'empty', + repo: 'foo/bar', + packages: [], + })).toThrow() + }) + + it('rejects duplicate aliases across packages', () => { + expect(() => registryEntrySchema.parse({ + name: 'Clash', + description: 'duplicate alias test', + repo: 'foo/bar', + packages: [ + { + name: 'a', + aliases: [{ ecosystem: 'npm' as const, name: 'shared' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + { + name: 'b', + aliases: [{ ecosystem: 'npm' as const, name: 'shared' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + ], + })).toThrow(/Duplicate alias/) + }) + + it('rejects package names that slugify to the same directory', () => { + // Different names, same slug: `@a/b-c` and `a-b-c` both → `a-b-c` + expect(() => registryEntrySchema.parse({ + name: 'SlugClash', + description: 'slug collision test', + repo: 'foo/bar', + packages: [ + { + name: '@a/b-c', + aliases: [{ ecosystem: 'npm' as const, name: '@a/b-c' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + { + name: 'a-b-c', + aliases: [{ ecosystem: 'npm' as const, name: 'a-b-c' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + ], + })).toThrow(/slugifies/) + }) + + it('rejects duplicate package names', () => { + expect(() => registryEntrySchema.parse({ + name: 'Clash', + description: 'duplicate package name', + repo: 'foo/bar', + packages: [ + { + name: 'dup', + aliases: [{ ecosystem: 'npm' as const, name: 'a' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + { + name: 'dup', + aliases: [{ ecosystem: 'npm' as const, name: 'b' }], + sources: [{ type: 'github' as const, repo: 'foo/bar' }], + }, + ], + })).toThrow(/Duplicate package name/) + }) +}) + +describe('findPackageByAlias', () => { + const entry = registryEntrySchema.parse({ + name: 'Mastra', + description: 'AI agent framework', + repo: 'mastra-ai/mastra', + packages: [ + { + name: '@mastra/core', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/core' }], + sources: [{ type: 'npm' as const, package: '@mastra/core', path: 'dist/docs' }], + }, + { + name: '@mastra/memory', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/memory' }], + sources: [{ type: 'npm' as const, package: '@mastra/memory', path: 'dist/docs' }], + }, + ], + }) + + it('finds the matching package', () => { + const pkg = findPackageByAlias(entry, 'npm', '@mastra/memory') + expect(pkg?.name).toBe('@mastra/memory') + }) + + it('returns undefined on alias miss', () => { + const pkg = findPackageByAlias(entry, 'npm', 'not-present') + expect(pkg).toBeUndefined() + }) + + it('returns undefined on ecosystem mismatch', () => { + const pkg = findPackageByAlias(entry, 'pypi', '@mastra/core') + expect(pkg).toBeUndefined() + }) +}) + +describe('isMonorepoEntry', () => { + it('returns true for multi-package entries', () => { + const entry = registryEntrySchema.parse({ + name: 'Mastra', + description: 'x', + repo: 'mastra-ai/mastra', + packages: [ + { + name: '@mastra/core', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/core' }], + sources: [{ type: 'npm' as const, package: '@mastra/core', path: 'dist/docs' }], + }, + { + name: '@mastra/memory', + aliases: [{ ecosystem: 'npm' as const, name: '@mastra/memory' }], + sources: [{ type: 'npm' as const, package: '@mastra/memory', path: 'dist/docs' }], + }, + ], + }) + expect(isMonorepoEntry(entry)).toBe(true) + }) + + it('returns false for single-package entries', () => { + const entry = registryEntrySchema.parse({ + name: 'Zod', + description: 'x', + repo: 'colinhacks/zod', + packages: [ + { + name: 'zod', + aliases: [{ ecosystem: 'npm' as const, name: 'zod' }], + sources: [{ type: 'github' as const, repo: 'colinhacks/zod', path: 'docs' }], + }, + ], + }) + expect(isMonorepoEntry(entry)).toBe(false) + }) +}) + +describe('slugifyPackageName', () => { + it('leaves bare names unchanged', () => { + expect(slugifyPackageName('zod')).toBe('zod') + expect(slugifyPackageName('next')).toBe('next') + }) + + it('slugifies scoped packages', () => { + expect(slugifyPackageName('@mastra/core')).toBe('mastra-core') + expect(slugifyPackageName('@scope/pkg-name')).toBe('scope-pkg-name') + }) })