Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .please/docs/tracks.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
{"id":"parallelize-sync-fetches-20260407","type":"chore","status":"review","phase":"review","issue":"#2","created":"2026-04-07","section":"completed","pr":"#8"}
{"id":"cli-shorthand-20260407","type":"feature","status":"review","phase":"finalize","issue":"#4","pr":"#10","created":"2026-04-07","section":"completed"}
{"id":"registry-meta-20260407","type":"feature","status":"in_progress","phase":"implement","issue":"#5","created":"2026-04-07","section":"active"}
{"id":"ecosystem-resolvers-20260407","type":"feature","status":"planned","phase":"spec","issue":"#6","created":"2026-04-07","section":"active"}
{"id":"ecosystem-resolvers-20260407","type":"feature","status":"in_progress","phase":"implement","issue":"#6","created":"2026-04-07","section":"active"}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"track_id": "ecosystem-resolvers-20260407",
"type": "feature",
"status": "planned",
"status": "review",
"created_at": "2026-04-07T00:00:00Z",
"updated_at": "2026-04-07T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z",
"issue": "#6",
"pr": "https://github.com/pleaseai/ask/pull/7",
"pr": "#13",
"project": ""
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,45 @@ ask docs add npm:lodash

## Tasks

- **T-1** [impl] `EcosystemResolver` interface, `parseRepoUrl` utility, and unit tests
- **T-2** [impl] npm resolver — registry API + dist-tags + semver resolution
- **T-3** [impl] pypi resolver — extract `project_urls`, PEP 440 handling
- **T-4** [impl] pub resolver — extract `pubspec.repository`
- **T-5** [test] Unit tests for each resolver (mocked fetch)
- **T-6** [impl] Wire resolvers into the `add` command — registry-miss fallback
- **T-7** [test] End-to-end smoke — `ask docs add npm:lodash`, `pub:riverpod`
- **T-8** [docs] Update README
- **T-9** [chore] Regression — confirm registry-hit `npm:next` still works
- [x] **T-1** [impl] `EcosystemResolver` interface, `parseRepoUrl` utility, and unit tests
- [x] **T-2** [impl] npm resolver — registry API + dist-tags + semver resolution
- [x] **T-3** [impl] pypi resolver — extract `project_urls`, PEP 440 handling
- [x] **T-4** [impl] pub resolver — extract `pubspec.repository`
- [x] **T-5** [test] Unit tests for each resolver (mocked fetch)
- [x] **T-6** [impl] Wire resolvers into the `add` command — registry-miss fallback
- [x] **T-7** [test] End-to-end smoke — `ask docs add npm:lodash`, `pub:riverpod`
- [x] **T-8** [docs] Update README
- [x] **T-9** [chore] Regression — confirm registry-hit `npm:next` still works

## Risks

- A package's `repository` field may be missing or wrong → emit a clear error and tell the user to fall back to `owner/repo` directly
- Git tag conventions differ (`v1.0.0` vs `1.0.0`) → try both, fall back on github 404
- Semver range parsing pulls in a new dependency — consider adding `semver`

## Outcomes & Retrospective

### What Was Shipped
- EcosystemResolver interface + factory (`getResolver`) for npm, pypi, pub
- `parseRepoUrl` utility for normalizing GitHub URLs from varied formats
- Semver range resolution for npm (`^15` → latest 15.x.x) via `semver` package
- Git ref fallback (`v{version}` → `{version}` or vice versa)
- Resolver fallback wired into `add` command on registry miss
- 146 tests passing, lint + tsc clean

### What Went Well
- Clean TDD cycle — tests defined expected behavior, implementation followed
- Spec compliance check caught semver range gap early before merge
- Resolvers are fully decoupled from sources, testable with mock fetch

### What Could Improve
- FR-5 fallback refs are declared but not yet exercised at download time (github source would need retry logic)
- Could add integration tests against real APIs (currently all mocked)

### Tech Debt Created
- `sources/npm.ts` marked deprecated but not removed — needs cleanup in a follow-up release
- FR-5 fallback ref retry logic not wired into github source fetch path

## Dependencies

- **Soft dependency** on `cli-shorthand-20260407`: once the github fast-path lands, the resolver can reuse the same code path. Parallel work is fine, but landing `cli-shorthand` first is preferable.
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ node packages/cli/dist/index.js docs add <spec> -s <source> [options]
- A PreToolUse Bash hook blocks shell commands when the current commit has no recorded review. Run `/review:code-review` (or `/review:run-cubic` / `/review:run-gemini`) and let it call `save-review-state.sh` before further bash.
- `apps/registry/` uses `vercel.ts` (programmatic config via `@vercel/config` devDep), not `vercel.json`. Use `git.deploymentEnabled` (not deprecated `github.enabled`) to control auto-deploy.
- Track artifacts live under `.please/docs/tracks/active/{slug}-{YYYYMMDD}/` with `spec.md`, `plan.md`, `metadata.json`. Append a JSON line to `.please/docs/tracks.jsonl` when creating a track.
- In git worktrees, `bun install` must be run before tests — dependencies are not shared across worktrees.
- `style/quote-props` ESLint rule: if any property in an interface requires quotes (e.g. `'dist-tags'`), all properties must be quoted for consistency.

## CLI Architecture (packages/cli/)

Expand All @@ -65,12 +67,19 @@ node packages/cli/dist/index.js docs add <spec> -s <source> [options]

**Source adapter pattern** (`src/sources/`):
- `index.ts` — defines `DocSource` interface, `SourceConfig` union type, and `getSource()` factory
- `npm.ts` — downloads npm tarballs, extracts doc files from package
- `npm.ts` — downloads npm tarballs, extracts doc files from package (deprecated — use resolver + github source)
- `github.ts` — downloads GitHub repo archives via tar.gz, extracts docs directory
- `web.ts` — crawls documentation websites, converts HTML to Markdown via `node-html-markdown`

All three sources implement `DocSource.fetch(options) -> Promise<FetchResult>` returning `{ files: DocFile[], resolvedVersion: string }`.

**Ecosystem resolver pattern** (`src/resolvers/`):
- `index.ts` — defines `EcosystemResolver` interface, `ResolveResult` type, and `getResolver()` factory
- `npm.ts` / `pypi.ts` / `pub.ts` — fetch package metadata APIs, extract GitHub `owner/repo`, delegate to github source
- `utils.ts` — `parseRepoUrl(url)` normalizes varied GitHub URL formats to `owner/repo`

Resolvers are orthogonal to sources — they only perform metadata lookups and hand off `repo` + `ref` to the github source. The `add` command tries the registry first; resolvers activate on registry miss for ecosystem-prefixed specs.

**Output pipeline** (executed in sequence by `add` command):
1. `storage.ts` — saves doc files to `.ask/docs/<name>@<version>/`, creates `INDEX.md`
2. `config.ts` — persists source config to `.ask/config.json` for `sync` re-download
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ Description and version notes here...

Add a new `.md` file under `apps/registry/content/registry/<ecosystem>/` and submit a PR.

## Ecosystem Resolvers

When a library isn't in the registry, ASK can **automatically resolve** its GitHub repository from ecosystem package metadata. This works for any ecosystem-prefixed spec:

```bash
ask docs add npm:lodash # Looks up registry.npmjs.org → lodash/lodash
ask docs add pypi:fastapi # Looks up pypi.org → fastapi/fastapi
ask docs add pub:riverpod # Looks up pub.dev → rrousselGit/riverpod
```

**Supported ecosystems:**

| Ecosystem | API | Metadata field |
|---|---|---|
| `npm` | `registry.npmjs.org/<name>` | `repository.url` |
| `pypi` | `pypi.org/pypi/<name>/json` | `info.project_urls.Source` |
| `pub` | `pub.dev/api/packages/<name>` | `latest.pubspec.repository` |

The resolver extracts the GitHub `owner/repo` from the package metadata and delegates to the GitHub source for download. The registry is always checked first — resolvers only activate on a registry miss.

## Source Adapters

### npm
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
"citty": "^0.2.2",
"consola": "^3.4.2",
"node-html-markdown": "^1.3.0",
"semver": "^7.7.4",
"zod": "^3.23.0"
},
"devDependencies": {
"@pleaseai/eslint-config": "^0.0.1",
"@types/node": "^22.0.0",
"@types/semver": "^7.7.1",
"eslint": "^10.1.0",
"jiti": "^2.6.1",
"typescript": "^5.7.0"
Expand Down
53 changes: 40 additions & 13 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { contentHash, getConfigPath, getLockPath, readLock, upsertLockEntry } from './io.js'
import { migrateLegacyWorkspace } from './migrate-legacy.js'
import { parseDocSpec, parseEcosystem, resolveFromRegistry } from './registry.js'
import { getResolver } from './resolvers/index.js'
import { generateSkill, removeSkill } from './skill.js'
import { getSource } from './sources/index.js'
import { listDocs, removeDocs, saveDocs } from './storage.js'
Expand Down Expand Up @@ -202,22 +203,48 @@ const addCmd = defineCommand({
else {
// Auto-detect from registry
const resolved = await resolveFromRegistry(args.spec, projectDir)
if (!resolved) {
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,
})
}
else if (parsed.kind === 'ecosystem') {
// Registry miss with ecosystem prefix → try ecosystem resolver
const resolver = getResolver(parsed.ecosystem)
if (!resolver) {
consola.error(
`'${args.spec}' not found in registry and no resolver for '${parsed.ecosystem}'. `
+ `Use --source to specify manually.`,
)
process.exit(1)
return // unreachable — hints TS that control flow ends
}
consola.info(`Registry miss — resolving via ${parsed.ecosystem} package metadata...`)
const resolveResult = await resolver.resolve(parsed.name, parsed.version)
consola.start(`Downloading ${parsed.name}@${resolveResult.resolvedVersion} docs (source: github via ${parsed.ecosystem} resolver)...`)
sourceConfig = {
source: 'github',
name: parsed.name,
version: resolveResult.resolvedVersion,
repo: resolveResult.repo,
tag: resolveResult.ref,
docsPath: args.docsPath,
} satisfies GithubSourceOptions
}
else {
consola.error(`'${args.spec}' not found in registry. Use --source to specify manually.`)
process.exit(1)
return // unreachable — hints TS that control flow ends
}
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 = getSource(sourceConfig.source)
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/resolvers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NpmResolver } from './npm.js'
import { PubResolver } from './pub.js'
import { PypiResolver } from './pypi.js'

/**
* Result of resolving an ecosystem package to a GitHub repository.
* Always handed off to the github source for download.
*/
export interface ResolveResult {
/** GitHub `owner/repo` */
repo: string
/** Primary git ref to try first (tag or branch) */
ref: string
/**
* Fallback refs to try if the primary ref doesn't exist.
* Implements FR-5: try `v{version}`, then `{version}`, then default branch.
*/
fallbackRefs?: string[]
/** Resolved version string (e.g. `4.17.21`) */
resolvedVersion: string
}

/**
* Ecosystem resolver: maps a package name + version to a GitHub repository.
*
* Resolvers are orthogonal to sources — they only perform metadata lookups
* and never download documentation themselves. The resolved `repo` + `ref`
* are handed to the `github` source for the actual download.
*/
export interface EcosystemResolver {
resolve: (name: string, version: string) => Promise<ResolveResult>
}

type SupportedEcosystem = 'npm' | 'pypi' | 'pub'

const resolvers: Record<SupportedEcosystem, EcosystemResolver> = {
npm: new NpmResolver(),
pypi: new PypiResolver(),
pub: new PubResolver(),
}

/**
* Return the resolver for the given ecosystem, or `null` if unsupported.
*/
export function getResolver(ecosystem: string): EcosystemResolver | null {
return (resolvers as Record<string, EcosystemResolver>)[ecosystem] ?? null
}
92 changes: 92 additions & 0 deletions packages/cli/src/resolvers/npm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { EcosystemResolver, ResolveResult } from './index.js'
import { consola } from 'consola'
import { maxSatisfying, validRange } from 'semver'
import { parseRepoUrl } from './utils.js'

const RE_SEMVER_RANGE_CHARS = /[~^>=<|]/g

/**
* npm registry API response (partial — only fields we need).
*/
interface NpmPackageMeta {
'repository'?: { type?: string, url?: string } | string
'dist-tags'?: Record<string, string>
'versions'?: Record<string, unknown>
}

/**
* Resolve an npm package to a GitHub repo + git tag.
*
* 1. Fetch `https://registry.npmjs.org/<name>`
* 2. Resolve version: dist-tag → exact, semver range → best match, exact → passthrough
* 3. Extract `repository.url` → `owner/repo`
* 4. Return `v{version}` as the git ref
*/
export class NpmResolver implements EcosystemResolver {
async resolve(name: string, version: string): Promise<ResolveResult> {
const url = `https://registry.npmjs.org/${name}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`npm registry returned ${response.status} for ${name}`)
}

const meta = await response.json() as NpmPackageMeta

const distTags = meta['dist-tags'] ?? {}
const allVersions = meta.versions ? Object.keys(meta.versions) : []

// Resolve version: dist-tag → semver range → exact
let resolvedVersion: string
if (distTags[version]) {
// dist-tag (e.g. 'latest', 'canary')
resolvedVersion = distTags[version]
}
else if (validRange(version) && version !== version.replace(RE_SEMVER_RANGE_CHARS, '')) {
// Semver range (e.g. '^15', '~3.22', '>=18.0.0')
const best = maxSatisfying(allVersions, version)
if (!best) {
throw new Error(
`No version matching '${version}' found for npm package '${name}'. `
+ `Available dist-tags: ${Object.keys(distTags).join(', ')}`,
)
}
resolvedVersion = best
}
else {
// Exact version string
resolvedVersion = version
}

// Verify the resolved version exists
if (allVersions.length > 0 && !allVersions.includes(resolvedVersion)) {
throw new Error(
`Version '${resolvedVersion}' not found for npm package '${name}'. `
+ `Available dist-tags: ${Object.keys(distTags).join(', ')}`,
)
}

// Extract repository URL
const repoField = meta.repository
const repoUrl = typeof repoField === 'string'
? repoField
: repoField?.url

const repo = parseRepoUrl(repoUrl)
if (!repo) {
throw new Error(
`Cannot resolve GitHub repository for npm package '${name}'. `
+ `The 'repository' field is missing or not a GitHub URL. `
+ `Use 'owner/repo' format instead: ask docs add owner/repo`,
)
}

consola.debug(`npm: ${name}@${version} → ${repo}@${resolvedVersion}`)

return {
repo,
ref: `v${resolvedVersion}`,
fallbackRefs: [resolvedVersion],
resolvedVersion,
}
}
}
Loading