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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ node packages/cli/dist/index.js docs add <spec> -s <source> [options]
- `parseSpec` lives in `packages/cli/src/spec.ts` and returns a discriminated union (`npm` / `github` / `unknown`). It is the single source of truth for translating an `ask.json` spec string into a library slug — do not reintroduce the legacy private `parseSpec(spec) -> { name, version }` from the old `index.ts`.
- In Nuxt Content v3 server routes, `queryCollection` is auto-imported — do NOT use `import { queryCollection } from '#content/server'` (that alias does not exist and breaks Cloudflare Pages build). Use the bare function or `import { queryCollection } from '@nuxt/content/server'`.
- Top-level commands: `ask install | add | remove | list | docs | skills | src | cache` (registered in `packages/cli/src/index.ts:323`). The **legacy** `ask docs` namespace that had `add | sync | list | remove` sub-commands is gone — there is no deprecated wrapper. The **current** `ask docs <spec>` is a NEW single-shot command (`packages/cli/src/commands/docs.ts`) that prints candidate doc paths: for npm specs it walks `node_modules/<pkg>/`, then always walks the cached checkout at `<askHome>/github/<host>/<owner>/<repo>/<ref>/` and emits the root plus any `/doc/i` subdirs. It shares `ensureCheckout` with `ask src` and does NOT go through `runInstall`, so strict ref validation does not apply — unqualified github specs default `ref = 'main'` and clone the branch directly. `ask install` is `postinstall`-friendly: per-entry failures emit a warning and exit code is always 0 (FR-10).
- `ensureCheckout` (shared by `ask src` and `ask docs`) always sets `skipDocExtraction: true` on its `GithubSourceOptions`. The source's `extractDocsFromDir` scan is a correctness precondition for `runInstall` (materializing `.ask/docs/<name>@<version>/` requires a real file list), but for `ask src`/`ask docs` it is actively wrong — those commands walk the checkout themselves (`findDocLikePaths`) and must not crash on repos without a conventional `docs/` folder (regression: `gitbutlerapp/gitbutler`). Keep the flag opt-in; never set it inside `runInstall`.
- `cpDirAtomic` (`packages/cli/src/store/index.ts`) passes `verbatimSymlinks: true` to `fs.cpSync`. Without it, Node resolves relative symlinks against the SOURCE directory at copy time, baking an absolute path pointing at the soon-deleted `ask-gh-clone-*` tmp dir into the store entry — every later `verifyEntry` call then hits ENOENT reading the now-broken link (observed on gitbutler's `claude.md`/`CLAUDE.md` → `AGENTS.md` links). `hashDir` walks every file via `collectFiles` + `readFileSync`, so it has no tolerance for a broken symlink. Keep the flag.
- PM-driven entries (`{ "spec": "npm:<pkg>" }`) get their version from the project's lockfile in priority order: `bun.lock → package-lock.json → pnpm-lock.yaml → yarn.lock → package.json` (range fallback). The chain lives in `packages/cli/src/lockfiles/index.ts:npmEcosystemReader`. Standalone github entries (`{ "spec": "github:owner/repo", "ref": "v1.2.3" }`) require an explicit `ref` enforced at the schema layer — there is no implicit default branch.
- 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.
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/ensure-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ export async function ensureCheckout(
// For implicit default refs, pass NEITHER `tag` nor `branch` so
// GithubSource.fetch can activate its default-branch fallback
// chain (main → vmain → master).
//
// `skipDocExtraction: true` — callers of `ensureCheckout` only
// need the cached source tree on disk; they walk it themselves
// (`findDocLikePaths`) and must not fail when the repo has no
// conventional `docs/` folder (regression: gitbutlerapp/gitbutler).
const refOpt: Partial<GithubSourceOptions> = isImplicitDefaultRef
? {}
: isFromBranch ? { branch: ref } : { tag: ref }
Expand All @@ -224,6 +229,7 @@ export async function ensureCheckout(
name: parsed.name,
version: resolvedVersion,
repo: `${owner}/${repo}`,
skipDocExtraction: true,
...refOpt,
...(fallbackRefs?.length ? { fallbackRefs } : {}),
}
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/sources/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,9 @@ export class GithubSource implements DocSource {
if (!fs.existsSync(storeDir))
continue
if (verifyEntry(storeDir)) {
const files = this.extractDocsFromDir(storeDir, repo, ref, docsPath)
const files = opts.skipDocExtraction
? []
: this.extractDocsFromDir(storeDir, repo, ref, docsPath)
return {
files,
resolvedVersion,
Expand Down Expand Up @@ -359,7 +361,9 @@ export class GithubSource implements DocSource {
}
}

const files = this.extractDocsFromDir(storeDir, repo, ref, docsPath)
const files = opts.skipDocExtraction
? []
: this.extractDocsFromDir(storeDir, repo, ref, docsPath)
return {
files,
resolvedVersion,
Expand Down Expand Up @@ -446,7 +450,9 @@ export class GithubSource implements DocSource {
}
}

const files = this.extractDocsFromDir(storeDir, repo, candidate, docsPath)
const files = opts.skipDocExtraction
? []
: this.extractDocsFromDir(storeDir, repo, candidate, docsPath)
return {
files,
resolvedVersion,
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export interface GithubSourceOptions {
* URL or path); production code should leave this unset.
*/
remoteUrl?: string
/**
* Skip the "walk the checkout for a docs directory" step after the
* clone lands in the store. Set by callers that only need the cached
* source tree on disk (e.g. `ask src`, `ask docs`) — those commands
* walk the directory themselves and must not fail when a repo has no
* conventional `docs/` folder (e.g. gitbutlerapp/gitbutler). The
* clone, verify, stamp, quarantine, and ref-fallback logic still run
* as before; only the final `extractDocsFromDir` call is bypassed.
*/
skipDocExtraction?: boolean
}

export interface WebSourceOptions {
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,21 @@ export function writeEntryAtomic(
* Copies to a temp directory first, then renames. Mirrors the atomicity
* guarantee of `writeEntryAtomic` for the case where source files are
* already on disk (e.g., extracted GitHub tarballs).
*
* `verbatimSymlinks: true` preserves the exact symlink target written
* in the source tree. Without it, Node resolves relative symlinks
* against the SOURCE directory at copy time, baking absolute paths
* pointing at the (soon-deleted) `ask-gh-clone-*` tmp dir into the
* store — once the tmp dir is cleaned up, every later `verifyEntry`
* would hit ENOENT reading the broken symlink. Observed on
* `gitbutlerapp/gitbutler`, whose `claude.md`/`CLAUDE.md` are relative
* symlinks to `AGENTS.md`.
*/
export function cpDirAtomic(sourceDir: string, targetDir: string): void {
const tmpDir = `${targetDir}.tmp-${crypto.randomUUID().slice(0, 8)}`
fs.mkdirSync(path.dirname(tmpDir), { recursive: true })
try {
fs.cpSync(sourceDir, tmpDir, { recursive: true })
fs.cpSync(sourceDir, tmpDir, { recursive: true, verbatimSymlinks: true })
if (fs.existsSync(targetDir)) {
const backupDir = `${targetDir}.bak-${crypto.randomUUID().slice(0, 8)}`
fs.renameSync(targetDir, backupDir)
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/test/commands/ensure-checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,32 @@ describe('ensureCheckout', () => {
expect(calls[0].opts.branch).toBeUndefined()
})

it('passes skipDocExtraction=true to fetcher so docs-less repos do not fail', async () => {
// Regression for: `bunx @pleaseai/ask src github:gitbutlerapp/gitbutler`
// failing with `No docs directory found` because `GithubSource.fetch`
// unconditionally walked the checkout for a `docs/` dir even though
// `ensureCheckout`'s callers (ask src, ask docs) only need the
// cached source tree. The flag tells the source to skip doc
// extraction after the clone lands in the store.
const { fetcher, calls } = makeFetcher(() => {
const dir = path.join(askHome, 'github', 'github.com', 'gitbutlerapp', 'gitbutler', 'main')
fs.mkdirSync(dir, { recursive: true })
})

await ensureCheckout(
{ spec: 'github:gitbutlerapp/gitbutler', projectDir },
{
askHome,
fetcher,
resolverFor: () => null,
lockfileReader: { read: () => null },
},
)

expect(calls).toHaveLength(1)
expect(calls[0].opts.skipDocExtraction).toBe(true)
})

it('handles github: spec with explicit @ref', async () => {
const expectedDir = path.join(askHome, 'github', 'github.com', 'facebook', 'react', 'v18.2.0')

Expand Down
102 changes: 102 additions & 0 deletions packages/cli/test/sources/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,108 @@ describe('default-branch fallback — main → master', () => {
})
})

describe('skipDocExtraction — callers that only need the checkout path', () => {
/**
* Regression for `bunx @pleaseai/ask src github:gitbutlerapp/gitbutler`
* failing with `No docs directory found in gitbutlerapp/gitbutler@master`:
*
* `ask src` (and `ask docs`) go through `ensureCheckout`, which calls
* `GithubSource.fetch` to land the clone in the store. Before this
* flag, `fetch` unconditionally scanned the checkout for a `docs/`
* subdirectory and threw when none existed — even though `ask src`
* never needs the docs-file list, only the cached source tree on disk.
*/
function createNoDocsRemote(): string {
const repoDir = path.join(tmpDir, 'no-docs-remote.git')
const workDir = path.join(tmpDir, 'no-docs-work')

fs.mkdirSync(workDir, { recursive: true })
execFileSync('git', ['init', '-b', 'master', workDir], { stdio: 'ignore' })
execFileSync('git', ['-C', workDir, 'config', 'user.email', 'test@test.com'], { stdio: 'ignore' })
execFileSync('git', ['-C', workDir, 'config', 'user.name', 'Test'], { stdio: 'ignore' })

// Intentionally no docs/ — mirrors gitbutlerapp/gitbutler's layout.
fs.writeFileSync(path.join(workDir, 'README.md'), '# No Docs Here\n')
fs.writeFileSync(path.join(workDir, 'src.ts'), 'export {}\n')

execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' })
execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' })

execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' })
return repoDir
}

it('succeeds with empty files and storePath when repo has no docs directory', async () => {
const remoteUrl = createNoDocsRemote()
const source = new GithubSource()

const result = await source.fetch({
source: 'github',
name: 'no-docs-repo',
version: 'master',
repo: 'test/no-docs-repo',
skipDocExtraction: true,
remoteUrl,
} as any)

expect(result.files).toEqual([])
expect(result.storePath).toBeDefined()
expect(fs.existsSync(result.storePath!)).toBe(true)
expect(fs.existsSync(path.join(result.storePath!, 'README.md'))).toBe(true)
})

it('still activates the main→master fallback when skipDocExtraction=true', async () => {
// Combines the gitbutler scenario: default branch is `master` AND
// there is no `docs/` dir. Both failure modes must be neutralized.
const remoteUrl = createNoDocsRemote()
const source = new GithubSource()

const result = await source.fetch({
source: 'github',
name: 'no-docs-repo',
version: 'main',
repo: 'test/no-docs-repo',
skipDocExtraction: true,
remoteUrl,
} as any)

expect(result.storePath).toContain('master')
expect(result.files).toEqual([])
})

it('default behavior (flag unset) still throws when no docs directory exists', async () => {
// Guardrail: `runInstall` still depends on the throw — it materializes
// `.ask/docs/<name>@<version>/` from `FetchResult.files`, so an empty
// list would silently publish an empty docs tree. The flag is opt-in.
//
// With the flag unset, the shallow-clone path's `extractDocsFromDir`
// rejects the no-docs repo. The outer `fetch()` catches and logs a
// warn, then falls through to the tar.gz path — which for this
// fake local remote 404s on github.com. Either way, an error
// escapes: the point is that `runInstall` does NOT receive a
// successful empty-files result.
const remoteUrl = createNoDocsRemote()
const source = new GithubSource()

let capturedError: Error | null = null
try {
await source.fetch({
source: 'github',
name: 'no-docs-repo',
version: 'master',
repo: 'test/no-docs-repo',
branch: 'master',
remoteUrl,
} as any)
}
catch (err) {
capturedError = err as Error
}

expect(capturedError).not.toBeNull()
})
})

describe('refCandidates via fetch behavior — fallbackRefs', () => {
/**
* Create a local remote with monorepo-style tags (e.g. `ai@6.0.158`).
Expand Down