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
22 changes: 22 additions & 0 deletions notebook_intelligence/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,16 @@ async def get(self, name):
self.finish(json.dumps({"plugins": plugins}))


class PluginsMarketplaceUpdateHandler(PluginsBaseHandler):
@tornado.web.authenticated
async def post(self, name):
try:
await self.manager.update_marketplace(name=name)
self.finish(json.dumps({"success": True}))
except (FileNotFoundError, PermissionError, TimeoutError, ValueError) as e:
self._error(e)


class SkillsBaseHandler(PolicyGatedHandler):
"""Shared helpers for skills endpoints."""

Expand Down Expand Up @@ -2702,6 +2712,14 @@ def _setup_handlers(self, web_app, feature_policies: dict, string_overrides: dic
route_pattern_plugins_marketplace_detail = url_path_join(
base_url, "notebook-intelligence", "plugins", "marketplace", r"([^/]+)"
)
route_pattern_plugins_marketplace_update = url_path_join(
base_url,
"notebook-intelligence",
"plugins",
"marketplace",
r"([^/]+)",
"update",
)
GetCapabilitiesHandler.disabled_tools = self.disabled_tools
GetCapabilitiesHandler.allow_enabling_tools_with_env = self.allow_enabling_tools_with_env
GetCapabilitiesHandler.disabled_providers = self.disabled_providers
Expand Down Expand Up @@ -2809,6 +2827,10 @@ def _setup_handlers(self, web_app, feature_policies: dict, string_overrides: dic
route_pattern_plugins_marketplace_plugins,
PluginsMarketplacePluginsHandler,
),
(
route_pattern_plugins_marketplace_update,
PluginsMarketplaceUpdateHandler,
),
(route_pattern_plugins_marketplace_detail, PluginsMarketplaceDetailHandler),
(route_pattern_plugins_marketplace, PluginsMarketplaceListHandler),
(route_pattern_plugins_detail, PluginsDetailHandler),
Expand Down
157 changes: 152 additions & 5 deletions notebook_intelligence/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- `claude plugin disable <plugin> [-s scope]`
- `claude plugin marketplace add <source> [--scope <scope>]`
- `claude plugin marketplace remove <name>`
- `claude plugin marketplace update [<name>]`

The CLI's JSON-output schema is not formally documented; the manager
forwards parsed objects to the frontend mostly untouched so newer Claude
Expand Down Expand Up @@ -165,12 +166,79 @@ async def list_plugins(self) -> list[dict[str, Any]]:

async def list_marketplaces(self) -> list[dict[str, Any]]:
out = await self._run_cli(["plugin", "marketplace", "list", "--json"])
return self._parse_json_array(out, "plugin marketplace list")
marketplaces = self._parse_json_array(out, "plugin marketplace list")
# Enrich each entry with description, version, and plugin list
# pulled from the cached marketplace.json. Older clients ignore
# the extra keys; the panel uses them to render a richer row. A
# missing or malformed manifest logs and skips so one bad entry
# cannot break the whole list endpoint. OSError covers
# FileNotFoundError, PermissionError, IsADirectoryError; the
# ValueError branch covers our own JSON-shape complaints and the
# raw UnicodeDecodeError that read_text can raise.
# `plugin_count` and `plugin_names` are coupled: if the CLI
# surfaces either, the row must show the CLI's view of both,
# otherwise the count and the visible name list could disagree
# (e.g. "42 plugins: alpha, beta" because count came from CLI
# and names came from the manifest).
_coupled_keys = ("plugin_count", "plugin_names")
for entry in marketplaces:
name = entry.get("name")
if not isinstance(name, str):
continue
try:
details = self._read_marketplace_details(name)
except (OSError, ValueError, UnicodeDecodeError):
continue
cli_supplied_plugin_meta = any(
key in entry and entry.get(key) is not None
for key in _coupled_keys
)
for key, value in details.items():
if key in _coupled_keys:
# Only overlay when CLI provided neither half.
if cli_supplied_plugin_meta:
continue
entry[key] = value
continue
# Other enrichment keys: prefer a future CLI value when
# present AND truthy. An empty-string description from
# the CLI must not shadow a useful description from the
# manifest.
if not entry.get(key):
entry[key] = value
return marketplaces

async def list_marketplace_plugins(
self, marketplace: str
) -> list[dict[str, Any]]:
marketplace_name = _validate_marketplace_name(marketplace)
doc = self._read_marketplace_manifest(marketplace_name)
plugins = doc.get("plugins")
if not isinstance(plugins, list):
return []
return [p for p in plugins if isinstance(p, dict)]

# Upper bound on marketplace.json size. The realistic shape is a few
# KB even for big marketplaces, so a 1 MiB cap covers every legitimate
# use while bounding the memory cost of a hostile or corrupt manifest
# the next list_marketplaces call would otherwise slurp whole.
_MARKETPLACE_MANIFEST_MAX_BYTES: int = 1 * 1024 * 1024

def _read_marketplace_manifest(self, marketplace_name: str) -> dict[str, Any]:
"""Read the cached marketplace.json. Raises FileNotFoundError /
ValueError on missing or malformed input.

Validate the name against the same path-traversal / NUL / flag
checks that gate ``list_marketplace_plugins``. The CLI sanitizes
its own output today, but consistency keeps the manifest-read
layer self-defending instead of relying on every caller to gate
upstream.
"""
# Raises ValueError on path-traversal / NUL / leading-dash /
# path-separator names. The enrichment caller in
# `list_marketplaces` catches ValueError and skips, so a single
# bad CLI-returned name still doesn't break the list.
marketplace_name = _validate_marketplace_name(marketplace_name)
manifest = (
_claude_plugins_root()
/ "marketplaces"
Expand All @@ -179,14 +247,64 @@ async def list_marketplace_plugins(
/ "marketplace.json"
)
try:
out = manifest.read_text(encoding="utf-8")
size = manifest.stat().st_size
except FileNotFoundError as exc:
raise FileNotFoundError(
f"Marketplace manifest not found for {marketplace_name!r}"
) from exc
return self._parse_json_array(
out, f"plugin marketplace {marketplace_name} manifest"
)
if size > self._MARKETPLACE_MANIFEST_MAX_BYTES:
raise ValueError(
f"marketplace.json for {marketplace_name!r} exceeds the "
f"{self._MARKETPLACE_MANIFEST_MAX_BYTES}-byte cap "
f"({size} bytes)"
)
try:
text = manifest.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise FileNotFoundError(
f"Marketplace manifest not found for {marketplace_name!r}"
) from exc
try:
doc = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(
f"Could not parse marketplace.json for {marketplace_name!r}: {exc}"
) from exc
if not isinstance(doc, dict):
raise ValueError(
f"marketplace.json for {marketplace_name!r} is not a JSON object"
)
return doc

def _read_marketplace_details(self, marketplace_name: str) -> dict[str, Any]:
"""Return ``description``, ``version``, ``plugin_count``, and
``plugin_names`` for the named marketplace.

Reads the cached ``.claude-plugin/marketplace.json`` written by
``claude plugin marketplace add``. Both top-level and
``metadata.*`` fields are recognized: the documented schema puts
``description``/``version`` at top level but the same keys are
accepted under ``metadata`` for backward compatibility, per the
Claude Code marketplace docs.
"""
doc = self._read_marketplace_manifest(marketplace_name)
metadata = doc.get("metadata") if isinstance(doc.get("metadata"), dict) else {}
description = doc.get("description") or metadata.get("description") or ""
version = doc.get("version") or metadata.get("version") or ""
plugins_raw = doc.get("plugins")
plugin_names: list[str] = []
if isinstance(plugins_raw, list):
for plugin in plugins_raw:
if isinstance(plugin, dict):
name = plugin.get("name")
if isinstance(name, str) and name:
plugin_names.append(name)
return {
"description": str(description) if description else "",
"version": str(version) if version else "",
"plugin_count": len(plugin_names),
"plugin_names": plugin_names,
}

# --- writes (CLI shell-outs) ---------------------------------------

Expand Down Expand Up @@ -289,6 +407,35 @@ async def remove_marketplace(self, *, name: str) -> None:
async with self._write_lock:
await self._run_cli(["plugin", "marketplace", "remove", name])

async def update_marketplace(self, *, name: str) -> None:
"""Run ``claude plugin marketplace update <name>``.

Refreshes the local cache of the marketplace manifest from its
source (git pull, HTTPS refetch, local-path recopy). The CLI
does not return JSON for this command; success is signaled by
exit code zero. Times out under the same network budget as
``add_marketplace`` because the underlying ``git`` call has the
same potential to stall.
"""
marketplace_name = _validate_marketplace_name(name)
# Marketplace `update` re-fetches from the original source; if it
# was a GitHub URL the same auth chain that worked for `add`
# needs to apply here too. The CLI persists the source from the
# initial add, so we cannot inspect it without re-reading the
# cached config, but injecting the token unconditionally is
# harmless for non-GitHub sources (the CLI just ignores it) and
# the env never lands in argv.
token = resolve_github_token()
env_overrides: Optional[dict[str, str]] = (
{"GITHUB_TOKEN": token, "GH_TOKEN": token} if token else None
)
async with self._write_lock:
await self._run_cli(
["plugin", "marketplace", "update", marketplace_name],
timeout=CLI_TIMEOUT_MARKETPLACE_ADD_SECONDS,
env_overrides=env_overrides,
)

# --- internals -----------------------------------------------------

# Keys we'll walk to find an array of objects when the CLI returns a
Expand Down
14 changes: 14 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export interface IPluginMarketplaceInfo {
name?: string;
source?: string;
scope?: PluginScope | string;
description?: string;
version?: string;
plugin_count?: number;
plugin_names?: string[];
[key: string]: unknown;
}

Expand Down Expand Up @@ -1025,6 +1029,16 @@ export class NBIAPI {
});
}

static async updatePluginMarketplace(name: string): Promise<void> {
await requestAPI<any>(
`plugins/marketplace/${encodeURIComponent(name)}/update`,
{
method: 'POST',
body: '{}'
}
);
}

static async reconcileManagedSkills(): Promise<IReconcileResult> {
const data = await requestAPI<any>('skills/reconcile', {
method: 'POST'
Expand Down
Loading
Loading