Summary
apm marketplace add cannot register a private GitHub repository as a marketplace. It reports "No marketplace.json found" despite the file existing at the repo root, because the command does not apply authentication when fetching the file. Other commands like apm install authenticate correctly against the same private repo.
Environment
- APM CLI version: 0.8.11
- OS: macOS (darwin, arm64)
- Auth:
gh auth login active, token available via git credential fill, GITHUB_TOKEN, and GITHUB_APM_PAT
Steps to reproduce
- Have a private GitHub repository with a valid
marketplace.json at the root
- Ensure authentication is configured (any of:
GITHUB_TOKEN, GITHUB_APM_PAT, GITHUB_APM_PAT_{ORG}, or git credential fill)
- Run:
GITHUB_TOKEN=ghp_valid_token apm marketplace add OWNER/REPO -v
Expected behavior
APM uses the available token to fetch marketplace.json from the private repo and registers the marketplace.
Actual behavior
[*] Registering marketplace 'REPO'...
Repository: OWNER/REPO
Branch: main
[x] No marketplace.json found in 'OWNER/REPO'. Checked:
marketplace.json, .github/plugin/marketplace.json,
.claude-plugin/marketplace.json
Evidence that auth works for other commands
The same token and repo work fine with apm install:
GITHUB_TOKEN=ghp_valid_token apm install OWNER/REPO --dry-run
# [+] OWNER/REPO — validates successfully
And the file is confirmed to exist via the GitHub API:
gh api repos/OWNER/REPO/contents/marketplace.json
# Returns valid marketplace.json content
Tokens tested (all fail identically)
GITHUB_TOKEN
GITHUB_APM_PAT
GITHUB_APM_PAT_{ORG}
git credential fill (confirmed working)
Root cause analysis
We traced this through the source code. The bug is a semantic mismatch between "file not found" and "repo not accessible" in _fetch_file().
The auth fallback doesn't trigger on 404
_fetch_file() in src/apm_cli/marketplace/client.py:138-176 uses the GitHub Contents API with unauth_first=True:
return auth_resolver.try_with_fallback(
source.host,
_do_fetch,
org=source.owner,
unauth_first=True, # <--- tries without token first
)
The fallback mechanism in AuthResolver.try_with_fallback() (auth.py:278-290) only escalates to an authenticated retry on exceptions:
if unauth_first:
try:
return operation(None, git_env) # 1. Try with NO token
except Exception:
if auth_ctx.token:
return operation(auth_ctx.token, git_env) # 2. Retry WITH token
raise
But _do_fetch handles HTTP 404 by returning None instead of raising:
# client.py:158-159
if resp.status_code == 404:
return None
For private repos, GitHub returns 404 (not 403) to avoid leaking repo existence. Since _do_fetch returns None rather than raising an exception, the auth fallback never fires. The None propagates back and APM concludes the file doesn't exist — when it actually never tried with a token.
Why apm install works with the same private repo
apm install uses GitHubPackageDownloader which:
- Resolves tokens eagerly per-dependency via
_resolve_dep_token()
- Uses
git clone with the token embedded in the HTTPS URL
- Tries authenticated first (
auth → SSH → unauth) — the inverse of marketplace's order
- Git raises actual exceptions on failure, so the fallback chain works correctly
| Aspect |
marketplace add |
apm install |
| Protocol |
GitHub REST API (Contents) |
git clone over HTTPS |
| Auth strategy |
unauth_first=True |
Auth-first (token in URL) |
| Fallback direction |
unauth → auth → credential-fill |
auth → SSH → unauth |
| 404 handling |
Returns None (no retry) |
Git raises exception (triggers fallback) |
Suggested fixes (in order of preference)
-
Make _do_fetch raise on 404 during the unauthenticated phase, so try_with_fallback escalates to the authenticated retry. Only treat 404-with-a-valid-token as "file not found." This preserves the rate-limit optimization for public repos while fixing private repos.
-
Use unauth_first=False for the marketplace fetch — try authenticated first, like apm install does. Simplest change but uses a token request even for public repos.
-
Two-phase approach: first verify repo accessibility (GET /repos/{owner}/{repo}), then probe for files.
Workaround
Install plugins directly using subdirectory syntax, bypassing the marketplace:
apm install OWNER/REPO/plugin-subdir
Summary
apm marketplace addcannot register a private GitHub repository as a marketplace. It reports "No marketplace.json found" despite the file existing at the repo root, because the command does not apply authentication when fetching the file. Other commands likeapm installauthenticate correctly against the same private repo.Environment
gh auth loginactive, token available viagit credential fill,GITHUB_TOKEN, andGITHUB_APM_PATSteps to reproduce
marketplace.jsonat the rootGITHUB_TOKEN,GITHUB_APM_PAT,GITHUB_APM_PAT_{ORG}, orgit credential fill)Expected behavior
APM uses the available token to fetch
marketplace.jsonfrom the private repo and registers the marketplace.Actual behavior
Evidence that auth works for other commands
The same token and repo work fine with
apm install:GITHUB_TOKEN=ghp_valid_token apm install OWNER/REPO --dry-run # [+] OWNER/REPO — validates successfullyAnd the file is confirmed to exist via the GitHub API:
gh api repos/OWNER/REPO/contents/marketplace.json # Returns valid marketplace.json contentTokens tested (all fail identically)
GITHUB_TOKENGITHUB_APM_PATGITHUB_APM_PAT_{ORG}git credential fill(confirmed working)Root cause analysis
We traced this through the source code. The bug is a semantic mismatch between "file not found" and "repo not accessible" in
_fetch_file().The auth fallback doesn't trigger on 404
_fetch_file()insrc/apm_cli/marketplace/client.py:138-176uses the GitHub Contents API withunauth_first=True:The fallback mechanism in
AuthResolver.try_with_fallback()(auth.py:278-290) only escalates to an authenticated retry on exceptions:But
_do_fetchhandles HTTP 404 by returningNoneinstead of raising:For private repos, GitHub returns 404 (not 403) to avoid leaking repo existence. Since
_do_fetchreturnsNonerather than raising an exception, the auth fallback never fires. TheNonepropagates back and APM concludes the file doesn't exist — when it actually never tried with a token.Why
apm installworks with the same private repoapm installusesGitHubPackageDownloaderwhich:_resolve_dep_token()git clonewith the token embedded in the HTTPS URLauth → SSH → unauth) — the inverse of marketplace's ordermarketplace addapm installgit cloneover HTTPSunauth_first=TrueNone(no retry)Suggested fixes (in order of preference)
Make
_do_fetchraise on 404 during the unauthenticated phase, sotry_with_fallbackescalates to the authenticated retry. Only treat 404-with-a-valid-token as "file not found." This preserves the rate-limit optimization for public repos while fixing private repos.Use
unauth_first=Falsefor the marketplace fetch — try authenticated first, likeapm installdoes. Simplest change but uses a token request even for public repos.Two-phase approach: first verify repo accessibility (
GET /repos/{owner}/{repo}), then probe for files.Workaround
Install plugins directly using subdirectory syntax, bypassing the marketplace: