feat(extensions): interactive multi-tab /extensions manager (Installed / Discover / Sources) (#4850)

* feat(extensions): multi-tab /extensions dialog (Discover/Installed/Marketplaces)

Upgrade the /extensions management dialog from a linear wizard into a
multi-tab interactive dialog aligned with Claude Code's /plugin command.

UI (packages/cli):
- Discover: pull installable plugins from configured marketplaces,
  multi-select (Space), batch install (i) with Global/Project/Local scope,
  open homepage, per-plugin details.
- Installed: plugins + standalone MCP servers grouped by
  Favorites/Local/User/Project/Disabled; Space toggles enable/disable,
  f toggles favorite, Enter opens details with an action menu
  (toggle/favorite/mark-for-update/update/uninstall).
- Marketplaces: add/list/view/remove marketplace sources
  (owner/repo, SSH, HTTP JSON, local path).
- Tabbed shell with Tab/arrow switching and a focus-lock contract so a tab
  owns Escape while in a sub-view.

Core (packages/core):
- ExtensionPreferencesStore: favorites + per-extension scope intent.
- MarketplaceRegistryStore + discoverPlugins(): persistent marketplace
  source registry and cross-source discovery.
- loadMarketplaceConfigFromSource() in marketplace.ts (GitHub/local/HTTP-JSON).
- ExtensionManager methods for marketplaces, discovery, favorites and scopes;
  preference cleanup on uninstall.

Scope mapping: Global -> User; Project/Local -> workspace-scoped enablement
(install then re-scope so the choice actually restricts where it is active).

The Errors tab is intentionally deferred per the spec.

Tests: 19 core unit tests (preferences/registry/discovery) and 11 tabbed
dialog integration tests; existing extension suites updated. typecheck,
lint and i18n checks pass.

* feat(extensions): align Discover plugin detail with Claude Code

Rework the Discover tab's Enter detail view to match Claude Code's
"Plugin details" page in both layout and interaction:

- Layout: "Plugin details" header, title, "from <marketplace>", last
  updated / version, description, "By: <author>", a "Will install:"
  component summary (Skills/Commands/Agents/MCP servers), and a trust
  warning.
- Interaction: the scope choice is now an inline action selector on the
  detail page (Install for you / for all collaborators / in this repo
  only / Open homepage / Back to plugin list), selected with Enter —
  replacing the previous i/h shortcuts and the separate scope step.
- Footer shows "Enter to select · Esc to go back" while a tab sub-view
  is open.

Core: DiscoveredPlugin now carries declared `components` and a
best-effort `lastUpdated`, surfaced by discoverPlugins().

Adds a core test for component/lastUpdated extraction and a UI test for
the detail layout + inline selector.

* feat(extensions): align Add Marketplace view with Claude Code

Match CC's "Add Marketplace" screen: a bold "Add Marketplace" header,
an "Enter marketplace source:" prompt, and an "Examples:" bullet list
(owner/repo · git@…:owner/repo.git (SSH) · https://…/marketplace.json ·
./path/to/marketplace) above a bare cursor input (placeholder removed).

Update the Marketplaces add-view tests accordingly.

* feat(extensions): fix Discover hang + add search/scrolling, align list with CC

Discover reliability and UX fixes:

- Fix the "Discovering plugins…" hang: marketplace network fetches had no
  timeout, so a slow/unreachable source could block discovery forever. Add a
  10s per-request timeout (resolve null) plus socket drain on non-200.
- Cache the fetched listing in ExtensionManager for the session so revisiting
  the tab no longer refetches over the network; `installed` flags are
  recomputed cheaply, and the cache is invalidated on add/remove marketplace.

CC-aligned Discover list:

- Windowed/scrolling viewport (no longer renders the entire 200+ list at
  once) with "↑ more above" / "↓ more below" hints, sized to the terminal.
- Type-to-search filter with a search box and a "Discover plugins (pos/total)"
  count header.
- Item layout: cursor "›", ○/●/✓ checkbox, bold title · marketplace ·
  "<N> installs", with a truncated description line.
- Space toggles selection, Enter views detail (or installs the selected set);
  the conflicting "i" shortcut was removed in favor of search.

Core: DiscoveredPlugin gains a best-effort `installs` count.

Adds tests for windowing, search filtering, and install-count extraction.

* feat(extensions): align marketplace detail with CC + Browse-to-Discover

Rework the Marketplaces tab detail to match Claude Code:

- Show marketplace name, source, "N available plugins", and the plugins
  from this marketplace that are installed ("Installed plugins (K):" with
  descriptions) — instead of dumping a truncated list of all plugins.
- Replace the ad-hoc "d to remove" hint with an action selector:
  Browse plugins (N) · Update marketplace [(last updated DATE)] ·
  Remove marketplace.
- "Browse plugins" switches to the Discover tab filtered to this
  marketplace (only its plugins); the filter clears on manual tab switch
  and is shown in the Discover header.
- "Update marketplace" re-fetches the marketplace config, stamps a fresh
  "last updated", and invalidates the discovery cache.

Core: MarketplaceSource gains lastUpdatedAt; addMarketplace stamps it and
ExtensionManager.markMarketplaceUpdated() refreshes it + clears the
discovery cache.

Adds tests for the marketplace detail layout and the Browse-to-Discover
filtering flow.

* feat(extensions): show plugin type in Installed; guide single-extension adds

- Installed list: plugin rows now show their type + version ("Extension
  v0.7.0"), parallel to MCP rows ("MCP"), instead of a bare version.
- Add Marketplace: when the source is not a Claude marketplace but is a
  valid single extension source (Gemini/Claude/git/npm), the error now
  guides the user to install it directly ("... looks like a single
  extension, not a marketplace. Install it with: /extensions install X")
  instead of the generic "expected marketplace.json" message.

* fix(extensions): resolve git@ SSH marketplace sources

The Marketplaces add flow advertises git@github.com:owner/repo.git (SSH)
as a supported format, but loadMarketplaceConfigFromSource relied on
parseGitHubRepoForReleases, which rejects the git@ scp-like form. Extract
owner/repo directly from the git@github.com:owner/repo(.git) form before
falling back to the URL parser, so SSH marketplace sources actually
resolve. Adds a regression test.

* feat(extensions): cap Discover list window at 6 items

* feat(extensions): unify 'Extension' wording, reorder tabs, expand Marketplaces tab

- Terminology: use 'Extension' instead of 'Plugin' across the dialog
  (Discover extensions, Extension details, Back to extension list, etc.).
- Tabs reordered to Installed, Discover, Marketplaces; the dialog now
  opens on Installed by default.
- Marketplaces tab is now a sources hub:
  - new 'Install new extension' action (installs a single Gemini/Qwen/
    Claude/git/npm extension directly via parseInstallSource).
  - 'Add new marketplace' annotated as a Claude plugin marketplace.
  - items grouped into 'Extensions' and 'Marketplaces' sections; an
    extension row opens a compact detail with Uninstall.

Updates the dialog tests for the new wording, tab order and layout.

* feat(extensions): update Marketplaces tab footer hint

* feat(extensions): full extension actions in both tabs + context-aware Marketplaces footer

- Add a shared ExtensionActionsView (info + components + action menu +
  scope-select + uninstall-confirm) used by both the Installed and
  Marketplaces tabs, so the Marketplaces extension detail now offers the
  full set (Enable/Disable, Favorite, Mark for Update, Update Now,
  Uninstall) instead of just Uninstall/Back.
- Add a new 'Change scope' action (Global/Project/Local) that re-scopes
  enablement (User vs workspace), available in both tabs.
- Context-aware Marketplaces footer: shows 'Enter details' for an
  extension row, 'Enter open · d remove marketplace' for a marketplace
  row, and a neutral hint for the action rows — no longer says
  'd remove marketplace' when an extension is selected.

Adds a test for the full extension actions in the Marketplaces detail.

* feat(extensions): rename Marketplaces tab to Sources, hide Favorites + fix enable/disable in Sources detail

- Rename the user-visible tab label 'Marketplaces' -> 'Sources' (TabBar +
  TABS). The in-tab 'Marketplaces' section header (grouping marketplace
  sources) is kept. Also update the Discover empty-state hint to point at
  the 'Sources' tab.
- Hide the Add/Remove Favorites action in the Sources extension detail via
  a showFavorite prop (default true; Installed keeps it).
- Fix a stale enable/disable label in the Sources extension detail:
  ExtensionActionsView re-read enablement through the manager cache keyed by
  a tick, but refreshCache() briefly empties that cache, so the read raced
  and fell back to the stale extension prop (isActive: true). It now holds
  authoritative local state (enabled/isFavorite/scope) updated optimistically
  after each action — no cache read-back. The Installed tab was immune only
  because it fed a fresh extension object each load.

Adds regression tests for the enable/disable toggle staying in sync and for
change-scope re-scoping + re-enabling a disabled extension.

* feat(extensions): group Sources action rows + show current scope in selector

- Add an 'Add new' section heading above the '+ Install new extension' and
  '+ Add new marketplace' rows on the Sources tab, so those two actions are
  grouped like the Extensions and Marketplaces sections.
- In the Change scope selector, default the cursor to the extension's current
  scope and show a 'Current: <scope>' line. Previously it always defaulted to
  Global, so after changing scope it was unclear whether the change took
  effect. Applies to both the Sources and Installed extension detail (shared
  ExtensionActionsView).

Updates tests to assert the 'Add new' section title renders and that
re-entering the scope selector reflects the now-current scope.

* fix(extensions): move uninstall note to confirm step; complete zh/zh-TW i18n

- Move the 'Note: Uninstall permanently removes this extension.' warning out
  of the detail-view action list and into the uninstall confirmation step
  (replacing the near-synonymous 'This action cannot be undone.').
- Fix the Chinese/English mix in the extensions manager: the new multi-tab UI
  added ~104 English strings that had no locale entries, so they fell back to
  the English key at runtime. Add Simplified (zh) and Traditional (zh-TW)
  translations for all of them, plus the matching en.js base keys (en.js is
  the canonical superset; zh/zh-TW require strict key parity per check-i18n).

Placeholders, keyboard tokens (Tab/Enter/Esc/Space/↑↓/·) and the ⚠ glyph are
preserved across all locales.

* feat(extensions): label Installed scope groups as 用户级/项目级/本地级

Rename the Installed-tab scope group headers from User/Project/Local to
'X level' (用户级/项目级/本地级) so the grouping reads as scope levels.
Adds the new keys to en/zh/zh-TW locales.

* feat(extensions): reuse /mcp server detail for installed MCP servers

The Installed-tab MCP item detail was a read-only view (name/type/scope/
transport/status) with a meaningless 'Enter to select' and no actions. Replace
it with McpServerActionsView, which reuses the /mcp dialog's ServerDetailStep,
ToolListStep, ToolDetailStep and AuthenticateStep so the behaviour matches
/mcp exactly: live connection status, View tools, Enable/Disable, Reconnect
(when disconnected), Re-authenticate and Clear authentication.

Handlers mirror MCPManagementDialog (mcp.excluded settings + toolRegistry
discover/disable/disconnect + MCPOAuthTokenStorage). Delete the now-unused
McpDetailView and its obsolete locale keys; add the two new status strings to
en/zh/zh-TW.

* fix(extensions): populate MCP promptCount from prompt registry

Review follow-up: buildServer hardcoded promptCount to 0, diverging from
/mcp's fetchServerData. Query the prompt registry like the original so the
reused MCPServerDisplayInfo is computed consistently.

* refactor(extensions): rename the source-management layer from marketplace to source

The Sources tab treats both single-extension sources and Claude plugin
marketplaces as 'sources', so the source-management layer is renamed for
consistency:
  MarketplaceSource        -> ExtensionSource
  marketplaceRegistry(.ts) -> sourceRegistry(.ts)
  MarketplaceRegistryStore -> SourceRegistryStore
  add/get/remove/markMarketplaceUpdated -> add/get/remove/markSourceUpdated
  loadMarketplace/updateMarketplace     -> loadSource/updateSource
  MarketplacesTab -> SourcesTab; EXTENSIONS_TABS.MARKETPLACES -> SOURCES
  + the source-detail UI handlers.

Terms that refer to the Claude marketplace manifest *format* are kept, since a
marketplace is one source type: ClaudeMarketplaceConfig,
loadMarketplaceConfigFromSource, the .claude-plugin/marketplace.json path,
DiscoveredPlugin.marketplaceName, and the in-tab 'Marketplaces' group label.

* fix(extensions): keep marketplaces.json filename so saved sources survive the rename

The source/* rename accidentally renamed the persisted registry file from
marketplaces.json to sources.json, so previously added sources (e.g. a Claude
marketplace) appeared to vanish — the data was intact in marketplaces.json but
the code read sources.json. Restore the marketplaces.json filename for
backward compatibility.

* fix(extensions): stay on the Discover detail when an install fails

Previously runInstall always returned to the list after attempting an install.
Now it only returns to the list on success; on failure it stays on the
extension detail page so the error message remains visible and the user can
retry without re-navigating.

* feat(extensions): support 'git-subdir' plugin source in Claude marketplaces

Some Claude marketplace plugins live in a subdirectory of a git repo and use a
'git-subdir' source ({url, path, ref, sha}), which resolvePluginSource didn't
handle — installing failed with 'Unsupported plugin source type'. Add the
git-subdir branch: clone the repo (pinned to ref/sha when provided) and return
the subdirectory as the plugin source.

Verified against github.com/42Crunch-AI/claude-plugins @ v1.5.5: the cloned
plugins/api-security-testing subdir is a valid plugin (.claude-plugin/plugin.json).

* feat(extensions): drop the unused 'local' install scope

Simplify the install/visibility scope model from three options (user /
project / local) down to two (user / project). The 'local' option duplicated
the workspace-level enablement of 'project' without providing a meaningfully
different storage location, so it was UI clutter rather than a real feature.

- core: ExtensionScope = 'user' | 'project'; read() filters unknown values
  via a type guard, so any stale 'local' (or otherwise invalid) entry in
  extension-preferences.json is dropped and falls back to 'user' downstream.
- UI: remove the 'local' option from the Discover install menu, the change-
  scope picker, and the Installed tab's group ordering.
- copy: rename 'Project (All Collaborators)' to 'Project (Workspace)' and
  'Install for all collaborators on this repository' to 'Install for the
  current workspace', matching the new two-tier model.
- i18n: clean up the now-unused 'Local *' keys in en / zh / zh-TW and
  retranslate the renamed keys.
- tests: update the scope-change spec and replace the legacy-scope
  migration test with one that exercises the unknown-value filter.

* feat(extensions): add Ctrl+R shortcut to refresh Discover tab

Press Ctrl+R in the Extensions Manager Discover tab to bypass the discover cache and re-fetch all marketplace sources. The refresh hint is merged into the dialog footer, and a success status is shown after the refresh completes.

* fix(extensions): keep j/k typeable in Discover search

The Discover tab list reused the global SELECTION_UP/DOWN matchers,
which include bare j/k as Vim-style navigation. Combined with
type-to-search input, that made it impossible to type j or k into the
search query.

Switch the Discover list navigation to explicit arrow keys plus
Ctrl+P/Ctrl+N, so bare j/k fall through to the printable-character
branch and append to the query. Other extension tabs (Installed,
Sources) remain pure lists and keep the Vim navigation.

* feat(extensions): install standalone Claude Code plugins from a git URL

A repo whose root holds .claude-plugin/plugin.json (no marketplace.json) is
a standalone Claude plugin. Previously installing one by git URL failed with
"Configuration file not found" because the converter only handled gemini
extensions and marketplace-based Claude plugins.

Add convertClaudePluginStandalone: read plugin.json, fold MCP servers from a
root .mcp.json into the config, collect skills/commands/agents, and write
qwen-extension.json. The marketplace path is refactored to share the build
step. MCP entries are normalized from Claude's transport shape (type:'http' +
url) to Qwen's (httpUrl), and the cloned .git is dropped from the result.

Note: cloneFromGit does not init git submodules, so submodule-provided skills are not installed yet.

* i18n(zh): relabel user-scope install as 全局安装

The Discover detail's user-scope install option now reads 全局安装(用户作用域) instead of 仅为你安装(用户作用域), which better conveys that user scope installs the extension globally.

* fix(extensions): keep the manager mounted during install consent prompts

Consent, setting-input and plugin-choice requests raised by an install
used to replace the ExtensionsManagerDialog in DialogManager, unmounting
it mid-install: the dialog remounted on the default Installed tab with
pre-install data, and the completion reload signal hit the dead
instance. Render those prompts inside the dialog instead (tab content
hidden but mounted), and gate the previously always-active key handlers
(MCP detail steps, uninstall confirm) so hidden views can't double-handle
keys while a prompt is shown.

* feat(extensions): show loading feedback for scope change and toggles

Changing an extension's scope now swaps the selector for a
"Changing scope..." line (Esc ignored while in flight), and the
Installed tab's Space toggle reports "Enabling/Disabling ..." in the
status line right away — MCP enable rediscovers tools and can take a
while. Overlapping toggle/favorite presses are ignored until the
in-flight mutation finishes.

* feat(extensions): add --scope to install and a sources CLI command group

Bring the qwen extensions CLI up to par with /extensions manage:
install --scope user|project (workspace accepted as an alias) records
the scope preference and, for project, re-scopes enablement to the
current workspace only — mirroring the Discover tab's install flow.
New sources add/list/update/remove subcommands manage the Claude
marketplace sources that power the Discover tab.

* feat(extensions): trim the Sources tab and add marketplace detail retry/refresh

Drop the redundant installed-extensions list from the Sources tab (those
live on the Installed tab); the marketplace detail still summarizes which
of its plugins are installed, now capped with a "… and N more" line so a
long list stays short. Add an R key in the marketplace detail that
re-fetches the source — surfaced in the footer as a retry on load
failure and a refresh once loaded. Reword the install row to "Install a
new extension".

* feat(extensions): nest extension-bundled MCP servers under their extension

The Installed tab now lists each extension's bundled MCP servers as
indented child rows beneath it, with Enter opening the shared MCP detail
view (tools, OAuth authenticate/clear) just like the /mcp panel.

- Skip child rows shadowed by a same-named user/project server and ones
  blocked by the MCP allow-list, matching the runtime merge semantics.
- Space only blocks the disable direction for bundled servers; an
  individually-excluded server under an active extension can be
  re-enabled. Blocked Space/favorite and Enter on a disabled extension's
  server give info feedback instead of failing silently.
- Hide the always-failing Disable action for active extension-provided
  servers in ServerDetailStep (also fixes the /mcp panel).
- Window the Installed list to the terminal height with scroll hints,
  clamped offset and group-header anchoring.
- Fall back to the list if the open detail's item disappears on reload
  so the tab can't get stuck locked.
- Hermetic tests: stub loadSettings, stabilize two flaky assertions.

* feat(extensions): per-server disable for extension MCPs and live status

Extension-bundled MCP servers can now be disabled individually, aligned
with Claude Code. The record lives in extension-preferences.json keyed
by extension name (not the global mcp.excluded list), so it never
affects same-named servers from other sources, survives restarts via
config.isMcpServerDisabled, and is cleaned up on uninstall.

- All three surfaces support the toggle: Installed tab Space, the
  extensions MCP detail view, and the /mcp panel; ServerDetailStep
  offers Disable for extension servers again.
- Installed tab MCP rows now show the live connection state (connected
  / needs authentication / connecting / disconnected) instead of a bare
  enabled flag; selected rows highlight the badge and status text too.
- "Needs authentication" (401 marker or declared OAuth with no stored
  token) renders consistently in the /mcp list and both detail views;
  a successful connect clears the sticky 401 marker in core.
- All three surfaces subscribe to MCP status changes for live updates,
  with statuses re-stamped synchronously before setState to close the
  load/listener race.
- Perf: extension preferences are cached by mtime, and the disabled
  predicate finds the owning extension without rebuilding the merged
  server map.

* docs(extensions): document interactive manager and backfill missing i18n

Add a 'The interactive extension manager' section covering the
Discover/Installed/Sources tabs, per-server MCP enable/disable, and
keybindings.

Backfill en/zh/zh-TW entries for six previously-untranslated strings
referenced via t() (npm install flags, the install command description,
'Description', and 'Delete Session') so they render localized instead of
falling back to English.

* fix(extensions): harden untrusted-marketplace handling from PR review

Address review findings on the new marketplace/plugin attack surface:

Security
- Strip ANSI/control sequences from marketplace-sourced display strings
  (Discover + Sources) so a hostile source can't manipulate the terminal
  before install consent.
- Confine git-subdir source.path to the cloned repo (reject absolute/.. /empty)
  to stop path traversal; prefer the immutable SHA pin over a named ref.
- Refuse absolute/relative local-path plugin sources from remote marketplaces.
- Reject absolute/escaping mcpServers and hooks file paths in plugin.json so
  the converter can't read arbitrary out-of-tree files.
- Only follow http(s) plugin homepages (block file:// etc. via open()).
- Cap marketplace HTTP response bodies and add a wall-clock fetch deadline
  (req.setTimeout is socket-idle only) to prevent OOM / indefinite hangs.

Correctness / robustness
- A post-install scope/enablement failure no longer marks the install failed.
- .mcp.json without an mcpServers object is skipped instead of misparsed.
- Guard Ctrl+R refresh against concurrent in-flight discovery.
- Log (not swallow) OAuth token-store lookup failures.
- InstalledTab: single-pass tool-count map (drop N+1 getAllTools) and O(1)
  row index lookup (drop per-row items.indexOf).

Tests
- Make the .git-stripping assertion meaningful and cover the absolute-path
  mcpServers guard and the .mcp.json fallback.

* fix(extensions): consent layout, per-extension update check, uninstall progress

- ConsentPrompt: render the markdown prompt in a column. The inner Box
  defaulted to flexDirection=row, so a multi-paragraph consent (e.g. an
  extension bundling several MCP servers) tiled its blocks into narrow
  vertical columns. It now stacks vertically.
- "Mark for Update" now checks only the selected extension via
  checkForExtensionUpdate (previously ran the full checkForAllExtensionUpdates
  with a discarded result), stores the result, and reports "update available"
  vs "already up to date" so the "Update Now" action shows up immediately.
- Uninstall: show an "Uninstalling ..." line while removal runs so the confirm
  prompt no longer looks frozen after Enter.
- i18n (en/zh/zh-TW): add the new strings; drop the now-unused
  "Checked ... for updates." key.

* feat(extensions): clearer update-check feedback for "Mark for Update"

Distinguish the four check outcomes instead of collapsing everything to
"up to date": update-available, up-to-date, not-updatable, and error.
For not-updatable Claude marketplace plugins, spell out the reason and
workaround (they are install-time conversions with no git remote, so they
update by reinstalling). Also show a "Checking ... for updates..." line
first, since git/github-release/npm checks hit the network.

* fix(extensions): confine resource/source paths, sanitize homepage (review round 2)

- claude-converter: route commands/skills/agents resource paths (collectResources)
  and the relative string plugin-source through path-confinement — reject
  absolute / ..-escaping values — matching the existing mcpServers/hooks guard.
- sourceRegistry: sanitize plugin.homepage like the other untrusted marketplace
  display fields (it otherwise reaches status toasts unsanitized).
- InstalledTab: construct MCPOAuthTokenStorage once instead of once per server.

* fix(extensions): confine symlink targets when converting untrusted plugins

A plugin source from an untrusted marketplace/git repo can embed a
symlink whose name stays inside the package but whose target points at a
host file (e.g. skills/leak.txt -> ~/.ssh/id_rsa). The path-confinement
checks were purely lexical (path.resolve), while the downstream
copy/read calls follow symlinks, so the target's content could be
shipped into the installed extension.

- copyDirectory: pin the package real-path root and skip symlinks whose
  resolved target escapes it (this is the bulk-copy vector at
  buildQwenExtensionFromPlugin).
- resolvePluginRelativeFile: re-verify the real path with realpathSync
  after the lexical check (covers mcpServers/hooks/single-file resources).
- collectResources: skip symlinked files in a collected folder whose
  target escapes the resource dir.
- resolvePluginSource: reject a string source that resolves outside the
  marketplace dir through a symlink.

Adds regression tests for the bulk-copy and collected-folder paths.

* fix(extensions): confine manifest reads, share path-containment helpers

Follow-up to the symlink confinement work (review round 3):

- Guard the three manifest reads that bypassed the new checks: a hostile
  clone could make marketplace.json / plugin.json / .mcp.json themselves
  symlinks to JSON-shaped host files (e.g. ~/.docker/config.json), whose
  content was parsed into the merged config. Each read now verifies the
  real path stays inside the package before reading; .mcp.json is treated
  as absent, plugin.json/marketplace.json throw.
- Hoist the containment logic into gemini-converter as exported
  isPathWithin (lexical) + realPathWithin (symlink-resolved) so the rule
  lives in one place instead of being duplicated across both converters.
- Document copyDirectory's confineRoot parameter in its JSDoc.

Adds targeted tests for the resolvePluginRelativeFile and
resolvePluginSource symlink-rejection branches and the plugin.json
manifest guard.

* fix(extensions): cap manager dialog width to the main content area

On a wide terminal the /extensions manager clipped its right-aligned
status column to a sliver (e.g. "扩…"). Root cause: the dialog sized
itself to boxWidth = columns - 4 with no cap, while the app's main
content area is capped at 100 columns (AppContainer's mainAreaWidth).
Past ~104 columns the dialog grew wider than its container and the right
edge — including the status column flexGrow pushes to the far right — was
clipped off-screen. Narrow terminals were unaffected because columns - 4
stayed within the cap.

Cap boxWidth at Math.min(columns - 4, 100) to match the main content
area, mirroring the existing DiffDialog idiom.

Adds a wide-terminal regression test that renders through a 200-column
stdout and asserts no line exceeds the content area (the uncapped dialog
produced ~196-col lines).

* fix(diff): cap /diff dialog width to the main content area

Same wide-terminal clipping as the extensions manager: DiffDialog sized
itself to Math.min(columns - 4, 110), but the app's main content area is
capped at 100 cols (AppContainer's mainAreaWidth). On a wide terminal the
dialog grew to 110 columns inside the 100-column container and its right
border/edge was clipped off-screen. Narrow terminals were unaffected
because columns - 4 stayed within the cap.

Cap dialogWidth at Math.min(columns - 4, 100) to match the container.

Adds a wide-terminal regression test (200-column stdout) asserting no
rendered line exceeds the content area.

* fix(extensions): address review round 3 — symlink/ANSI confinement, crash & data-loss hardening

Security:
- claude-converter: confine git-subdir subdir against symlink escape;
  fail strict mode on a symlinked plugin.json; sanitize untrusted
  source/path in conversion error messages
- sourceRegistry: sanitize version/category/lastUpdated and component
  names before TUI render
- gemini-converter: guard gemini-extension.json reads with realPathWithin
- SourcesTab: sanitize persisted marketplace name in list + remove-confirm

Robustness:
- SourcesTab: wrap sync removeSource in try/catch (was crashing the TUI);
  only mark a marketplace updated when the refresh actually loaded
- InstalledTab: move bundled-MCP enable write inside try/catch
- DiscoverTab: return to the list (not an arbitrary plugin's detail) after
  a failed batch install
- install.ts: roll back the User-scope disable when the Workspace enable fails
- claude-converter: reject a null/non-object plugin.json with a clear error;
  warn instead of silently skipping a symlinked .mcp.json
- extensionPreferences/sourceRegistry: quarantine a corrupt state file to
  ${path}.corrupted so the next write can't clobber recoverable data

Tests:
- cover marketplace.json/plugin.json/.mcp.json symlink guards, strict-mode
  rejection, and null plugin.json
- delete the process.stdout.columns override on non-TTY in dialog width tests

* fix(extensions): guard toggleFavorite write against unhandled rejection

* test(extensions): normalize realpathSync mock so gemini guard passes on Windows

* fix(extensions): address review round 4 — shared sanitizer, narrowed quarantine, visible warnings, tests

- consolidate the three drifted ANSI/control-char strippers into a single
  shared stripAnsiAndControl in core textUtils (fixes the C1-range gap in
  workflow-orchestrator's copy); sourceRegistry/claude-converter now delegate
- claude-converter: also sanitize the git-subdir ref/sha in the not-found error
- corruptFile: surface the quarantine on stderr (debug log is gated off, so a
  silent move would still look like data loss)
- extensionPreferences/sourceRegistry: only quarantine on a JSON parse failure,
  not on transient read errors (EACCES/EMFILE/EISDIR) that leave a valid file
- install: surface a failed scope-change rollback instead of swallowing it

Tests:
- gemini-converter: negative-path coverage for the realPathWithin guards
- new corruptFile.test (rename-aside + best-effort failure)
- stripAnsiAndControl unit tests (ANSI/OSC/C0/C1)
- install rollback + rollback-also-fails cases

* test(extensions): assert discoverPlugins strips ANSI/control chars from display fields

Locks in the untrusted-metadata sanitization that was only covered
empirically before. Feeds a hostile plugin (cursor moves, line clears,
OSC title-injection, BEL) through discoverPlugins and asserts every
rendered field — name/version/description/author/homepage/category/
lastUpdated/component names plus the marketplace name — comes out clean.

Addresses the maintainer verification note on PR #4850.

* fix(extensions): address review round 5 — scope-change rollback, version sanitization, visible read warnings

- Critical: the UI scope-change (ExtensionActionsView) and project-scope
  install (DiscoverTab) disabled User then enabled Workspace with no
  rollback — a failed Workspace enable left the extension disabled at all
  scopes (silently dead, and in DiscoverTab the outer catch swallowed it so
  the install still reported success). Mirror the CLI install.ts pattern:
  roll the User enable back on failure.
- Persist the scope preference only AFTER enablement succeeds (install.ts +
  both UI paths), so a rolled-back enable can't leave prefs pointing at a
  scope the extension isn't actually enabled at (Installed tab mislabel).
- Security: the persisted 'version' is rendered raw on the Installed tab and
  PluginDetailView — only 'name' is validated on load, so a marketplace
  plugin could inject ANSI/control sequences post-install on every render.
  Scrub via stripUnsafeCharacters at both sinks (covers already-installed
  extensions; the Discover-side sanitization doesn't reach this path).
- Transient read errors in extensionPreferences/sourceRegistry only logged
  via the gated debugLogger, so a user's favorites/scopes/sources could
  vanish with no trail. Add an stderr warning matching quarantineCorruptFile.
- Tests: assert quarantine runs on a parse error (.corrupted sibling) and
  does NOT run on a transient read error (EISDIR), for both stores.

* fix(extensions): address review round 6 — marketplace-name sanitization, rollback-failure surfacing, url-source guard, security tests

Code:
- ANSI injection via the Discover marketplace filter: the marketplace name
  (untrusted, from a remote marketplace.json) flowed through onBrowse to the
  Discover hint render unsanitized. Scrub it in handleBrowseSource — this also
  fixes the filter comparison (it is matched against the already-sanitized
  DiscoveredPlugin.marketplaceName).
- '(Tab to clear)' hint was misleading: Tab cycled tabs rather than clearing
  the marketplace filter in place. On Discover with an active filter, Tab now
  clears the filter in place, matching the hint.
- Scope-change rollback failures were silently swallowed by a bare catch in
  both the Discover batch install and ExtensionActionsView, unlike the CLI
  install.ts which surfaces them. Both now report the rollback failure so the
  user knows the extension may be disabled at all scopes (new i18n keys for
  en/zh/zh-TW).
- resolveInstallSource: the structured { source: 'url' } branch bypassed the
  local-path guard applied to string sources, letting a remote http
  marketplace redirect the installer at a local filesystem path. Apply the
  same guard.

Tests:
- marketplace fetchUrl: wall-clock deadline (stalled server) and body-size cap
  (oversized stream) now covered.
- sourceRegistry: remote-marketplace local-path rejection covered for both the
  string and { source: 'url' } source forms.
- claude-converter git-subdir: clone+sha-pin happy path plus path-escape,
  absolute-path, missing-subdir, and symlink-escape rejections covered.

Note: the bot's 'missing scope rollback' threads target a pre-631e271fb
snapshot — that rollback already landed in round 5.

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Edenman 2026-06-20 13:18:19 +08:00 committed by GitHub
parent 69ac09a728
commit 61dcf865de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 8621 additions and 730 deletions

View file

@ -18,6 +18,16 @@ You can manage extensions at runtime within the interactive CLI using `/extensio
| `/extensions install <source>` | Install an extension from a git URL, local path, npm package, or marketplace |
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
#### The interactive extension manager
Running `/extensions` (or `/extensions manage`) opens an interactive manager with three tabs. Press `Tab` or the `←`/`→` arrows to switch between them.
- **Discover** — browse plugins from your configured marketplace sources. Type to search, `Enter` to view a plugin's details, and install it (you'll be asked to choose an install scope). Press `Ctrl+R` to re-fetch the listings, and `Esc` to go back.
- **Installed** — your installed extensions, grouped by scope (**User level**, **Project level**, and favorites). Use `↑`/`↓` to navigate, `Space` to enable/disable an extension, `f` to favorite it, and `Enter` to open its details. MCP servers bundled by an extension appear nested under their parent extension with live connection status; you can enable or disable each server individually from there.
- **Sources** — manage the marketplace sources that feed the Discover tab. Use `↑`/`↓` to navigate, `Enter` to select a source, and `d` to remove one. These are the same sources managed by the `qwen extensions sources` CLI commands described below.
Changes made here hot-reload immediately, without restarting Qwen Code.
### CLI Extension Management
You can also manage extensions using `qwen extensions` CLI commands. Note that changes made via CLI commands will be reflected in active CLI sessions on restart.
@ -133,6 +143,34 @@ qwen extensions install /path/to/your/extension
Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
#### Choosing an install scope
By default, an installed extension is enabled globally (user scope). Pass `--scope project` to enable it only for the current workspace:
```bash
qwen extensions install <source> --scope project
```
`--scope workspace` is accepted as an alias of `--scope project`. This matches the scope choice offered when installing from the `/extensions manage` Discover tab.
### Managing marketplace sources
Marketplace sources (Claude plugin marketplaces) power the Discover tab in `/extensions manage`. You can manage them from the CLI as well:
```bash
# Add a marketplace (owner/repo, git URL, https URL to marketplace.json, or local path)
qwen extensions sources add <source>
# List configured marketplaces
qwen extensions sources list
# Re-fetch a marketplace's plugin listing
qwen extensions sources update <name>
# Remove a marketplace
qwen extensions sources remove <name>
```
### Uninstalling an extension
To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:

View file

@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { settingsCommand } from './extensions/settings.js';
import { sourcesCommand } from './extensions/sources.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@ -29,6 +30,7 @@ export const extensionsCommand: CommandModule = {
.command(linkCommand)
.command(newCommand)
.command(settingsCommand)
.command(sourcesCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View file

@ -10,6 +10,9 @@ import yargs from 'yargs';
const mockInstallExtension = vi.hoisted(() => vi.fn());
const mockRefreshCache = vi.hoisted(() => vi.fn());
const mockSetExtensionScope = vi.hoisted(() => vi.fn());
const mockEnableExtension = vi.hoisted(() => vi.fn());
const mockDisableExtension = vi.hoisted(() => vi.fn());
const mockParseInstallSource = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockRequestConsentOrFail = vi.hoisted(() => vi.fn());
@ -22,6 +25,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi.fn().mockImplementation(() => ({
installExtension: mockInstallExtension,
refreshCache: mockRefreshCache,
setExtensionScope: mockSetExtensionScope,
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
})),
parseInstallSource: mockParseInstallSource,
}));
@ -38,6 +44,12 @@ vi.mock('../../config/trustedFolders.js', () => ({
vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
SettingScope: {
User: 'User',
Workspace: 'Workspace',
System: 'System',
SystemDefaults: 'SystemDefaults',
},
}));
vi.mock('../../utils/errors.js', () => ({
@ -225,4 +237,133 @@ describe('handleInstall', () => {
processSpy.mockRestore();
});
it('should re-scope enablement to the workspace for a project-scope install', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
await handleInstall({ source: 'git@some-url', scope: 'project' });
expect(mockSetExtensionScope).toHaveBeenCalledWith(
'scoped-extension',
'project',
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'scoped-extension',
'User',
);
expect(mockEnableExtension).toHaveBeenCalledWith(
'scoped-extension',
'Workspace',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Extension "scoped-extension" installed successfully and enabled for the current workspace.',
);
});
it('rolls back the User-scope disable when the Workspace enable fails', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
// Workspace enable (first call) fails; the rollback User enable succeeds.
mockEnableExtension.mockRejectedValueOnce(
new Error('workspace enable failed'),
);
mockEnableExtension.mockResolvedValueOnce(undefined);
await handleInstall({ source: 'git@some-url', scope: 'project' });
expect(mockDisableExtension).toHaveBeenCalledWith(
'scoped-extension',
'User',
);
// Both the failed Workspace enable and the rollback User enable were attempted.
expect(mockEnableExtension).toHaveBeenNthCalledWith(
1,
'scoped-extension',
'Workspace',
);
expect(mockEnableExtension).toHaveBeenNthCalledWith(
2,
'scoped-extension',
'User',
);
// The original failure is surfaced and the command exits non-zero.
expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
it('surfaces a rollback failure when the recovery enable also fails', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
// Both the Workspace enable and the rollback User enable fail.
mockEnableExtension.mockRejectedValueOnce(
new Error('workspace enable failed'),
);
mockEnableExtension.mockRejectedValueOnce(new Error('rollback failed'));
await handleInstall({ source: 'git@some-url', scope: 'project' });
// A warning naming the failed rollback, plus the original error, are shown.
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('failed to roll back the scope change'),
);
expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
it('should accept workspace as an alias of project scope', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
await handleInstall({ source: 'git@some-url', scope: 'workspace' });
expect(mockSetExtensionScope).toHaveBeenCalledWith(
'scoped-extension',
'project',
);
expect(mockEnableExtension).toHaveBeenCalledWith(
'scoped-extension',
'Workspace',
);
});
it('should record user scope without re-scoping enablement', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'user-extension' });
await handleInstall({ source: 'git@some-url', scope: 'user' });
expect(mockSetExtensionScope).toHaveBeenCalledWith(
'user-extension',
'user',
);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(mockEnableExtension).not.toHaveBeenCalled();
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Extension "user-extension" installed successfully and enabled.',
);
});
});

View file

@ -9,11 +9,12 @@ import type { CommandModule } from 'yargs';
import {
ExtensionManager,
parseInstallSource,
type ExtensionScope,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
@ -28,6 +29,12 @@ interface InstallArgs {
allowPreRelease?: boolean;
consent?: boolean;
registry?: string;
scope?: string;
}
// "workspace" is accepted as an alias of "project" to match enable/disable.
function normalizeScope(scope: string | undefined): ExtensionScope {
return scope === 'project' || scope === 'workspace' ? 'project' : 'user';
}
export async function handleInstall(args: InstallArgs) {
@ -87,10 +94,55 @@ export async function handleInstall(args: InstallArgs) {
},
requestConsent,
);
const scope = normalizeScope(args.scope);
if (args.scope) {
// installExtension auto-enables at the user (global) scope. For a
// project-scoped install, re-scope enablement to this workspace only —
// BEFORE recording the scope preference, so a failed Workspace enable
// (which rolls back to User) can't leave the prefs claiming "project".
if (scope === 'project') {
await extensionManager.disableExtension(
extension.name,
SettingScope.User,
);
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.Workspace,
);
} catch (enableError) {
// The User-scope disable already landed. If the Workspace enable
// fails, the extension would be left disabled everywhere — roll the
// User enable back so it isn't silently dead, then surface the error.
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.User,
);
} catch (rollbackError) {
// Rollback failed too: the extension is now disabled at every
// scope. Surface this so the user knows recovery also failed,
// before the original error is reported below.
writeStderrLine(
`Warning: failed to roll back the scope change for "${extension.name}"; it may be disabled at all scopes: ${getErrorMessage(rollbackError)}`,
);
}
throw enableError;
}
}
// Enablement succeeded (or scope is user/local with no enablement change):
// now it's safe to persist the scope preference.
extensionManager.setExtensionScope(extension.name, scope);
}
writeStdoutLine(
t('Extension "{{name}}" installed successfully and enabled.', {
name: extension.name,
}),
scope === 'project'
? t(
'Extension "{{name}}" installed successfully and enabled for the current workspace.',
{ name: extension.name },
)
: t('Extension "{{name}}" installed successfully and enabled.', {
name: extension.name,
}),
);
} catch (error) {
writeStderrLine(getErrorMessage(error));
@ -135,6 +187,13 @@ export const installCommand: CommandModule = {
type: 'boolean',
default: false,
})
.option('scope', {
describe: t(
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).',
),
type: 'string',
choices: ['user', 'project', 'workspace'],
})
.check((argv) => {
if (!argv.source) {
throw new Error(t('The source argument must be provided.'));
@ -149,6 +208,7 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
consent: argv['consent'] as boolean | undefined,
registry: argv['registry'] as string | undefined,
scope: argv['scope'] as string | undefined,
});
},
};

View file

@ -0,0 +1,231 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
sourcesCommand,
handleSourcesAdd,
handleSourcesRemove,
handleSourcesList,
handleSourcesUpdate,
} from './sources.js';
import yargs from 'yargs';
const mockAddSource = vi.hoisted(() => vi.fn());
const mockRemoveSource = vi.hoisted(() => vi.fn());
const mockGetSources = vi.hoisted(() => vi.fn());
const mockLoadSource = vi.hoisted(() => vi.fn());
const mockMarkSourceUpdated = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
vi.mock('./utils.js', () => ({
getExtensionManager: vi.fn().mockResolvedValue({
addSource: mockAddSource,
removeSource: mockRemoveSource,
getSources: mockGetSources,
loadSource: mockLoadSource,
markSourceUpdated: mockMarkSourceUpdated,
}),
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
writeStderrLine: mockWriteStderrLine,
clearScreen: vi.fn(),
}));
describe('extensions sources command', () => {
it('should parse the sources subcommands', () => {
// Benign mock returns: parse() invokes the handlers asynchronously.
mockAddSource.mockResolvedValue({ name: 'my-marketplace' });
mockRemoveSource.mockReturnValue(true);
mockGetSources.mockReturnValue([
{ name: 'my-marketplace', source: 'owner/repo', type: 'github' },
]);
mockLoadSource.mockResolvedValue({ name: 'my-marketplace', plugins: [] });
// A fresh parser per parse: yargs carries validation state across calls.
const parse = (command: string) =>
yargs([]).command(sourcesCommand).fail(false).locale('en').parse(command);
expect(() => parse('sources add owner/repo')).not.toThrow();
expect(() => parse('sources remove my-marketplace')).not.toThrow();
expect(() => parse('sources list')).not.toThrow();
expect(() => parse('sources update my-marketplace')).not.toThrow();
});
it('should fail without a subcommand', () => {
const parser = yargs([]).command(sourcesCommand).fail(false).locale('en');
expect(() => parser.parse('sources')).toThrow();
});
});
describe('handleSourcesAdd', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('adds a marketplace and reports its name', async () => {
mockAddSource.mockResolvedValue({
name: 'my-marketplace',
source: 'owner/repo',
type: 'github',
});
await handleSourcesAdd({ source: 'owner/repo' });
expect(mockAddSource).toHaveBeenCalledWith('owner/repo');
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Added marketplace "my-marketplace".',
);
});
it('reports errors and exits with code 1', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockAddSource.mockRejectedValue(new Error('No marketplace found'));
await handleSourcesAdd({ source: 'owner/repo' });
expect(mockWriteStderrLine).toHaveBeenCalledWith('No marketplace found');
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
});
describe('handleSourcesRemove', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('removes a marketplace by name', async () => {
mockRemoveSource.mockReturnValue(true);
await handleSourcesRemove({ name: 'my-marketplace' });
expect(mockRemoveSource).toHaveBeenCalledWith('my-marketplace');
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Removed marketplace "my-marketplace".',
);
});
it('errors when the marketplace is unknown', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockRemoveSource.mockReturnValue(false);
await handleSourcesRemove({ name: 'missing' });
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Marketplace "missing" not found.',
);
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
});
describe('handleSourcesList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('prints a message when no sources are configured', async () => {
mockGetSources.mockReturnValue([]);
await handleSourcesList();
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'No marketplace sources added yet.',
);
});
it('lists configured sources with source and type', async () => {
mockGetSources.mockReturnValue([
{
name: 'market-a',
source: 'owner/repo',
type: 'github',
lastUpdatedAt: '2026-06-10T00:00:00.000Z',
},
{
name: 'market-b',
source: 'https://example.com/marketplace.json',
type: 'http',
},
]);
await handleSourcesList();
const output = mockWriteStdoutLine.mock.calls[0][0] as string;
expect(output).toContain('market-a');
expect(output).toContain('owner/repo');
expect(output).toContain('market-b');
expect(output).toContain('https://example.com/marketplace.json');
});
});
describe('handleSourcesUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('re-fetches the marketplace and reports the plugin count', async () => {
mockGetSources.mockReturnValue([
{ name: 'market-a', source: 'owner/repo', type: 'github' },
]);
mockLoadSource.mockResolvedValue({
name: 'market-a',
plugins: [{ name: 'p1' }, { name: 'p2' }],
});
await handleSourcesUpdate({ name: 'market-a' });
expect(mockLoadSource).toHaveBeenCalledWith('owner/repo');
expect(mockMarkSourceUpdated).toHaveBeenCalledWith('market-a');
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Updated marketplace "market-a".',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith('2 available extensions');
});
it('errors when the marketplace is unknown', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockGetSources.mockReturnValue([]);
await handleSourcesUpdate({ name: 'missing' });
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Marketplace "missing" not found.',
);
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
it('errors when the marketplace cannot be loaded', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockGetSources.mockReturnValue([
{ name: 'market-a', source: 'owner/repo', type: 'github' },
]);
mockLoadSource.mockResolvedValue(null);
await handleSourcesUpdate({ name: 'market-a' });
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Could not load this marketplace.',
);
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});
});

View file

@ -0,0 +1,168 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { redactUrlCredentials } from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
export async function handleSourcesAdd(args: { source: string }) {
try {
const extensionManager = await getExtensionManager();
const entry = await extensionManager.addSource(args.source);
writeStdoutLine(t('Added marketplace "{{name}}".', { name: entry.name }));
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
export async function handleSourcesRemove(args: { name: string }) {
try {
const extensionManager = await getExtensionManager();
if (!extensionManager.removeSource(args.name)) {
writeStderrLine(
t('Marketplace "{{name}}" not found.', { name: args.name }),
);
process.exit(1);
return;
}
writeStdoutLine(t('Removed marketplace "{{name}}".', { name: args.name }));
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
export async function handleSourcesList() {
try {
const extensionManager = await getExtensionManager();
const sources = extensionManager.getSources();
if (sources.length === 0) {
writeStdoutLine(t('No marketplace sources added yet.'));
return;
}
writeStdoutLine(
sources
.map((entry) => {
let output = `${entry.name}`;
output += `\n ${t('Source:')} ${redactUrlCredentials(entry.source)} (${t('Type:')} ${entry.type})`;
const updated = entry.lastUpdatedAt ?? entry.addedAt;
if (updated) {
output += `\n ${t('Last updated: {{date}}', { date: updated })}`;
}
return output;
})
.join('\n\n'),
);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
export async function handleSourcesUpdate(args: { name: string }) {
try {
const extensionManager = await getExtensionManager();
const entry = extensionManager
.getSources()
.find((source) => source.name === args.name);
if (!entry) {
writeStderrLine(
t('Marketplace "{{name}}" not found.', { name: args.name }),
);
process.exit(1);
return;
}
const config = await extensionManager.loadSource(entry.source);
if (!config) {
writeStderrLine(t('Could not load this marketplace.'));
process.exit(1);
return;
}
extensionManager.markSourceUpdated(entry.name);
writeStdoutLine(t('Updated marketplace "{{name}}".', { name: entry.name }));
writeStdoutLine(
t('{{count}} available extensions', {
count: String(config.plugins?.length ?? 0),
}),
);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
const addCommand: CommandModule = {
command: 'add <source>',
describe: t('Adds a marketplace source (Claude format).'),
builder: (yargs) =>
yargs.positional('source', {
describe: t(
'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.',
),
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
await handleSourcesAdd({ source: argv['source'] as string });
},
};
const removeCommand: CommandModule = {
command: 'remove <name>',
describe: t('Removes a marketplace source.'),
builder: (yargs) =>
yargs.positional('name', {
describe: t('The name of the marketplace to remove.'),
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
await handleSourcesRemove({ name: argv['name'] as string });
},
};
const listCommand: CommandModule = {
command: 'list',
describe: t('Lists configured marketplace sources.'),
builder: (yargs) => yargs,
handler: async () => {
await handleSourcesList();
},
};
const updateCommand: CommandModule = {
command: 'update <name>',
describe: t('Re-fetches a marketplace source and its plugin listing.'),
builder: (yargs) =>
yargs.positional('name', {
describe: t('The name of the marketplace to update.'),
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
await handleSourcesUpdate({ name: argv['name'] as string });
},
};
export const sourcesCommand: CommandModule = {
command: 'sources <command>',
describe: t('Manage marketplace sources for discovering extensions.'),
builder: (yargs) =>
yargs
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.command(updateCommand)
.demandCommand(1, t('You need at least one command before continuing.'))
.version(false),
handler: () => {
// Yargs shows the help menu when no subcommand is provided.
},
};

View file

@ -8,6 +8,164 @@
// The key serves as both the translation key and the default English text
export default {
'Cannot disable an extension-provided MCP server here.':
'Cannot disable an extension-provided MCP server here.',
'Cleared authentication for "{{name}}".':
'Cleared authentication for "{{name}}".',
'MCP "{{name}}" disabled for all projects.':
'MCP "{{name}}" disabled for all projects.',
'Enable extension "{{name}}" to manage this MCP server.':
'Enable extension "{{name}}" to manage this MCP server.',
'Extension-provided MCP servers cannot be favorited.':
'Extension-provided MCP servers cannot be favorited.',
'User level': 'User level',
'Project level': 'Project level',
// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
// ==========================================================================
' · {{marketplace}} (Tab to clear)': ' · {{marketplace}} (Tab to clear)',
'"{{name}}" {{state}}.': '"{{name}}" {{state}}.',
'(Tab / ←→ to switch)': '(Tab / ←→ to switch)',
'+ Add new marketplace': '+ Add new marketplace',
'+ Install a new extension': '+ Install a new extension',
Actions: 'Actions',
'Add Marketplace': 'Add Marketplace',
'Add a marketplace in the Sources tab to discover extensions.':
'Add a marketplace in the Sources tab to discover extensions.',
'Add new': 'Add new',
'Add to Favorites': 'Add to Favorites',
'Added "{{name}}" to favorites.': 'Added "{{name}}" to favorites.',
'Added marketplace "{{name}}".': 'Added marketplace "{{name}}".',
'Adding...': 'Adding...',
'Back to extension list': 'Back to extension list',
'Browse extensions ({{count}})': 'Browse extensions ({{count}})',
'By: {{a}}': 'By: {{a}}',
'Change scope': 'Change scope',
'Change scope for "{{name}}":': 'Change scope for "{{name}}":',
'Changing scope...': 'Changing scope...',
'Uninstalling "{{name}}"...': 'Uninstalling "{{name}}"...',
'Update available for "{{name}}".': 'Update available for "{{name}}".',
'"{{name}}" is already up to date.': '"{{name}}" is already up to date.',
'Checking "{{name}}" for updates...': 'Checking "{{name}}" for updates...',
'"{{name}}" does not support update checks.':
'"{{name}}" does not support update checks.',
'"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).':
'"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).',
'Failed to check "{{name}}" for updates.':
'Failed to check "{{name}}" for updates.',
'Claude plugin marketplace': 'Claude plugin marketplace',
Commands: 'Commands',
'Components:': 'Components:',
'Could not load this marketplace.': 'Could not load this marketplace.',
'Current: {{scope}}': 'Current: {{scope}}',
Disabled: 'Disabled',
Discover: 'Discover',
'Disabling "{{name}}"...': 'Disabling "{{name}}"...',
'Disabling MCP "{{name}}"...': 'Disabling MCP "{{name}}"...',
'Discover extensions': 'Discover extensions',
'Discovering extensions...': 'Discovering extensions...',
'Enabling "{{name}}"...': 'Enabling "{{name}}"...',
'Enabling MCP "{{name}}"...': 'Enabling MCP "{{name}}"...',
'Enter extension source:': 'Enter extension source:',
'Enter marketplace source (Claude format):':
'Enter marketplace source (Claude format):',
'Examples:': 'Examples:',
'Extension details': 'Extension details',
'Extension v{{version}}': 'Extension v{{version}}',
'Extensions are not available in this environment.':
'Extensions are not available in this environment.',
'Failed to open {{url}}': 'Failed to open {{url}}',
Favorites: 'Favorites',
'Global (User Scope)': 'Global (User Scope)',
'Install Extension': 'Install Extension',
'Install for the current workspace (project scope)':
'Install for the current workspace (project scope)',
'Install for you (user scope)': 'Install for you (user scope)',
'Install {{count}} extension(s) to which scope?':
'Install {{count}} extension(s) to which scope?',
Installed: 'Installed',
'Installed extension "{{name}}".': 'Installed extension "{{name}}".',
'Installed extensions ({{count}}):': 'Installed extensions ({{count}}):',
'Installed {{count}} extension(s).': 'Installed {{count}} extension(s).',
'{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.':
'{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.',
'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})':
'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})',
'Installed {{ok}}, failed {{fail}}: {{detail}}':
'Installed {{ok}}, failed {{fail}}: {{detail}}',
'Installing...': 'Installing...',
'Last updated: {{date}}': 'Last updated: {{date}}',
MCP: 'MCP',
'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}.',
'MCP servers': 'MCP servers',
'Mark for Update': 'Mark for Update',
Marketplaces: 'Marketplaces',
'No extensions discovered.': 'No extensions discovered.',
'No extensions match your search.': 'No extensions match your search.',
'No extensions or marketplaces added yet.':
'No extensions or marketplaces added yet.',
'No homepage available.': 'No homepage available.',
'No installable extensions selected.': 'No installable extensions selected.',
'No plugins or MCP servers installed.':
'No plugins or MCP servers installed.',
None: 'None',
'Note: Uninstall permanently removes this extension.':
'Note: Uninstall permanently removes this extension.',
'Open homepage': 'Open homepage',
'Project (Workspace)': 'Project (Workspace)',
'Refreshed {{count}} extension(s).': 'Refreshed {{count}} extension(s).',
'Remove from Favorites': 'Remove from Favorites',
'Remove marketplace': 'Remove marketplace',
'Remove marketplace "{{name}}"?': 'Remove marketplace "{{name}}"?',
'Removed "{{name}}" from favorites.': 'Removed "{{name}}" from favorites.',
'Removed marketplace "{{name}}".': 'Removed marketplace "{{name}}".',
'Scope:': 'Scope:',
'Set "{{name}}" scope to {{scope}}.': 'Set "{{name}}" scope to {{scope}}.',
Sources: 'Sources',
'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back':
'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back',
Uninstall: 'Uninstall',
'Uninstalled "{{name}}".': 'Uninstalled "{{name}}".',
'Update Now': 'Update Now',
'Update marketplace': 'Update marketplace',
'Update marketplace (last updated {{date}})':
'Update marketplace (last updated {{date}})',
'Could not update marketplace "{{name}}".':
'Could not update marketplace "{{name}}".',
'Updated "{{name}}".': 'Updated "{{name}}".',
'Updated marketplace "{{name}}".': 'Updated marketplace "{{name}}".',
'Use the Discover tab to find and install plugins.':
'Use the Discover tab to find and install plugins.',
'Version: {{v}}': 'Version: {{v}}',
'Will install:': 'Will install:',
'Would open: {{url}}': 'Would open: {{url}}',
'Y/Enter to confirm · N/Esc to cancel':
'Y/Enter to confirm · N/Esc to cancel',
'Press R to retry · Esc to go back': 'Press R to retry · Esc to go back',
'Enter to select · R refresh · Esc to go back':
'Enter to select · R refresh · Esc to go back',
'from {{marketplace}}': 'from {{marketplace}}',
installed: 'installed',
'{{count}} Agents': '{{count}} Agents',
'{{count}} Commands': '{{count}} Commands',
'{{count}} MCP': '{{count}} MCP',
'{{count}} Skills': '{{count}} Skills',
'{{count}} available extensions': '{{count}} available extensions',
'↑ more above': '↑ more above',
'↑↓ navigate · Enter open · d remove marketplace · Esc close':
'↑↓ navigate · Enter open · d remove marketplace · Esc close',
'↑↓ navigate · Enter select · Esc close':
'↑↓ navigate · Enter select · Esc close',
'↑↓ navigate · Enter select · d remove marketplace · Esc close':
'↑↓ navigate · Enter select · d remove marketplace · Esc close',
'↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close':
'↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close',
'↓ more below': '↓ more below',
'⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.':
'⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.',
// ============================================================================
// Tool display names (chat-stream badge labels)
// ----------------------------------------------------------------------------
@ -633,6 +791,30 @@ export default {
'Uninstall an extension': 'Uninstall an extension',
'No extensions installed.': 'No extensions installed.',
'Extension "{{name}}" not found.': 'Extension "{{name}}" not found.',
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).':
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).',
'Extension "{{name}}" installed successfully and enabled for the current workspace.':
'Extension "{{name}}" installed successfully and enabled for the current workspace.',
'Marketplace "{{name}}" not found.': 'Marketplace "{{name}}" not found.',
'No marketplace sources added yet.': 'No marketplace sources added yet.',
'No marketplaces added yet.': 'No marketplaces added yet.',
'Adds a marketplace source (Claude format).':
'Adds a marketplace source (Claude format).',
'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.':
'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.',
'Removes a marketplace source.': 'Removes a marketplace source.',
'The name of the marketplace to remove.':
'The name of the marketplace to remove.',
'Lists configured marketplace sources.':
'Lists configured marketplace sources.',
'Re-fetches a marketplace source and its plugin listing.':
'Re-fetches a marketplace source and its plugin listing.',
'The name of the marketplace to update.':
'The name of the marketplace to update.',
'Manage marketplace sources for discovering extensions.':
'Manage marketplace sources for discovering extensions.',
'You need at least one command before continuing.':
'You need at least one command before continuing.',
'No extensions to update.': 'No extensions to update.',
'Usage: /extensions install <source>': 'Usage: /extensions install <source>',
'Installing extension from "{{source}}"...':
@ -669,6 +851,16 @@ export default {
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
'The git ref to install from.': 'The git ref to install from.',
'--registry is only applicable for npm extensions.':
'--registry is only applicable for npm extensions.',
'Custom npm registry URL (only for npm extensions).':
'Custom npm registry URL (only for npm extensions).',
'--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).':
'--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).',
'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).':
'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).',
Description: 'Description',
'Delete Session': 'Delete Session',
'Enable auto-update for this extension.':
'Enable auto-update for this extension.',
'Enable pre-release versions for this extension.':
@ -1142,6 +1334,7 @@ export default {
connected: 'connected',
connecting: 'connecting',
disconnected: 'disconnected',
'needs authentication': 'needs authentication',
// MCP Server List
'User MCPs': 'User MCPs',

View file

@ -9,7 +9,156 @@
// then extensively hand-corrected for Taiwan vocabulary conventions.
// This file is the authoritative source — do not overwrite with auto-generated output.
export default {
// ============================================================================
'Cannot disable an extension-provided MCP server here.':
'無法在此處停用擴展提供的 MCP 伺服器。',
'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的認證資訊。',
'MCP "{{name}}" disabled for all projects.':
'MCP "{{name}}" 已在所有專案中停用。',
'Enable extension "{{name}}" to manage this MCP server.':
'啟用擴展 "{{name}}" 後才能管理此 MCP 伺服器。',
'Extension-provided MCP servers cannot be favorited.':
'擴展提供的 MCP 伺服器無法單獨收藏。',
'User level': '使用者層級',
'Project level': '專案層級',
// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
// ==========================================================================
' · {{marketplace}} (Tab to clear)': ' · {{marketplace}}Tab 清除)',
'"{{name}}" {{state}}.': '"{{name}}" {{state}}。',
'(Tab / ←→ to switch)': 'Tab / ←→ 切換)',
'+ Add new marketplace': '+ 新增市場來源',
'+ Install a new extension': '+ 安裝一個新擴展',
Actions: '操作',
'Add Marketplace': '新增市場來源',
'Add a marketplace in the Sources tab to discover extensions.':
'在「來源」分頁中新增市場來源以發現擴展。',
'Add new': '新增',
'Add to Favorites': '加入收藏',
'Added "{{name}}" to favorites.': '已將 "{{name}}" 加入收藏。',
'Added marketplace "{{name}}".': '已新增市場來源 "{{name}}"。',
'Adding...': '新增中...',
'Back to extension list': '返回擴展清單',
'Browse extensions ({{count}})': '瀏覽擴展({{count}}',
'By: {{a}}': '作者:{{a}}',
'Change scope': '變更作用域',
'Change scope for "{{name}}":': '變更 "{{name}}" 的作用域:',
'Changing scope...': '正在變更作用域...',
'Uninstalling "{{name}}"...': '正在卸載 "{{name}}"...',
'Update available for "{{name}}".': '"{{name}}" 有可用更新。',
'"{{name}}" is already up to date.': '"{{name}}" 已是最新。',
'Checking "{{name}}" for updates...': '正在檢查 "{{name}}" 的更新...',
'"{{name}}" does not support update checks.': '"{{name}}" 不支援檢查更新。',
'"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).':
'"{{name}}" 無法檢查更新Claude 市場源插件需卸載後重裝來更新)。',
'Failed to check "{{name}}" for updates.': '檢查 "{{name}}" 的更新失敗。',
'Claude plugin marketplace': 'Claude 外掛市場',
Commands: '命令',
'Components:': '元件:',
'Could not load this marketplace.': '無法載入此市場來源。',
'Current: {{scope}}': '目前:{{scope}}',
Disabled: '已禁用',
Discover: '發現',
'Disabling "{{name}}"...': '正在禁用 "{{name}}"...',
'Disabling MCP "{{name}}"...': '正在禁用 MCP "{{name}}"...',
'Discover extensions': '發現擴展',
'Discovering extensions...': '正在發現擴展...',
'Enabling "{{name}}"...': '正在啟用 "{{name}}"...',
'Enabling MCP "{{name}}"...': '正在啟用 MCP "{{name}}"...',
'Enter extension source:': '輸入擴展來源:',
'Enter marketplace source (Claude format):':
'輸入市場來源位址Claude 格式):',
'Examples:': '範例:',
'Extension details': '擴展詳情',
'Extension v{{version}}': '擴展 v{{version}}',
'Extensions are not available in this environment.': '目前環境中擴展不可用。',
'Failed to open {{url}}': '開啟 {{url}} 失敗',
Favorites: '收藏',
'Global (User Scope)': '全域(使用者作用域)',
'Install Extension': '安裝擴展',
'Install for the current workspace (project scope)':
'為目前工作區安裝(專案作用域)',
'Install for you (user scope)': '僅為你安裝(使用者作用域)',
'Install {{count}} extension(s) to which scope?':
'將 {{count}} 個擴展安裝到哪個作用域?',
Installed: '已安裝',
'Installed extension "{{name}}".': '已安裝擴展 "{{name}}"。',
'Installed extensions ({{count}}):': '已安裝的擴展({{count}}',
'Installed {{count}} extension(s).': '已安裝 {{count}} 個擴展。',
'{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.':
'{{name}}:已安裝,但作用域回滾失敗 —— 該擴展可能在所有作用域均被停用;請在「已安裝」頁重新啟用。',
'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})':
'無法變更作用域,且回滾也失敗 ——「{{name}}」可能在所有作用域均被停用。請在「已安裝」頁重新啟用。({{error}})',
'Installed {{ok}}, failed {{fail}}: {{detail}}':
'成功 {{ok}} 個,失敗 {{fail}} 個:{{detail}}',
'Installing...': '安裝中...',
'Last updated: {{date}}': '最近更新:{{date}}',
MCP: 'MCP',
'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。',
'MCP servers': 'MCP 伺服器',
'Mark for Update': '標記為待更新',
Marketplaces: '市場來源',
'No extensions discovered.': '未發現任何擴展。',
'No extensions match your search.': '沒有與搜尋相符的擴展。',
'No extensions or marketplaces added yet.': '尚未新增任何擴展或市場來源。',
'No homepage available.': '沒有可用的主頁。',
'No installable extensions selected.': '未選取可安裝的擴展。',
'No plugins or MCP servers installed.': '尚未安裝任何外掛或 MCP 伺服器。',
None: '無',
'Note: Uninstall permanently removes this extension.':
'注意:卸載將永久移除此擴展。',
'Open homepage': '開啟主頁',
'Project (Workspace)': '專案(工作區)',
'Refreshed {{count}} extension(s).': '已刷新 {{count}} 個擴充。',
'Remove from Favorites': '從收藏中移除',
'Remove marketplace': '移除市場來源',
'Remove marketplace "{{name}}"?': '移除市場來源 "{{name}}"',
'Removed "{{name}}" from favorites.': '已將 "{{name}}" 從收藏中移除。',
'Removed marketplace "{{name}}".': '已移除市場來源 "{{name}}"。',
'Scope:': '作用域:',
'Set "{{name}}" scope to {{scope}}.':
'已將 "{{name}}" 的作用域設為 {{scope}}。',
Sources: '來源',
'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back':
'輸入以搜尋 · Space 切換 · Enter 查看 · Ctrl+R 刷新 · Esc 返回',
Uninstall: '卸載',
'Uninstalled "{{name}}".': '已卸載 "{{name}}"。',
'Update Now': '立即更新',
'Update marketplace': '更新市場來源',
'Update marketplace (last updated {{date}})':
'更新市場來源(最近更新 {{date}}',
'Could not update marketplace "{{name}}".': '無法更新市場來源 "{{name}}"。',
'Updated "{{name}}".': '已更新 "{{name}}"。',
'Updated marketplace "{{name}}".': '已更新市場來源 "{{name}}"。',
'Use the Discover tab to find and install plugins.':
'使用「發現」分頁尋找並安裝擴展。',
'Version: {{v}}': '版本:{{v}}',
'Will install:': '將安裝:',
'Would open: {{url}}': '將開啟:{{url}}',
'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 確認 · N/Esc 取消',
'Press R to retry · Esc to go back': '按 R 重試 · Esc 返回',
'Enter to select · R refresh · Esc to go back':
'Enter 選擇 · R 刷新 · Esc 返回',
'from {{marketplace}}': '來自 {{marketplace}}',
installed: '已安裝',
'{{count}} Agents': '{{count}} 個智能體',
'{{count}} Commands': '{{count}} 個命令',
'{{count}} MCP': '{{count}} 個 MCP',
'{{count}} Skills': '{{count}} 個技能',
'{{count}} available extensions': '{{count}} 個可用擴展',
'↑ more above': '↑ 上方更多',
'↑↓ navigate · Enter open · d remove marketplace · Esc close':
'↑↓ 導覽 · Enter 開啟 · d 移除市場來源 · Esc 關閉',
'↑↓ navigate · Enter select · Esc close': '↑↓ 導覽 · Enter 選擇 · Esc 關閉',
'↑↓ navigate · Enter select · d remove marketplace · Esc close':
'↑↓ 導覽 · Enter 選擇 · d 移除市場來源 · Esc 關閉',
'↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close':
'↑↓ 導覽 · Space 啟用/禁用 · f 收藏 · Enter 查看詳情 · Esc 關閉',
'↓ more below': '↓ 下方更多',
'⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.':
'⚠ 在安裝、更新或使用擴展前,請確保你信任它。我們無法驗證擴展包含哪些 MCP 伺服器、檔案或其他軟體,也無法保證其按預期運作。更多資訊請查看擴展主頁。',
// Tool display names (chat-stream badge labels)
// ----------------------------------------------------------------------------
// Keyed by `toolDisplayName.<English display name>` (from core
@ -53,6 +202,7 @@ export default {
'toolDisplayName.EnterWorktree': '進入 Worktree',
'toolDisplayName.ExitWorktree': '退出 Worktree',
'toolDisplayName.Workflow': '工作流程',
'↑ to manage attachments': '↑ 管理附件',
'← → select, Delete to remove, ↓ to exit': '← → 選擇Delete 刪除,↓ 退出',
'Attachments: ': '附件:',
@ -562,6 +712,27 @@ export default {
'Uninstall an extension': '卸載擴展',
'No extensions installed.': '未安裝擴展。',
'Extension "{{name}}" not found.': '未找到擴展 "{{name}}"。',
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).':
'安裝擴展的作用域:"user"(全域,預設)或 "project"(僅當前工作區)。',
'Extension "{{name}}" installed successfully and enabled for the current workspace.':
'擴展 "{{name}}" 安裝成功,並已在當前工作區啟用。',
'Marketplace "{{name}}" not found.': '未找到市場源 "{{name}}"。',
'No marketplace sources added yet.': '尚未添加任何市場源。',
'No marketplaces added yet.': '尚未添加任何市場源。',
'Adds a marketplace source (Claude format).':
'添加一個市場源Claude 格式)。',
'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.':
'要添加的市場源owner/repoGitHub、git 或 https URL或本地路徑。',
'Removes a marketplace source.': '移除一個市場源。',
'The name of the marketplace to remove.': '要移除的市場源名稱。',
'Lists configured marketplace sources.': '列出已配置的市場源。',
'Re-fetches a marketplace source and its plugin listing.':
'重新拉取市場源及其插件列表。',
'The name of the marketplace to update.': '要更新的市場源名稱。',
'Manage marketplace sources for discovering extensions.':
'管理用於發現擴展的市場源。',
'You need at least one command before continuing.':
'需要至少提供一個子命令。',
'No extensions to update.': '沒有可更新的擴展。',
'Usage: /extensions install <source>': '用法:/extensions install <來源>',
'Installing extension from "{{source}}"...':
@ -595,6 +766,16 @@ export default {
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'要安裝的擴展的 GitHub URL、本地路徑或市場源marketplace-url:plugin-name。',
'The git ref to install from.': '要安裝的 Git 引用。',
'--registry is only applicable for npm extensions.':
'--registry 僅適用於 npm 擴展。',
'Custom npm registry URL (only for npm extensions).':
'自訂 npm registry URL僅適用於 npm 擴展)。',
'--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).':
'--ref 不適用於 npm 擴展。請改用 @version 後綴(例如 @scope/package@1.2.0)。',
'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).':
'從 Git 倉庫 URL、本地路徑、帶作用域的 npm 套件(@scope/name或 Claude 市場源marketplace-url:plugin-name安裝擴展。',
Description: '描述',
'Delete Session': '刪除會話',
'Enable auto-update for this extension.': '為此擴展啟用自動更新。',
'Enable pre-release versions for this extension.': '為此擴展啟用預發佈版本。',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
@ -968,6 +1149,7 @@ export default {
connected: '已連接',
connecting: '連接中',
disconnected: '已斷開',
'needs authentication': '需要認證',
'User MCPs': '用戶 MCP',
'Project MCPs': '項目 MCP',
'Extension MCPs': '擴展 MCP',

View file

@ -7,6 +7,156 @@
// Chinese translations for Qwen Code CLI
export default {
'Cannot disable an extension-provided MCP server here.':
'无法在此处禁用扩展提供的 MCP 服务器。',
'Cleared authentication for "{{name}}".': '已清空 "{{name}}" 的认证信息。',
'MCP "{{name}}" disabled for all projects.':
'MCP "{{name}}" 已在所有项目中禁用。',
'Enable extension "{{name}}" to manage this MCP server.':
'启用扩展 "{{name}}" 后才能管理此 MCP 服务器。',
'Extension-provided MCP servers cannot be favorited.':
'扩展提供的 MCP 服务器无法单独收藏。',
'User level': '用户级',
'Project level': '项目级',
// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
// ==========================================================================
' · {{marketplace}} (Tab to clear)': ' · {{marketplace}}Tab 清除)',
'"{{name}}" {{state}}.': '"{{name}}" {{state}}。',
'(Tab / ←→ to switch)': '(Tab / ←→ 切换)',
'+ Add new marketplace': '+ 添加新市场源',
'+ Install a new extension': '+ 安装一个新扩展',
Actions: '操作',
'Add Marketplace': '添加市场源',
'Add a marketplace in the Sources tab to discover extensions.':
'在“来源”标签页中添加市场源以发现扩展。',
'Add new': '新增',
'Add to Favorites': '添加到收藏',
'Added "{{name}}" to favorites.': '已将 "{{name}}" 添加到收藏。',
'Added marketplace "{{name}}".': '已添加市场源 "{{name}}"。',
'Adding...': '添加中...',
'Back to extension list': '返回扩展列表',
'Browse extensions ({{count}})': '浏览扩展({{count}}',
'By: {{a}}': '作者:{{a}}',
'Change scope': '更改作用域',
'Change scope for "{{name}}":': '更改 "{{name}}" 的作用域:',
'Changing scope...': '正在更改作用域...',
'Uninstalling "{{name}}"...': '正在卸载 "{{name}}"...',
'Update available for "{{name}}".': '"{{name}}" 有可用更新。',
'"{{name}}" is already up to date.': '"{{name}}" 已是最新。',
'Checking "{{name}}" for updates...': '正在检查 "{{name}}" 的更新...',
'"{{name}}" does not support update checks.': '"{{name}}" 不支持检查更新。',
'"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).':
'"{{name}}" 无法检查更新Claude 市场源插件需卸载后重装来更新)。',
'Failed to check "{{name}}" for updates.': '检查 "{{name}}" 的更新失败。',
'Claude plugin marketplace': 'Claude 插件市场',
Commands: '命令',
'Components:': '组件:',
'Could not load this marketplace.': '无法加载该市场源。',
'Current: {{scope}}': '当前:{{scope}}',
Disabled: '已禁用',
Discover: '发现',
'Disabling "{{name}}"...': '正在禁用 "{{name}}"...',
'Disabling MCP "{{name}}"...': '正在禁用 MCP "{{name}}"...',
'Discover extensions': '发现扩展',
'Discovering extensions...': '正在发现扩展...',
'Enabling "{{name}}"...': '正在启用 "{{name}}"...',
'Enabling MCP "{{name}}"...': '正在启用 MCP "{{name}}"...',
'Enter extension source:': '输入扩展来源:',
'Enter marketplace source (Claude format):':
'输入市场源地址Claude 格式):',
'Examples:': '示例:',
'Extension details': '扩展详情',
'Extension v{{version}}': '扩展 v{{version}}',
'Extensions are not available in this environment.': '当前环境中扩展不可用。',
'Failed to open {{url}}': '打开 {{url}} 失败',
Favorites: '收藏',
'Global (User Scope)': '全局(用户作用域)',
'Install Extension': '安装扩展',
'Install for the current workspace (project scope)':
'为当前工作区安装(项目作用域)',
'Install for you (user scope)': '全局安装(用户作用域)',
'Install {{count}} extension(s) to which scope?':
'将 {{count}} 个扩展安装到哪个作用域?',
Installed: '已安装',
'Installed extension "{{name}}".': '已安装扩展 "{{name}}"。',
'Installed extensions ({{count}}):': '已安装的扩展({{count}}',
'Installed {{count}} extension(s).': '已安装 {{count}} 个扩展。',
'{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.':
'{{name}}:已安装,但作用域回滚失败 —— 该扩展可能在所有作用域均被禁用;请在“已安装”页重新启用。',
'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})':
'无法更改作用域,且回滚也失败 ——“{{name}}”可能在所有作用域均被禁用。请在“已安装”页重新启用。({{error}})',
'Installed {{ok}}, failed {{fail}}: {{detail}}':
'成功 {{ok}} 个,失败 {{fail}} 个:{{detail}}',
'Installing...': '安装中...',
'Last updated: {{date}}': '最近更新:{{date}}',
MCP: 'MCP',
'MCP "{{name}}" {{state}}.': 'MCP "{{name}}" {{state}}。',
'MCP servers': 'MCP 服务器',
'Mark for Update': '标记为待更新',
Marketplaces: '市场源',
'No extensions discovered.': '未发现任何扩展。',
'No extensions match your search.': '没有与搜索匹配的扩展。',
'No extensions or marketplaces added yet.': '尚未添加任何扩展或市场源。',
'No homepage available.': '没有可用的主页。',
'No installable extensions selected.': '未选择可安装的扩展。',
'No plugins or MCP servers installed.': '尚未安装任何插件或 MCP 服务器。',
None: '无',
'Note: Uninstall permanently removes this extension.':
'注意:卸载将永久移除此扩展。',
'Open homepage': '打开主页',
'Project (Workspace)': '项目(工作区)',
'Refreshed {{count}} extension(s).': '已刷新 {{count}} 个扩展。',
'Remove from Favorites': '从收藏中移除',
'Remove marketplace': '移除市场源',
'Remove marketplace "{{name}}"?': '移除市场源 "{{name}}"',
'Removed "{{name}}" from favorites.': '已将 "{{name}}" 从收藏中移除。',
'Removed marketplace "{{name}}".': '已移除市场源 "{{name}}"。',
'Scope:': '作用域:',
'Set "{{name}}" scope to {{scope}}.':
'已将 "{{name}}" 的作用域设为 {{scope}}。',
Sources: '来源',
'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back':
'输入以搜索 · Space 切换 · Enter 查看 · Ctrl+R 刷新 · Esc 返回',
Uninstall: '卸载',
'Uninstalled "{{name}}".': '已卸载 "{{name}}"。',
'Update Now': '立即更新',
'Update marketplace': '更新市场源',
'Update marketplace (last updated {{date}})':
'更新市场源(最近更新 {{date}}',
'Could not update marketplace "{{name}}".': '无法更新市场源 "{{name}}"。',
'Updated "{{name}}".': '已更新 "{{name}}"。',
'Updated marketplace "{{name}}".': '已更新市场源 "{{name}}"。',
'Use the Discover tab to find and install plugins.':
'使用“发现”标签页查找并安装扩展。',
'Version: {{v}}': '版本:{{v}}',
'Will install:': '将安装:',
'Would open: {{url}}': '将打开:{{url}}',
'Y/Enter to confirm · N/Esc to cancel': 'Y/Enter 确认 · N/Esc 取消',
'Press R to retry · Esc to go back': '按 R 重试 · Esc 返回',
'Enter to select · R refresh · Esc to go back':
'Enter 选择 · R 刷新 · Esc 返回',
'from {{marketplace}}': '来自 {{marketplace}}',
installed: '已安装',
'{{count}} Agents': '{{count}} 个智能体',
'{{count}} Commands': '{{count}} 个命令',
'{{count}} MCP': '{{count}} 个 MCP',
'{{count}} Skills': '{{count}} 个技能',
'{{count}} available extensions': '{{count}} 个可用扩展',
'↑ more above': '↑ 上方更多',
'↑↓ navigate · Enter open · d remove marketplace · Esc close':
'↑↓ 导航 · Enter 打开 · d 移除市场源 · Esc 关闭',
'↑↓ navigate · Enter select · Esc close': '↑↓ 导航 · Enter 选择 · Esc 关闭',
'↑↓ navigate · Enter select · d remove marketplace · Esc close':
'↑↓ 导航 · Enter 选择 · d 移除市场源 · Esc 关闭',
'↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close':
'↑↓ 导航 · Space 启用/禁用 · f 收藏 · Enter 查看详情 · Esc 关闭',
'↓ more below': '↓ 下方更多',
'⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.':
'⚠ 在安装、更新或使用扩展前,请确保你信任它。我们无法验证扩展包含哪些 MCP 服务器、文件或其他软件,也无法保证其按预期工作。更多信息请查看扩展主页。',
// ============================================================================
// Tool display names (chat-stream badge labels)
// ----------------------------------------------------------------------------
@ -606,6 +756,27 @@ export default {
'Uninstall an extension': '卸载扩展',
'No extensions installed.': '未安装扩展。',
'Extension "{{name}}" not found.': '未找到扩展 "{{name}}"。',
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).':
'安装扩展的作用域:"user"(全局,默认)或 "project"(仅当前工作区)。',
'Extension "{{name}}" installed successfully and enabled for the current workspace.':
'扩展 "{{name}}" 安装成功,并已在当前工作区启用。',
'Marketplace "{{name}}" not found.': '未找到市场源 "{{name}}"。',
'No marketplace sources added yet.': '尚未添加任何市场源。',
'No marketplaces added yet.': '尚未添加任何市场源。',
'Adds a marketplace source (Claude format).':
'添加一个市场源Claude 格式)。',
'The marketplace source to add: owner/repo (GitHub), a git or https URL, or a local path.':
'要添加的市场源owner/repoGitHub、git 或 https URL或本地路径。',
'Removes a marketplace source.': '移除一个市场源。',
'The name of the marketplace to remove.': '要移除的市场源名称。',
'Lists configured marketplace sources.': '列出已配置的市场源。',
'Re-fetches a marketplace source and its plugin listing.':
'重新拉取市场源及其插件列表。',
'The name of the marketplace to update.': '要更新的市场源名称。',
'Manage marketplace sources for discovering extensions.':
'管理用于发现扩展的市场源。',
'You need at least one command before continuing.':
'需要至少提供一个子命令。',
'No extensions to update.': '没有可更新的扩展。',
'Usage: /extensions install <source>': '用法:/extensions install <来源>',
'Installing extension from "{{source}}"...':
@ -639,6 +810,16 @@ export default {
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.':
'要安装的扩展的 GitHub URL、本地路径或市场源marketplace-url:plugin-name。',
'The git ref to install from.': '要安装的 Git 引用。',
'--registry is only applicable for npm extensions.':
'--registry 仅适用于 npm 扩展。',
'Custom npm registry URL (only for npm extensions).':
'自定义 npm registry URL仅适用于 npm 扩展)。',
'--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).':
'--ref 不适用于 npm 扩展。请改用 @version 后缀(例如 @scope/package@1.2.0)。',
'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).':
'从 Git 仓库 URL、本地路径、带作用域的 npm 包(@scope/name或 Claude 市场源marketplace-url:plugin-name安装扩展。',
Description: '描述',
'Delete Session': '删除会话',
'Enable auto-update for this extension.': '为此扩展启用自动更新。',
'Enable pre-release versions for this extension.': '为此扩展启用预发布版本。',
'Acknowledge the security risks of installing an extension and skip the confirmation prompt.':
@ -1060,6 +1241,7 @@ export default {
connected: '已连接',
connecting: '连接中',
disconnected: '已断开',
'needs authentication': '需要认证',
// MCP Server List
'User MCPs': '用户 MCP',

View file

@ -49,7 +49,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
height={constrainedHeight}
overflow="hidden"
>
<Box flexShrink={1} overflow="hidden">
<Box flexDirection="column" flexShrink={1} overflow="hidden">
{typeof prompt === 'string' ? (
<MarkdownDisplay
isPending={true}

View file

@ -170,7 +170,13 @@ export const DialogManager = ({
/>
);
}
if (uiState.confirmUpdateExtensionRequests.length > 0) {
// Extension install/update requests (consent, setting input, plugin choice)
// are rendered inside the ExtensionsManagerDialog when it is open, so the
// dialog keeps its tab/list state instead of being unmounted.
if (
uiState.confirmUpdateExtensionRequests.length > 0 &&
!uiState.isExtensionsManagerDialogOpen
) {
const request = uiState.confirmUpdateExtensionRequests[0];
return (
<ConsentPrompt
@ -189,7 +195,10 @@ export const DialogManager = ({
/>
);
}
if (uiState.settingInputRequests.length > 0) {
if (
uiState.settingInputRequests.length > 0 &&
!uiState.isExtensionsManagerDialogOpen
) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
return (
@ -204,7 +213,10 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
if (
uiState.pluginChoiceRequests.length > 0 &&
!uiState.isExtensionsManagerDialogOpen
) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt

View file

@ -0,0 +1,116 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render as inkRender } from 'ink';
import { EventEmitter } from 'node:events';
import { describe, it, expect, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
import { DiffDialog } from './DiffDialog.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { ShellFocusContext } from '../contexts/ShellFocusContext.js';
// Keep the dialog hermetic: a clean working tree and no turn diffs, matching
// the "Working tree is clean." state, so no git/filesystem access is needed.
vi.mock('../hooks/useDiffData.js', () => ({
useDiffData: () => ({ result: null, hunks: new Map(), loading: false }),
}));
vi.mock('../hooks/useTurnDiffs.js', () => ({
useTurnDiffs: () => ({ turns: [], loading: false }),
}));
const stripAnsi = (s: string): string =>
// eslint-disable-next-line no-control-regex
s.replace(/\u001b\[[0-9;]*m/g, '');
// ink-testing-library hard-codes a 100-column buffer, too narrow to reproduce
// wide-terminal layout bugs. Render through ink directly with a custom wide
// stdout so the dialog lays out at the requested width.
const renderWide = (columns: number) => {
let lastFrame = '';
const stdout = Object.assign(new EventEmitter(), {
columns,
rows: 50,
write: (frame: string) => {
lastFrame = frame;
},
});
const stderr = Object.assign(new EventEmitter(), {
columns,
rows: 50,
write: () => {},
});
const stdin = Object.assign(new EventEmitter(), {
isTTY: true,
setRawMode: () => {},
setEncoding: () => {},
resume: () => {},
pause: () => {},
ref: () => {},
unref: () => {},
read: () => null,
});
const instance = inkRender(
<ShellFocusContext.Provider value={true}>
<KeypressProvider kittyProtocolEnabled={false}>
<DiffDialog
history={[]}
cwd="/tmp"
fileHistoryService={undefined}
fileCheckpointingEnabled={false}
onClose={vi.fn()}
/>
</KeypressProvider>
</ShellFocusContext.Provider>,
{
stdout: stdout as unknown as NodeJS.WriteStream,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
// debug:true writes the full frame synchronously (true widths) instead of
// throttled cursor-diff output, so line widths can be measured.
debug: true,
patchConsole: false,
exitOnCtrlC: false,
},
);
return { lastFrame: () => lastFrame, unmount: instance.unmount };
};
describe('DiffDialog', () => {
it('caps its width on a wide terminal so the right border is not clipped', async () => {
// Regression: dialogWidth was Math.min(columns - 4, 110), but the app's
// main content area is capped at 100 cols (AppContainer). On a wide
// terminal the dialog overflowed its container and its right border was
// clipped off-screen.
const original = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
Object.defineProperty(process.stdout, 'columns', {
value: 200,
configurable: true,
});
let unmount: (() => void) | undefined;
try {
const r = renderWide(200);
unmount = r.unmount;
await waitFor(() => {
expect(stripAnsi(r.lastFrame())).toContain('Working tree vs HEAD');
});
const frame = stripAnsi(r.lastFrame());
const widest = Math.max(...frame.split('\n').map((line) => line.length));
expect(widest).toBeLessThanOrEqual(102);
} finally {
unmount?.();
if (original) {
Object.defineProperty(process.stdout, 'columns', original);
} else {
// Non-TTY (CI/piped stdout): `columns` is inherited from the prototype,
// so there was no own-property to restore. Delete the override we added
// so it doesn't leak into later test files via useTerminalSize.
delete (process.stdout as unknown as Record<string, unknown>)[
'columns'
];
}
}
});
});

View file

@ -235,7 +235,10 @@ export function DiffDialog({
useKeypress(handleKeypress, { isActive: true });
const { columns, rows } = useTerminalSize();
const dialogWidth = Math.min(columns - 4, 110);
// Cap to the app's main content area (AppContainer caps it at 100). The old
// 110 cap exceeded that container, so on wide terminals the dialog overflowed
// and its right border/edge was clipped off-screen.
const dialogWidth = Math.min(columns - 4, 100);
const detailHeight = Math.max(8, rows - 12);
const headerTitle =

View file

@ -5,29 +5,49 @@
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render as inkRender } from 'ink';
import { EventEmitter } from 'node:events';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { waitFor } from '@testing-library/react';
import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
import { EXTENSIONS_TABS } from './types.js';
import { UIStateContext } from '../../contexts/UIStateContext.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
import { ShellFocusContext } from '../../contexts/ShellFocusContext.js';
import { LoadedSettings } from '../../../config/settings.js';
import type { UIState } from '../../contexts/UIStateContext.js';
import type { Config, Extension } from '@qwen-code/qwen-code-core';
import { ExtensionUpdateState } from '../../state/extensions.js';
import type {
Config,
Extension,
DiscoveredPlugin,
ExtensionSource,
} from '@qwen-code/qwen-code-core';
import { mcpServerRequiresOAuth } from '@qwen-code/qwen-code-core';
import type { ExtensionUpdateState } from '../../state/extensions.js';
const createMockExtension = (
name: string,
isActive = true,
version = '1.0.0',
): Extension =>
// The Installed tab reads real user/workspace settings from disk when MCP
// servers exist; stub loadSettings to keep these tests hermetic.
vi.mock('../../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(() => ({
forScope: () => ({ settings: {} }),
setValue: vi.fn(),
})),
};
});
const mockExtension = (name: string, isActive = true): Extension =>
({
id: name,
name,
version,
version: '1.0.0',
path: `/home/user/.qwen/extensions/${name}`,
isActive,
installMetadata: {
type: 'git',
source: `github:user/${name}`,
},
installMetadata: { type: 'git', source: `github:user/${name}` },
mcpServers: {},
commands: [],
skills: [],
@ -37,117 +57,806 @@ const createMockExtension = (
contextFiles: [],
}) as unknown as Extension;
const createMockConfig = (extensions: Extension[] = []): Config =>
interface ManagerOverrides {
extensions?: Extension[];
discovered?: DiscoveredPlugin[];
sources?: ExtensionSource[];
favorites?: string[];
scopes?: Record<string, string>;
}
const createManager = (o: ManagerOverrides = {}) => {
const extensions = o.extensions ?? [];
return {
refreshCache: vi.fn().mockResolvedValue(undefined),
getLoadedExtensions: vi.fn(() => extensions),
getFavorites: vi.fn(() => o.favorites ?? []),
getExtensionScopes: vi.fn(() => o.scopes ?? {}),
isFavorite: vi.fn((name: string) => (o.favorites ?? []).includes(name)),
getExtensionScope: vi.fn((name: string) => o.scopes?.[name] ?? 'user'),
getSources: vi.fn(() => o.sources ?? []),
discoverPlugins: vi.fn().mockResolvedValue(o.discovered ?? []),
toggleFavorite: vi.fn(() => true),
setExtensionScope: vi.fn(),
getDisabledMcpServers: vi.fn(() => []),
setMcpServerDisabled: vi.fn(),
enableExtension: vi.fn().mockResolvedValue(undefined),
disableExtension: vi.fn().mockResolvedValue(undefined),
uninstallExtension: vi.fn().mockResolvedValue(undefined),
checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined),
updateExtension: vi.fn().mockResolvedValue(undefined),
addSource: vi.fn(),
removeSource: vi.fn(() => true),
loadSource: vi.fn().mockResolvedValue(null),
};
};
const createConfig = (
manager: ReturnType<typeof createManager>,
overrides: { mcpServers?: Record<string, unknown> } = {},
): Config =>
({
getExtensions: () => extensions,
getExtensionManager: () => ({
getLoadedExtensions: () => extensions,
refreshCache: vi.fn().mockResolvedValue(undefined),
checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined),
disableExtension: vi.fn().mockResolvedValue(undefined),
enableExtension: vi.fn().mockResolvedValue(undefined),
uninstallExtension: vi.fn().mockResolvedValue(undefined),
updateExtension: vi.fn().mockResolvedValue(undefined),
}),
getLoadedExtensions: () => extensions,
getExtensionManager: () => manager,
getMcpServers: () => overrides.mcpServers ?? {},
getToolRegistry: () => undefined,
getPromptRegistry: () => undefined,
isMcpServerDisabled: () => false,
getExcludedMcpServers: () => [],
setExcludedMcpServers: vi.fn(),
}) as unknown as Config;
const createMockUIState = (
const createUIState = (
extensionsUpdateState = new Map<string, ExtensionUpdateState>(),
): UIState =>
({
extensionsUpdateState,
}) as unknown as UIState;
): UIState => ({ extensionsUpdateState }) as unknown as UIState;
describe('ExtensionsManagerDialog Snapshots', () => {
const baseProps = {
onClose: vi.fn(),
config: createMockConfig(),
};
const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
const renderDialog = (
config: Config,
opts: {
onClose?: () => void;
initialTab?: (typeof EXTENSIONS_TABS)[keyof typeof EXTENSIONS_TABS];
uiState?: UIState;
} = {},
) =>
render(
<SettingsContext.Provider value={mockSettings}>
<ShellFocusContext.Provider value={true}>
<UIStateContext.Provider value={opts.uiState ?? createUIState()}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
onClose={opts.onClose ?? vi.fn()}
config={config}
initialTab={opts.initialTab}
/>
</KeypressProvider>
</UIStateContext.Provider>
</ShellFocusContext.Provider>
</SettingsContext.Provider>,
);
const stripAnsi = (s: string): string =>
// eslint-disable-next-line no-control-regex
s.replace(/\u001b\[[0-9;]*m/g, '');
// ink-testing-library hard-codes an 80/100-column buffer, which is too narrow
// to reproduce wide-terminal layout bugs. Render through ink directly with a
// custom wide stdout so the dialog lays out at the requested width.
const renderWide = (config: Config, columns: number) => {
let lastFrame = '';
const stdout = Object.assign(new EventEmitter(), {
columns,
rows: 50,
write: (frame: string) => {
lastFrame = frame;
},
});
const stderr = Object.assign(new EventEmitter(), {
columns,
rows: 50,
write: () => {},
});
// A TTY-like stdin so KeypressProvider can enable raw mode (ink-testing-
// library supplies one; ink's real render against a custom stdout does not).
const stdin = Object.assign(new EventEmitter(), {
isTTY: true,
setRawMode: () => {},
setEncoding: () => {},
resume: () => {},
pause: () => {},
ref: () => {},
unref: () => {},
read: () => null,
});
const instance = inkRender(
<SettingsContext.Provider value={mockSettings}>
<ShellFocusContext.Provider value={true}>
<UIStateContext.Provider value={createUIState()}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog onClose={vi.fn()} config={config} />
</KeypressProvider>
</UIStateContext.Provider>
</ShellFocusContext.Provider>
</SettingsContext.Provider>,
{
stdout: stdout as unknown as NodeJS.WriteStream,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
// debug:true makes ink write the full frame synchronously (as
// ink-testing-library does) instead of throttled cursor-diff output.
debug: true,
patchConsole: false,
exitOnCtrlC: false,
},
);
return { lastFrame: () => lastFrame, unmount: instance.unmount };
};
describe('ExtensionsManagerDialog (tabbed)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
it('renders the tab bar with all three tabs', () => {
const { lastFrame } = renderDialog(createConfig(createManager()));
const frame = lastFrame();
expect(frame).toContain('Discover');
expect(frame).toContain('Installed');
expect(frame).toContain('Sources');
});
it('should render empty state when no extensions installed', () => {
const uiState = createMockUIState();
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog {...baseProps} />
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
it('caps its width on a wide terminal so the status column is not clipped', async () => {
// Regression: the dialog computed boxWidth = columns - 4 with no cap, while
// the app's main content area is capped at 100 cols (AppContainer). On a
// wide terminal the dialog overflowed its container and the right-aligned
// status column ("Extension v… (…)") was clipped off-screen to a sliver
// ("扩…"). The dialog must stay within ~100 columns regardless of terminal
// width. Rendered through a 200-column stdout to exercise the wide case.
const original = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
Object.defineProperty(process.stdout, 'columns', {
value: 200,
configurable: true,
});
let unmount: (() => void) | undefined;
try {
const r = renderWide(
createConfig(
createManager({
extensions: [mockExtension('alpha'), mockExtension('beta', false)],
}),
),
200,
);
unmount = r.unmount;
await waitFor(() => {
expect(stripAnsi(r.lastFrame())).toContain('alpha');
});
const frame = stripAnsi(r.lastFrame());
// No rendered line spills past the ~100-col content area (the uncapped
// dialog produced ~196-col lines and clipped the status column).
const widest = Math.max(...frame.split('\n').map((line) => line.length));
expect(widest).toBeLessThanOrEqual(102);
// And the status column is fully present, not truncated to a sliver.
expect(frame).toMatch(/v1\.0\.0\s*\([^)]+\)/);
} finally {
unmount?.();
if (original) {
Object.defineProperty(process.stdout, 'columns', original);
} else {
// Non-TTY (CI/piped stdout): `columns` is inherited from the prototype,
// so there was no own-property to restore. Delete the override we added
// so it doesn't leak `200` into later test files via useTerminalSize.
delete (process.stdout as unknown as Record<string, unknown>)[
'columns'
];
}
}
});
it('should render extension list with extensions', () => {
const extensions = [
createMockExtension('test-extension', true),
createMockExtension('another-extension', false),
it('shows discovered plugins on the Discover tab', async () => {
const discovered: DiscoveredPlugin[] = [
{
marketplaceName: 'Skills',
name: 'pdf',
description: 'PDF tools',
installSource: 'anthropics/skills:pdf',
installed: false,
},
{
marketplaceName: 'Skills',
name: 'docx',
installSource: 'anthropics/skills:docx',
installed: true,
},
];
const uiState = createMockUIState(
new Map([
['test-extension', ExtensionUpdateState.UP_TO_DATE],
['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE],
]),
const { lastFrame } = renderDialog(
createConfig(createManager({ discovered })),
{ initialTab: EXTENSIONS_TABS.DISCOVER },
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
await waitFor(() => {
expect(lastFrame()).toContain('pdf');
});
expect(lastFrame()).toContain('docx');
expect(lastFrame()).toContain('installed');
});
it('should render with update available status', () => {
const extensions = [createMockExtension('outdated-extension', true)];
const uiState = createMockUIState(
new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]),
it('opens a CC-style plugin detail with an inline scope selector on Enter', async () => {
const discovered: DiscoveredPlugin[] = [
{
marketplaceName: 'claude-plugins-official',
name: '42crunch-api-security-testing',
description: 'Automate API security directly in your workflow.',
author: '42Crunch',
homepage: 'https://example.com/42crunch',
components: { skills: ['42crunch-audit', '42crunch-scan'] },
installSource: 'owner/repo:42crunch-api-security-testing',
installed: false,
},
];
const { stdin, lastFrame } = renderDialog(
createConfig(createManager({ discovered })),
{ initialTab: EXTENSIONS_TABS.DISCOVER },
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
await waitFor(() => {
expect(lastFrame()).toContain('42crunch-api-security-testing');
});
stdin.write('\r'); // Enter -> detail
await waitFor(() => {
expect(lastFrame()).toContain('Extension details');
});
const frame = lastFrame();
expect(frame).toContain('from claude-plugins-official');
expect(frame).toContain('By: 42Crunch');
expect(frame).toContain('Will install:');
expect(frame).toContain('42crunch-audit');
// Inline action selector with the two scopes + homepage + back.
expect(frame).toContain('Install for you (user scope)');
expect(frame).toContain('project scope');
expect(frame).toContain('Open homepage');
expect(frame).toContain('Back to extension list');
});
it('should render with checking status', () => {
const extensions = [createMockExtension('checking-extension', true)];
const uiState = createMockUIState(
new Map([
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
]),
it('windows a long Discover list with a scroll hint and count header', async () => {
const discovered: DiscoveredPlugin[] = Array.from(
{ length: 15 },
(_, i) => ({
marketplaceName: 'mkt',
name: `plugin-${i}`,
installSource: `owner/repo:plugin-${i}`,
installed: false,
}),
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
const { lastFrame } = renderDialog(
createConfig(createManager({ discovered })),
{ initialTab: EXTENSIONS_TABS.DISCOVER },
);
await waitFor(() => {
expect(lastFrame()).toContain('plugin-0');
});
const frame = lastFrame();
expect(frame).toContain('Discover extensions');
expect(frame).toContain('(1/15)');
expect(frame).toContain('Search'); // search box
// Not all 15 fit; the more-below indicator is shown.
expect(frame).toContain('more below');
// The last item is scrolled out of the initial window.
expect(frame).not.toContain('plugin-14');
});
expect(lastFrame()).toMatchSnapshot();
it('filters the Discover list as you type', async () => {
const discovered: DiscoveredPlugin[] = [
{
marketplaceName: 'm',
name: 'alpha',
installSource: 'o/r:alpha',
installed: false,
},
{
marketplaceName: 'm',
name: 'beta',
installSource: 'o/r:beta',
installed: false,
},
{
marketplaceName: 'm',
name: 'gamma',
installSource: 'o/r:gamma',
installed: false,
},
];
const { stdin, lastFrame } = renderDialog(
createConfig(createManager({ discovered })),
{ initialTab: EXTENSIONS_TABS.DISCOVER },
);
await waitFor(() => {
expect(lastFrame()).toContain('alpha');
});
for (const ch of 'beta') {
stdin.write(ch); // type-to-search, one printable char at a time
}
await waitFor(() => {
expect(lastFrame()).toContain('beta');
expect(lastFrame()).not.toContain('alpha');
});
expect(lastFrame()).not.toContain('gamma');
expect(lastFrame()).toContain('(1/1)');
});
it('prompts to add a marketplace when none discovered', async () => {
const { lastFrame } = renderDialog(createConfig(createManager()), {
initialTab: EXTENSIONS_TABS.DISCOVER,
});
await waitFor(() => {
expect(lastFrame()).toContain('No extensions discovered');
});
});
it('groups installed plugins by scope on the Installed tab', async () => {
const config = createConfig(
createManager({
extensions: [
mockExtension('alpha', true),
mockExtension('beta', false),
],
scopes: { alpha: 'user' },
}),
);
const { lastFrame } = renderDialog(config, {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('alpha');
});
const frame = lastFrame();
expect(frame).toContain('User level');
expect(frame).toContain('Disabled');
expect(frame).toContain('beta');
// Plugins show their type + version (parallel to "MCP"), not a bare version.
expect(frame).toContain('Extension v1.0.0');
});
it('nests extension-bundled MCP servers under their extension on the Installed tab', async () => {
const ext = mockExtension('alpha', true);
(ext as unknown as { mcpServers: Record<string, unknown> }).mcpServers = {
'alpha-mcp': { command: 'node' },
};
const config = createConfig(
createManager({ extensions: [ext, mockExtension('beta', true)] }),
{
mcpServers: {
'alpha-mcp': { command: 'node', extensionName: 'alpha' },
},
},
);
const { stdin, lastFrame } = renderDialog(config, {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('alpha-mcp');
});
const frame = lastFrame()!;
// Bundled MCP renders indented under its parent extension.
expect(frame).toContain('└ alpha-mcp');
// The group count only includes top-level items (the two extensions).
expect(frame).toContain('User level (2)');
// MCP rows show the live connection state, not a bare enabled flag.
expect(frame).toContain('disconnected');
// Enter on the nested row opens the MCP detail view with Extension source.
stdin.write('\x1B[B'); // down: alpha -> alpha-mcp
await waitFor(() => {
expect(lastFrame()).toMatch(/●\s+└ alpha-mcp/);
});
stdin.write('\r');
await waitFor(() => {
expect(lastFrame()).toContain('Source:');
});
expect(lastFrame()).toContain('Extension');
});
it('shows needs-authentication for an MCP that failed auth instead of a bare active state', async () => {
mcpServerRequiresOAuth.set('oauth-mcp', true);
try {
const config = createConfig(createManager(), {
mcpServers: { 'oauth-mcp': { command: 'node' } },
});
const { lastFrame } = renderDialog(config, {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('oauth-mcp');
});
expect(lastFrame()).toContain('needs authentication');
expect(lastFrame()).not.toContain('(active)');
} finally {
mcpServerRequiresOAuth.delete('oauth-mcp');
}
});
it('disables an extension-bundled MCP server individually with Space', async () => {
const ext = mockExtension('alpha', true);
(ext as unknown as { mcpServers: Record<string, unknown> }).mcpServers = {
'alpha-mcp': { command: 'node' },
};
const manager = createManager({ extensions: [ext] });
const config = createConfig(manager, {
mcpServers: {
'alpha-mcp': { command: 'node', extensionName: 'alpha' },
},
});
const { stdin, lastFrame } = renderDialog(config, {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('alpha-mcp');
});
stdin.write('\x1B[B'); // down: alpha -> alpha-mcp
await waitFor(() => {
expect(lastFrame()).toMatch(/●\s+└ alpha-mcp/);
});
stdin.write(' '); // Space -> disable just this server
await waitFor(() => {
// The disable is recorded against the extension, not mcp.excluded.
expect(manager.setMcpServerDisabled).toHaveBeenCalledWith(
'alpha',
'alpha-mcp',
true,
);
});
});
it('toggles favorite when pressing f on the Installed tab', async () => {
const manager = createManager({
extensions: [mockExtension('alpha', true)],
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('alpha');
});
stdin.write('f');
await waitFor(() => {
expect(manager.toggleFavorite).toHaveBeenCalledWith('alpha');
});
});
it('moves a plugin into the Favorites group after favoriting (regroup/reload)', async () => {
const favorites: string[] = [];
const manager = createManager({
extensions: [mockExtension('alpha', true), mockExtension('beta', true)],
});
manager.getFavorites = vi.fn(() => [...favorites]);
manager.toggleFavorite = vi.fn((name: string) => {
const i = favorites.indexOf(name);
if (i >= 0) {
favorites.splice(i, 1);
return false;
}
favorites.push(name);
return true;
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.INSTALLED,
});
await waitFor(() => {
expect(lastFrame()).toContain('alpha');
});
// Initially there is no Favorites group.
expect(lastFrame()).not.toContain('Favorites');
stdin.write('f'); // favorite the selected item (alpha, first in the list)
await waitFor(() => {
expect(lastFrame()).toContain('Favorites');
});
expect(manager.toggleFavorite).toHaveBeenCalledWith('alpha');
// The list re-rendered cleanly (no stuck/empty frame) and still shows beta.
expect(lastFrame()).toContain('beta');
});
it('shows the install/add actions and grouped sections on the Marketplaces tab', async () => {
const config = createConfig(
createManager({
sources: [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
],
}),
);
const { lastFrame } = renderDialog(config, {
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Add new marketplace');
});
const frame = lastFrame();
// 'Add new' is the section title for the action rows; it also appears inside
// '+ Add new marketplace', so assert it renders at least twice (title + row).
expect((frame?.split('Add new').length ?? 1) - 1).toBeGreaterThanOrEqual(2);
expect(frame).toContain('Install a new extension');
expect(frame).toContain('Claude plugin marketplace'); // (Claude) annotation
expect(frame).toContain('Marketplaces'); // section header
expect(frame).toContain('Skills');
});
it('shows a CC-style marketplace detail with installed plugins and actions', async () => {
const manager = createManager({
extensions: [mockExtension('pdf', true)], // installed, belongs to Skills
sources: [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
],
});
manager.loadSource = vi.fn().mockResolvedValue({
name: 'Skills',
owner: { name: 'o', email: 'e' },
plugins: [
{ name: 'pdf', version: '1', description: 'PDF tools', source: 'a/s' },
{ name: 'docx', version: '1', source: 'a/s' },
],
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.SOURCES,
});
// Wait until the list has loaded (and the keypress handler re-subscribed).
await waitFor(() => {
expect(lastFrame()).toContain('Skills');
});
// Installed extensions are not listed here; rows are:
// Install ext (0), Add marketplace (1), Skills (2).
stdin.write('\x1B[B');
stdin.write('\x1B[B');
await waitFor(() => {
expect(lastFrame()).toContain('● Skills');
});
stdin.write('\r'); // Enter -> open detail
await waitFor(() => {
expect(lastFrame()).toContain('available extensions');
});
const frame = lastFrame();
expect(frame).toContain('2 available extensions');
// The detail still reflects which of this marketplace's plugins are
// installed, even though the tab list no longer shows extensions.
expect(frame).toContain('Installed extensions (1):');
expect(frame).toContain('pdf');
expect(frame).toContain('Browse extensions (2)');
expect(frame).toContain('Update marketplace');
expect(frame).toContain('Remove marketplace');
// The footer advertises the R refresh shortcut once the detail has loaded.
await waitFor(() => {
expect(lastFrame()).toContain('R refresh');
});
});
it('collapses a long installed-plugins list in the marketplace detail', async () => {
const installed = Array.from({ length: 8 }, (_, i) =>
mockExtension(`ext-${i}`, true),
);
const manager = createManager({
extensions: installed,
sources: [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
],
});
manager.loadSource = vi.fn().mockResolvedValue({
name: 'Skills',
owner: { name: 'o', email: 'e' },
plugins: installed.map((ext) => ({
name: ext.name,
version: '1',
source: 'a/s',
})),
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Skills');
});
stdin.write('\x1B[B');
stdin.write('\x1B[B');
await waitFor(() => {
expect(lastFrame()).toContain('● Skills');
});
stdin.write('\r'); // open detail
await waitFor(() => {
expect(lastFrame()).toContain('Installed extensions (8):');
});
const frame = lastFrame();
// First five are shown; the rest collapse into a summary line.
expect(frame).toContain('ext-0');
expect(frame).toContain('ext-4');
expect(frame).not.toContain('ext-5');
expect(frame).toContain('and 3 more');
});
it('retries a failed marketplace load when R is pressed', async () => {
const manager = createManager({
sources: [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
],
});
// First load fails (null), retry succeeds.
manager.loadSource = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
name: 'Skills',
owner: { name: 'o', email: 'e' },
plugins: [{ name: 'pdf', version: '1', source: 'a/s' }],
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Skills');
});
stdin.write('\x1B[B');
stdin.write('\x1B[B');
await waitFor(() => {
expect(lastFrame()).toContain('● Skills');
});
stdin.write('\r'); // open detail -> first load fails
await waitFor(() => {
expect(lastFrame()).toContain('Could not load this marketplace.');
});
// The retry hint shows both inline and in the bottom footer.
await waitFor(() => {
expect((lastFrame()?.split('Press R to retry').length ?? 1) - 1).toBe(2);
});
stdin.write('r'); // retry -> second load succeeds
await waitFor(() => {
expect(lastFrame()).toContain('1 available extensions');
});
expect(manager.loadSource).toHaveBeenCalledTimes(2);
});
it('Browse plugins jumps to Discover filtered by the marketplace', async () => {
const manager = createManager({
sources: [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
],
discovered: [
{
marketplaceName: 'Skills',
name: 'pdf',
installSource: 'a/s:pdf',
installed: false,
},
{
marketplaceName: 'Skills',
name: 'docx',
installSource: 'a/s:docx',
installed: false,
},
{
marketplaceName: 'Other',
name: 'zzz',
installSource: 'o/o:zzz',
installed: false,
},
],
});
manager.loadSource = vi.fn().mockResolvedValue({
name: 'Skills',
owner: { name: 'o', email: 'e' },
plugins: [
{ name: 'pdf', version: '1', source: 'a/s' },
{ name: 'docx', version: '1', source: 'a/s' },
],
});
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Skills'); // list loaded
});
// Rows: Install ext (0), Add marketplace (1), Skills (2).
stdin.write('\x1B[B');
stdin.write('\x1B[B');
await waitFor(() => {
expect(lastFrame()).toContain('● Skills');
});
stdin.write('\r'); // Enter -> detail
await waitFor(() => {
expect(lastFrame()).toContain('Browse extensions (2)');
});
stdin.write('\r'); // select "Browse extensions" -> Discover filtered
await waitFor(() => {
expect(lastFrame()).toContain('Discover extensions');
});
const frame = lastFrame();
// Filtered to the Skills marketplace: its extensions show, the other not.
expect(frame).toContain('Skills');
expect(frame).toContain('pdf');
expect(frame).toContain('docx');
expect(frame).not.toContain('zzz');
});
it('switches tabs with the Tab key', async () => {
const config = createConfig(
createManager({ extensions: [mockExtension('alpha', true)] }),
);
const { stdin, lastFrame } = renderDialog(config);
// Starts on Installed (first tab).
await waitFor(() => {
expect(lastFrame()).toContain('alpha');
});
stdin.write('\t'); // -> Discover
await waitFor(() => {
expect(lastFrame()).toContain('No extensions discovered');
});
});
it('closes on Escape from a tab root', async () => {
const onClose = vi.fn();
const { stdin } = renderDialog(createConfig(createManager()), { onClose });
await waitFor(() => {});
stdin.write('\x1b'); // escape
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not close on Escape while a tab sub-view is open', async () => {
const onClose = vi.fn();
const manager = createManager();
const { stdin, lastFrame } = renderDialog(createConfig(manager), {
onClose,
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Install a new extension');
});
stdin.write('\r'); // Enter on row 0 -> install-extension sub-view (locks tabs)
await waitFor(() => {
expect(lastFrame()).toContain('Enter extension source:');
});
stdin.write('\x1b'); // Escape should return to the list, not close the dialog
await waitFor(() => {
expect(lastFrame()).toContain('Install a new extension');
});
expect(onClose).not.toHaveBeenCalled();
});
it('opens the install-extension input view on Enter', async () => {
const { stdin, lastFrame } = renderDialog(createConfig(createManager()), {
initialTab: EXTENSIONS_TABS.SOURCES,
});
await waitFor(() => {
expect(lastFrame()).toContain('Install a new extension');
});
stdin.write('\r'); // Enter on the first row (Install a new extension)
await waitFor(() => {
expect(lastFrame()).toContain('Install Extension');
});
const frame = lastFrame();
expect(frame).toContain('Enter extension source:');
expect(frame).toContain('owner/repo (GitHub)');
expect(frame).toContain('@scope/name (npm)');
});
it('renders a pending install consent prompt in place of the tabs', async () => {
const uiState = {
extensionsUpdateState: new Map<string, ExtensionUpdateState>(),
confirmUpdateExtensionRequests: [
{ prompt: 'Do you trust this extension?', onConfirm: vi.fn() },
],
settingInputRequests: [],
pluginChoiceRequests: [],
} as unknown as UIState;
const { lastFrame } = renderDialog(createConfig(createManager()), {
initialTab: EXTENSIONS_TABS.DISCOVER,
uiState,
});
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust this extension?');
});
// The tab content is hidden while the prompt is shown, but the dialog
// (and its tab state) stays mounted.
expect(lastFrame()).not.toContain('Discover extensions');
});
});

View file

@ -4,516 +4,201 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import {
ExtensionListStep,
ExtensionDetailStep,
ActionSelectionStep,
UninstallConfirmStep,
ScopeSelectStep,
} from './steps/index.js';
import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { t, getCurrentLanguage } from '../../../i18n/index.js';
import type { Extension, Config } from '@qwen-code/qwen-code-core';
import {
SettingScope,
createDebugLogger,
getExtensionDisplayName,
} from '@qwen-code/qwen-code-core';
import { ExtensionUpdateState } from '../../state/extensions.js';
import { getErrorMessage } from '../../../utils/errors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { t } from '../../../i18n/index.js';
import { stripUnsafeCharacters } from '../../utils/textUtils.js';
import {
EXTENSIONS_TABS,
type ExtensionsTab,
type ExtensionsTabDef,
type ExtensionsManagerDialogProps,
} from './types.js';
import { TabBar } from './TabBar.js';
import { DiscoverTab } from './tabs/DiscoverTab.js';
import { InstalledTab } from './tabs/InstalledTab.js';
import { SourcesTab } from './tabs/SourcesTab.js';
import { ConsentPrompt } from '../ConsentPrompt.js';
import { SettingInputPrompt } from '../SettingInputPrompt.js';
import { PluginChoicePrompt } from '../PluginChoicePrompt.js';
interface ExtensionsManagerDialogProps {
onClose: () => void;
config: Config | null;
export interface StatusMessage {
type: 'info' | 'success' | 'error';
text: string;
}
const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG');
const TABS: ExtensionsTabDef[] = [
{ id: EXTENSIONS_TABS.INSTALLED, label: 'Installed' },
{ id: EXTENSIONS_TABS.DISCOVER, label: 'Discover' },
{ id: EXTENSIONS_TABS.SOURCES, label: 'Sources' },
];
// Literal t() calls keep the footer hints extractable for translation.
function footerHint(tab: ExtensionsTab): string {
switch (tab) {
case EXTENSIONS_TABS.DISCOVER:
return t(
'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back',
);
case EXTENSIONS_TABS.INSTALLED:
return t(
'↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close',
);
case EXTENSIONS_TABS.SOURCES:
return t('↑↓ navigate · Enter select · d remove marketplace · Esc close');
default:
return '';
}
}
export function ExtensionsManagerDialog({
onClose,
config,
initialTab,
}: ExtensionsManagerDialogProps) {
const { extensionsUpdateState } = useUIState();
const [extensions, setExtensions] = useState<Extension[]>([]);
const [selectedExtensionIndex, setSelectedExtensionIndex] =
useState<number>(-1);
const [navigationStack, setNavigationStack] = useState<string[]>([
MANAGEMENT_STEPS.EXTENSION_LIST,
]);
const [updateInProgress, setUpdateInProgress] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { columns } = useTerminalSize();
const boxWidth = columns - 4;
// Load extensions
const loadExtensions = useCallback(async () => {
if (!config) return;
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
debugLogger.error('ExtensionManager not available');
return;
}
try {
await extensionManager.refreshCache();
const loadedExtensions = extensionManager.getLoadedExtensions();
setExtensions(loadedExtensions);
} catch (error) {
debugLogger.error('Failed to load extensions:', error);
}
}, [config]);
// Initial load
useEffect(() => {
loadExtensions();
}, [loadExtensions]);
// Memoized selected extension
const selectedExtension = useMemo(
() =>
selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null,
[extensions, selectedExtensionIndex],
);
// Check if update is available for selected extension
const hasUpdateAvailable = useMemo(() => {
if (!selectedExtension) return false;
const state = extensionsUpdateState.get(selectedExtension.name);
return state === ExtensionUpdateState.UPDATE_AVAILABLE;
}, [selectedExtension, extensionsUpdateState]);
// Helper to get current step
const getCurrentStep = useCallback(
() =>
navigationStack[navigationStack.length - 1] ||
MANAGEMENT_STEPS.EXTENSION_LIST,
[navigationStack],
);
const handleSelectExtension = useCallback((extensionIndex: number) => {
setSelectedExtensionIndex(extensionIndex);
setSuccessMessage(null); // Clear success message when navigating
setErrorMessage(null); // Clear error message when navigating
setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]);
}, []);
const handleNavigateToStep = useCallback((step: string) => {
setNavigationStack((prev) => [...prev, step]);
}, []);
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.slice(0, -1);
});
// Clear messages when navigating back
setErrorMessage(null);
}, []);
const handleUpdateExtension = useCallback(async () => {
if (!config || !selectedExtension) return;
setUpdateInProgress(true);
setUpdateError(null);
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
const state = extensionsUpdateState.get(selectedExtension.name);
if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) {
throw new Error('No update available');
}
// Use the extension manager to update
await extensionManager.updateExtension(
selectedExtension,
ExtensionUpdateState.UPDATE_AVAILABLE,
(name, newState) => {
debugLogger.debug(`Update state for ${name}:`, newState);
},
);
// Reload extensions after update to get new version info
await loadExtensions();
// Trigger a re-check of update status for all extensions
await extensionManager.checkForAllExtensionUpdates((name, newState) => {
debugLogger.debug(`Recheck update state for ${name}:`, newState);
});
// Show success message
setSuccessMessage(
t('Extension "{{name}}" updated successfully.', {
name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()),
}),
);
// Go back to action selection
handleNavigateBack();
} catch (error) {
debugLogger.error('Failed to update extension:', error);
setUpdateError(
error instanceof Error ? error.message : 'Unknown error occurred',
);
} finally {
setUpdateInProgress(false);
}
}, [
config,
selectedExtension,
const {
extensionsUpdateState,
loadExtensions,
handleNavigateBack,
]);
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
} = useUIState();
const { columns } = useTerminalSize();
// Cap the width to the app's main content area (AppContainer caps it at 100).
// Without this the dialog grows to the full terminal width on wide terminals
// and overflows its container, clipping the right-aligned status column.
const boxWidth = Math.min(columns - 4, 100);
const handleActionSelect = useCallback(
(action: ExtensionAction) => {
switch (action) {
case 'view':
handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL);
break;
case 'update':
handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS);
handleUpdateExtension();
break;
case 'disable':
handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
break;
case 'enable':
handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT);
break;
case 'uninstall':
handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION);
break;
default:
break;
}
},
[handleNavigateToStep, handleUpdateExtension],
// Install flows raise interactive requests (consent, setting input, plugin
// choice). They are rendered here, inside the dialog, so the dialog stays
// mounted and keeps its tab/list state; DialogManager skips them while this
// dialog is open. (Unmounting would reset the active tab and drop the
// reload signal that refreshes the Installed tab after an install.)
const consentRequest = confirmUpdateExtensionRequests?.[0];
const settingRequest = settingInputRequests?.[0];
const pluginChoiceRequest = pluginChoiceRequests?.[0];
const hasPendingRequest =
!!consentRequest || !!settingRequest || !!pluginChoiceRequest;
const [activeTab, setActiveTab] = useState<ExtensionsTab>(
initialTab ?? EXTENSIONS_TABS.INSTALLED,
);
const [tabLocked, setTabLocked] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(null);
// Bumped to force tabs to re-load when a cross-tab change happens
// (e.g. installing from Discover should refresh Installed).
const [reloadSignal, setReloadSignal] = useState(0);
// When set, the Discover tab is restricted to this marketplace (set by the
// Marketplaces tab's "Browse plugins" action; cleared on manual tab switch).
const [discoverFilter, setDiscoverFilter] = useState<string | null>(null);
// Optional context-aware footer hint provided by the active tab.
const [tabFooter, setTabFooter] = useState<string | null>(null);
// Unified handler for toggling extension state (enable/disable)
const handleToggleExtensionState = useCallback(
async (scope: 'user' | 'workspace', newState: boolean) => {
if (!config || !selectedExtension) return;
const cycleTab = useCallback((direction: 1 | -1) => {
setStatus(null);
setDiscoverFilter(null);
setTabFooter(null);
setActiveTab((current) => {
const index = TABS.findIndex((tab) => tab.id === current);
const next = (index + direction + TABS.length) % TABS.length;
return TABS[next].id;
});
}, []);
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
const handleBrowseSource = useCallback((marketplaceName: string) => {
setStatus(null);
setTabLocked(false);
// The marketplace name is untrusted (it originates from a remote
// marketplace.json). Scrub terminal escapes before it becomes the Discover
// filter: it is rendered as a hint AND compared against the
// already-sanitized DiscoveredPlugin.marketplaceName, so sanitizing here
// both blocks ANSI injection and keeps the filter comparison matching.
setDiscoverFilter(stripUnsafeCharacters(marketplaceName));
setActiveTab(EXTENSIONS_TABS.DISCOVER);
}, []);
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const bumpReload = useCallback(() => {
setReloadSignal((value) => value + 1);
}, []);
if (newState) {
await extensionManager.enableExtension(
selectedExtension.name,
settingScope,
);
} else {
await extensionManager.disableExtension(
selectedExtension.name,
settingScope,
);
}
const handleLockChange = useCallback((locked: boolean) => {
setTabLocked(locked);
}, []);
// Update local state
setExtensions((prev) =>
prev.map((ext) =>
ext.name === selectedExtension.name
? { ...ext, isActive: newState }
: ext,
),
);
// Show success message
const actionKey = newState ? 'enabled' : 'disabled';
setSuccessMessage(
t(`Extension "{{name}}" ${actionKey} successfully.`, {
name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()),
}),
);
setErrorMessage(null);
// Go back to extension list to show success message
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
} catch (error) {
debugLogger.error(
`Failed to ${newState ? 'enable' : 'disable'} extension:`,
error,
);
setErrorMessage(
t('Failed to {{action}} extension "{{name}}": {{error}}', {
action: newState ? 'enable' : 'disable',
name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()),
error: getErrorMessage(error),
}),
);
setSuccessMessage(null);
}
},
[config, selectedExtension],
);
const handleDisableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, false);
},
[handleToggleExtensionState],
);
const handleEnableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, true);
},
[handleToggleExtensionState],
);
const handleUninstallExtension = useCallback(
async (extension: Extension) => {
if (!config) return;
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
await extensionManager.uninstallExtension(extension.name, false);
// Reload extensions
await loadExtensions();
// Navigate back to extension list
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
setSelectedExtensionIndex(-1);
} catch (error) {
debugLogger.error('Failed to uninstall extension:', error);
throw error;
}
},
[config, loadExtensions],
);
// Centralized ESC key handling
// Tab switching + close. Inactive while a tab owns a sub-view (locked).
useKeypress(
(key) => {
if (key.name !== 'escape') {
return;
}
const currentStep = getCurrentStep();
// If there's a success message, clear it first instead of closing
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
setSuccessMessage(null);
return;
}
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
if (key.name === 'tab') {
// On Discover with an active marketplace filter, Tab clears the filter
// in place (revealing all extensions) instead of leaving the tab — this
// is what the "(Tab to clear)" hint promises. Otherwise it cycles tabs.
if (activeTab === EXTENSIONS_TABS.DISCOVER && discoverFilter) {
setStatus(null);
setDiscoverFilter(null);
} else {
cycleTab(key.shift ? -1 : 1);
}
} else if (key.name === 'right') {
cycleTab(1);
} else if (key.name === 'left') {
cycleTab(-1);
} else if (key.name === 'escape') {
onClose();
} else {
handleNavigateBack();
}
},
{ isActive: true },
{ isActive: !tabLocked && !hasPendingRequest },
);
const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
const getStepHeaderText = () => {
switch (currentStep) {
case MANAGEMENT_STEPS.EXTENSION_LIST:
return t('Manage Extensions');
case MANAGEMENT_STEPS.ACTION_SELECTION:
return selectedExtension ? getExtensionDisplayName(selectedExtension, getCurrentLanguage()) : t('Choose Action');
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
return t('Extension Details');
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return t('Disable Extension');
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return t('Enable Extension');
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
return t('Uninstall Extension');
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
return t('Update Extension');
default:
return t('Unknown Step');
}
};
if (!config) {
return (
<Box>
<Text color={theme.text.accent} bold>
{getStepHeaderText()}
</Text>
<Box flexDirection="column" width={boxWidth}>
<Box
borderStyle="single"
borderColor={theme.border.default}
padding={1}
width={boxWidth}
>
<Text color={theme.status.error}>
{t('Extensions are not available in this environment.')}
</Text>
</Box>
</Box>
);
}, [getCurrentStep, selectedExtension]);
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
const getNavigationInstructions = () => {
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
if (extensions.length === 0 || successMessage) {
return t('Esc to close');
}
return t('↑↓ to navigate · Enter to select · Esc to close');
}
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
return t('Esc to go back');
}
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
return t('Y/Enter to confirm · N/Esc to cancel');
}
if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
return updateInProgress ? t('Updating...') : '';
}
return t('↑↓ to navigate · Enter to select · Esc to go back');
};
return (
<Box>
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [getCurrentStep, extensions.length, updateInProgress, successMessage]);
const renderStepContent = useCallback(() => {
const currentStep = getCurrentStep();
// Show error message if present (only on extension list step)
if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
);
}
// Show success message if present (only on extension list step)
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.success}>{successMessage}</Text>
</Box>
);
}
if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{t('Update failed:')}</Text>
<Text>{updateError}</Text>
</Box>
);
}
switch (currentStep) {
case MANAGEMENT_STEPS.EXTENSION_LIST:
return (
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={extensionsUpdateState}
onExtensionSelect={handleSelectExtension}
/>
);
case MANAGEMENT_STEPS.ACTION_SELECTION:
return (
<ActionSelectionStep
selectedExtension={selectedExtension}
hasUpdateAvailable={hasUpdateAvailable}
onNavigateToStep={handleNavigateToStep}
onActionSelect={handleActionSelect}
/>
);
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
return <ExtensionDetailStep selectedExtension={selectedExtension} />;
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="disable"
onScopeSelect={handleDisableExtension}
/>
);
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="enable"
onScopeSelect={handleEnableExtension}
/>
);
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
return (
<UninstallConfirmStep
selectedExtension={selectedExtension}
onConfirm={handleUninstallExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
return (
<Box flexDirection="column" gap={1}>
<Text>
{updateInProgress
? t('Updating {{name}}...', {
name: selectedExtension ? getExtensionDisplayName(selectedExtension, getCurrentLanguage()) : '',
})
: t('Update complete!')}
</Text>
</Box>
);
default:
return (
<Box>
<Text color={theme.status.error}>
{t('Invalid step: {{step}}', { step: currentStep })}
</Text>
</Box>
);
}
}, [
getCurrentStep,
extensions,
extensionsUpdateState,
selectedExtension,
hasUpdateAvailable,
updateInProgress,
updateError,
successMessage,
errorMessage,
handleSelectExtension,
handleNavigateToStep,
handleNavigateBack,
handleActionSelect,
handleDisableExtension,
handleEnableExtension,
handleUninstallExtension,
]);
}
return (
<Box flexDirection="column" width={boxWidth}>
{consentRequest ? (
<ConsentPrompt
prompt={consentRequest.prompt}
onConfirm={consentRequest.onConfirm}
terminalWidth={boxWidth}
/>
) : settingRequest ? (
<SettingInputPrompt
key={settingRequest.settingName}
settingName={settingRequest.settingName}
settingDescription={settingRequest.settingDescription}
sensitive={settingRequest.sensitive}
onSubmit={settingRequest.onSubmit}
onCancel={settingRequest.onCancel}
terminalWidth={boxWidth}
/>
) : pluginChoiceRequest ? (
<PluginChoicePrompt
key={pluginChoiceRequest.marketplaceName}
marketplaceName={pluginChoiceRequest.marketplaceName}
plugins={pluginChoiceRequest.plugins}
onSelect={pluginChoiceRequest.onSelect}
onCancel={pluginChoiceRequest.onCancel}
terminalWidth={boxWidth}
/>
) : null}
<Box
display={hasPendingRequest ? 'none' : 'flex'}
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
@ -522,9 +207,73 @@ export function ExtensionsManagerDialog({
width={boxWidth}
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
<TabBar tabs={TABS} activeTab={activeTab} canSwitch={!tabLocked} />
<Box flexDirection="column">
{activeTab === EXTENSIONS_TABS.DISCOVER && (
<DiscoverTab
config={config}
isActive={
activeTab === EXTENSIONS_TABS.DISCOVER && !hasPendingRequest
}
onLockChange={handleLockChange}
onStatus={setStatus}
onInstalled={bumpReload}
marketplaceFilter={discoverFilter ?? undefined}
reloadSignal={reloadSignal}
/>
)}
{activeTab === EXTENSIONS_TABS.INSTALLED && (
<InstalledTab
config={config}
isActive={
activeTab === EXTENSIONS_TABS.INSTALLED && !hasPendingRequest
}
onLockChange={handleLockChange}
onStatus={setStatus}
extensionsUpdateState={extensionsUpdateState}
reloadSignal={reloadSignal}
/>
)}
{activeTab === EXTENSIONS_TABS.SOURCES && (
<SourcesTab
config={config}
isActive={
activeTab === EXTENSIONS_TABS.SOURCES && !hasPendingRequest
}
onLockChange={handleLockChange}
onStatus={setStatus}
onChanged={bumpReload}
onBrowse={handleBrowseSource}
onFooter={setTabFooter}
reloadSignal={reloadSignal}
/>
)}
</Box>
{status && (
<Text
color={
status.type === 'error'
? theme.status.error
: status.type === 'success'
? theme.status.success
: theme.text.secondary
}
>
{status.text}
</Text>
)}
<Text color={theme.text.secondary}>
{/* A tab-provided hint wins even while a sub-view is locked, so a
locked view (e.g. a failed marketplace load offering R to retry)
can surface its own footer instead of the generic locked text. */}
{tabFooter ??
(tabLocked
? t('Enter to select · Esc to go back')
: footerHint(activeTab))}
</Text>
</Box>
</Box>
);

View file

@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
import {
EXTENSIONS_TABS,
type ExtensionsTab,
type ExtensionsTabDef,
} from './types.js';
interface TabBarProps {
tabs: ExtensionsTabDef[];
activeTab: ExtensionsTab;
/** When false, the "tab to cycle" hint is dimmed to signal it is locked. */
canSwitch: boolean;
}
// Literal t() calls keep the labels extractable for translation.
function tabLabel(id: ExtensionsTab): string {
switch (id) {
case EXTENSIONS_TABS.DISCOVER:
return t('Discover');
case EXTENSIONS_TABS.INSTALLED:
return t('Installed');
case EXTENSIONS_TABS.SOURCES:
return t('Sources');
default:
return id;
}
}
export const TabBar = ({ tabs, activeTab, canSwitch }: TabBarProps) => (
<Box>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<Box key={tab.id} marginRight={2}>
{isActive ? (
<Text
bold
backgroundColor={theme.text.accent}
color={theme.background.primary}
>
{` ${tabLabel(tab.id)} `}
</Text>
) : (
<Text color={theme.text.secondary}>{` ${tabLabel(tab.id)} `}</Text>
)}
</Box>
);
})}
<Text color={theme.text.secondary} dimColor={!canSwitch}>
{t('(Tab / ←→ to switch)')}
</Text>
</Box>
);

View file

@ -1,45 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = `
"┌──────────────────────────────────────────────────────────────────────────┐
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
└──────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = `
"┌──────────────────────────────────────────────────────────────────────────┐
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
└──────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = `
"┌──────────────────────────────────────────────────────────────────────────┐
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
└──────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = `
"┌──────────────────────────────────────────────────────────────────────────┐
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
└──────────────────────────────────────────────────────────────────────────┘"
`;

View file

@ -4,6 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
export {
ExtensionsManagerDialog,
type StatusMessage,
} from './ExtensionsManagerDialog.js';
export type { ExtensionsManagerDialogProps } from './types.js';
export { MANAGEMENT_STEPS } from './types.js';
export { MANAGEMENT_STEPS, EXTENSIONS_TABS } from './types.js';
export type { ExtensionsTab } from './types.js';

View file

@ -18,6 +18,8 @@ interface UninstallConfirmStepProps {
selectedExtension: Extension | null;
onConfirm: (extension: Extension) => Promise<void>;
onNavigateBack: () => void;
/** Whether this step should respond to keyboard input (default true). */
isActive?: boolean;
}
const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP');
@ -26,6 +28,7 @@ export function UninstallConfirmStep({
selectedExtension,
onConfirm,
onNavigateBack,
isActive = true,
}: UninstallConfirmStepProps) {
useKeypress(
async (key) => {
@ -42,7 +45,7 @@ export function UninstallConfirmStep({
onNavigateBack();
}
},
{ isActive: true },
{ isActive },
);
if (!selectedExtension) {
@ -60,8 +63,8 @@ export function UninstallConfirmStep({
name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()),
})}
</Text>
<Text color={theme.text.secondary}>
{t('This action cannot be undone.')}
<Text color={theme.status.error}>
{t('Note: Uninstall permanently removes this extension.')}
</Text>
</Box>
);

View file

@ -0,0 +1,725 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import open from 'open';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { useTerminalSize } from '../../../hooks/useTerminalSize.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import {
type Config,
type DiscoveredPlugin,
type ExtensionScope,
SettingScope,
parseInstallSource,
redactUrlCredentials,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../../../utils/errors.js';
import type { StatusMessage } from '../ExtensionsManagerDialog.js';
const debugLogger = createDebugLogger('DISCOVER_TAB');
type DiscoverView = 'list' | 'detail' | 'scope-select';
interface DiscoverTabProps {
config: Config;
isActive: boolean;
onLockChange: (locked: boolean) => void;
onStatus: (status: StatusMessage | null) => void;
onInstalled: () => void;
/** When set, only plugins from this marketplace are shown. */
marketplaceFilter?: string;
reloadSignal: number;
}
/** Formats a raw install count like 787100 -> "787.1K". */
function formatInstalls(n?: number): string | null {
if (typeof n !== 'number' || !Number.isFinite(n)) return null;
if (n >= 1_000_000)
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return String(n);
}
function truncateText(text: string, max: number): string {
if (max <= 1 || text.length <= max) return text;
return `${text.slice(0, max - 1)}`;
}
// Built per-render so the literal t() labels stay extractable and localize.
function scopeItems(): Array<{
key: string;
label: string;
value: ExtensionScope;
}> {
return [
{ key: 'user', label: t('Global (User Scope)'), value: 'user' },
{
key: 'project',
label: t('Project (Workspace)'),
value: 'project',
},
];
}
export const DiscoverTab = ({
config,
isActive,
onLockChange,
onStatus,
onInstalled,
marketplaceFilter,
reloadSignal,
}: DiscoverTabProps) => {
const [plugins, setPlugins] = useState<DiscoveredPlugin[]>([]);
const [cursor, setCursor] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0);
const [query, setQuery] = useState('');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [view, setView] = useState<DiscoverView>('list');
const [loading, setLoading] = useState(true);
const [installing, setInstalling] = useState(false);
const { columns, rows } = useTerminalSize();
const availableWidth = Math.max(24, columns - 8);
// Each item renders as 3 lines (title, description, gap). Reserve rows for
// the tab bar, header, search box, scroll hints, status and footer.
const visibleCount = Math.max(
3,
Math.min(6, Math.floor(((rows || 24) - 13) / 3)),
);
const extensionManager = config.getExtensionManager();
const keyOf = (p: DiscoveredPlugin) => `${p.marketplaceName}/${p.name}`;
const filtered = useMemo(() => {
const byMarketplace = marketplaceFilter
? plugins.filter((p) => p.marketplaceName === marketplaceFilter)
: plugins;
const q = query.trim().toLowerCase();
if (!q) return byMarketplace;
return byMarketplace.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.marketplaceName.toLowerCase().includes(q) ||
(p.description?.toLowerCase().includes(q) ?? false),
);
}, [plugins, query, marketplaceFilter]);
// Reset the cursor to the top when the marketplace filter changes.
useEffect(() => {
setCursor(0);
setScrollOffset(0);
}, [marketplaceFilter]);
const load = useCallback(
async (options?: { refresh?: boolean }) => {
if (!extensionManager) {
setLoading(false);
return;
}
setLoading(true);
try {
const discovered = await extensionManager.discoverPlugins(options);
setPlugins(discovered);
setCursor((prev) => (prev < discovered.length ? prev : 0));
if (options?.refresh) {
onStatus({
type: 'success',
text: t('Refreshed {{count}} extension(s).', {
count: String(discovered.length),
}),
});
}
} catch (error) {
debugLogger.error('Failed to discover plugins:', error);
onStatus({ type: 'error', text: getErrorMessage(error) });
} finally {
setLoading(false);
}
},
[extensionManager, onStatus],
);
const handleReload = useCallback(() => {
// Ignore repeat presses while a refresh is in flight so rapid Ctrl+R
// doesn't stack concurrent network fetches across every marketplace.
if (loading) return;
void load({ refresh: true });
}, [load, loading]);
useEffect(() => {
load();
}, [load, reloadSignal]);
const goToList = useCallback(() => {
setView('list');
onLockChange(false);
}, [onLockChange]);
const selected = filtered[cursor] ?? null;
// Keep the cursor in range as the filtered list changes (e.g. while typing).
useEffect(() => {
if (cursor > filtered.length - 1) {
setCursor(filtered.length > 0 ? filtered.length - 1 : 0);
}
}, [filtered.length, cursor]);
// Keep the cursor inside the visible window (scrolling viewport).
useEffect(() => {
if (cursor < scrollOffset) {
setScrollOffset(cursor);
} else if (cursor >= scrollOffset + visibleCount) {
setScrollOffset(cursor - visibleCount + 1);
}
}, [cursor, scrollOffset, visibleCount]);
// Plugins queued for installation when the scope is chosen.
const pendingInstall = useCallback((): DiscoveredPlugin[] => {
const chosen = plugins.filter(
(p) => selectedKeys.has(keyOf(p)) && !p.installed,
);
if (chosen.length > 0) return chosen;
if (selected && !selected.installed) return [selected];
return [];
}, [plugins, selectedKeys, selected]);
const beginInstall = useCallback(() => {
if (pendingInstall().length === 0) {
onStatus({
type: 'info',
text: t('No installable extensions selected.'),
});
return;
}
setView('scope-select');
onLockChange(true);
}, [pendingInstall, onLockChange, onStatus]);
const runInstall = useCallback(
async (
targets: DiscoveredPlugin[],
scope: ExtensionScope,
origin: 'detail' | 'list',
) => {
if (!extensionManager || targets.length === 0) return;
setInstalling(true);
let installed = 0;
const errors: string[] = [];
for (const plugin of targets) {
let ext;
try {
const metadata = await parseInstallSource(plugin.installSource);
ext = await extensionManager.installExtension(metadata);
} catch (error) {
errors.push(
`${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`,
);
continue;
}
// The extension is installed on disk now. Recording the scope/enablement
// preference below is non-critical: a failure there must not flip a
// successful install to "failed" (which would prompt a confusing retry).
installed++;
try {
// installExtension auto-enables at User (global) scope. For a
// workspace-scoped choice, re-scope enablement to this workspace
// only: disable the global enable and enable for the workspace path.
if (scope !== 'user') {
await extensionManager.disableExtension(
ext.name,
SettingScope.User,
);
try {
await extensionManager.enableExtension(
ext.name,
SettingScope.Workspace,
);
} catch (enableError) {
// The User-scope disable already landed; roll it back so a failed
// Workspace enable doesn't leave the extension disabled at every
// scope (the outer catch only logs, so the install still reports
// success — without this the extension would be silently dead).
try {
await extensionManager.enableExtension(
ext.name,
SettingScope.User,
);
} catch (rollbackError) {
// Rollback failed: the extension is now disabled at every scope.
// The outer catch only debug-logs, so surface it through the
// batch error list — otherwise the user is told the install
// succeeded with no hint the extension is silently dead.
debugLogger.error(
'Scope rollback failed after install:',
rollbackError,
);
errors.push(
t(
'{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.',
{ name: plugin.name },
),
);
}
throw enableError;
}
}
// Record the scope preference only after enablement succeeds, so the
// Installed tab can't show a "Project level" extension that is
// actually enabled at User scope after a rollback.
extensionManager.setExtensionScope(ext.name, scope);
} catch (scopeError) {
debugLogger.error(
'Installed extension but failed to apply scope preference:',
scopeError,
);
}
}
setInstalling(false);
setSelectedKeys(new Set());
if (errors.length === 0) {
onStatus({
type: 'success',
text: t('Installed {{count}} extension(s).', {
count: String(installed),
}),
});
} else {
onStatus({
type: 'error',
text: t('Installed {{ok}}, failed {{fail}}: {{detail}}', {
ok: String(installed),
fail: String(errors.length),
detail: errors.join('; '),
}),
});
}
await load();
onInstalled();
if (errors.length === 0) {
goToList();
} else if (origin === 'detail') {
// Single install from a plugin's detail: stay on detail so the error
// remains visible over the right plugin and the user can retry.
setView('detail');
onLockChange(true);
} else {
// Batch install started from the list: the detail view renders
// filtered[cursor] — an arbitrary row unrelated to what failed — so
// returning there would offer a misleading retry. Keep the error over
// the list instead.
goToList();
}
},
[extensionManager, onStatus, load, onInstalled, goToList, onLockChange],
);
const installWithScope = useCallback(
(scope: ExtensionScope) => void runInstall(pendingInstall(), scope, 'list'),
[runInstall, pendingInstall],
);
const openHomepage = useCallback(
async (plugin: DiscoveredPlugin) => {
if (!plugin.homepage) {
onStatus({ type: 'info', text: t('No homepage available.') });
return;
}
if (process.env['NODE_ENV'] === 'test') {
onStatus({
type: 'info',
text: t('Would open: {{url}}', { url: plugin.homepage }),
});
return;
}
// homepage comes from untrusted marketplace metadata; only follow web
// links. `open()` would otherwise launch file:// / other schemes in the
// OS default handler (e.g. file:///Users/victim/.ssh/id_rsa).
let protocol: string;
try {
protocol = new URL(plugin.homepage).protocol;
} catch {
protocol = '';
}
if (protocol !== 'http:' && protocol !== 'https:') {
onStatus({
type: 'error',
text: t('Failed to open {{url}}', { url: plugin.homepage }),
});
return;
}
try {
await open(plugin.homepage);
} catch {
onStatus({
type: 'error',
text: t('Failed to open {{url}}', { url: plugin.homepage }),
});
}
},
[onStatus],
);
// Inline action selector on the detail page (mirrors Claude Code).
type DetailAction = ExtensionScope | 'homepage' | 'back';
const handleDetailAction = useCallback(
(action: DetailAction) => {
if (action === 'back') {
goToList();
} else if (action === 'homepage') {
if (selected) void openHomepage(selected);
} else if (selected) {
void runInstall([selected], action, 'detail');
}
},
[selected, goToList, openHomepage, runInstall],
);
const detailActionItems = useCallback(() => {
const items: Array<{ key: string; label: string; value: DetailAction }> =
[];
if (selected && !selected.installed) {
items.push(
{
key: 'user',
label: t('Install for you (user scope)'),
value: 'user',
},
{
key: 'project',
label: t('Install for the current workspace (project scope)'),
value: 'project',
},
);
}
if (selected?.homepage) {
items.push({
key: 'homepage',
label: t('Open homepage'),
value: 'homepage',
});
}
items.push({
key: 'back',
label: t('Back to extension list'),
value: 'back',
});
return items;
}, [selected]);
// List keyboard: navigate, type-to-search, Space to toggle, Enter to view
// (or install the selected set), matching Claude Code's Discover list.
// Note: navigation here intentionally bypasses the global SELECTION_UP/DOWN
// matchers (which include bare j/k) so that j and k stay available as
// printable characters for the type-to-search query.
useKeypress(
(key) => {
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
if (filtered.length > 0)
setCursor((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
return;
}
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
if (filtered.length > 0)
setCursor((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
return;
}
if (key.name === 'return') {
if (selectedKeys.size > 0) {
beginInstall();
} else if (selected) {
onStatus(null);
setView('detail');
onLockChange(true);
}
return;
}
if (key.name === 'space' || key.sequence === ' ') {
if (!selected || selected.installed) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
const k = keyOf(selected);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return;
}
if (key.name === 'backspace' || key.name === 'delete') {
setQuery((q) => q.slice(0, -1));
return;
}
// Ctrl+R: refresh / re-discover all sources.
if (key.ctrl && key.name === 'r') {
handleReload();
return;
}
// Printable character -> append to the search query.
if (
!key.ctrl &&
!key.meta &&
key.sequence &&
key.sequence.length === 1 &&
key.sequence >= ' '
) {
setQuery((q) => q + key.sequence);
}
},
{ isActive: isActive && view === 'list' },
);
// Detail: Escape goes back; the action selector (RadioButtonSelect) owns Enter.
useKeypress(
(key) => {
if (key.name === 'escape') {
goToList();
}
},
{ isActive: isActive && view === 'detail' },
);
// Scope-select (batch install from the list) escape returns to the list.
useKeypress(
(key) => {
if (key.name === 'escape' && !installing) {
goToList();
}
},
{ isActive: isActive && view === 'scope-select' },
);
if (loading) {
return (
<Text color={theme.text.secondary}>{t('Discovering extensions...')}</Text>
);
}
if (view === 'scope-select') {
const count = pendingInstall().length;
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary}>
{t('Install {{count}} extension(s) to which scope?', {
count: String(count),
})}
</Text>
{installing ? (
<Text color={theme.text.secondary}>{t('Installing...')}</Text>
) : (
<RadioButtonSelect
items={scopeItems()}
isFocused={isActive}
showNumbers={false}
onSelect={(scope) => void installWithScope(scope)}
/>
)}
</Box>
);
}
if (view === 'detail' && selected) {
const comps = selected.components;
const componentLines: Array<{ label: string; names: string[] }> = [];
if (comps?.skills?.length)
componentLines.push({ label: t('Skills'), names: comps.skills });
if (comps?.commands?.length)
componentLines.push({ label: t('Commands'), names: comps.commands });
if (comps?.agents?.length)
componentLines.push({ label: t('Agents'), names: comps.agents });
if (comps?.mcpServers?.length)
componentLines.push({
label: t('MCP servers'),
names: comps.mcpServers,
});
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary} bold>
{t('Extension details')}
</Text>
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{selected.name}
</Text>
<Text color={theme.text.secondary}>
{t('from {{marketplace}}', {
marketplace: selected.marketplaceName,
})}
</Text>
{selected.lastUpdated ? (
<Text color={theme.text.secondary}>
{t('Last updated: {{date}}', { date: selected.lastUpdated })}
</Text>
) : selected.version ? (
<Text color={theme.text.secondary}>
{t('Version: {{v}}', { v: selected.version })}
</Text>
) : null}
</Box>
{selected.description ? <Text>{selected.description}</Text> : null}
{selected.author ? (
<Text color={theme.text.secondary}>
{t('By: {{a}}', { a: selected.author })}
</Text>
) : null}
{componentLines.length > 0 ? (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Will install:')}
</Text>
{componentLines.map((line) => (
<Text key={line.label} color={theme.text.secondary}>
{`· ${line.label}: ${line.names.join(', ')}`}
</Text>
))}
</Box>
) : null}
<Text color={theme.text.secondary} italic>
{t(
'⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.',
)}
</Text>
{installing ? (
<Text color={theme.text.secondary}>{t('Installing...')}</Text>
) : (
<RadioButtonSelect
items={detailActionItems()}
isFocused={isActive}
showNumbers={false}
onSelect={handleDetailAction}
/>
)}
</Box>
);
}
if (plugins.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No extensions discovered.')}
</Text>
<Text color={theme.text.secondary}>
{t('Add a marketplace in the Sources tab to discover extensions.')}
</Text>
</Box>
);
}
const windowItems = filtered.slice(scrollOffset, scrollOffset + visibleCount);
const hasAbove = scrollOffset > 0;
const hasBelow = scrollOffset + visibleCount < filtered.length;
return (
<Box flexDirection="column">
<Box>
<Text color={theme.text.primary} bold>
{t('Discover extensions')}
</Text>
<Text color={theme.text.secondary}>
{` (${filtered.length ? cursor + 1 : 0}/${filtered.length})`}
</Text>
{marketplaceFilter ? (
<Text color={theme.text.secondary}>
{t(' · {{marketplace}} (Tab to clear)', {
marketplace: marketplaceFilter,
})}
</Text>
) : null}
</Box>
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={availableWidth}
>
<Text color={theme.text.secondary}>{'⌕ '}</Text>
{query ? (
<Text color={theme.text.primary}>{query}</Text>
) : (
<Text color={theme.text.secondary}>{t('Search…')}</Text>
)}
</Box>
{filtered.length === 0 ? (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('No extensions match your search.')}
</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
{hasAbove ? (
<Text color={theme.text.secondary}>{t('↑ more above')}</Text>
) : null}
{windowItems.map((plugin, i) => {
const absIndex = scrollOffset + i;
const isCursor = absIndex === cursor;
const isChecked = selectedKeys.has(keyOf(plugin));
const installs = formatInstalls(plugin.installs);
const checkbox = plugin.installed ? '✓' : isChecked ? '●' : '○';
const titleColor = isCursor
? theme.text.accent
: theme.text.primary;
const meta =
` · ${plugin.marketplaceName}` +
(installs ? ` · ${installs} installs` : '') +
(plugin.installed ? ` · ${t('installed')}` : '');
return (
<Box key={keyOf(plugin)} flexDirection="column" marginBottom={1}>
<Box>
<Box minWidth={2} flexShrink={0}>
<Text color={theme.text.accent}>
{isCursor ? '' : ' '}
</Text>
</Box>
<Box minWidth={2} flexShrink={0}>
<Text
color={
plugin.installed
? theme.status.success
: theme.text.primary
}
>
{checkbox}
</Text>
</Box>
<Text bold color={titleColor}>
{plugin.name}
</Text>
<Text color={theme.text.secondary}>{meta}</Text>
</Box>
{plugin.description ? (
<Box paddingLeft={4}>
<Text color={theme.text.secondary}>
{truncateText(plugin.description, availableWidth - 4)}
</Text>
</Box>
) : null}
</Box>
);
})}
{hasBelow ? (
<Text color={theme.text.secondary}>{t('↓ more below')}</Text>
) : null}
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,814 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { useTerminalSize } from '../../../hooks/useTerminalSize.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { t } from '../../../../i18n/index.js';
import { stripUnsafeCharacters } from '../../../utils/textUtils.js';
import {
type Config,
type Extension,
type ExtensionScope,
type MCPServerConfig,
SettingScope,
MCPServerStatus,
getMCPServerStatus,
removeMCPServerStatus,
addMCPStatusChangeListener,
removeMCPStatusChangeListener,
mcpServerRequiresOAuth,
MCPOAuthTokenStorage,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import {
loadSettings,
SettingScope as CliSettingScope,
} from '../../../../config/settings.js';
import { getErrorMessage } from '../../../../utils/errors.js';
import type {
InstalledItem,
InstalledGroup,
InstalledMcpInfo,
} from '../types.js';
import { McpServerActionsView } from '../views/McpServerActionsView.js';
import { ExtensionActionsView } from '../views/ExtensionActionsView.js';
import type { StatusMessage } from '../ExtensionsManagerDialog.js';
const debugLogger = createDebugLogger('INSTALLED_TAB');
const GROUP_ORDER: InstalledGroup[] = [
'favorites',
'user',
'project',
'disabled',
];
// Localized group/scope label. Literal t() calls keep the strings extractable.
const groupLabel = (group: InstalledGroup): string => {
switch (group) {
case 'favorites':
return t('Favorites');
case 'user':
return t('User level');
case 'project':
return t('Project level');
case 'disabled':
return t('Disabled');
default:
return group;
}
};
type InstalledView = 'list' | 'plugin-detail' | 'mcp-detail';
interface InstalledTabProps {
config: Config;
isActive: boolean;
onLockChange: (locked: boolean) => void;
onStatus: (status: StatusMessage | null) => void;
extensionsUpdateState: Map<string, string>;
reloadSignal: number;
}
function groupFor(
isActive: boolean,
isFavorite: boolean,
scope: InstalledGroup | ExtensionScope,
): InstalledGroup {
if (!isActive) return 'disabled';
if (isFavorite) return 'favorites';
if (scope === 'project') return 'project';
return 'user';
}
export const InstalledTab = ({
config,
isActive,
onLockChange,
onStatus,
extensionsUpdateState,
reloadSignal,
}: InstalledTabProps) => {
const [items, setItems] = useState<InstalledItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [view, setView] = useState<InstalledView>('list');
const [loading, setLoading] = useState(true);
// Tracks the currently-selected item's stable key so that after a reload
// re-sorts the list (e.g. favorite/enable/disable moves an item to another
// group) the cursor — and any open detail view — stays on the SAME item
// rather than whatever now sits at the old index.
const selectedKeyRef = useRef<string | null>(null);
// Guards against overlapping mutations (e.g. mashing Space) while an
// enable/disable is still being applied.
const mutatingRef = useRef(false);
const extensionManager = config.getExtensionManager();
const load = useCallback(async () => {
if (!extensionManager) {
setLoading(false);
return;
}
setLoading(true);
try {
await extensionManager.refreshCache();
const extensions = extensionManager.getLoadedExtensions();
const favorites = new Set(extensionManager.getFavorites());
const scopes = extensionManager.getExtensionScopes();
const pluginItems: InstalledItem[] = extensions.map((ext: Extension) => {
const isFavorite = favorites.has(ext.name);
const scope: ExtensionScope = scopes[ext.name] ?? 'user';
return {
kind: 'plugin',
key: `plugin:${ext.name}`,
name: ext.name,
extension: ext,
isActive: ext.isActive,
isFavorite,
scope,
group: groupFor(ext.isActive, isFavorite, scope),
};
});
// MCP servers: standalone ones are top-level rows; extension-bundled
// ones are nested under their parent extension.
const mcpServers = config.getMcpServers() ?? {};
const hasAnyMcp =
Object.keys(mcpServers).length > 0 ||
extensions.some((ext) => Object.keys(ext.mcpServers ?? {}).length > 0);
// Only touch settings/tool registry when there are MCP servers.
const workspaceMcp = hasAnyMcp
? loadSettings().forScope(CliSettingScope.Workspace).settings.mcpServers
: undefined;
const toolRegistry = hasAnyMcp ? config.getToolRegistry() : undefined;
// Count tools per server in a single pass; buildMcpInfo is called once per
// MCP server (top-level + extension children), so re-scanning getAllTools()
// inside it would be O(servers × tools).
const toolCountByServer = new Map<string, number>();
if (toolRegistry) {
for (const tool of toolRegistry.getAllTools()) {
const sn = (tool as { serverName?: string }).serverName;
if (sn)
toolCountByServer.set(sn, (toolCountByServer.get(sn) ?? 0) + 1);
}
}
// Servers that need (re-)authentication: either the connect attempt hit
// a 401 (runtime signal), or OAuth is declared but no token is stored.
// Connected servers are skipped — they are evidently authenticated.
const needsAuthNames = new Set<string>();
const tokenStorage = new MCPOAuthTokenStorage();
for (const [name, sc] of Object.entries(mcpServers)) {
if (getMCPServerStatus(name) === MCPServerStatus.CONNECTED) continue;
if (mcpServerRequiresOAuth.get(name)) {
needsAuthNames.add(name);
continue;
}
if (sc.oauth?.enabled) {
try {
const creds = await tokenStorage.getCredentials(name);
if (!creds) needsAuthNames.add(name);
} catch {
needsAuthNames.add(name);
}
}
}
const buildMcpInfo = (
name: string,
serverConfig: MCPServerConfig,
scope: InstalledMcpInfo['scope'],
isDisabled: boolean,
): InstalledMcpInfo => {
// Status (and the status-derived needs-auth flag) are read here, in
// the same synchronous tick as setItems, so a transition during the
// earlier token-check awaits can't leave a stale combination.
const status = getMCPServerStatus(name);
return {
name,
status,
scope,
isDisabled,
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(name) === true ||
needsAuthNames.has(name),
transport: serverConfig.command
? 'stdio'
: serverConfig.httpUrl
? 'http'
: serverConfig.url
? 'sse'
: 'unknown',
toolCount: toolCountByServer.get(name) ?? 0,
};
};
const mcpItems: InstalledItem[] = [];
for (const [name, serverConfig] of Object.entries(mcpServers)) {
if (serverConfig.extensionName) continue;
const scope: InstalledMcpInfo['scope'] = workspaceMcp?.[name]
? 'project'
: 'user';
const isDisabled = config.isMcpServerDisabled(name);
const isFavorite = favorites.has(name);
mcpItems.push({
kind: 'mcp',
key: `mcp:${name}`,
name,
mcp: buildMcpInfo(name, serverConfig, scope, isDisabled),
isActive: !isDisabled,
isFavorite,
group: groupFor(!isDisabled, isFavorite, scope),
});
}
// Extension-bundled MCP servers, keyed by parent extension name. They
// inherit the parent's group so they always render right under it.
const childMcpItems = new Map<string, InstalledItem[]>();
for (const item of pluginItems) {
if (item.kind !== 'plugin') continue;
const ext = item.extension;
const children: InstalledItem[] = [];
for (const name of Object.keys(ext.mcpServers ?? {})) {
const merged = mcpServers[name];
// The merged runtime config wins on name collisions (a user/project
// server, or another extension's, shadows this one) — don't render a
// child row for a server this extension didn't actually contribute.
if (merged && merged.extensionName !== ext.name) continue;
// Active extension but absent from the runtime config: blocked by
// the MCP allow-list. Hide it, matching standalone behavior.
if (!merged && ext.isActive) continue;
const isDisabled = !ext.isActive || config.isMcpServerDisabled(name);
children.push({
kind: 'mcp',
key: `mcp:${ext.name}:${name}`,
name,
mcp: buildMcpInfo(
name,
merged ?? ext.mcpServers![name],
'extension',
isDisabled,
),
isActive: !isDisabled,
isFavorite: false,
group: item.group,
parentExtension: ext.name,
});
}
if (children.length) childMcpItems.set(ext.name, children);
}
const topLevel = [...pluginItems, ...mcpItems];
// Stable sort by group order then name.
topLevel.sort((a, b) => {
const ga = GROUP_ORDER.indexOf(a.group);
const gb = GROUP_ORDER.indexOf(b.group);
if (ga !== gb) return ga - gb;
return a.name.localeCompare(b.name);
});
// Expand each extension's bundled MCP servers directly beneath it.
const all: InstalledItem[] = [];
for (const item of topLevel) {
all.push(item);
if (item.kind === 'plugin') {
all.push(...(childMcpItems.get(item.name) ?? []));
}
}
setItems(all);
// Re-point the cursor at the same item by key (it may have moved groups).
const prevKey = selectedKeyRef.current;
setSelectedIndex((prev) => {
if (prevKey) {
const idx = all.findIndex((it) => it.key === prevKey);
if (idx >= 0) return idx;
}
return prev < all.length ? prev : 0;
});
} catch (error) {
debugLogger.error('Failed to load installed items:', error);
} finally {
setLoading(false);
}
}, [config, extensionManager]);
useEffect(() => {
load();
}, [load, reloadSignal]);
const selectedItem = items[selectedIndex] ?? null;
// Keep the stable-key ref in sync with the current selection.
useEffect(() => {
selectedKeyRef.current = selectedItem?.key ?? null;
}, [selectedItem]);
// Live-update MCP rows when a server's connection status changes (e.g. a
// "connecting" server finishing) without re-running the full load().
useEffect(() => {
const listener = (serverName: string, status?: MCPServerStatus) => {
if (status === undefined) return; // removals are handled by reloads
setItems((prev) =>
prev.map((it) =>
it.kind === 'mcp' && it.mcp.name === serverName
? {
...it,
mcp: {
...it.mcp,
status,
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(serverName) === true ||
it.mcp.requiresAuth,
},
}
: it,
),
);
};
addMCPStatusChangeListener(listener);
return () => removeMCPStatusChangeListener(listener);
}, []);
const { rows: terminalRows } = useTerminalSize();
// One line per display row; reserve space for the dialog border, tab bar,
// scroll hints, status line (which may wrap) and footer around this tab.
const visibleCount = Math.max(6, (terminalRows || 24) - 12);
const [scrollOffset, setScrollOffset] = useState(0);
// Flatten the grouped list into display rows (headers, items, gaps) so the
// list can be windowed to the terminal height.
type DisplayRow =
| { type: 'header'; group: InstalledGroup; count: number }
| { type: 'item'; item: InstalledItem }
| { type: 'gap' };
const displayRows = useMemo(() => {
const out: DisplayRow[] = [];
for (const group of GROUP_ORDER) {
const rows = items.filter((it) => it.group === group);
if (!rows.length) continue;
out.push({
type: 'header',
group,
// Bundled MCP rows travel with their extension; the header counts
// only top-level entries.
count: rows.filter((it) => !(it.kind === 'mcp' && it.parentExtension))
.length,
});
for (const item of rows) out.push({ type: 'item', item });
out.push({ type: 'gap' });
}
if (out.at(-1)?.type === 'gap') out.pop();
return out;
}, [items]);
// Keep the cursor — and its group header when directly above — visible,
// and re-clamp the offset when the list shrinks or the window grows.
useEffect(() => {
const maxOffset = Math.max(0, displayRows.length - visibleCount);
if (scrollOffset > maxOffset) {
setScrollOffset(maxOffset);
return;
}
const idx = displayRows.findIndex(
(r) => r.type === 'item' && r.item.key === selectedItem?.key,
);
if (idx < 0) return;
const top = displayRows[idx - 1]?.type === 'header' ? idx - 1 : idx;
if (top < scrollOffset) {
setScrollOffset(top);
} else if (idx >= scrollOffset + visibleCount) {
setScrollOffset(idx - visibleCount + 1);
}
}, [displayRows, selectedItem, scrollOffset, visibleCount]);
// O(1) row→index lookup for render, instead of items.indexOf() per visible row.
const indexByKey = useMemo(() => {
const map = new Map<string, number>();
items.forEach((it, i) => map.set(it.key, i));
return map;
}, [items]);
const goToList = useCallback(() => {
setView('list');
onLockChange(false);
}, [onLockChange]);
// If a reload removed (or re-typed) the item whose detail is open, fall back
// to the list — otherwise the tab stays locked with no active key handler.
useEffect(() => {
if (view === 'list' || loading) return;
const matches =
selectedItem && (view === 'mcp-detail') === (selectedItem.kind === 'mcp');
if (!matches) goToList();
}, [view, loading, selectedItem, goToList]);
const isParentExtensionActive = useCallback(
(item: Extract<InstalledItem, { kind: 'mcp' }>): boolean =>
items.some(
(p) =>
p.kind === 'plugin' && p.name === item.parentExtension && p.isActive,
),
[items],
);
const enterDetail = useCallback(
(item: InstalledItem) => {
// A disabled extension's servers are not loaded into the runtime config,
// so the MCP detail view would have nothing to show.
if (
item.kind === 'mcp' &&
item.parentExtension &&
!isParentExtensionActive(item)
) {
onStatus({
type: 'info',
text: t('Enable extension "{{name}}" to manage this MCP server.', {
name: item.parentExtension,
}),
});
return;
}
onStatus(null);
setView(item.kind === 'plugin' ? 'plugin-detail' : 'mcp-detail');
onLockChange(true);
},
[onLockChange, onStatus, isParentExtensionActive],
);
const togglePlugin = useCallback(
async (item: Extract<InstalledItem, { kind: 'plugin' }>) => {
if (!extensionManager || mutatingRef.current) return;
const scope =
item.scope === 'user' ? SettingScope.User : SettingScope.Workspace;
mutatingRef.current = true;
onStatus({
type: 'info',
text: item.isActive
? t('Disabling "{{name}}"...', { name: item.name })
: t('Enabling "{{name}}"...', { name: item.name }),
});
try {
if (item.isActive) {
await extensionManager.disableExtension(item.name, scope);
} else {
await extensionManager.enableExtension(item.name, scope);
}
onStatus({
type: 'success',
text: t('"{{name}}" {{state}}.', {
name: item.name,
state: item.isActive ? t('disabled') : t('enabled'),
}),
});
await load();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
} finally {
mutatingRef.current = false;
}
},
[extensionManager, load, onStatus],
);
const toggleMcp = useCallback(
async (item: Extract<InstalledItem, { kind: 'mcp' }>) => {
if (mutatingRef.current) return;
if (item.parentExtension) {
if (!isParentExtensionActive(item)) {
onStatus({
type: 'info',
text: t('Enable extension "{{name}}" to manage this MCP server.', {
name: item.parentExtension,
}),
});
return;
}
if (item.isActive) {
// Disable via the extension-scoped preference (not the global
// mcp.excluded list) so same-named servers from other sources are
// unaffected and uninstalling the extension cleans it up.
if (!extensionManager) return;
mutatingRef.current = true;
onStatus({
type: 'info',
text: t('Disabling MCP "{{name}}"...', { name: item.name }),
});
try {
extensionManager.setMcpServerDisabled(
item.parentExtension,
item.name,
true,
);
await config.getToolRegistry()?.disconnectServer(item.name);
// Drop the status entry so the footer health pill doesn't keep
// counting an intentionally disabled server as offline.
removeMCPServerStatus(item.name);
// The per-extension disable record is user-global, unlike the
// scope-aware standalone toggle — say so.
onStatus({
type: 'success',
text: t('MCP "{{name}}" disabled for all projects.', {
name: item.name,
}),
});
await load();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
} finally {
mutatingRef.current = false;
}
return;
}
// Enable: clear the extension-scoped flag inside the try below (so a
// write failure routes to the error toast instead of crashing as an
// unhandled rejection), then fall through to the shared enable path
// (which also clears any manual exclusions).
}
// Whether this is the bundled-MCP enable fall-through that still needs the
// extension-scoped disable flag cleared.
const clearBundledDisable =
Boolean(item.parentExtension) && !item.isActive;
const toolRegistry = config.getToolRegistry();
mutatingRef.current = true;
// Enabling rediscovers the server's tools, which can take a while.
onStatus({
type: 'info',
text: item.isActive
? t('Disabling MCP "{{name}}"...', { name: item.name })
: t('Enabling MCP "{{name}}"...', { name: item.name }),
});
try {
if (clearBundledDisable && extensionManager && item.parentExtension) {
extensionManager.setMcpServerDisabled(
item.parentExtension,
item.name,
false,
);
}
const settings = loadSettings();
const targetScope =
item.mcp.scope === 'project'
? CliSettingScope.Workspace
: CliSettingScope.User;
if (item.isActive) {
// Disable: add to excluded + disconnect.
const excluded =
settings.forScope(targetScope).settings.mcp?.excluded ?? [];
if (!excluded.includes(item.name)) {
settings.setValue(targetScope, 'mcp.excluded', [
...excluded,
item.name,
]);
}
await toolRegistry?.disableMcpServer(item.name);
} else {
// Enable: remove from excluded in both scopes + rediscover.
for (const scope of [
CliSettingScope.User,
CliSettingScope.Workspace,
]) {
const excluded =
settings.forScope(scope).settings.mcp?.excluded ?? [];
if (excluded.includes(item.name)) {
settings.setValue(
scope,
'mcp.excluded',
excluded.filter((n: string) => n !== item.name),
);
}
}
const runtimeExcluded = config.getExcludedMcpServers() ?? [];
config.setExcludedMcpServers(
runtimeExcluded.filter((n) => n !== item.name),
);
await toolRegistry?.discoverToolsForServer(item.name);
}
onStatus({
type: 'success',
text: t('MCP "{{name}}" {{state}}.', {
name: item.name,
state: item.isActive ? t('disabled') : t('enabled'),
}),
});
await load();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
} finally {
mutatingRef.current = false;
}
},
[config, extensionManager, load, onStatus, isParentExtensionActive],
);
const toggleFavorite = useCallback(
async (item: InstalledItem) => {
if (!extensionManager) return;
// toggleFavorite() -> atomicWriteFileSync can throw; this runs in a
// void-invoked handler, so an unguarded throw surfaces as the alarming
// "Unhandled Promise Rejection" banner. Route it to an error toast.
try {
const nowFavorite = extensionManager.toggleFavorite(item.name);
onStatus({
type: 'info',
text: nowFavorite
? t('Added "{{name}}" to favorites.', { name: item.name })
: t('Removed "{{name}}" from favorites.', { name: item.name }),
});
await load();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
},
[extensionManager, load, onStatus],
);
// List keyboard handling.
useKeypress(
(key) => {
if (items.length === 0) return;
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
} else if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
} else if (key.name === 'return') {
if (selectedItem) enterDetail(selectedItem);
} else if (key.name === 'space' || key.sequence === ' ') {
if (!selectedItem) return;
if (selectedItem.kind === 'plugin') {
void togglePlugin(selectedItem);
} else {
void toggleMcp(selectedItem);
}
} else if (key.sequence === 'f' && !key.ctrl && !key.meta) {
if (!selectedItem || mutatingRef.current) return;
// Bundled MCP servers stay nested under their extension, so they
// cannot be favorited independently.
if (selectedItem.kind === 'mcp' && selectedItem.parentExtension) {
onStatus({
type: 'info',
text: t('Extension-provided MCP servers cannot be favorited.'),
});
return;
}
void toggleFavorite(selectedItem);
}
},
{ isActive: isActive && view === 'list' },
);
if (loading) {
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
}
if (view === 'plugin-detail' && selectedItem?.kind === 'plugin') {
return (
<ExtensionActionsView
config={config}
extension={selectedItem.extension}
isActive={isActive}
updateState={extensionsUpdateState.get(selectedItem.name)}
onStatus={onStatus}
onReload={load}
onExit={goToList}
/>
);
}
if (view === 'mcp-detail' && selectedItem?.kind === 'mcp') {
return (
<McpServerActionsView
config={config}
serverName={selectedItem.mcp.name}
isActive={isActive}
onStatus={onStatus}
onReload={load}
onExit={goToList}
/>
);
}
if (items.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No plugins or MCP servers installed.')}
</Text>
<Text color={theme.text.secondary}>
{t('Use the Discover tab to find and install plugins.')}
</Text>
</Box>
);
}
// Windowed list rendering. A gap row at the window's top edge would render
// as a stray blank line under the "more above" hint, so trim it.
const visibleRows = displayRows.slice(
scrollOffset,
scrollOffset + visibleCount,
);
while (visibleRows[0]?.type === 'gap') visibleRows.shift();
const hasAbove = scrollOffset > 0;
const hasBelow = scrollOffset + visibleCount < displayRows.length;
return (
<Box flexDirection="column">
{hasAbove ? (
<Text color={theme.text.secondary}>{t('↑ more above')}</Text>
) : null}
{visibleRows.map((row, i) => {
if (row.type === 'gap') {
return <Box key={`gap-${scrollOffset + i}`} height={1} />;
}
if (row.type === 'header') {
return (
<Text key={`header-${row.group}`} color={theme.text.accent} bold>
{groupLabel(row.group)} ({row.count})
</Text>
);
}
const item = row.item;
const globalIndex = indexByKey.get(item.key) ?? -1;
const isSelected = globalIndex === selectedIndex;
const marker = isSelected ? '●' : ' ';
const isChild = item.kind === 'mcp' && !!item.parentExtension;
const kindBadge =
item.kind === 'mcp'
? t('MCP')
: t('Extension v{{version}}', {
// Persisted marketplace metadata: `version` is stored verbatim
// by the converter and only `name` is validated on load, so
// scrub it here (the Discover-side sanitization doesn't cover
// this persisted/Installed render path).
version: stripUnsafeCharacters(item.extension.version ?? ''),
});
// MCP rows surface the live connection state — "enabled" alone would
// read as usable even when the server failed to connect or still
// needs authentication.
let statusLabel: string;
let statusColor: string;
if (item.kind === 'mcp') {
if (!item.isActive) {
statusLabel = t('disabled');
statusColor = theme.text.secondary;
} else if (item.mcp.status === MCPServerStatus.CONNECTED) {
statusLabel = t('connected');
statusColor = theme.status.success;
} else if (item.mcp.requiresAuth) {
statusLabel = t('needs authentication');
statusColor = theme.status.warning;
} else if (item.mcp.status === MCPServerStatus.CONNECTING) {
statusLabel = t('connecting');
statusColor = theme.status.warning;
} else {
statusLabel = t('disconnected');
statusColor = theme.status.error;
}
} else {
statusLabel = item.isActive ? t('active') : t('disabled');
statusColor = item.isActive
? theme.status.success
: theme.text.secondary;
}
return (
<Box key={item.key}>
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{marker}
</Text>
</Box>
<Box flexGrow={1}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isChild ? ' └ ' : ''}
{item.name}
</Text>
{item.isFavorite ? (
<Text color={theme.status.warning}> </Text>
) : null}
</Box>
<Text color={isSelected ? theme.text.accent : theme.text.secondary}>
{kindBadge}{' '}
</Text>
<Text color={isSelected ? theme.text.accent : statusColor}>
({statusLabel})
</Text>
</Box>
);
})}
{hasBelow ? (
<Text color={theme.text.secondary}>{t('↓ more below')}</Text>
) : null}
</Box>
);
};

View file

@ -0,0 +1,693 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { TextInput } from '../../shared/TextInput.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import {
type Config,
type Extension,
type ExtensionSource,
type ClaudeMarketplaceConfig,
parseInstallSource,
redactUrlCredentials,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../../../utils/errors.js';
import { stripUnsafeCharacters } from '../../../utils/textUtils.js';
import type { StatusMessage } from '../ExtensionsManagerDialog.js';
const debugLogger = createDebugLogger('SOURCES_TAB');
// How many installed plugins to list in the marketplace detail before
// collapsing the rest into a "… and N more" summary (keeps the view short).
const INSTALLED_PREVIEW_LIMIT = 5;
type SourcesView =
| 'list'
| 'install-extension'
| 'add'
| 'detail'
| 'remove-confirm';
type SourceDetailAction = 'browse' | 'update' | 'remove';
// Flat, navigable entries shown on the Marketplaces tab list. Installed
// extensions are not listed here — they live on the Installed tab.
type Entry =
| { kind: 'install-extension' }
| { kind: 'add-marketplace' }
| { kind: 'marketplace'; source: ExtensionSource };
interface SourcesTabProps {
config: Config;
isActive: boolean;
onLockChange: (locked: boolean) => void;
onStatus: (status: StatusMessage | null) => void;
onChanged: () => void;
/** Switch to the Discover tab filtered to the given marketplace. */
onBrowse: (marketplaceName: string) => void;
/** Provide a context-aware footer hint for the list (null = default). */
onFooter: (hint: string | null) => void;
reloadSignal: number;
}
function formatDate(iso?: string): string | null {
if (!iso) return null;
const time = Date.parse(iso);
if (Number.isNaN(time)) return null;
return new Date(time).toLocaleDateString();
}
export const SourcesTab = ({
config,
isActive,
onLockChange,
onStatus,
onChanged,
onBrowse,
onFooter,
reloadSignal,
}: SourcesTabProps) => {
const [sources, setSources] = useState<ExtensionSource[]>([]);
const [extensions, setExtensions] = useState<Extension[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [view, setView] = useState<SourcesView>('list');
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [detailConfig, setDetailConfig] =
useState<ClaudeMarketplaceConfig | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// The marketplace currently being viewed or confirmed.
const [detailSource, setDetailSource] = useState<ExtensionSource | null>(
null,
);
const extensionManager = config.getExtensionManager();
const load = useCallback(async () => {
if (!extensionManager) return;
try {
await extensionManager.refreshCache();
} catch (error) {
debugLogger.error('Failed to refresh extensions:', error);
}
setExtensions(extensionManager.getLoadedExtensions());
setSources(extensionManager.getSources());
}, [extensionManager]);
useEffect(() => {
load();
}, [load, reloadSignal]);
// Entries: two action rows, then the configured marketplaces.
const entries = useMemo<Entry[]>(
() => [
{ kind: 'install-extension' },
{ kind: 'add-marketplace' },
...sources.map((source) => ({ kind: 'marketplace' as const, source })),
],
[sources],
);
// Keep the cursor in range as the list changes.
useEffect(() => {
if (selectedIndex >= entries.length) {
setSelectedIndex(0);
}
}, [entries.length, selectedIndex]);
const selectedEntry = entries[selectedIndex];
// Context-aware footer hint. Mostly list-view only, but the marketplace
// detail surfaces an R-to-retry hint when its load failed.
useEffect(() => {
if (!isActive) {
onFooter(null);
return;
}
if (view === 'detail') {
// R re-fetches in the detail view either way; advertise it in the
// footer (as a retry on failure, a refresh once loaded).
if (detailLoading) {
onFooter(null);
} else if (!detailConfig) {
onFooter(t('Press R to retry · Esc to go back'));
} else {
onFooter(t('Enter to select · R refresh · Esc to go back'));
}
return () => onFooter(null);
}
if (view !== 'list') {
onFooter(null);
return;
}
const kind = selectedEntry?.kind;
if (kind === 'marketplace') {
onFooter(
t('↑↓ navigate · Enter open · d remove marketplace · Esc close'),
);
} else {
onFooter(t('↑↓ navigate · Enter select · Esc close'));
}
return () => onFooter(null);
}, [
isActive,
view,
selectedEntry?.kind,
onFooter,
detailLoading,
detailConfig,
]);
const goToList = useCallback(() => {
setView('list');
setInput('');
setDetailConfig(null);
setDetailSource(null);
onLockChange(false);
}, [onLockChange]);
const submitAdd = useCallback(async () => {
if (!extensionManager || !input.trim()) return;
setBusy(true);
try {
const entry = await extensionManager.addSource(input.trim());
onStatus({
type: 'success',
text: t('Added marketplace "{{name}}".', { name: entry.name }),
});
await load();
onChanged();
goToList();
} catch (error) {
onStatus({
type: 'error',
text: redactUrlCredentials(getErrorMessage(error)),
});
} finally {
setBusy(false);
}
}, [extensionManager, input, onStatus, load, onChanged, goToList]);
const submitInstall = useCallback(async () => {
if (!extensionManager || !input.trim()) return;
setBusy(true);
try {
const metadata = await parseInstallSource(input.trim());
const ext = await extensionManager.installExtension(metadata);
onStatus({
type: 'success',
text: t('Installed extension "{{name}}".', { name: ext.name }),
});
await load();
onChanged();
goToList();
} catch (error) {
onStatus({
type: 'error',
text: redactUrlCredentials(getErrorMessage(error)),
});
} finally {
setBusy(false);
}
}, [extensionManager, input, onStatus, load, onChanged, goToList]);
const openSourceDetail = useCallback(
async (source: ExtensionSource) => {
onStatus(null);
setDetailSource(source);
setView('detail');
onLockChange(true);
setDetailLoading(true);
setDetailConfig(null);
try {
const cfg = await extensionManager?.loadSource(source.source);
setDetailConfig(cfg ?? null);
} catch (error) {
debugLogger.error('Failed to load marketplace detail:', error);
} finally {
setDetailLoading(false);
}
},
[extensionManager, onLockChange, onStatus],
);
// Re-fetch the marketplace config for the currently-open detail. Used by the
// R key so a failed load can be retried without leaving the detail view.
const refetchDetail = useCallback(async () => {
if (!extensionManager || !detailSource) return;
setDetailLoading(true);
setDetailConfig(null);
try {
const cfg = await extensionManager.loadSource(detailSource.source);
setDetailConfig(cfg ?? null);
} catch (error) {
debugLogger.error('Failed to load marketplace detail:', error);
} finally {
setDetailLoading(false);
}
}, [extensionManager, detailSource]);
const removeSource = useCallback(() => {
if (!extensionManager || !detailSource) return;
// removeSource() -> atomicWriteFileSync can throw (EACCES/EROFS/ENOSPC, or
// a Windows lock on marketplaces.json). Unlike the async sibling handlers,
// this runs synchronously inside the keypress broadcast loop, so an
// unguarded throw would tear down the whole TUI session. Degrade to an
// error toast instead.
try {
const removed = extensionManager.removeSource(detailSource.name);
if (removed) {
onStatus({
type: 'success',
text: t('Removed marketplace "{{name}}".', {
name: detailSource.name,
}),
});
void load();
onChanged();
}
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
goToList();
}, [extensionManager, detailSource, onStatus, load, onChanged, goToList]);
const updateSource = useCallback(async () => {
if (!extensionManager || !detailSource) return;
setDetailLoading(true);
try {
const cfg = await extensionManager.loadSource(detailSource.source);
setDetailConfig(cfg ?? null);
// loadSource returns null when the marketplace is unreachable / invalid.
// Only advance the lastUpdated timestamp and report success on a real
// refresh — otherwise a failed update would show "Updated marketplace X".
if (cfg === null) {
onStatus({
type: 'error',
text: t('Could not update marketplace "{{name}}".', {
name: detailSource.name,
}),
});
await load();
return;
}
extensionManager.markSourceUpdated(detailSource.name);
await load();
onChanged();
onStatus({
type: 'success',
text: t('Updated marketplace "{{name}}".', { name: detailSource.name }),
});
} catch (error) {
onStatus({
type: 'error',
text: redactUrlCredentials(getErrorMessage(error)),
});
} finally {
setDetailLoading(false);
}
}, [extensionManager, detailSource, load, onChanged, onStatus]);
const handleSourceDetailAction = useCallback(
(action: SourceDetailAction) => {
if (!detailSource) return;
if (action === 'browse') {
onBrowse(detailSource.name);
} else if (action === 'update') {
void updateSource();
} else if (action === 'remove') {
setView('remove-confirm');
}
},
[detailSource, onBrowse, updateSource],
);
// List keyboard: navigate entries, Enter dispatches by kind, d removes.
useKeypress(
(key) => {
if (entries.length === 0) return;
if (keyMatchers[Command.SELECTION_UP](key)) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : entries.length - 1));
return;
}
if (keyMatchers[Command.SELECTION_DOWN](key)) {
setSelectedIndex((prev) => (prev < entries.length - 1 ? prev + 1 : 0));
return;
}
if (key.name === 'return') {
if (!selectedEntry) return;
onStatus(null);
switch (selectedEntry.kind) {
case 'install-extension':
setView('install-extension');
onLockChange(true);
break;
case 'add-marketplace':
setView('add');
onLockChange(true);
break;
case 'marketplace':
void openSourceDetail(selectedEntry.source);
break;
default:
break;
}
return;
}
if (
(key.sequence === 'd' || key.sequence === 'x') &&
!key.ctrl &&
!key.meta &&
selectedEntry?.kind === 'marketplace'
) {
setDetailSource(selectedEntry.source);
setView('remove-confirm');
onLockChange(true);
}
},
{ isActive: isActive && view === 'list' },
);
// Input views: Escape cancels.
useKeypress(
(key) => {
if (key.name === 'escape' && !busy) {
goToList();
}
},
{
isActive: isActive && (view === 'add' || view === 'install-extension'),
},
);
// Marketplace detail: Escape goes back; R re-fetches (retry on load failure);
// the selector owns Enter.
useKeypress(
(key) => {
if (key.name === 'escape') {
goToList();
} else if (
(key.name === 'r' || key.sequence === 'r') &&
!key.ctrl &&
!key.meta &&
!detailLoading
) {
void refetchDetail();
}
},
{ isActive: isActive && view === 'detail' },
);
// Remove-marketplace confirmation.
useKeypress(
(key) => {
if (key.name === 'return' || key.sequence === 'y') {
removeSource();
} else if (key.name === 'escape' || key.sequence === 'n') {
goToList();
}
},
{ isActive: isActive && view === 'remove-confirm' },
);
if (view === 'install-extension') {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary} bold>
{t('Install Extension')}
</Text>
<Box flexDirection="column">
<Text color={theme.text.primary}>{t('Enter extension source:')}</Text>
<Text color={theme.text.secondary}>{t('Examples:')}</Text>
<Text color={theme.text.secondary}>{' · owner/repo (GitHub)'}</Text>
<Text color={theme.text.secondary}>
{' · git@github.com:owner/repo.git (SSH)'}
</Text>
<Text color={theme.text.secondary}>{' · @scope/name (npm)'}</Text>
<Text color={theme.text.secondary}>{' · ./path/to/extension'}</Text>
</Box>
{busy ? (
<Text color={theme.text.secondary}>{t('Installing...')}</Text>
) : (
<TextInput
value={input}
onChange={setInput}
onSubmit={() => void submitInstall()}
isActive={isActive}
/>
)}
</Box>
);
}
if (view === 'add') {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary} bold>
{t('Add Marketplace')}
</Text>
<Box flexDirection="column">
<Text color={theme.text.primary}>
{t('Enter marketplace source (Claude format):')}
</Text>
<Text color={theme.text.secondary}>{t('Examples:')}</Text>
<Text color={theme.text.secondary}>{' · owner/repo (GitHub)'}</Text>
<Text color={theme.text.secondary}>
{' · git@github.com:owner/repo.git (SSH)'}
</Text>
<Text color={theme.text.secondary}>
{' · https://example.com/marketplace.json'}
</Text>
<Text color={theme.text.secondary}>{' · ./path/to/marketplace'}</Text>
</Box>
{busy ? (
<Text color={theme.text.secondary}>{t('Adding...')}</Text>
) : (
<TextInput
value={input}
onChange={setInput}
onSubmit={() => void submitAdd()}
isActive={isActive}
/>
)}
</Box>
);
}
if (view === 'detail' && detailSource) {
const plugins = detailConfig?.plugins ?? [];
const availableCount = plugins.length;
const installedNames = new Set(extensions.map((ext) => ext.name));
const installedHere = plugins.filter((p) => installedNames.has(p.name));
const lastUpdated = formatDate(
detailSource.lastUpdatedAt ?? detailSource.addedAt,
);
const actions: Array<{
key: string;
label: string;
value: SourceDetailAction;
}> = [
{
key: 'browse',
label: t('Browse extensions ({{count}})', {
count: String(availableCount),
}),
value: 'browse',
},
{
key: 'update',
label: lastUpdated
? t('Update marketplace (last updated {{date}})', {
date: lastUpdated,
})
: t('Update marketplace'),
value: 'update',
},
{ key: 'remove', label: t('Remove marketplace'), value: 'remove' },
];
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{stripUnsafeCharacters(detailSource.name)}
</Text>
<Text color={theme.text.secondary}>
{redactUrlCredentials(detailSource.source)}
</Text>
</Box>
{detailLoading ? (
<Text color={theme.text.secondary}>{t('Loading...')}</Text>
) : detailConfig ? (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary}>
{t('{{count}} available extensions', {
count: String(availableCount),
})}
</Text>
{installedHere.length > 0 ? (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Installed extensions ({{count}}):', {
count: String(installedHere.length),
})}
</Text>
{installedHere.slice(0, INSTALLED_PREVIEW_LIMIT).map((p) => (
<Box key={p.name}>
<Box minWidth={2} flexShrink={0}>
<Text color={theme.status.success}>{'●'}</Text>
</Box>
<Text color={theme.text.primary}>
{stripUnsafeCharacters(p.name)}
</Text>
</Box>
))}
{installedHere.length > INSTALLED_PREVIEW_LIMIT ? (
<Text color={theme.text.secondary}>
{t('... and {{count}} more', {
count: String(
installedHere.length - INSTALLED_PREVIEW_LIMIT,
),
})}
</Text>
) : null}
</Box>
) : null}
<RadioButtonSelect
items={actions}
isFocused={isActive}
showNumbers={false}
onSelect={handleSourceDetailAction}
/>
</Box>
) : (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>
{t('Could not load this marketplace.')}
</Text>
<Text color={theme.text.secondary}>
{t('Press R to retry · Esc to go back')}
</Text>
<RadioButtonSelect
items={[
{
key: 'remove',
label: t('Remove marketplace'),
value: 'remove' as SourceDetailAction,
},
]}
isFocused={isActive}
showNumbers={false}
onSelect={handleSourceDetailAction}
/>
</Box>
)}
</Box>
);
}
if (view === 'remove-confirm') {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.warning}>
{t('Remove marketplace "{{name}}"?', {
name: stripUnsafeCharacters(detailSource?.name ?? ''),
})}
</Text>
<Text color={theme.text.secondary}>
{t('Y/Enter to confirm · N/Esc to cancel')}
</Text>
</Box>
);
}
// List view.
const renderRow = (
index: number,
label: string,
rightText?: string,
isAction = false,
) => {
const isSelected = index === selectedIndex;
const labelColor = isSelected
? theme.text.accent
: isAction
? theme.text.link
: theme.text.primary;
return (
<Box key={`row-${index}`}>
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Box flexGrow={1}>
<Text color={labelColor}>{label}</Text>
</Box>
{rightText ? (
<Text color={theme.text.secondary}>{rightText}</Text>
) : null}
</Box>
);
};
const sourcesStart = 2;
return (
<Box flexDirection="column">
<Box flexDirection="column">
<Text color={theme.text.accent} bold>
{t('Add new')}
</Text>
{renderRow(0, t('+ Install a new extension'), undefined, true)}
{renderRow(
1,
t('+ Add new marketplace'),
t('Claude plugin marketplace'),
true,
)}
</Box>
{sources.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.accent} bold>
{t('Marketplaces')} ({sources.length})
</Text>
{sources.map((source, j) =>
renderRow(
sourcesStart + j,
// Persisted marketplace name is stored raw from untrusted config;
// scrub it at the render site (also defends already-persisted
// entries) like the detail header does.
stripUnsafeCharacters(source.name),
`${redactUrlCredentials(source.source)} (${source.type})`,
),
)}
</Box>
) : (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('No marketplaces added yet.')}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -4,7 +4,71 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Extension, Config } from '@qwen-code/qwen-code-core';
import type {
Extension,
Config,
ExtensionScope,
} from '@qwen-code/qwen-code-core';
/**
* Top-level tabs of the extensions manager dialog, aligned with the Claude Code
* `/plugin` command. The Errors tab is intentionally deferred per the spec.
*/
export const EXTENSIONS_TABS = {
DISCOVER: 'discover',
INSTALLED: 'installed',
SOURCES: 'sources',
} as const;
export type ExtensionsTab =
(typeof EXTENSIONS_TABS)[keyof typeof EXTENSIONS_TABS];
export interface ExtensionsTabDef {
id: ExtensionsTab;
label: string;
}
/**
* Scope groups used to organize the Installed tab.
*/
export type InstalledGroup = 'favorites' | 'user' | 'project' | 'disabled';
/** Minimal display info for an MCP server shown in the Installed tab. */
export interface InstalledMcpInfo {
name: string;
status: string;
scope: 'user' | 'project' | 'extension';
isDisabled: boolean;
transport: string;
toolCount: number;
/** The server failed to connect because it needs (re-)authentication. */
requiresAuth: boolean;
}
/** A single row in the Installed tab — either a plugin/extension or an MCP. */
export type InstalledItem =
| {
kind: 'plugin';
key: string;
name: string;
extension: Extension;
isActive: boolean;
isFavorite: boolean;
scope: ExtensionScope;
group: InstalledGroup;
}
| {
kind: 'mcp';
key: string;
name: string;
mcp: InstalledMcpInfo;
isActive: boolean;
isFavorite: boolean;
group: InstalledGroup;
/** Set when the MCP server is bundled with an extension; the row is
* rendered indented under that extension. */
parentExtension?: string;
};
/**
* Management steps for the extensions manager dialog.
@ -86,4 +150,5 @@ export type ExtensionAction =
export interface ExtensionsManagerDialogProps {
onClose: () => void;
config: Config | null;
initialTab?: ExtensionsTab;
}

View file

@ -0,0 +1,368 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import {
type Config,
type Extension,
type ExtensionScope,
SettingScope,
checkForExtensionUpdate,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../../../utils/errors.js';
import { ExtensionUpdateState } from '../../../state/extensions.js';
import {
PluginDetailView,
type PluginDetailAction,
} from './PluginDetailView.js';
import { UninstallConfirmStep } from '../steps/UninstallConfirmStep.js';
import type { StatusMessage } from '../ExtensionsManagerDialog.js';
type SubView = 'detail' | 'scope-select' | 'uninstall-confirm';
interface ExtensionActionsViewProps {
config: Config;
/** The extension to manage. A fresh mount is expected per detail open. */
extension: Extension;
isActive: boolean;
/** Current update state for this extension, if known. */
updateState?: string;
/** Whether to offer the favorite toggle (hidden in the Sources tab). */
showFavorite?: boolean;
onStatus: (status: StatusMessage | null) => void;
/** Ask the parent list to reload (state changed). */
onReload: () => void;
/** Leave the detail and return to the list. */
onExit: () => void;
}
const SCOPE_LABEL: Record<ExtensionScope, string> = {
user: 'User',
project: 'Project',
};
function scopeItems(): Array<{
key: string;
label: string;
value: ExtensionScope;
}> {
return [
{ key: 'user', label: t('Global (User Scope)'), value: 'user' },
{
key: 'project',
label: t('Project (Workspace)'),
value: 'project',
},
];
}
export const ExtensionActionsView = ({
config,
extension,
isActive,
updateState,
showFavorite = true,
onStatus,
onReload,
onExit,
}: ExtensionActionsViewProps) => {
const manager = config.getExtensionManager();
const [sub, setSub] = useState<SubView>('detail');
// Authoritative local state. Initialised once on mount and updated
// optimistically after each action — we do NOT read enablement back through
// the manager's cache, which is briefly empty during refreshCache().
const [enabled, setEnabled] = useState(extension.isActive);
const [isFavorite, setIsFavorite] = useState(
() => manager?.isFavorite(extension.name) ?? false,
);
const [scope, setScope] = useState<ExtensionScope>(
() => manager?.getExtensionScope(extension.name) ?? 'user',
);
// Changing scope re-writes enablement settings and can take a moment;
// surfaced as a loading line so the selection doesn't look ignored.
const [scopeBusy, setScopeBusy] = useState(false);
// Uninstall removes files (and can take a moment); surfaced as a loading
// line so the confirm prompt doesn't appear frozen after pressing Enter.
const [uninstallBusy, setUninstallBusy] = useState(false);
// Result of an in-view "check for updates" (Mark for Update), which takes
// precedence over the background-checked state passed in via props so the
// "Update Now" action appears immediately after a positive check.
const [checkedUpdateState, setCheckedUpdateState] = useState<
string | undefined
>(undefined);
const hasUpdate =
(checkedUpdateState ?? updateState) ===
ExtensionUpdateState.UPDATE_AVAILABLE;
const settingScopeFor = (s: ExtensionScope) =>
s === 'user' ? SettingScope.User : SettingScope.Workspace;
const handleAction = useCallback(
async (action: PluginDetailAction) => {
if (!manager) return;
const name = extension.name;
try {
switch (action) {
case 'toggle':
if (enabled) {
await manager.disableExtension(name, settingScopeFor(scope));
} else {
await manager.enableExtension(name, settingScopeFor(scope));
}
setEnabled(!enabled);
onStatus({
type: 'success',
text: t('"{{name}}" {{state}}.', {
name,
state: enabled ? t('disabled') : t('enabled'),
}),
});
onReload();
break;
case 'favorite': {
const now = manager.toggleFavorite(name);
setIsFavorite(now);
onStatus({
type: 'info',
text: now
? t('Added "{{name}}" to favorites.', { name })
: t('Removed "{{name}}" from favorites.', { name }),
});
onReload();
break;
}
case 'change-scope':
setSub('scope-select');
break;
case 'mark-update': {
// Check only the selected extension (not every installed one), and
// surface the result so "Update Now" can appear right away. Git /
// GitHub-release / npm checks hit the network, so show a pending
// line first; other types resolve instantly.
onStatus({
type: 'info',
text: t('Checking "{{name}}" for updates...', { name }),
});
const checked = (await checkForExtensionUpdate(
extension,
manager,
)) as string;
setCheckedUpdateState(checked);
if (checked === ExtensionUpdateState.UPDATE_AVAILABLE) {
onStatus({
type: 'info',
text: t('Update available for "{{name}}".', { name }),
});
} else if (checked === ExtensionUpdateState.ERROR) {
onStatus({
type: 'error',
text: t('Failed to check "{{name}}" for updates.', { name }),
});
} else if (checked === ExtensionUpdateState.NOT_UPDATABLE) {
onStatus({
type: 'info',
// Claude marketplace plugins are install-time conversions with
// no git remote, so there's nothing to diff against — spell out
// the reason and the workaround.
text:
extension.installMetadata?.originSource === 'Claude'
? t(
'"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).',
{ name },
)
: t('"{{name}}" does not support update checks.', { name }),
});
} else {
onStatus({
type: 'info',
text: t('"{{name}}" is already up to date.', { name }),
});
}
break;
}
case 'update':
await manager.updateExtension(
extension,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
onStatus({
type: 'success',
text: t('Updated "{{name}}".', { name }),
});
onReload();
break;
case 'uninstall':
setSub('uninstall-confirm');
break;
default:
break;
}
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
},
[manager, extension, enabled, scope, onStatus, onReload],
);
const handleScope = useCallback(
async (newScope: ExtensionScope) => {
if (!manager) return;
const name = extension.name;
setScopeBusy(true);
try {
// Apply enablement first: Global -> User; Project/Local -> workspace
// only. Record the scope preference only once enablement succeeds so a
// failed enable can't leave the prefs pointing at a scope the extension
// isn't actually enabled at.
if (newScope === 'user') {
await manager.enableExtension(name, SettingScope.User);
} else {
await manager.disableExtension(name, SettingScope.User);
try {
await manager.enableExtension(name, SettingScope.Workspace);
} catch (enableError) {
// The User-scope disable already landed; if the Workspace enable
// fails the extension would be disabled everywhere. Roll the User
// enable back so it isn't silently dead.
try {
await manager.enableExtension(name, SettingScope.User);
} catch (rollbackError) {
// Rollback also failed: the extension is now disabled at every
// scope. Surface that explicitly — the bare enable error wouldn't
// tell the user the extension is dead and needs manual recovery.
throw new Error(
t(
'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})',
{ name, error: getErrorMessage(rollbackError) },
),
);
}
throw enableError;
}
}
manager.setExtensionScope(name, newScope);
setScope(newScope);
setEnabled(true);
onStatus({
type: 'success',
text: t('Set "{{name}}" scope to {{scope}}.', {
name,
scope: t(SCOPE_LABEL[newScope]),
}),
});
onReload();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
setScopeBusy(false);
setSub('detail');
},
[manager, extension, onStatus, onReload],
);
const handleUninstall = useCallback(
async (ext: Extension) => {
if (!manager) return;
setUninstallBusy(true);
try {
await manager.uninstallExtension(ext.name, false);
onStatus({
type: 'success',
text: t('Uninstalled "{{name}}".', { name: ext.name }),
});
onReload();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
} finally {
setUninstallBusy(false);
}
onExit();
},
[manager, onStatus, onReload, onExit],
);
// Escape: from the detail leaves; from a sub-view returns to the detail.
// Ignored while a scope change is in flight so it can't be abandoned midway.
useKeypress(
(key) => {
if (key.name === 'escape' && !scopeBusy) {
if (sub === 'detail') onExit();
else setSub('detail');
}
},
{ isActive: isActive && sub !== 'uninstall-confirm' },
);
if (sub === 'scope-select') {
const items = scopeItems();
// Default the cursor to the extension's current scope so the user can see
// what is in effect (and that a prior change took hold).
const currentIndex = Math.max(
0,
items.findIndex((item) => item.value === scope),
);
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text color={theme.text.primary}>
{t('Change scope for "{{name}}":', { name: extension.name })}
</Text>
<Text color={theme.text.secondary}>
{t('Current: {{scope}}', { scope: items[currentIndex].label })}
</Text>
</Box>
{scopeBusy ? (
<Text color={theme.text.secondary}>{t('Changing scope...')}</Text>
) : (
<RadioButtonSelect
items={items}
initialIndex={currentIndex}
isFocused={isActive}
showNumbers={false}
onSelect={(value) => void handleScope(value)}
/>
)}
</Box>
);
}
if (sub === 'uninstall-confirm') {
if (uninstallBusy) {
return (
<Text color={theme.text.secondary}>
{t('Uninstalling "{{name}}"...', { name: extension.name })}
</Text>
);
}
return (
<UninstallConfirmStep
selectedExtension={extension}
isActive={isActive}
onConfirm={handleUninstall}
onNavigateBack={() => setSub('detail')}
/>
);
}
return (
<PluginDetailView
extension={{ ...extension, isActive: enabled }}
scope={t(SCOPE_LABEL[scope])}
isFavorite={isFavorite}
showFavorite={showFavorite}
hasUpdateAvailable={hasUpdate}
isFocused={isActive && sub === 'detail'}
onAction={handleAction}
/>
);
};

View file

@ -0,0 +1,378 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect } from 'react';
import { Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { t } from '../../../../i18n/index.js';
import {
type Config,
getMCPServerStatus,
removeMCPServerStatus,
addMCPStatusChangeListener,
removeMCPStatusChangeListener,
mcpServerRequiresOAuth,
MCPServerStatus,
DiscoveredMCPTool,
MCPOAuthTokenStorage,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../../../config/settings.js';
import { getErrorMessage } from '../../../../utils/errors.js';
import { ServerDetailStep } from '../../mcp/steps/ServerDetailStep.js';
import { ToolListStep } from '../../mcp/steps/ToolListStep.js';
import { ToolDetailStep } from '../../mcp/steps/ToolDetailStep.js';
import { AuthenticateStep } from '../../mcp/steps/AuthenticateStep.js';
import { isToolValid, getToolInvalidReasons } from '../../mcp/utils.js';
import type {
MCPServerDisplayInfo,
MCPToolDisplayInfo,
} from '../../mcp/types.js';
import type { StatusMessage } from '../ExtensionsManagerDialog.js';
const debugLogger = createDebugLogger('EXT_MCP_DETAIL');
type SubView = 'detail' | 'tools' | 'tool-detail' | 'authenticate';
interface McpServerActionsViewProps {
config: Config;
/** Name of the installed MCP server to manage. */
serverName: string;
/** Whether this view should respond to keyboard input. */
isActive: boolean;
onStatus: (status: StatusMessage | null) => void;
/** Ask the parent list to reload (state changed). */
onReload: () => void;
/** Leave the detail and return to the list. */
onExit: () => void;
}
/**
* MCP server detail + actions inside the extensions manager, reusing the
* `/mcp` dialog's ServerDetailStep / ToolListStep / AuthenticateStep so the
* behaviour (live status, view tools, enable/disable, re-authenticate, clear
* auth) stays identical to `/mcp`.
*/
export const McpServerActionsView = ({
config,
serverName,
isActive,
onStatus,
onReload,
onExit,
}: McpServerActionsViewProps) => {
const [sub, setSub] = useState<SubView>('detail');
const [server, setServer] = useState<MCPServerDisplayInfo | null>(null);
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
null,
);
const [loading, setLoading] = useState(true);
const buildServer =
useCallback(async (): Promise<MCPServerDisplayInfo | null> => {
const mcpServers = config.getMcpServers() || {};
const serverConfig = mcpServers[serverName];
if (!serverConfig) return null;
const settings = loadSettings();
const userSettings = settings.forScope(SettingScope.User).settings;
const workspaceSettings = settings.forScope(
SettingScope.Workspace,
).settings;
const status = getMCPServerStatus(serverName);
const toolRegistry = config.getToolRegistry();
const serverTools = (toolRegistry?.getAllTools() || []).filter(
(tool): tool is DiscoveredMCPTool =>
tool instanceof DiscoveredMCPTool && tool.serverName === serverName,
);
const promptRegistry = config.getPromptRegistry();
const serverPrompts = (promptRegistry?.getAllPrompts() || []).filter(
(p) => 'serverName' in p && p.serverName === serverName,
);
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
} else if (workspaceSettings.mcpServers?.[serverName]) {
source = 'project';
} else if (userSettings.mcpServers?.[serverName]) {
source = 'user';
}
let hasOAuthTokens = false;
try {
const credentials = await new MCPOAuthTokenStorage().getCredentials(
serverName,
);
hasOAuthTokens = credentials !== null;
} catch (error) {
// A broken credential store (e.g. missing libsecret, locked keychain)
// leaves hasOAuthTokens false, which can mislabel an authenticated
// server as needing auth — log it so that's diagnosable.
debugLogger.warn('OAuth token lookup failed for', serverName, error);
}
return {
name: serverName,
status,
source,
config: serverConfig,
toolCount: serverTools.length,
invalidToolCount: serverTools.filter((x) => !x.name || !x.description)
.length,
promptCount: serverPrompts.length,
isDisabled: config.isMcpServerDisabled(serverName),
hasOAuthTokens,
// Needs (re-)authentication: a 401 during connect, or OAuth declared
// with no stored token. Only meaningful while not connected.
requiresAuth:
status !== MCPServerStatus.CONNECTED &&
(mcpServerRequiresOAuth.get(serverName) === true ||
(Boolean(serverConfig.oauth?.enabled) && !hasOAuthTokens)),
};
}, [config, serverName]);
// Re-stamp status (and the status-derived needs-auth flag) synchronously
// right before setState: a status change landing during buildServer's
// awaits would fire the listener against the old state and then be
// overwritten by the stale snapshot.
const freshen = useCallback(
(info: MCPServerDisplayInfo | null): MCPServerDisplayInfo | null => {
if (!info) return info;
const status = getMCPServerStatus(info.name);
return {
...info,
status,
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(info.name) === true ||
info.requiresAuth,
};
},
[],
);
const reload = useCallback(async () => {
setLoading(true);
try {
setServer(freshen(await buildServer()));
} catch (error) {
debugLogger.error('Failed to load MCP server:', error);
} finally {
setLoading(false);
}
onReload();
}, [buildServer, freshen, onReload]);
useEffect(() => {
let cancelled = false;
void (async () => {
const info = await buildServer();
if (!cancelled) {
setServer(freshen(info));
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [buildServer, freshen]);
// Live-update the connection status shown in the detail view.
useEffect(() => {
const listener = (name: string, status?: MCPServerStatus) => {
if (name !== serverName || status === undefined) return;
setServer((prev) =>
prev
? {
...prev,
status,
// Keep needs-auth in step with the live status (the 401 marker
// is written before the DISCONNECTED event fires).
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(name) === true ||
prev.requiresAuth,
}
: prev,
);
};
addMCPStatusChangeListener(listener);
return () => removeMCPStatusChangeListener(listener);
}, [serverName]);
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) return [];
const out: MCPToolDisplayInfo[] = [];
for (const tool of toolRegistry.getAllTools()) {
if (
!(tool instanceof DiscoveredMCPTool) ||
tool.serverName !== serverName
)
continue;
const valid = isToolValid(tool.name, tool.description);
out.push({
name: tool.name || t('(unnamed)'),
description: tool.description,
serverName: tool.serverName,
schema: tool.parameterSchema as object | undefined,
annotations: tool.annotations,
isValid: valid,
invalidReason: valid
? undefined
: getToolInvalidReasons(tool.name, tool.description).join(', '),
});
}
return out;
}, [config, serverName]);
const handleReconnect = useCallback(async () => {
try {
await config.getToolRegistry()?.discoverToolsForServer(serverName);
await reload();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
}, [config, serverName, reload, onStatus]);
const handleToggleDisable = useCallback(async () => {
if (!server) return;
const toolRegistry = config.getToolRegistry();
try {
if (server.isDisabled) {
// Enable: clear the extension-scoped disable flag (if any), drop from
// both exclusion lists + runtime, then rediscover.
const extensionName = server.config.extensionName;
if (extensionName) {
config
.getExtensionManager()
?.setMcpServerDisabled(extensionName, serverName, false);
}
const settings = loadSettings();
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const excluded =
settings.forScope(scope).settings.mcp?.excluded || [];
if (excluded.includes(serverName)) {
settings.setValue(
scope,
'mcp.excluded',
excluded.filter((n: string) => n !== serverName),
);
}
}
const runtimeExcluded = config.getExcludedMcpServers() || [];
config.setExcludedMcpServers(
runtimeExcluded.filter((n) => n !== serverName),
);
await toolRegistry?.discoverToolsForServer(serverName);
} else if (server.source === 'extension') {
// Disable via the extension-scoped preference so the global
// mcp.excluded list (and same-named servers elsewhere) are untouched.
const extensionName = server.config.extensionName;
const manager = config.getExtensionManager();
if (!extensionName || !manager) {
onStatus({
type: 'info',
text: t('Cannot disable an extension-provided MCP server here.'),
});
return;
}
manager.setMcpServerDisabled(extensionName, serverName, true);
await toolRegistry?.disconnectServer(serverName);
// Drop the status entry so the footer health pill doesn't keep
// counting an intentionally disabled server as offline.
removeMCPServerStatus(serverName);
} else {
const scope =
server.source === 'project'
? SettingScope.Workspace
: SettingScope.User;
const settings = loadSettings();
const excluded = settings.forScope(scope).settings.mcp?.excluded || [];
if (!excluded.includes(serverName)) {
settings.setValue(scope, 'mcp.excluded', [...excluded, serverName]);
}
await toolRegistry?.disableMcpServer(serverName);
}
await reload();
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
}, [server, config, serverName, reload, onStatus]);
const handleClearAuth = useCallback(async () => {
try {
await new MCPOAuthTokenStorage().deleteCredentials(serverName);
await config.getToolRegistry()?.disconnectServer(serverName);
await reload();
onStatus({
type: 'success',
text: t('Cleared authentication for "{{name}}".', { name: serverName }),
});
} catch (error) {
onStatus({ type: 'error', text: getErrorMessage(error) });
}
}, [config, serverName, reload, onStatus]);
if (loading && !server) {
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
}
if (sub === 'authenticate') {
return (
<AuthenticateStep
server={server}
isActive={isActive}
onBack={() => {
setSub('detail');
void reload();
}}
/>
);
}
if (sub === 'tool-detail') {
return (
<ToolDetailStep
tool={selectedTool}
isActive={isActive}
onBack={() => setSub('tools')}
/>
);
}
if (sub === 'tools') {
return (
<ToolListStep
tools={getServerTools()}
serverName={serverName}
isActive={isActive}
onSelect={(tool) => {
setSelectedTool(tool);
setSub('tool-detail');
}}
onBack={() => setSub('detail')}
/>
);
}
return (
<ServerDetailStep
server={server}
isActive={isActive}
onViewTools={() => setSub('tools')}
onReconnect={() => void handleReconnect()}
onDisable={() => void handleToggleDisable()}
onAuthenticate={() => setSub('authenticate')}
onClearAuth={() => void handleClearAuth()}
onBack={onExit}
/>
);
};

View file

@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import {
redactUrlCredentials,
type Extension,
} from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
import { stripUnsafeCharacters } from '../../../utils/textUtils.js';
export type PluginDetailAction =
| 'toggle'
| 'favorite'
| 'change-scope'
| 'mark-update'
| 'update'
| 'uninstall';
interface PluginDetailViewProps {
extension: Extension;
scope: string;
isFavorite: boolean;
hasUpdateAvailable: boolean;
isFocused: boolean;
/** Whether to offer the favorite toggle (hidden in the Sources tab). */
showFavorite?: boolean;
onAction: (action: PluginDetailAction) => void;
}
const LABEL_WIDTH = 14;
const InfoRow = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{label}</Text>
</Box>
<Box flexGrow={1}>
<Text>{children}</Text>
</Box>
</Box>
);
function componentSummary(ext: Extension): string {
const parts: string[] = [];
const mcpCount = ext.mcpServers ? Object.keys(ext.mcpServers).length : 0;
if (mcpCount) parts.push(t('{{count}} MCP', { count: String(mcpCount) }));
if (ext.skills?.length)
parts.push(t('{{count}} Skills', { count: String(ext.skills.length) }));
if (ext.commands?.length)
parts.push(t('{{count}} Commands', { count: String(ext.commands.length) }));
if (ext.agents?.length)
parts.push(t('{{count}} Agents', { count: String(ext.agents.length) }));
return parts.length ? parts.join(' · ') : t('None');
}
export const PluginDetailView = ({
extension,
scope,
isFavorite,
hasUpdateAvailable,
isFocused,
showFavorite = true,
onAction,
}: PluginDetailViewProps) => {
const ext = extension;
const isActive = ext.isActive;
const actions = useMemo(() => {
const items: Array<{
key: string;
label: string;
value: PluginDetailAction;
}> = [
{
key: 'toggle',
label: isActive ? t('Disable') : t('Enable'),
value: 'toggle',
},
...(showFavorite
? [
{
key: 'favorite',
label: isFavorite
? t('Remove from Favorites')
: t('Add to Favorites'),
value: 'favorite' as const,
},
]
: []),
{
key: 'change-scope',
label: t('Change scope'),
value: 'change-scope',
},
{
key: 'mark-update',
label: t('Mark for Update'),
value: 'mark-update',
},
...(hasUpdateAvailable
? [{ key: 'update', label: t('Update Now'), value: 'update' as const }]
: []),
{
key: 'uninstall',
label: t('Uninstall'),
value: 'uninstall',
},
];
return items;
}, [isActive, isFavorite, hasUpdateAvailable, showFavorite]);
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<InfoRow label={t('Name:')}>{ext.name}</InfoRow>
<InfoRow label={t('Version:')}>
{stripUnsafeCharacters(ext.version ?? '')}
</InfoRow>
<InfoRow label={t('Scope:')}>{scope}</InfoRow>
<InfoRow label={t('Status:')}>
<Text color={isActive ? theme.status.success : theme.text.secondary}>
{isActive ? t('active') : t('disabled')}
</Text>
{isFavorite ? <Text color={theme.status.warning}> </Text> : null}
</InfoRow>
{ext.installMetadata && (
<InfoRow label={t('Source:')}>
{redactUrlCredentials(ext.installMetadata.source)}
</InfoRow>
)}
<InfoRow label={t('Components:')}>{componentSummary(ext)}</InfoRow>
</Box>
<Box flexDirection="column">
<Text color={theme.text.secondary}>{t('Actions')}</Text>
<RadioButtonSelect
items={actions}
isFocused={isFocused}
showNumbers={false}
onSelect={onAction}
/>
</Box>
</Box>
);
};

View file

@ -24,6 +24,11 @@ import { AuthenticateStep } from './steps/AuthenticateStep.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
removeMCPServerStatus,
addMCPStatusChangeListener,
removeMCPStatusChangeListener,
mcpServerRequiresOAuth,
MCPServerStatus,
DiscoveredMCPTool,
MCPOAuthTokenStorage,
type MCPServerConfig,
@ -115,6 +120,13 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
// Ignore errors when checking token existence
}
// Needs (re-)authentication: a 401 during connect, or OAuth declared
// with no stored token. Only meaningful while not connected.
const requiresAuth =
status !== MCPServerStatus.CONNECTED &&
(mcpServerRequiresOAuth.get(name) === true ||
(Boolean(serverConfig.oauth?.enabled) && !hasOAuthTokens));
serverInfos.push({
name,
status,
@ -125,19 +137,37 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
promptCount: serverPrompts.length,
isDisabled,
hasOAuthTokens,
requiresAuth,
});
}
return serverInfos;
}, [config]);
// Synchronously refresh status + needs-auth on a fetched snapshot.
const restampStatus = useCallback((s: MCPServerDisplayInfo) => {
const status = getMCPServerStatus(s.name);
return {
...s,
status,
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(s.name) === true || s.requiresAuth,
};
}, []);
// Load MCP server data on initial render
useEffect(() => {
const loadServers = async () => {
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
// Re-stamp statuses (and the status-derived needs-auth flag)
// synchronously right before setState: a status change landing
// during fetch's awaits would fire the listener against the OLD
// state and then be overwritten by this snapshot.
setServers(serverInfos.map(restampStatus));
} catch (error) {
debugLogger.error('Error loading MCP servers:', error);
} finally {
@ -146,7 +176,35 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
};
loadServers();
}, [fetchServerData]);
}, [fetchServerData, restampStatus]);
// Live-update server rows (and the derived detail view) when a connection
// status changes, e.g. a "connecting" server finishing.
useEffect(() => {
const listener = (serverName: string, status?: MCPServerStatus) => {
if (status === undefined) return; // removals are handled by reloads
setServers((prev) =>
prev.map((s) =>
s.name === serverName
? {
...s,
status,
// Keep needs-auth in step with the live status: a connect
// proves auth works; a failure may have just set the 401
// marker (it is written before the DISCONNECTED event).
requiresAuth:
status === MCPServerStatus.CONNECTED
? false
: mcpServerRequiresOAuth.get(serverName) === true ||
s.requiresAuth,
}
: s,
),
);
};
addMCPStatusChangeListener(listener);
return () => removeMCPStatusChangeListener(listener);
}, []);
// Selected server
const selectedServer = useMemo(() => {
@ -248,13 +306,14 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
// Same synchronous re-stamp as the initial load (see comment there).
setServers(serverInfos.map(restampStatus));
} catch (error) {
debugLogger.error('Error reloading MCP servers:', error);
} finally {
setIsLoading(false);
}
}, [fetchServerData]);
}, [fetchServerData, restampStatus]);
// Clear OAuth authentication tokens and disconnect the server
const handleClearAuth = useCallback(async () => {
@ -318,6 +377,14 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
const server = selectedServer;
const settings = loadSettings();
// Clear the extension-scoped disable flag, if any.
const extensionName = server.config.extensionName;
if (extensionName) {
config
.getExtensionManager()
?.setMcpServerDisabled(extensionName, server.name, false);
}
// Remove from user and workspace exclusion lists
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
@ -371,17 +438,31 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
const server = selectedServer;
const settings = loadSettings();
// Determine the scope based on server configuration location
let targetScope: 'user' | 'workspace' = 'user';
// Extension servers are disabled via the extension-scoped preference
// instead of user/workspace mcp.excluded settings.
if (server.source === 'extension') {
// Extension servers should not be disabled through user/workspace settings
// Show error message and return
debugLogger.warn(
`Cannot disable extension MCP server '${server.name}'`,
);
const extensionName = server.config.extensionName;
const manager = config.getExtensionManager();
if (!extensionName || !manager) {
debugLogger.warn(
`Cannot disable extension MCP server '${server.name}'`,
);
setIsLoading(false);
return;
}
manager.setMcpServerDisabled(extensionName, server.name, true);
await config.getToolRegistry()?.disconnectServer(server.name);
// Drop the status entry so the footer health pill doesn't keep
// counting an intentionally disabled server as offline.
removeMCPServerStatus(server.name);
await reloadServers();
setIsLoading(false);
return;
} else if (server.source === 'project') {
}
// Determine the scope based on server configuration location
let targetScope: 'user' | 'workspace' = 'user';
if (server.source === 'project') {
targetScope = 'workspace';
}

View file

@ -68,6 +68,7 @@ function copyToClipboardViaOsc52(text: string): boolean {
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
server,
onBack,
isActive = true,
}) => {
const config = useConfig();
const [authState, setAuthState] = useState<AuthState>('idle');
@ -213,7 +214,7 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
});
}
},
{ isActive: true },
{ isActive },
);
useEffect(() => {

View file

@ -10,6 +10,7 @@ import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
import type { ServerDetailStepProps } from '../types.js';
import {
getStatusColor,
@ -35,9 +36,17 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
onAuthenticate,
onClearAuth,
onBack,
isActive = true,
}) => {
// 未连接且需要认证时,状态以"需要认证"展示,避免误导用户去排查连接问题。
// requiresAuth 是加载时的快照,状态被实时推到 connected 后不再适用。
const needsAuth =
!!server &&
!server.isDisabled &&
!!server.requiresAuth &&
server.status !== MCPServerStatus.CONNECTED;
const statusColor = server
? server.isDisabled
? server.isDisabled || needsAuth
? 'yellow'
: getStatusColor(server.status)
: 'gray';
@ -72,10 +81,10 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
});
}
// 始终显示启用/禁用选项
// 始终显示启用/禁用选项(扩展提供的服务器走扩展级禁用记录)
result.push({
key: 'toggle-disable',
label: server?.isDisabled ? t('Enable') : t('Disable'),
label: server.isDisabled ? t('Enable') : t('Disable'),
value: 'toggle-disable',
});
@ -106,7 +115,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
onBack();
}
},
{ isActive: true },
{ isActive },
);
if (!server) {
@ -136,7 +145,11 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
}
>
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : t(server.status)}
{server.isDisabled
? t('disabled')
: needsAuth
? t('needs authentication')
: t(server.status)}
</Text>
</Box>
</Box>
@ -222,6 +235,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
isFocused={isActive}
showNumbers={false}
onSelect={(value: ServerAction) => {
switch (value) {

View file

@ -10,6 +10,7 @@ import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../../keyMatchers.js';
import { t } from '../../../../i18n/index.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
import {
groupServersBySource,
@ -108,9 +109,16 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = server.isDisabled
? 'yellow'
: getStatusColor(server.status);
// 未连接且需要认证时,状态以"需要认证"展示requiresAuth 是
// 加载时的快照,状态被实时推到 connected 后不再适用)
const needsAuth =
!server.isDisabled &&
!!server.requiresAuth &&
server.status !== MCPServerStatus.CONNECTED;
const statusColor =
server.isDisabled || needsAuth
? 'yellow'
: getStatusColor(server.status);
return (
<Box key={server.name}>
@ -146,7 +154,11 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
}
>
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : t(server.status)}
{server.isDisabled
? t('disabled')
: needsAuth
? t('needs authentication')
: t(server.status)}
</Text>
{/* 显示无效工具警告 */}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (

View file

@ -81,6 +81,7 @@ const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
tool,
onBack,
isActive = true,
}) => {
useKeypress(
(key) => {
@ -88,7 +89,7 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
onBack();
}
},
{ isActive: true },
{ isActive },
);
if (!tool) {

View file

@ -17,6 +17,7 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,
onSelect,
onBack,
isActive = true,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
@ -63,7 +64,7 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
}
}
},
{ isActive: true },
{ isActive },
);
if (tools.length === 0) {

View file

@ -50,6 +50,8 @@ export interface MCPServerDisplayInfo {
isDisabled: boolean;
/** 是否存储有 OAuth 认证信息 */
hasOAuthTokens?: boolean;
/** 未连接且需要(重新)认证:连接时收到 401或声明了 OAuth 但无已存 token */
requiresAuth?: boolean;
}
/**
@ -138,6 +140,8 @@ export interface ServerDetailStepProps {
onClearAuth?: () => void;
/** 返回回调 */
onBack: () => void;
/** 是否响应键盘输入(默认 true */
isActive?: boolean;
}
/**
@ -164,6 +168,8 @@ export interface ToolListStepProps {
onSelect: (tool: MCPToolDisplayInfo) => void;
/** 返回回调 */
onBack: () => void;
/** 是否响应键盘输入(默认 true */
isActive?: boolean;
}
/**
@ -174,6 +180,8 @@ export interface ToolDetailStepProps {
tool: MCPToolDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
/** 是否响应键盘输入(默认 true */
isActive?: boolean;
}
/**
@ -184,6 +192,8 @@ export interface AuthenticateStepProps {
server: MCPServerDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
/** 是否响应键盘输入(默认 true */
isActive?: boolean;
}
/**

View file

@ -29,6 +29,7 @@ import type {
} from './agent-events.js';
import { ToolNames } from '../../tools/tool-names.js';
import { createConcurrencyLimiter } from '../../utils/concurrencyLimiter.js';
import { stripAnsiAndControl } from '../../utils/textUtils.js';
import type { SubagentConfig } from '../../subagents/types.js';
import {
GitWorktreeService,
@ -274,8 +275,11 @@ function generateRunId(): string {
* `runOverridePath` for `opts.agentType`.
*/
function sanitizeForErrorMessage(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001f\u007f]+/g, ' ');
// Shared with the extension converters via stripAnsiAndControl: strips ANSI/VT
// escape sequences and removes C0/C1 control chars (incl. the C1 range the old
// local regex missed). Removing rather than spacing still keeps the error
// single-line, which is the original intent here.
return stripAnsiAndControl(value);
}
/**

View file

@ -1005,6 +1005,38 @@ describe('Server Config (config.ts)', () => {
expect(config.getFailedMcpServerNames()).toEqual([]);
});
it('isMcpServerDisabled consults extension preferences only for the contributing extension', () => {
const config = new Config({
...baseParams,
checkpointing: false,
// baseParams pins overrideExtensions to []; lift it so the mocked
// loaded extension is visible to getActiveExtensions().
overrideExtensions: undefined,
// A user-configured server that shadows the extension's same-named one.
mcpServers: { foo: new MCPServerConfig() },
} as ConfigParameters);
const manager = config.getExtensionManager();
vi.spyOn(manager, 'getLoadedExtensions').mockReturnValue([
{
name: 'my-ext',
isActive: true,
config: { name: 'my-ext', mcpServers: { bar: {}, foo: {} } },
} as unknown as ReturnType<typeof manager.getLoadedExtensions>[number],
]);
vi.spyOn(manager, 'getDisabledMcpServers').mockImplementation(
(extensionName: string) =>
extensionName === 'my-ext' ? ['bar', 'foo'] : [],
);
// `bar` is contributed by the extension and disabled in its preferences.
expect(config.isMcpServerDisabled('bar')).toBe(true);
// `foo` is shadowed by the user config (no extensionName on the merged
// entry), so the extension's disable record must not affect it.
expect(config.isMcpServerDisabled('foo')).toBe(false);
// The global exclusion list still applies to anything.
config.setExcludedMcpServers(['foo']);
expect(config.isMcpServerDisabled('foo')).toBe(true);
});
it('getFailedMcpServerNames skips pending approval servers', () => {
const config = new Config({
...baseParams,

View file

@ -3262,7 +3262,25 @@ export class Config {
}
isMcpServerDisabled(serverName: string): boolean {
return this.excludedMcpServers?.includes(serverName) ?? false;
if (this.excludedMcpServers?.includes(serverName)) return true;
// Extension-bundled servers can be disabled individually via extension
// preferences. Only the extension that actually contributed the server is
// consulted, so a same-named server from another source (e.g. a shadowing
// user config) is never affected. The owner lookup mirrors the
// getMcpServers() merge (user/project config wins, then first active
// extension) without rebuilding the merged map — this predicate runs per
// server in discovery loops and on every resource read.
if (this.mcpServers?.[serverName]) return false;
for (const extension of this.getActiveExtensions()) {
if (extension.config.mcpServers?.[serverName]) {
return (
this.extensionManager
?.getDisabledMcpServers(extension.config.name)
.includes(serverName) ?? false
);
}
}
return false;
}
/**

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@ -14,13 +14,27 @@ import {
mergeClaudeConfigs,
isClaudePluginConfig,
convertClaudePluginPackage,
convertClaudePluginStandalone,
type ClaudePluginConfig,
type ClaudeMarketplacePluginConfig,
type ClaudeMarketplaceConfig,
} from './claude-converter.js';
import { cloneFromGit } from './github.js';
import { HookType } from '../hooks/types.js';
import { performVariableReplacement } from './variables.js';
// The git-subdir source clones a repo; stub the network clone so the security
// guards around the cloned subdirectory can be exercised against a real fs.
// Other tests use local sources and never call these, so the stubs are inert.
vi.mock('./github.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./github.js')>();
return {
...actual,
cloneFromGit: vi.fn(),
downloadFromGitHubRelease: vi.fn(),
};
});
describe('convertClaudeToQwenConfig', () => {
it('should convert basic Claude config', () => {
const claudeConfig: ClaudePluginConfig = {
@ -171,7 +185,7 @@ describe('isClaudePluginConfig', () => {
it('should identify Claude plugin directory', () => {
const extensionDir = '/tmp/test-extension';
const marketplace = {
marketplaceSource: 'https://test.com',
extensionSource: 'https://test.com',
pluginName: 'test-plugin',
};
@ -282,6 +296,88 @@ describe('convertClaudePluginPackage', () => {
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
it('skips a symlink inside a collected resource folder that escapes the plugin', async () => {
const pluginSourceDir = path.join(testDir, 'plugin-symlink');
const skillDir = path.join(pluginSourceDir, 'skills', 'mine');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# mine', 'utf-8');
// A host file outside the plugin, reachable via a symlink whose name stays
// inside the collected folder. collectResources must not copy its content.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'id_rsa');
fs.writeFileSync(secretFile, 'TOP SECRET', 'utf-8');
fs.symlinkSync(secretFile, path.join(skillDir, 'leak.txt'));
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'leaky',
version: '1.0.0',
description: 'Leaky plugin',
source: './',
strict: false,
skills: ['./skills/mine'],
},
],
};
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);
const result = await convertClaudePluginPackage(pluginSourceDir, 'leaky');
const dest = path.join(result.convertedDir, 'skills', 'mine');
expect(fs.existsSync(path.join(dest, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(dest, 'leak.txt'))).toBe(false);
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('throws when a marketplace source is a symlink resolving outside the marketplace dir', async () => {
// A host directory reachable via a symlink whose relative name stays inside
// the marketplace dir. resolvePluginSource must reject it before copying.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
fs.writeFileSync(path.join(secretDir, 'SKILL.md'), 'secret', 'utf-8');
const pluginSourceDir = path.join(testDir, 'plugin-evil-source');
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
fs.symlinkSync(secretDir, path.join(pluginSourceDir, 'evil-link'));
const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'evil',
version: '1.0.0',
description: 'Evil plugin',
source: './evil-link',
strict: false,
},
],
};
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);
await expect(
convertClaudePluginPackage(pluginSourceDir, 'evil'),
).rejects.toThrow(/resolves through a symlink outside the marketplace/);
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('should use all skills from folder when config does not specify skills', async () => {
// Setup: Create a plugin source with skills but no skills config
const pluginSourceDir = path.join(testDir, 'plugin-source-default');
@ -690,6 +786,394 @@ describe('convertClaudePluginPackage', () => {
// Clean up converted directory
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
it('throws when marketplace.json itself is a symlink resolving outside the plugin', async () => {
// A hostile clone makes the marketplace manifest a symlink to a JSON-shaped
// host file. The converter must refuse to follow it (realPathWithin guard).
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'marketplace.json');
fs.writeFileSync(
secretFile,
JSON.stringify({
name: 'leaked',
owner: { name: 'x', email: 'x@x' },
plugins: [{ name: 'evil', version: '1.0.0', source: './' }],
}),
'utf-8',
);
const pluginSourceDir = path.join(testDir, 'plugin-mp-symlink');
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
fs.symlinkSync(secretFile, path.join(marketplaceDir, 'marketplace.json'));
await expect(
convertClaudePluginPackage(pluginSourceDir, 'evil'),
).rejects.toThrow(/resolves through a symlink outside the plugin/);
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('throws in strict mode when plugin.json is a symlink escaping the plugin', async () => {
// existsSync follows the symlink so the strict-missing check passes, but the
// target is untrusted — strict mode must fail instead of silently falling
// back to the marketplace entry.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'plugin.json');
fs.writeFileSync(
secretFile,
JSON.stringify({ name: 'leaked', version: '9.9.9' }),
'utf-8',
);
const pluginSourceDir = path.join(testDir, 'plugin-strict-symlink');
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [{ name: 'evil', version: '1.0.0', source: './', strict: true }],
};
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);
// plugin.json lives at pluginSource/.claude-plugin/plugin.json (source './'
// resolves the plugin source to the package root).
fs.symlinkSync(secretFile, path.join(marketplaceDir, 'plugin.json'));
await expect(
convertClaudePluginPackage(pluginSourceDir, 'evil'),
).rejects.toThrow(/Strict mode requires a trusted plugin\.json/);
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('ignores a symlinked plugin.json (non-strict) and uses the marketplace entry', async () => {
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'plugin.json');
fs.writeFileSync(
secretFile,
JSON.stringify({
name: 'leaked',
version: '9.9.9',
mcpServers: { leaked: { command: 'cat', args: ['/etc/passwd'] } },
}),
'utf-8',
);
const pluginSourceDir = path.join(testDir, 'plugin-nonstrict-symlink');
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{ name: 'evil', version: '1.0.0', source: './', strict: false },
],
};
fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);
fs.symlinkSync(secretFile, path.join(marketplaceDir, 'plugin.json'));
const result = await convertClaudePluginPackage(pluginSourceDir, 'evil');
// The marketplace entry is used; the symlinked target is never read.
expect(result.config.name).toBe('evil');
expect(
(result.config.mcpServers as Record<string, unknown> | undefined)?.[
'leaked'
],
).toBeUndefined();
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
});
describe('convertClaudePluginStandalone', () => {
let testDir: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-standalone-'));
});
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('converts a repo with root .claude-plugin/plugin.json, .mcp.json and skills', async () => {
// Mirror the ClickHouse plugin layout: plugin.json metadata only, MCP in
// a root .mcp.json, and a skills/ folder with no commands/agents.
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({
name: 'clickhouse',
version: '1.0.0',
description: 'ClickHouse plugin',
}),
'utf-8',
);
fs.writeFileSync(
path.join(testDir, '.mcp.json'),
JSON.stringify({
mcpServers: {
clickhouse: { type: 'http', url: 'https://mcp.clickhouse.cloud/mcp' },
},
}),
'utf-8',
);
const skillDir = path.join(testDir, 'skills', 'best-practices');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
'# best practices',
'utf-8',
);
// A real git clone carries a .git directory; create one so the assertion
// below actually exercises the VCS-metadata stripping in the converter.
const gitDir = path.join(testDir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(
path.join(gitDir, 'HEAD'),
'ref: refs/heads/main',
'utf-8',
);
const result = await convertClaudePluginStandalone(testDir);
// A qwen-extension.json must exist so the installer can load it.
expect(
fs.existsSync(path.join(result.convertedDir, 'qwen-extension.json')),
).toBe(true);
expect(result.config.name).toBe('clickhouse');
expect(result.config.version).toBe('1.0.0');
// MCP server folded in from .mcp.json and remapped to Qwen's transport
// shape: Claude `type: 'http'` + `url` becomes `httpUrl` (streamable HTTP).
const mcp = result.config.mcpServers?.['clickhouse'] as
| { httpUrl?: string; url?: string; type?: string }
| undefined;
expect(mcp?.httpUrl).toBe('https://mcp.clickhouse.cloud/mcp');
expect(mcp?.url).toBeUndefined();
expect(mcp?.type).toBeUndefined();
// Skills folder preserved.
expect(
fs.existsSync(
path.join(result.convertedDir, 'skills', 'best-practices', 'SKILL.md'),
),
).toBe(true);
// VCS metadata is not shipped into the installed extension.
expect(fs.existsSync(path.join(result.convertedDir, '.git'))).toBe(false);
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
it('throws when there is no .claude-plugin/plugin.json', async () => {
await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow(
/Plugin configuration not found/,
);
});
it('ignores an absolute mcpServers path so it cannot read out-of-tree files', async () => {
// A hostile plugin.json points mcpServers at an absolute file outside the
// plugin. The converter must NOT read it (path-confinement guard).
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'secret-mcp.json');
fs.writeFileSync(
secretFile,
JSON.stringify({ leaked: { command: 'cat', args: ['/etc/passwd'] } }),
'utf-8',
);
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({
name: 'evil',
version: '1.0.0',
mcpServers: secretFile,
}),
'utf-8',
);
const result = await convertClaudePluginStandalone(testDir);
// The absolute path was not read, so no servers were folded in.
expect(result.config.mcpServers?.['leaked']).toBeUndefined();
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('throws when plugin.json is a symlink resolving outside the plugin', async () => {
// A hostile clone makes the manifest itself a symlink to a JSON-shaped host
// file. The converter must refuse to follow it rather than read the target.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'config.json');
fs.writeFileSync(
secretFile,
JSON.stringify({ name: 'leaked', version: '9.9.9' }),
'utf-8',
);
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.symlinkSync(secretFile, path.join(pluginDir, 'plugin.json'));
await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow(
/resolves through a symlink outside/,
);
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('does not load mcpServers from a relative path that is a symlink escaping the plugin', async () => {
// mcpServers is a relative path whose name stays inside the plugin, but the
// file is a symlink to a host secret. resolvePluginRelativeFile must reject
// it so the target is never read into the config.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'servers.json');
fs.writeFileSync(
secretFile,
JSON.stringify({ leaked: { command: 'cat', args: ['/etc/passwd'] } }),
'utf-8',
);
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({
name: 'evil',
version: '1.0.0',
mcpServers: './servers.json',
}),
'utf-8',
);
fs.symlinkSync(secretFile, path.join(testDir, 'servers.json'));
const result = await convertClaudePluginStandalone(testDir);
const servers = result.config.mcpServers as
| Record<string, unknown>
| undefined;
expect(servers?.['leaked']).toBeUndefined();
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('does not copy a symlink whose target escapes the plugin directory', async () => {
// git preserves symlinks, so a hostile repo can embed one pointing at a
// host file. The bulk copy dereferences symlinks; without confinement the
// target's content would be shipped inside the converted extension.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'id_rsa');
fs.writeFileSync(secretFile, 'TOP SECRET KEY', 'utf-8');
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({ name: 'evil', version: '1.0.0' }),
'utf-8',
);
const skillsDir = path.join(testDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '# ok', 'utf-8');
// A symlink whose name stays inside the package but points outside it.
fs.symlinkSync(secretFile, path.join(skillsDir, 'leak.txt'));
const result = await convertClaudePluginStandalone(testDir);
// The legitimate file is copied; the escaping symlink is dropped.
expect(
fs.existsSync(path.join(result.convertedDir, 'skills', 'SKILL.md')),
).toBe(true);
expect(
fs.existsSync(path.join(result.convertedDir, 'skills', 'leak.txt')),
).toBe(false);
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('skips a .mcp.json that has no mcpServers object instead of misparsing it', async () => {
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({ name: 'no-servers', version: '1.0.0' }),
'utf-8',
);
// No `mcpServers` key — the whole object must not be treated as the map.
fs.writeFileSync(
path.join(testDir, '.mcp.json'),
JSON.stringify({ name: 'foo', other: 'bar' }),
'utf-8',
);
const result = await convertClaudePluginStandalone(testDir);
expect(result.config.mcpServers).toBeUndefined();
expect(
(result.config.mcpServers as Record<string, unknown> | undefined)?.[
'name'
],
).toBeUndefined();
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
it('does not load a .mcp.json that is a symlink escaping the plugin', async () => {
// .mcp.json's name stays inside the plugin but it's a symlink to a host
// file. realPathWithin must reject it so the target servers are never read.
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
const secretFile = path.join(secretDir, 'servers.json');
fs.writeFileSync(
secretFile,
JSON.stringify({
mcpServers: { leaked: { command: 'cat', args: ['/etc/passwd'] } },
}),
'utf-8',
);
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'plugin.json'),
JSON.stringify({ name: 'evil', version: '1.0.0' }),
'utf-8',
);
fs.symlinkSync(secretFile, path.join(testDir, '.mcp.json'));
const result = await convertClaudePluginStandalone(testDir);
expect(
(result.config.mcpServers as Record<string, unknown> | undefined)?.[
'leaked'
],
).toBeUndefined();
fs.rmSync(result.convertedDir, { recursive: true, force: true });
fs.rmSync(secretDir, { recursive: true, force: true });
});
it('throws a clear error when plugin.json parses to null', async () => {
// A plugin.json whose body is the JSON literal `null` would otherwise throw
// an opaque "Cannot read properties of null" on the mcpServers deref.
const pluginDir = path.join(testDir, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), 'null', 'utf-8');
await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow(
/Invalid plugin configuration/,
);
});
});
describe('performVariableReplacement for Claude extensions', () => {
@ -754,3 +1238,121 @@ describe('performVariableReplacement for Claude extensions', () => {
expect(result).not.toContain('.message.content');
});
});
describe('convertClaudePluginPackage — git-subdir source', () => {
let extDir: string;
beforeEach(() => {
extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-gitsub-'));
vi.mocked(cloneFromGit).mockReset();
});
afterEach(() => {
if (fs.existsSync(extDir)) {
fs.rmSync(extDir, { recursive: true, force: true });
}
});
// Writes a marketplace.json declaring a single git-subdir plugin.
const writeMarketplace = (source: unknown) => {
const mp = path.join(extDir, '.claude-plugin');
fs.mkdirSync(mp, { recursive: true });
fs.writeFileSync(
path.join(mp, 'marketplace.json'),
JSON.stringify({
name: 'm',
owner: { name: 'o', email: 'e' },
plugins: [{ name: 'p', version: '1.0.0', source }],
}),
'utf-8',
);
};
it('clones, pins to the sha over the ref, and returns the subdirectory', async () => {
vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => {
const sub = path.join(dir as string, 'packages', 'plugin');
fs.mkdirSync(path.join(sub, '.claude-plugin'), { recursive: true });
fs.writeFileSync(
path.join(sub, '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'p', version: '1.0.0' }),
'utf-8',
);
});
writeMarketplace({
source: 'git-subdir',
url: 'https://example.com/repo.git',
path: 'packages/plugin',
ref: 'main',
sha: 'abc123',
});
const result = await convertClaudePluginPackage(extDir, 'p');
expect(result.config.name).toBe('p');
// The immutable sha is preferred over the named ref when both are present.
const meta = vi.mocked(cloneFromGit).mock.calls[0][0] as { ref?: string };
expect(meta.ref).toBe('abc123');
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
it('rejects a subdirectory that escapes the repository root', async () => {
vi.mocked(cloneFromGit).mockResolvedValue(undefined as never);
writeMarketplace({
source: 'git-subdir',
url: 'https://example.com/repo.git',
path: '../../etc',
});
await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow(
/escapes the repository root/,
);
});
it('rejects an absolute subdirectory path', async () => {
vi.mocked(cloneFromGit).mockResolvedValue(undefined as never);
writeMarketplace({
source: 'git-subdir',
url: 'https://example.com/repo.git',
path: path.resolve(path.sep, 'etc'),
});
await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow(
/Invalid plugin subdirectory/,
);
});
it('rejects a missing subdirectory', async () => {
vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => {
// The clone succeeded but does not contain the requested subdir.
fs.mkdirSync(path.join(dir as string, 'other'), { recursive: true });
});
writeMarketplace({
source: 'git-subdir',
url: 'https://example.com/repo.git',
path: 'packages/missing',
});
await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow(
/not found/,
);
});
it('rejects a subdirectory that is a symlink escaping the clone', async () => {
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-secret-'));
fs.writeFileSync(path.join(secretDir, 'SKILL.md'), 'secret', 'utf-8');
vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => {
// A hostile repo commits the subdir as a symlink whose name stays inside
// the clone but whose target escapes it.
fs.symlinkSync(secretDir, path.join(dir as string, 'sub'));
});
writeMarketplace({
source: 'git-subdir',
url: 'https://example.com/repo.git',
path: 'sub',
});
await expect(convertClaudePluginPackage(extDir, 'p')).rejects.toThrow(
/resolves through a symlink/,
);
fs.rmSync(secretDir, { recursive: true, force: true });
});
});

View file

@ -19,17 +19,30 @@ import type {
import type { HookEventName, HookDefinition } from '../hooks/types.js';
import { cloneFromGit, downloadFromGitHubRelease } from './github.js';
import { createHash } from 'node:crypto';
import { copyDirectory } from './gemini-converter.js';
import {
copyDirectory,
isPathWithin,
realPathWithin,
} from './gemini-converter.js';
import {
parse as parseYaml,
stringify as stringifyYaml,
} from '../utils/yaml-parser.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { normalizeContent } from '../utils/textUtils.js';
import { normalizeContent, stripAnsiAndControl } from '../utils/textUtils.js';
import { substituteHookVariables } from './variables.js';
const debugLogger = createDebugLogger('CLAUDE_CONVERTER');
/**
* Strips terminal escape/control sequences from untrusted values before they
* are interpolated into error messages. Conversion errors here propagate to the
* TUI install status area, so a hostile plugin `source`/`path` could otherwise
* smuggle ANSI/OSC sequences to the terminal during a failed install. Aliases
* the shared `stripAnsiAndControl` so the rule stays in one place.
*/
const sanitizeForError = stripAnsiAndControl;
export interface ClaudePluginConfig {
name: string;
version: string;
@ -79,7 +92,15 @@ export interface ClaudeAgentConfig {
export type ClaudePluginSource =
| { source: 'github'; repo: string }
| { source: 'url'; url: string };
| { source: 'url'; url: string }
| {
// A plugin that lives in a subdirectory of a git repository.
source: 'git-subdir';
url: string;
path: string;
ref?: string;
sha?: string;
};
export interface ClaudeMarketplacePluginConfig extends ClaudePluginConfig {
source: string | ClaudePluginSource;
@ -309,6 +330,41 @@ ${systemPrompt}
}
}
/**
* Maps Claude `.mcp.json` server entries to Qwen's MCPServerConfig shape.
* Claude discriminates transport with a `type` field (`http`/`sse`/`stdio`),
* whereas Qwen keys off which field is set: `httpUrl` (streamable HTTP),
* `url` (SSE) or `command` (stdio). A Claude `type: 'http'` entry therefore
* has to move its `url` to `httpUrl`, and the now-meaningless `type` is dropped.
*/
function normalizeClaudeMcpServers(
servers: Record<string, MCPServerConfig>,
): Record<string, MCPServerConfig> {
const normalized: Record<string, MCPServerConfig> = {};
for (const [name, raw] of Object.entries(servers)) {
const server = raw as unknown as Record<string, unknown>;
// stdio / already-Qwen-shaped configs pass through unchanged.
if (server['command'] || server['httpUrl'] || server['tcp']) {
normalized[name] = raw;
continue;
}
if (typeof server['url'] === 'string') {
const rest = { ...server };
delete rest['type'];
delete rest['url'];
normalized[name] = {
...rest,
...(server['type'] === 'http'
? { httpUrl: server['url'] }
: { url: server['url'] }),
} as unknown as MCPServerConfig;
continue;
}
normalized[name] = raw;
}
return normalized;
}
/**
* Converts a Claude plugin config to Qwen Code format.
* @param claudeConfig Claude plugin configuration
@ -331,7 +387,7 @@ export function convertClaudeToQwenConfig(
`[Claude Converter] MCP servers path not yet supported: ${claudeConfig.mcpServers}`,
);
} else {
mcpServers = claudeConfig.mcpServers;
mcpServers = normalizeClaudeMcpServers(claudeConfig.mcpServers);
}
}
@ -389,6 +445,13 @@ export async function convertClaudePluginPackage(
`Marketplace configuration not found at ${marketplaceJsonPath}`,
);
}
// The manifest itself can be a symlink in an untrusted clone; refuse to read
// it when it resolves outside the plugin (would leak a JSON-shaped host file).
if (!realPathWithin(marketplaceJsonPath, extensionDir)) {
throw new Error(
`Marketplace configuration at ${marketplaceJsonPath} resolves through a symlink outside the plugin`,
);
}
const marketplaceContent = fs.readFileSync(marketplaceJsonPath, 'utf-8');
const marketplaceConfig: ClaudeMarketplaceConfig =
@ -431,21 +494,92 @@ export async function convertClaudePluginPackage(
if (strict && !fs.existsSync(pluginJsonPath)) {
throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`);
}
if (fs.existsSync(pluginJsonPath)) {
// Treat a symlinked plugin.json (pointing outside the source) as absent
// rather than reading an arbitrary host file into the merged config.
const pluginJsonSafe =
fs.existsSync(pluginJsonPath) &&
realPathWithin(pluginJsonPath, pluginSource);
if (pluginJsonSafe) {
const pluginContent = fs.readFileSync(pluginJsonPath, 'utf-8');
const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent);
mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig);
} else {
// `existsSync` follows symlinks, so the strict check at line 500 passes
// when plugin.json is a symlink to an existing host file — but the file is
// not trusted (`realPathWithin` rejected it). Strict mode must fail here
// rather than silently fall back to the marketplace entry.
if (strict) {
throw new Error(
`Strict mode requires a trusted plugin.json at ${pluginJsonPath}`,
);
}
if (fs.existsSync(pluginJsonPath)) {
debugLogger.warn(
`Ignoring plugin.json at ${pluginJsonPath}; it resolves through a symlink outside the plugin.`,
);
}
mergedConfig = marketplacePlugin as ClaudePluginConfig;
}
// Step 4: Resolve MCP servers from JSON files if needed
if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') {
const mcpServersPath = path.isAbsolute(mergedConfig.mcpServers)
? mergedConfig.mcpServers
: path.join(pluginSource, mergedConfig.mcpServers);
return buildQwenExtensionFromPlugin(pluginSource, mergedConfig);
}
if (fs.existsSync(mcpServersPath)) {
/**
* Resolves a plugin-relative file reference, refusing absolute paths or any
* path that escapes `pluginSource`. Plugin configs come from untrusted sources
* (arbitrary git repos / marketplaces), so an absolute or `../`-laden value
* could otherwise make the converter read sensitive files outside the plugin.
* Returns the confined absolute path, or null when the reference is unsafe.
*/
function resolvePluginRelativeFile(
pluginSource: string,
relativePath: string,
): string | null {
if (path.isAbsolute(relativePath)) {
debugLogger.warn(
`Ignoring absolute path "${relativePath}" in plugin config; only paths inside the plugin are allowed.`,
);
return null;
}
const resolved = path.resolve(pluginSource, relativePath);
const base = path.resolve(pluginSource);
if (!isPathWithin(resolved, base)) {
debugLogger.warn(
`Ignoring path "${relativePath}" in plugin config; it escapes the plugin directory.`,
);
return null;
}
// The lexical check above is purely string-based; a symlink whose name stays
// inside the plugin can still point its target outside it (e.g.
// `skills/leak.txt -> ~/.ssh/id_rsa`). Downstream reads/copies follow
// symlinks, so re-verify the real path when the target exists.
if (fs.existsSync(resolved) && !realPathWithin(resolved, pluginSource)) {
debugLogger.warn(
`Ignoring path "${relativePath}" in plugin config; it resolves through a symlink outside the plugin directory.`,
);
return null;
}
return resolved;
}
/**
* Builds a converted Qwen extension directory from a resolved Claude plugin
* source directory and its merged config. Shared by the marketplace-based
* (`convertClaudePluginPackage`) and standalone (`convertClaudePluginStandalone`)
* conversion paths.
*/
async function buildQwenExtensionFromPlugin(
pluginSource: string,
mergedConfig: ClaudePluginConfig,
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
// Resolve MCP servers from a JSON file path if needed.
if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') {
const mcpServersPath = resolvePluginRelativeFile(
pluginSource,
mergedConfig.mcpServers,
);
if (mcpServersPath && fs.existsSync(mcpServersPath)) {
try {
const mcpContent = fs.readFileSync(mcpServersPath, 'utf-8');
mergedConfig.mcpServers = JSON.parse(mcpContent) as Record<
@ -460,16 +594,20 @@ export async function convertClaudePluginPackage(
}
}
// Step 5: Create temporary directory for converted extension
const tmpDir = await ExtensionStorage.createTmpDir();
try {
// Step 6: Copy plugin files to temporary directory
await copyDirectory(pluginSource, tmpDir);
// Step 6.1: Handle commands/skills/agents folders based on configuration
// If configuration specifies resources, only collect those
// If configuration doesn't specify, keep the existing folder (if exists)
// A standalone plugin's source is a full git clone; drop VCS metadata so
// it isn't shipped into the installed extension.
const gitDir = path.join(tmpDir, '.git');
if (fs.existsSync(gitDir)) {
fs.rmSync(gitDir, { recursive: true, force: true });
}
// Handle commands/skills/agents folders: if the config specifies resources
// collect only those, otherwise keep the existing folder from the source.
const resourceConfigs = [
{ name: 'commands', config: mergedConfig.commands },
{ name: 'skills', config: mergedConfig.skills },
@ -480,47 +618,42 @@ export async function convertClaudePluginPackage(
const folderPath = path.join(tmpDir, name);
const sourceFolderPath = path.join(pluginSource, name);
// If config explicitly specifies resources, remove existing folder and collect only specified ones
if (config) {
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true });
}
await collectResources(config, pluginSource, folderPath);
}
// If config doesn't specify and source folder doesn't exist in pluginSource,
// remove it from tmpDir (it was copied but not needed)
else if (!fs.existsSync(sourceFolderPath) && fs.existsSync(folderPath)) {
} else if (
!fs.existsSync(sourceFolderPath) &&
fs.existsSync(folderPath)
) {
fs.rmSync(folderPath, { recursive: true, force: true });
}
// Otherwise, keep the existing folder from pluginSource (default behavior)
}
// Step 7: Handle hooks from file paths if needed
// Handle hooks from a file path if needed.
if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') {
const hooksPath = path.isAbsolute(mergedConfig.hooks)
? mergedConfig.hooks
: path.join(pluginSource, mergedConfig.hooks);
const hooksPath = resolvePluginRelativeFile(
pluginSource,
mergedConfig.hooks,
);
if (fs.existsSync(hooksPath)) {
if (hooksPath && fs.existsSync(hooksPath)) {
try {
const hooksContent = fs.readFileSync(hooksPath, 'utf-8');
const parsedHooks = JSON.parse(hooksContent);
// Check if the file has a top-level "hooks" property (like Claude plugins use)
// or if the entire file content is the hooks object
let hooksData;
if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') {
hooksData = parsedHooks.hooks as {
[K in HookEventName]?: HookDefinition[];
};
} else {
// Assume the entire file content is the hooks object
hooksData = parsedHooks as {
[K in HookEventName]?: HookDefinition[];
};
}
// Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT}
mergedConfig.hooks = substituteHookVariables(hooksData, pluginSource);
} catch (error) {
debugLogger.warn(
@ -530,14 +663,11 @@ export async function convertClaudePluginPackage(
}
}
// Step 9: Convert collected agent files from Claude format to Qwen format
const agentsDestDir = path.join(tmpDir, 'agents');
await convertAgentFiles(agentsDestDir);
// Step 10: Convert to Qwen format config
const qwenConfig = convertClaudeToQwenConfig(mergedConfig);
// Step 11: Write qwen-extension.json
const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json');
fs.writeFileSync(
qwenConfigPath,
@ -550,7 +680,6 @@ export async function convertClaudePluginPackage(
convertedDir: tmpDir,
};
} catch (error) {
// Clean up temporary directory on error
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
@ -560,6 +689,91 @@ export async function convertClaudePluginPackage(
}
}
/**
* Converts a standalone Claude plugin to Qwen Code format. A standalone plugin
* is a repo whose root holds `.claude-plugin/plugin.json` (no marketplace.json),
* as produced by installing a Claude Code plugin directly from a git URL.
*
* MCP servers declared in a root `.mcp.json` are folded into the config when
* plugin.json does not list them itself.
*/
export async function convertClaudePluginStandalone(
extensionDir: string,
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
const pluginJsonPath = path.join(
extensionDir,
'.claude-plugin',
'plugin.json',
);
if (!fs.existsSync(pluginJsonPath)) {
throw new Error(`Plugin configuration not found at ${pluginJsonPath}`);
}
// The manifest may be a symlink in an untrusted clone; refuse to follow it
// outside the package (would read an arbitrary JSON-shaped host file).
if (!realPathWithin(pluginJsonPath, extensionDir)) {
throw new Error(
`Plugin configuration at ${pluginJsonPath} resolves through a symlink outside the plugin`,
);
}
const parsedConfig: unknown = JSON.parse(
fs.readFileSync(pluginJsonPath, 'utf-8'),
);
// A plugin.json whose body is `null`, an array, or a scalar would otherwise
// throw an opaque `Cannot read properties of null` on the deref below. Fail
// with a clear message instead (the marketplace path tolerates this via
// `mergeClaudeConfigs`, so guard the standalone path to match).
if (
typeof parsedConfig !== 'object' ||
parsedConfig === null ||
Array.isArray(parsedConfig)
) {
throw new Error(
`Invalid plugin configuration at ${pluginJsonPath}: expected a JSON object`,
);
}
const mergedConfig = parsedConfig as ClaudePluginConfig;
if (!mergedConfig.mcpServers) {
const mcpJsonPath = path.join(extensionDir, '.mcp.json');
if (
fs.existsSync(mcpJsonPath) &&
realPathWithin(mcpJsonPath, extensionDir)
) {
try {
const parsed = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'));
if (
parsed?.mcpServers &&
typeof parsed.mcpServers === 'object' &&
!Array.isArray(parsed.mcpServers)
) {
mergedConfig.mcpServers = parsed.mcpServers as Record<
string,
MCPServerConfig
>;
} else {
debugLogger.warn(
`.mcp.json at ${mcpJsonPath} has no valid "mcpServers" object; skipping.`,
);
}
} catch (error) {
debugLogger.warn(
`Failed to parse .mcp.json at ${mcpJsonPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else if (fs.existsSync(mcpJsonPath)) {
// The file exists but resolves through a symlink outside the plugin.
// Mirror the plugin.json skip-warning so a missing-MCP-servers
// investigation has a breadcrumb instead of a silent drop.
debugLogger.warn(
`Ignoring .mcp.json at ${mcpJsonPath}; it resolves through a symlink outside the plugin.`,
);
}
}
return buildQwenExtensionFromPlugin(extensionDir, mergedConfig);
}
/**
* Collects resources (commands, skills, agents) to a destination folder.
* Resources are always copied unconditionally the caller
@ -585,9 +799,10 @@ async function collectResources(
const destFolderName = path.basename(destDir);
for (const resourcePath of paths) {
const resolvedPath = path.isAbsolute(resourcePath)
? resourcePath
: path.join(pluginRoot, resourcePath);
// Resource paths come from an untrusted manifest; confine them to the
// plugin so a value like "/etc/ssh" or "../../secrets" can't be copied in.
const resolvedPath = resolvePluginRelativeFile(pluginRoot, resourcePath);
if (!resolvedPath) continue;
if (!fs.existsSync(resolvedPath)) {
debugLogger.warn(`Resource path not found: ${resolvedPath}`);
@ -630,6 +845,19 @@ async function collectResources(
// Check if the source is a regular file (skip sockets, FIFOs, directories behind symlinks, etc.)
try {
// A symlink inside the resource folder can point its target outside
// the plugin; statSync would follow it and copy the host file. Skip
// any symlink whose real target escapes the resource directory.
const fileLstat = fs.lstatSync(srcFile);
if (
fileLstat.isSymbolicLink() &&
!realPathWithin(srcFile, resolvedPath)
) {
debugLogger.warn(
`Skipping symlink that escapes the plugin: ${srcFile}`,
);
continue;
}
const fileStat = fs.statSync(srcFile);
if (!fileStat.isFile()) {
debugLogger.debug(`Skipping non-regular file: ${srcFile}`);
@ -717,7 +945,7 @@ export function mergeClaudeConfigs(
*/
export function isClaudePluginConfig(
extensionDir: string,
marketplace: { marketplaceSource: string; pluginName: string },
marketplace: { extensionSource: string; pluginName: string },
) {
const marketplaceConfigFilePath = path.join(
extensionDir,
@ -790,12 +1018,33 @@ async function resolvePluginSource(
return pluginDir;
}
// Relative path within marketplace
// Relative path within marketplace. Confine it: a manifest source like
// "../../../../etc/ssh" must not resolve outside the marketplace dir.
const pluginRoot = marketplaceDir;
const sourcePath = path.join(pluginRoot, source);
const resolvedSource = path.resolve(sourcePath);
const marketplaceBase = path.resolve(marketplaceDir);
if (
resolvedSource !== marketplaceBase &&
!resolvedSource.startsWith(marketplaceBase + path.sep)
) {
throw new Error(
`Plugin source "${sanitizeForError(source)}" escapes the marketplace directory`,
);
}
if (!fs.existsSync(sourcePath)) {
throw new Error(`Plugin source not found at ${sourcePath}`);
throw new Error(
`Plugin source not found at ${sanitizeForError(sourcePath)}`,
);
}
// The lexical check is string-only; reject a source that reaches outside
// the marketplace dir through a symlink before copying it in.
if (!realPathWithin(sourcePath, marketplaceDir)) {
throw new Error(
`Plugin source "${sanitizeForError(source)}" resolves through a symlink outside the marketplace directory`,
);
}
// If source path equals marketplace dir (source is '.' or ''),
@ -836,5 +1085,48 @@ async function resolvePluginSource(
return pluginDir;
}
if (source.source === 'git-subdir') {
// The plugin lives in a subdirectory of a git repository. Clone the repo
// (pinned to the provided ref/sha when present) and return the subdir.
const installMetadata: ExtensionInstallMetadata = {
source: source.url,
type: 'git',
// Prefer the immutable SHA pin when present; fall back to a named ref.
ref: source.sha || source.ref,
originSource: 'Claude',
};
await cloneFromGit(installMetadata, pluginDir);
// `source.path` comes from an untrusted manifest. Confine it to the cloned
// repo so a value like "../../.ssh" (or an absolute path) cannot escape.
if (!source.path || source.path === '.' || path.isAbsolute(source.path)) {
throw new Error(
`Invalid plugin subdirectory "${sanitizeForError(String(source.path))}" for ${sanitizeForError(source.url)}`,
);
}
const subDir = path.resolve(pluginDir, source.path);
const repoRoot = path.resolve(pluginDir);
if (!subDir.startsWith(repoRoot + path.sep)) {
throw new Error(
`Plugin subdirectory "${sanitizeForError(source.path)}" escapes the repository root of ${sanitizeForError(source.url)}`,
);
}
if (!fs.existsSync(subDir)) {
throw new Error(
`Plugin subdirectory "${sanitizeForError(source.path)}" not found in ${sanitizeForError(source.url)} (ref: ${sanitizeForError(source.ref ?? source.sha ?? 'HEAD')})`,
);
}
// The lexical `startsWith` check above is string-only; `cloneFromGit`
// checks out symlinks on macOS/Linux, so a hostile repo can commit the
// subdir as a symlink whose name stays inside the clone but whose target
// escapes it (e.g. `evil -> /etc`). Re-verify the real path before
// returning it as the copy source.
if (!realPathWithin(subDir, pluginDir)) {
throw new Error(
`Plugin subdirectory "${sanitizeForError(source.path)}" resolves through a symlink outside the repository root of ${sanitizeForError(source.url)}`,
);
}
return subDir;
}
throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`);
}

View file

@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type MockInstance,
} from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { quarantineCorruptFile } from './corruptFile.js';
describe('quarantineCorruptFile', () => {
let testDir: string;
let stderrSpy: MockInstance<(...args: unknown[]) => unknown>;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'corrupt-file-'));
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true) as unknown as MockInstance<
(...args: unknown[]) => unknown
>;
});
afterEach(() => {
stderrSpy.mockRestore();
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('renames the file aside to ${path}.corrupted, preserving its bytes', () => {
const filePath = path.join(testDir, 'prefs.json');
fs.writeFileSync(filePath, '{ broken json', 'utf-8');
quarantineCorruptFile(filePath);
// Original is gone; the bytes are preserved in the .corrupted sibling.
expect(fs.existsSync(filePath)).toBe(false);
const quarantined = `${filePath}.corrupted`;
expect(fs.existsSync(quarantined)).toBe(true);
expect(fs.readFileSync(quarantined, 'utf-8')).toBe('{ broken json');
// The quarantine event is surfaced on stderr (debug log is gated off).
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('moved aside'),
);
});
it('does not throw when the file cannot be moved aside', () => {
// Renaming a non-existent file throws ENOENT inside the helper; it must be
// swallowed (best-effort) and reported on stderr rather than propagated.
const missing = path.join(testDir, 'does-not-exist.json');
expect(() => quarantineCorruptFile(missing)).not.toThrow();
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('could not be moved aside'),
);
});
});

View file

@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('CORRUPT_FILE');
/**
* Moves a corrupt (un-parseable) JSON state file aside to `${filePath}.corrupted`
* so the next write does not clobber recoverable user data.
*
* The extension state stores (favorites/scopes, the marketplace source list)
* fall back to an empty default when their backing file fails to parse, then
* persist that empty default on the next mutation silently wiping data that a
* truncated/partial third-party write (disk-full, editor save error, cloud
* partial sync) left recoverable. Renaming the bad file aside keeps the
* original bytes for recovery and lets the next write start cleanly. Best
* effort: any rename failure is logged and swallowed.
*/
export function quarantineCorruptFile(filePath: string): void {
const quarantinePath = `${filePath}.corrupted`;
try {
fs.renameSync(filePath, quarantinePath);
// `debugLogger.warn` is gated behind QWEN_DEBUG_LOG_FILE (unset for almost
// all users), so without this the user's favorites/scopes/sources would
// appear to vanish with no trail. Surface the quarantine on stderr too.
process.stderr.write(
`[warn] Corrupt extension state file ${filePath} moved aside to ${quarantinePath}\n`,
);
debugLogger.warn(
`Corrupt file ${filePath} could not be parsed; moved aside to ${quarantinePath}.`,
);
} catch (error) {
process.stderr.write(
`[warn] Corrupt extension state file ${filePath} could not be moved aside: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
debugLogger.warn(
`Corrupt file ${filePath} could not be parsed and could not be moved aside: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

View file

@ -47,11 +47,29 @@ import { downloadFromNpmRegistry } from './npm.js';
import { redactUrlCredentials } from './redaction.js';
import type { LoadExtensionContext } from './variableSchema.js';
import { Override, type AllExtensionsEnablementConfig } from './override.js';
import {
ExtensionPreferencesStore,
type ExtensionScope,
} from './extensionPreferences.js';
import {
SourceRegistryStore,
discoverPlugins,
parseExtensionSourceType,
type ExtensionSource,
type DiscoveredPlugin,
} from './sourceRegistry.js';
import {
loadMarketplaceConfigFromSource,
parseInstallSource,
} from './marketplace.js';
import {
isGeminiExtensionConfig,
convertGeminiExtensionPackage,
} from './gemini-converter.js';
import { convertClaudePluginPackage } from './claude-converter.js';
import {
convertClaudePluginPackage,
convertClaudePluginStandalone,
} from './claude-converter.js';
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { ExtensionStorage } from './storage.js';
@ -299,8 +317,15 @@ async function convertGeminiOrClaudeExtension(
await convertClaudePluginPackage(extensionDir, pluginName)
).convertedDir;
originSource = 'Claude';
} else if (
fs.existsSync(path.join(extensionDir, '.claude-plugin', 'plugin.json'))
) {
// A standalone Claude plugin installed directly from a git URL: its root
// holds `.claude-plugin/plugin.json` with no marketplace.json.
newExtensionDir = (await convertClaudePluginStandalone(extensionDir))
.convertedDir;
originSource = 'Claude';
}
// Claude plugin conversion not yet implemented
return { extensionDir: newExtensionDir, originSource };
}
@ -316,6 +341,9 @@ export class ExtensionManager {
private readonly configFilePath: string;
private readonly enabledExtensionNamesOverride: string[];
private readonly workspaceDir: string;
private readonly preferencesStore: ExtensionPreferencesStore;
private readonly sourceRegistryStore: SourceRegistryStore;
private discoverCache: DiscoveredPlugin[] | null = null;
private config?: Config;
private telemetrySettings?: TelemetrySettings;
@ -338,6 +366,14 @@ export class ExtensionManager {
this.configDir,
'extension-enablement.json',
);
this.preferencesStore = new ExtensionPreferencesStore(
path.join(this.configDir, 'extension-preferences.json'),
);
this.sourceRegistryStore = new SourceRegistryStore(
// Keep the on-disk filename as marketplaces.json for backward
// compatibility with sources added before the source/* rename.
path.join(this.configDir, 'marketplaces.json'),
);
this.requestSetting = options.requestSetting;
this.requestChoicePlugin =
options.requestChoicePlugin || (() => Promise.resolve(''));
@ -497,6 +533,160 @@ export class ExtensionManager {
}
}
// ==========================================================================
// Favorites & scope preferences (Installed view grouping)
// ==========================================================================
isFavorite(name: string): boolean {
return this.preferencesStore.isFavorite(name);
}
getFavorites(): string[] {
return this.preferencesStore.getFavorites();
}
/** Toggles favorite state for an extension/MCP server; returns new state. */
toggleFavorite(name: string): boolean {
return this.preferencesStore.toggleFavorite(name);
}
getExtensionScope(name: string): ExtensionScope | undefined {
return this.preferencesStore.getScope(name);
}
getExtensionScopes(): Record<string, ExtensionScope> {
return this.preferencesStore.getScopes();
}
setExtensionScope(name: string, scope: ExtensionScope): void {
this.preferencesStore.setScope(name, scope);
}
/** MCP servers individually disabled inside the given extension. */
getDisabledMcpServers(extensionName: string): string[] {
return this.preferencesStore.getDisabledMcpServers(extensionName);
}
setMcpServerDisabled(
extensionName: string,
serverName: string,
disabled: boolean,
): void {
this.preferencesStore.setMcpServerDisabled(
extensionName,
serverName,
disabled,
);
}
// ==========================================================================
// Marketplace registry & discovery
// ==========================================================================
getSources(): ExtensionSource[] {
return this.sourceRegistryStore.read();
}
/**
* Adds a marketplace source. Loads the marketplace config to resolve a
* human-readable name (falling back to the raw source). Throws if no
* marketplace config can be resolved from the source.
*/
async addSource(source: string): Promise<ExtensionSource> {
const trimmed = source.trim();
if (!trimmed) {
throw new Error('Marketplace source cannot be empty.');
}
const config = await loadMarketplaceConfigFromSource(trimmed);
if (!config) {
// A "marketplace" is a Claude-format collection (.claude-plugin/
// marketplace.json). A single extension repo (Gemini/Claude/git/npm) is
// not a marketplace — guide the user to install it directly instead.
let isInstallableExtension = false;
try {
await parseInstallSource(trimmed);
isInstallableExtension = true;
} catch {
// Not a recognizable install source either.
}
const redacted = redactUrlCredentials(trimmed);
if (isInstallableExtension) {
throw new Error(
`"${redacted}" looks like a single extension, not a marketplace. ` +
`Install it directly with: /extensions install ${redacted}`,
);
}
throw new Error(
`No marketplace found at "${redacted}". ` +
`Expected a .claude-plugin/marketplace.json.`,
);
}
const now = new Date().toISOString();
const entry: ExtensionSource = {
name: config.name || trimmed,
source: trimmed,
type: parseExtensionSourceType(trimmed),
addedAt: now,
lastUpdatedAt: now,
};
this.sourceRegistryStore.add(entry);
this.discoverCache = null; // sources changed -> refetch on next discover
return entry;
}
removeSource(name: string): boolean {
const removed = this.sourceRegistryStore.remove(name);
if (removed) {
this.discoverCache = null;
}
return removed;
}
/**
* Records a fresh "last updated" timestamp for a marketplace and invalidates
* the discovery cache so the next discover re-fetches it.
*/
markSourceUpdated(name: string): ExtensionSource | undefined {
const entry = this.getSources().find((m) => m.name === name);
if (!entry) {
return undefined;
}
const updated: ExtensionSource = {
...entry,
lastUpdatedAt: new Date().toISOString(),
};
this.sourceRegistryStore.add(updated); // add() replaces by name
this.discoverCache = null;
return updated;
}
loadSource(source: string): Promise<ClaudeMarketplaceConfig | null> {
return loadMarketplaceConfigFromSource(source);
}
/**
* Discovers all installable plugins across configured sources, marking
* which are already installed. The fetched listing is cached for the session;
* pass `{ refresh: true }` to force a re-fetch. The cheap `installed` flags are
* always recomputed against the current install state.
*/
async discoverPlugins(options?: {
refresh?: boolean;
}): Promise<DiscoveredPlugin[]> {
const installedNames = new Set(
this.getLoadedExtensions().map((ext) => ext.name),
);
if (this.discoverCache && !options?.refresh) {
return this.discoverCache.map((plugin) => ({
...plugin,
installed: installedNames.has(plugin.name),
}));
}
const result = await discoverPlugins(this.getSources(), installedNames);
this.discoverCache = result;
return result;
}
private enableByPath(
extensionName: string,
includeSubdirs: boolean,
@ -1127,7 +1317,10 @@ export class ExtensionManager {
'success',
),
);
this.enableExtension(newExtensionConfig.name, SettingScope.User);
await this.enableExtension(
newExtensionConfig.name,
SettingScope.User,
);
}
} finally {
if (tempDir) {
@ -1229,6 +1422,7 @@ export class ExtensionManager {
if (isUpdate) return;
this.removeEnablementConfig(extension.name);
this.preferencesStore.clear(extension.name);
await this.refreshTools();
logExtensionUninstall(

View file

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ExtensionPreferencesStore } from './extensionPreferences.js';
describe('ExtensionPreferencesStore', () => {
let tmpDir: string;
let filePath: string;
let store: ExtensionPreferencesStore;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-prefs-'));
filePath = path.join(tmpDir, 'nested', 'extension-preferences.json');
store = new ExtensionPreferencesStore(filePath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns empty defaults when the file does not exist', () => {
expect(store.getFavorites()).toEqual([]);
expect(store.getScopes()).toEqual({});
expect(store.isFavorite('foo')).toBe(false);
expect(store.getScope('foo')).toBeUndefined();
});
it('toggles favorites on and off and persists them', () => {
expect(store.toggleFavorite('alpha')).toBe(true);
expect(store.isFavorite('alpha')).toBe(true);
expect(store.getFavorites()).toEqual(['alpha']);
// A fresh store reading the same file sees the persisted state.
const reopened = new ExtensionPreferencesStore(filePath);
expect(reopened.isFavorite('alpha')).toBe(true);
expect(store.toggleFavorite('alpha')).toBe(false);
expect(store.isFavorite('alpha')).toBe(false);
expect(store.getFavorites()).toEqual([]);
});
it('records and reads per-extension scope intent', () => {
store.setScope('alpha', 'project');
store.setScope('beta', 'user');
expect(store.getScope('alpha')).toBe('project');
expect(store.getScope('beta')).toBe('user');
expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'user' });
});
it('drops unknown scope values when reading persisted preferences', () => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(
filePath,
JSON.stringify({
favorites: [],
scopes: { alpha: 'project', beta: 'user', gamma: 'bogus' },
}),
);
expect(store.getScope('alpha')).toBe('project');
expect(store.getScope('beta')).toBe('user');
expect(store.getScope('gamma')).toBeUndefined();
expect(store.getScopes()).toEqual({ alpha: 'project', beta: 'user' });
});
it('clears all preference state for an extension', () => {
store.toggleFavorite('alpha');
store.setScope('alpha', 'user');
store.setMcpServerDisabled('alpha', 'srv', true);
store.toggleFavorite('beta');
store.clear('alpha');
expect(store.isFavorite('alpha')).toBe(false);
expect(store.getScope('alpha')).toBeUndefined();
expect(store.getDisabledMcpServers('alpha')).toEqual([]);
// Unrelated entries are untouched.
expect(store.isFavorite('beta')).toBe(true);
});
it('records per-extension disabled MCP servers and persists them', () => {
store.setMcpServerDisabled('alpha', 'srv-a', true);
store.setMcpServerDisabled('alpha', 'srv-b', true);
store.setMcpServerDisabled('beta', 'srv-a', true);
expect(store.getDisabledMcpServers('alpha')).toEqual(['srv-a', 'srv-b']);
// Namespaced: beta's same-named entry is independent of alpha's.
expect(store.getDisabledMcpServers('beta')).toEqual(['srv-a']);
const reopened = new ExtensionPreferencesStore(filePath);
expect(reopened.getDisabledMcpServers('alpha')).toEqual(['srv-a', 'srv-b']);
store.setMcpServerDisabled('alpha', 'srv-a', false);
expect(store.getDisabledMcpServers('alpha')).toEqual(['srv-b']);
// Removing the last entry drops the extension key entirely.
store.setMcpServerDisabled('alpha', 'srv-b', false);
expect(store.getDisabledMcpServers('alpha')).toEqual([]);
expect(store.read().disabledMcpServers['alpha']).toBeUndefined();
});
it('drops malformed disabledMcpServers values when reading', () => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(
filePath,
JSON.stringify({
favorites: [],
scopes: {},
disabledMcpServers: { alpha: ['ok', 42], beta: 'oops' },
}),
);
expect(store.getDisabledMcpServers('alpha')).toEqual(['ok']);
expect(store.getDisabledMcpServers('beta')).toEqual([]);
});
it('does not leak favorites between fresh stores via a shared default array', () => {
// Toggling a favorite on a store whose file does not exist must not
// mutate a shared module-level default, polluting other instances.
const otherFile = path.join(tmpDir, 'other', 'extension-preferences.json');
const a = new ExtensionPreferencesStore(filePath);
const b = new ExtensionPreferencesStore(otherFile);
a.toggleFavorite('alpha');
expect(b.getFavorites()).toEqual([]);
});
it('recovers from a corrupted preferences file', () => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '{ not valid json');
expect(store.getFavorites()).toEqual([]);
expect(store.getScopes()).toEqual({});
// And can still write afterwards.
expect(store.toggleFavorite('alpha')).toBe(true);
expect(store.isFavorite('alpha')).toBe(true);
});
it('quarantines a corrupt file (parse error) to a .corrupted sibling', () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '{ not valid json');
expect(store.getFavorites()).toEqual([]);
// The unparseable file is moved aside so the next write can't clobber it.
expect(fs.existsSync(`${filePath}.corrupted`)).toBe(true);
expect(fs.readFileSync(`${filePath}.corrupted`, 'utf-8')).toBe(
'{ not valid json',
);
expect(stderr).toHaveBeenCalled();
stderr.mockRestore();
});
it('does NOT quarantine on a transient read error, but warns on stderr', () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// A path that exists but momentarily can't be read as a file (here a
// directory → EISDIR; same class as EACCES/EMFILE) must NOT be moved aside:
// only a genuine JSON parse failure quarantines. statSync succeeds, then
// readFileSync throws the transient error → outer catch returns defaults.
fs.mkdirSync(filePath, { recursive: true });
expect(store.getFavorites()).toEqual([]);
// Not quarantined, and the path is left untouched for the next read.
expect(fs.existsSync(`${filePath}.corrupted`)).toBe(false);
expect(fs.existsSync(filePath)).toBe(true);
expect(stderr).toHaveBeenCalled();
stderr.mockRestore();
});
});

View file

@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { atomicWriteFileSync } from '../utils/atomicFileWrite.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { quarantineCorruptFile } from './corruptFile.js';
const debugLogger = createDebugLogger('EXT_PREFERENCES');
/**
* Install/visibility scope intent recorded for an extension. The Installed
* view uses it to group extensions the way the user installed them:
* - `user` -> Global (User Scope), available everywhere.
* - `project` -> Project (Workspace), enabled for the current workspace only.
*
* Enable/disable state itself still lives in `extension-enablement.json`; this
* value only records *where the user chose to install* an extension so the UI
* can render the right grouping.
*/
export type ExtensionScope = 'user' | 'project';
function isExtensionScope(value: unknown): value is ExtensionScope {
return value === 'user' || value === 'project';
}
export interface ExtensionPreferences {
/** Names of extensions/MCP servers the user has favorited. */
favorites: string[];
/** Per-extension scope intent, keyed by extension name. */
scopes: Record<string, ExtensionScope>;
/**
* MCP servers the user disabled individually inside an extension, keyed by
* extension name. Namespaced per extension (instead of the global
* `mcp.excluded` list) so a disable can never affect a same-named server
* from another source, and uninstalling the extension cleans it up.
*/
disabledMcpServers: Record<string, string[]>;
}
/** Always returns fresh containers so callers can safely mutate the result. */
function emptyPreferences(): ExtensionPreferences {
return { favorites: [], scopes: {}, disabledMcpServers: {} };
}
/**
* Persists user preferences for extensions (favorites, scope intent) that are
* orthogonal to the enable/disable enablement config. Backed by a single JSON
* file so it is cheap to read/write and easy to reason about.
*/
export class ExtensionPreferencesStore {
// Parsed-file cache keyed by mtime. `read()` sits on hot paths now
// (Config.isMcpServerDisabled is consulted per server during discovery and
// resource reads), so avoid re-reading/re-parsing when the file hasn't
// changed; the mtime check keeps cross-process writes visible.
private cache: { prefs: ExtensionPreferences; mtimeMs: number } | null = null;
constructor(private readonly filePath: string) {}
read(): ExtensionPreferences {
try {
const { mtimeMs } = fs.statSync(this.filePath);
if (this.cache?.mtimeMs === mtimeMs) {
// Clone so callers can mutate the result without corrupting the cache.
return structuredClone(this.cache.prefs);
}
const content = fs.readFileSync(this.filePath, 'utf-8');
let parsed: Partial<ExtensionPreferences>;
try {
parsed = JSON.parse(content) as Partial<ExtensionPreferences>;
} catch (parseError) {
// Only a genuine parse failure means the content is corrupt — move it
// aside so the next write can't clobber recoverable favorites/scopes.
// Transient read errors (EACCES/EMFILE/EISDIR/…) fall through to the
// outer catch, which must NOT quarantine an otherwise-valid file.
debugLogger.error('Corrupt extension preferences:', parseError);
quarantineCorruptFile(this.filePath);
return emptyPreferences();
}
const rawScopes =
parsed.scopes && typeof parsed.scopes === 'object' ? parsed.scopes : {};
const scopes: Record<string, ExtensionScope> = {};
for (const [name, value] of Object.entries(rawScopes)) {
if (isExtensionScope(value)) scopes[name] = value;
}
const rawDisabled =
parsed.disabledMcpServers &&
typeof parsed.disabledMcpServers === 'object'
? parsed.disabledMcpServers
: {};
const disabledMcpServers: Record<string, string[]> = {};
for (const [name, value] of Object.entries(rawDisabled)) {
if (Array.isArray(value)) {
const servers = value.filter(
(v): v is string => typeof v === 'string',
);
if (servers.length) disabledMcpServers[name] = servers;
}
}
const prefs: ExtensionPreferences = {
favorites: Array.isArray(parsed.favorites) ? parsed.favorites : [],
scopes,
disabledMcpServers,
};
this.cache = { prefs: structuredClone(prefs), mtimeMs };
return prefs;
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return emptyPreferences();
}
// A transient read error (permission/too-many-files/…) — the file may be
// perfectly valid, so do NOT quarantine it here; only parse failures
// above do that. Return the default for this read.
//
// `debugLogger.error` is gated behind QWEN_DEBUG_LOG_FILE (unset for
// almost all users), so without an stderr line the user's
// favorites/scopes would appear to vanish with no trail. Mirror the
// `quarantineCorruptFile` pattern and surface it on stderr too.
process.stderr.write(
`[warn] Could not read extension preferences at ${this.filePath}: ${
error instanceof Error ? error.message : String(error)
}. Using defaults for this session.\n`,
);
debugLogger.error('Error reading extension preferences:', error);
return emptyPreferences();
}
}
private write(prefs: ExtensionPreferences): void {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
atomicWriteFileSync(this.filePath, JSON.stringify(prefs, null, 2));
// Drop the cache; the next read re-stats and re-parses the new file.
this.cache = null;
}
isFavorite(name: string): boolean {
return this.read().favorites.includes(name);
}
getFavorites(): string[] {
return this.read().favorites;
}
/**
* Toggles the favorite state for an item and returns the new state.
*/
toggleFavorite(name: string): boolean {
const prefs = this.read();
const index = prefs.favorites.indexOf(name);
let nowFavorite: boolean;
if (index >= 0) {
prefs.favorites.splice(index, 1);
nowFavorite = false;
} else {
prefs.favorites.push(name);
nowFavorite = true;
}
this.write(prefs);
return nowFavorite;
}
getScope(name: string): ExtensionScope | undefined {
return this.read().scopes[name];
}
getScopes(): Record<string, ExtensionScope> {
return this.read().scopes;
}
setScope(name: string, scope: ExtensionScope): void {
const prefs = this.read();
prefs.scopes[name] = scope;
this.write(prefs);
}
/** MCP servers individually disabled inside the given extension. */
getDisabledMcpServers(extensionName: string): string[] {
return this.read().disabledMcpServers[extensionName] ?? [];
}
setMcpServerDisabled(
extensionName: string,
serverName: string,
disabled: boolean,
): void {
const prefs = this.read();
const current = prefs.disabledMcpServers[extensionName] ?? [];
if (disabled) {
if (current.includes(serverName)) return;
prefs.disabledMcpServers[extensionName] = [...current, serverName];
} else {
if (!current.includes(serverName)) return;
const next = current.filter((n) => n !== serverName);
if (next.length) {
prefs.disabledMcpServers[extensionName] = next;
} else {
delete prefs.disabledMcpServers[extensionName];
}
}
this.write(prefs);
}
/** Removes all preference state for an extension (used on uninstall). */
clear(name: string): void {
const prefs = this.read();
const favIndex = prefs.favorites.indexOf(name);
let changed = false;
if (favIndex >= 0) {
prefs.favorites.splice(favIndex, 1);
changed = true;
}
if (prefs.scopes[name]) {
delete prefs.scopes[name];
changed = true;
}
if (prefs.disabledMcpServers[name]) {
delete prefs.disabledMcpServers[name];
changed = true;
}
if (changed) {
this.write(prefs);
}
}
}

View file

@ -16,10 +16,20 @@ import {
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
// Imported inside the (hoisted) factory — the top-level `path` import isn't
// initialized yet when this runs.
const nodePath = await import('node:path');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
// The symlink-confinement guard (realPathWithin) resolves both the config
// path and its dir via realpathSync. These unit tests use in-memory mock
// dirs that don't exist on disk; normalize via path.resolve (as the real
// realpathSync would) so the config path and its dir share separators —
// otherwise on Windows path.join() backslashes vs. the raw forward-slash
// mock dir make the containment startsWith() spuriously fail.
realpathSync: vi.fn((p: string) => nodePath.resolve(p)),
};
});
@ -100,6 +110,19 @@ describe('convertGeminiToQwenConfig', () => {
'Gemini extension config must have name and version fields',
);
});
it('throws when gemini-extension.json resolves through a symlink outside the extension', () => {
const mockDir = '/mock/extension/dir';
// realPathWithin resolves the config path first: pretend it points outside
// the extension dir (a symlink escape). The root resolves normally.
vi.mocked(fs.realpathSync).mockReturnValueOnce(
'/outside/extension/gemini-extension.json',
);
expect(() => convertGeminiToQwenConfig(mockDir)).toThrow(
/resolves through a symlink outside the extension/,
);
});
});
describe('isGeminiExtensionConfig', () => {
@ -171,6 +194,17 @@ describe('isGeminiExtensionConfig', () => {
expect(isGeminiExtensionConfig(mockDir)).toBe(true);
});
it('returns false when gemini-extension.json symlink escapes during detection', () => {
const mockDir = '/mock/extension/dir';
vi.mocked(fs.existsSync).mockReturnValue(true);
// The config path resolves outside the extension dir (symlink escape);
// detection must refuse to read it and report "not a Gemini extension".
vi.mocked(fs.realpathSync).mockReturnValueOnce('/outside/path');
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
});
});
// Note: convertGeminiExtensionPackage() is tested through integration tests

View file

@ -36,6 +36,14 @@ export function convertGeminiToQwenConfig(
extensionDir: string,
): ExtensionConfig {
const configFilePath = path.join(extensionDir, 'gemini-extension.json');
// The manifest may be a symlink in an untrusted clone; refuse to follow it
// outside the extension (would read an arbitrary JSON-shaped host file),
// matching the Claude-format manifest guards.
if (!realPathWithin(configFilePath, extensionDir)) {
throw new Error(
`Gemini extension config at ${configFilePath} resolves through a symlink outside the extension`,
);
}
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const geminiConfig: GeminiExtensionConfig = JSON.parse(configContent);
// Validate required fields
@ -108,20 +116,61 @@ export async function convertGeminiExtensionPackage(
}
}
/**
* True when `child` equals or is nested under `parent`. Both must already be
* absolute, resolved paths. Shared containment primitive for the symlink
* confinement guards (kept in one place so the rule can't drift between files).
*/
export function isPathWithin(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent + path.sep);
}
/**
* True when `target` exists and its real (symlink-resolved) path stays within
* `root`'s real path. Both sides are resolved with `fs.realpathSync` so a
* symlink in an untrusted source cannot point a read/copy at a file outside
* the package. Returns false for missing or broken paths.
*/
export function realPathWithin(target: string, root: string): boolean {
try {
return isPathWithin(fs.realpathSync(target), fs.realpathSync(root));
} catch {
return false;
}
}
/**
* Recursively copies a directory and its contents.
* @param source Source directory path
* @param destination Destination directory path
* @param confineRoot If set, any symlink whose real target escapes this
* directory is skipped. Defaults to `fs.realpathSync(source)` when omitted.
* Always pass this explicitly when `source` originates from untrusted input.
*/
export async function copyDirectory(
source: string,
destination: string,
confineRoot?: string,
): Promise<void> {
// Create destination directory if it doesn't exist
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
// Symlinks in an (untrusted) source are dereferenced and their *target*
// content is copied below, so a link escaping the package — e.g.
// `skills/leak.txt -> ~/.ssh/id_rsa` — would otherwise pull host files into
// the output. Pin a confinement root (the package's real path) on the first
// call and thread it through recursion to reject escaping symlink targets.
let root = confineRoot;
if (root === undefined) {
try {
root = fs.realpathSync(source);
} catch {
root = path.resolve(source);
}
}
const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
@ -129,14 +178,21 @@ export async function copyDirectory(
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await copyDirectory(sourcePath, destPath);
await copyDirectory(sourcePath, destPath, root);
} else if (entry.isSymbolicLink()) {
// Resolve symlink and copy the target content
// Resolve symlink and copy the target content, but only when the target
// stays inside the package root.
try {
const realPath = fs.realpathSync(sourcePath);
if (!isPathWithin(realPath, root)) {
debugLogger.warn(
`Skipping symlink that escapes the package: ${sourcePath} -> ${realPath}`,
);
continue;
}
const targetStat = fs.statSync(realPath);
if (targetStat.isDirectory()) {
await copyDirectory(realPath, destPath);
await copyDirectory(realPath, destPath, root);
} else if (targetStat.isFile()) {
fs.copyFileSync(realPath, destPath);
}
@ -202,6 +258,10 @@ export function isGeminiExtensionConfig(extensionDir: string) {
if (!fs.existsSync(configFilePath)) {
return false;
}
// Don't read through a symlink that escapes the extension during detection.
if (!realPathWithin(configFilePath, extensionDir)) {
return false;
}
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const parsedConfig = JSON.parse(configContent);

View file

@ -4,6 +4,8 @@ export * from './variables.js';
export * from './github.js';
export * from './extensionSettings.js';
export * from './marketplace.js';
export * from './sourceRegistry.js';
export * from './extensionPreferences.js';
export * from './npm.js';
export * from './claude-converter.js';
export * from './redaction.js';

View file

@ -5,7 +5,10 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseInstallSource } from './marketplace.js';
import {
parseInstallSource,
loadMarketplaceConfigFromSource,
} from './marketplace.js';
import * as fs from 'node:fs/promises';
import * as https from 'node:https';
@ -338,4 +341,95 @@ describe('parseInstallSource', () => {
expect(result.marketplaceConfig).toBeUndefined();
});
});
describe('loadMarketplaceConfigFromSource', () => {
it('resolves a marketplace from a git@ SSH source', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
const cfg = {
name: 'ssh-marketplace',
owner: { name: 'Owner' },
plugins: [{ name: 'p1' }],
};
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
const mockRes = {
statusCode: 200,
resume: vi.fn(),
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from(JSON.stringify(cfg)));
}
if (event === 'end') {
handler();
}
}),
};
if (typeof callback === 'function') {
callback(mockRes as never);
}
return { on: vi.fn(), setTimeout: vi.fn(), destroy: vi.fn() } as never;
});
const result = await loadMarketplaceConfigFromSource(
'git@github.com:owner/repo.git',
);
expect(result).toEqual(cfg);
});
// A non-GitHub https URL reaches fetchUrl via a single direct-JSON fetch,
// so these exercise the fetchUrl security guards in isolation.
it('aborts and returns null when the response body exceeds the size cap', async () => {
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
const destroy = vi.fn();
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
const handlers: Record<string, (chunk?: Buffer) => void> = {};
const res = {
statusCode: 200,
resume: vi.fn(),
on: vi.fn((event: string, handler: (chunk?: Buffer) => void) => {
handlers[event] = handler;
}),
};
if (typeof callback === 'function') callback(res as never);
// Emit one chunk past the 10 MB cap AFTER `req` is assigned in fetchUrl
// (the real `https.get` invokes the response callback asynchronously);
// 'end' never fires, so the guard must abort mid-stream.
process.nextTick(() =>
handlers['data']?.(Buffer.alloc(11 * 1024 * 1024)),
);
return { on: vi.fn(), setTimeout: vi.fn(), destroy } as never;
});
const result = await loadMarketplaceConfigFromSource(
'https://example.com/marketplace.json',
);
expect(result).toBeNull();
expect(destroy).toHaveBeenCalled();
});
it('aborts and returns null when the wall-clock deadline elapses', async () => {
vi.useFakeTimers();
try {
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
const destroy = vi.fn();
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
// A stalled/trickling server: connects with 200 but never emits
// 'data' or 'end'. The socket-idle req.setTimeout (mocked no-op) would
// never fire, so only the absolute wall-clock deadline can resolve it.
const res = { statusCode: 200, resume: vi.fn(), on: vi.fn() };
if (typeof callback === 'function') callback(res as never);
return { on: vi.fn(), setTimeout: vi.fn(), destroy } as never;
});
const promise = loadMarketplaceConfigFromSource(
'https://example.com/marketplace.json',
);
// MARKETPLACE_FETCH_TIMEOUT_MS is 10s; advance just past it.
await vi.advanceTimersByTimeAsync(10_000 + 50);
await expect(promise).resolves.toBeNull();
expect(destroy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
});

View file

@ -124,27 +124,62 @@ function isGitUrl(source: string): boolean {
);
}
/** Max time to wait for a single marketplace network request. */
const MARKETPLACE_FETCH_TIMEOUT_MS = 10000;
/** Max marketplace response body. A marketplace.json is tiny; this guards
* against a hostile source streaming unbounded data to exhaust memory. */
const MARKETPLACE_MAX_BODY_BYTES = 10 * 1024 * 1024;
/**
* Fetch content from a URL
* Fetch content from a URL. Resolves to null on non-200, error, timeout, or
* oversized body so a slow/unreachable/hostile marketplace can never hang
* discovery indefinitely or exhaust process memory.
*/
function fetchUrl(
url: string,
headers: Record<string, string>,
): Promise<string | null> {
return new Promise((resolve) => {
https
.get(url, { headers }, (res) => {
if (res.statusCode !== 200) {
resolve(null);
let settled = false;
const done = (value: string | null) => {
if (settled) return;
settled = true;
clearTimeout(hardDeadline);
resolve(value);
};
// `req.setTimeout` only fires on socket inactivity and resets on every
// chunk, so a server trickling bytes can keep the request alive forever.
// Pair it with an absolute wall-clock deadline.
const hardDeadline = setTimeout(() => {
req.destroy();
done(null);
}, MARKETPLACE_FETCH_TIMEOUT_MS);
const req = https.get(url, { headers }, (res) => {
if (res.statusCode !== 200) {
res.resume(); // drain so the socket can be freed
done(null);
return;
}
const chunks: Buffer[] = [];
let total = 0;
res.on('data', (chunk) => {
total += chunk.length;
if (total > MARKETPLACE_MAX_BODY_BYTES) {
req.destroy();
done(null);
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve(Buffer.concat(chunks).toString());
});
})
.on('error', () => resolve(null));
chunks.push(chunk);
});
res.on('end', () => done(Buffer.concat(chunks).toString()));
res.on('error', () => done(null));
});
req.on('error', () => done(null));
req.setTimeout(MARKETPLACE_FETCH_TIMEOUT_MS, () => {
req.destroy();
done(null);
});
});
}
@ -210,6 +245,92 @@ async function readLocalMarketplaceConfig(
}
}
/**
* Loads a Claude-format marketplace config (`.claude-plugin/marketplace.json`)
* from any supported source string, without installing anything. Used by the
* marketplace registry / Discover view to enumerate installable plugins.
*
* Supported sources:
* - Local directory containing `.claude-plugin/marketplace.json`
* - Local path directly to a `marketplace.json` file
* - `owner/repo`, `https://github.com/owner/repo`, `git@github.com:owner/repo.git`
* - Arbitrary `https://host/.../marketplace.json` returning the JSON document
*
* Returns `null` when no marketplace config can be resolved.
*/
export async function loadMarketplaceConfigFromSource(
source: string,
): Promise<ClaudeMarketplaceConfig | null> {
const trimmed = source.trim();
// Priority 1: local path (directory with .claude-plugin/marketplace.json,
// or a direct marketplace.json file).
try {
const stats = await stat(trimmed);
if (stats.isDirectory()) {
return await readLocalMarketplaceConfig(trimmed);
}
if (stats.isFile()) {
try {
const content = await fs.promises.readFile(trimmed, 'utf-8');
return JSON.parse(content) as ClaudeMarketplaceConfig;
} catch {
return null;
}
}
} catch {
// Not a local path; continue.
}
// Priority 2: http(s) URL — try GitHub repo first, then a direct JSON doc.
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const { owner, repo } = parseGitHubRepoForReleases(trimmed);
const ghConfig = await fetchGitHubMarketplaceConfig(owner, repo);
if (ghConfig) {
return ghConfig;
}
} catch {
// Not a github.com repo URL — fall through to direct-JSON fetch.
}
const content = await fetchUrl(trimmed, { 'User-Agent': 'qwen-code' });
if (!content) {
return null;
}
try {
return JSON.parse(content) as ClaudeMarketplaceConfig;
} catch {
return null;
}
}
// Priority 3: ssh/sso git URLs -> resolve owner/repo via github.
if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) {
// `git@github.com:owner/repo(.git)` isn't a parseable URL, so extract
// owner/repo directly before falling back to the URL-based parser.
const sshMatch = trimmed.match(
/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/,
);
if (sshMatch) {
return fetchGitHubMarketplaceConfig(sshMatch[1], sshMatch[2]);
}
try {
const { owner, repo } = parseGitHubRepoForReleases(trimmed);
return await fetchGitHubMarketplaceConfig(owner, repo);
} catch {
return null;
}
}
// Priority 4: owner/repo shorthand.
if (isOwnerRepoFormat(trimmed)) {
const [owner, repo] = trimmed.split('/');
return await fetchGitHubMarketplaceConfig(owner, repo);
}
return null;
}
export async function parseInstallSource(
source: string,
): Promise<ExtensionInstallMetadata> {

View file

@ -0,0 +1,361 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
SourceRegistryStore,
parseExtensionSourceType,
discoverPlugins,
type ExtensionSource,
} from './sourceRegistry.js';
import { loadMarketplaceConfigFromSource } from './marketplace.js';
import type { ClaudeMarketplaceConfig } from './claude-converter.js';
vi.mock('./marketplace.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./marketplace.js')>();
return {
...actual,
loadMarketplaceConfigFromSource: vi.fn(),
};
});
describe('parseExtensionSourceType', () => {
it.each([
['anthropics/skills', 'github'],
['https://github.com/owner/repo', 'github'],
['git@github.com:owner/repo.git', 'git'],
['sso://team/repo', 'git'],
['https://example.com/marketplace.json', 'http'],
['./local/marketplace', 'local'],
['/abs/path/marketplace', 'local'],
] as const)('classifies %s as %s', (input, expected) => {
expect(parseExtensionSourceType(input)).toBe(expected);
});
});
describe('SourceRegistryStore', () => {
let tmpDir: string;
let filePath: string;
let store: SourceRegistryStore;
const make = (name: string, source: string): ExtensionSource => ({
name,
source,
type: 'github',
});
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mkt-reg-'));
filePath = path.join(tmpDir, 'nested', 'marketplaces.json');
store = new SourceRegistryStore(filePath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns an empty list when no file exists', () => {
expect(store.read()).toEqual([]);
});
it('adds and persists sources', () => {
store.add(make('Skills', 'anthropics/skills'));
expect(store.read()).toHaveLength(1);
const reopened = new SourceRegistryStore(filePath);
expect(reopened.read()[0].name).toBe('Skills');
});
it('replaces an entry with the same name or source instead of duplicating', () => {
store.add(make('Skills', 'anthropics/skills'));
store.add(make('Skills', 'anthropics/skills-v2'));
store.add(make('Other', 'anthropics/skills-v2'));
const all = store.read();
expect(all).toHaveLength(1);
expect(all[0].name).toBe('Other');
expect(all[0].source).toBe('anthropics/skills-v2');
});
it('removes by name', () => {
store.add(make('A', 'a/a'));
store.add(make('B', 'b/b'));
expect(store.remove('A')).toBe(true);
expect(store.read().map((s) => s.name)).toEqual(['B']);
expect(store.remove('missing')).toBe(false);
});
it('quarantines a corrupt registry (parse error) to a .corrupted sibling', () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '[ not valid json');
expect(store.read()).toEqual([]);
// The unparseable file is moved aside so the next add/remove can't clobber
// a recoverable (e.g. truncated) source list with the empty default.
expect(fs.existsSync(`${filePath}.corrupted`)).toBe(true);
expect(fs.readFileSync(`${filePath}.corrupted`, 'utf-8')).toBe(
'[ not valid json',
);
expect(stderr).toHaveBeenCalled();
stderr.mockRestore();
});
it('does NOT quarantine on a transient read error, but warns on stderr', () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// A path that exists but momentarily can't be read as a file (here a
// directory → EISDIR; same class as EACCES/EMFILE) must NOT be moved aside:
// only a genuine JSON parse failure quarantines.
fs.mkdirSync(filePath, { recursive: true });
expect(store.read()).toEqual([]);
expect(fs.existsSync(`${filePath}.corrupted`)).toBe(false);
expect(fs.existsSync(filePath)).toBe(true);
expect(stderr).toHaveBeenCalled();
stderr.mockRestore();
});
});
describe('discoverPlugins', () => {
beforeEach(() => {
vi.mocked(loadMarketplaceConfigFromSource).mockReset();
});
const config = (
name: string,
plugins: ClaudeMarketplaceConfig['plugins'],
): ClaudeMarketplaceConfig => ({
name,
owner: { name: 'o', email: 'e' },
plugins,
});
it('flattens plugins across sources and marks installed ones', async () => {
vi.mocked(loadMarketplaceConfigFromSource).mockImplementation(
async (source: string) => {
if (source === 'anthropics/skills') {
return config('Skills', [
{
name: 'pdf',
version: '1.0.0',
description: 'PDF tools',
homepage: 'https://example.com/pdf',
source: 'anthropics/skills',
},
{
name: 'docx',
version: '1.0.0',
source: 'anthropics/skills',
},
]);
}
return config('Other', [
{ name: 'xlsx', version: '2.0.0', source: 'me/other' },
]);
},
);
const sources: ExtensionSource[] = [
{ name: 'Skills', source: 'anthropics/skills', type: 'github' },
{ name: 'Other', source: 'me/other', type: 'github' },
];
const discovered = await discoverPlugins(sources, new Set(['docx']));
expect(discovered).toHaveLength(3);
const pdf = discovered.find((p) => p.name === 'pdf')!;
expect(pdf.installed).toBe(false);
expect(pdf.homepage).toBe('https://example.com/pdf');
expect(pdf.installSource).toBe('anthropics/skills:pdf');
expect(discovered.find((p) => p.name === 'docx')!.installed).toBe(true);
});
it('surfaces declared components and lastUpdated for the detail view', async () => {
vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue(
config('Skills', [
{
name: 'pdf',
version: '1.0.0',
source: 'anthropics/skills',
skills: ['pdf-audit', 'pdf-scan'],
mcpServers: { 'pdf-server': { command: 'node' } },
// Arbitrary marketplace metadata field, read best-effort.
lastUpdated: 'Jun 5, 2026',
} as never,
]),
);
const [plugin] = await discoverPlugins(
[{ name: 'Skills', source: 'anthropics/skills', type: 'github' }],
new Set(),
);
expect(plugin.components?.skills).toEqual(['pdf-audit', 'pdf-scan']);
expect(plugin.components?.mcpServers).toEqual(['pdf-server']);
expect(plugin.components?.commands).toBeUndefined();
expect(plugin.lastUpdated).toBe('Jun 5, 2026');
});
it('strips ANSI/control sequences from untrusted display fields', async () => {
// Every displayed field renders in the pre-consent Discover view, so a
// hostile marketplace must not smuggle terminal escapes (cursor moves, line
// clears, OSC title-injection) through any of them. Payload mirrors the PoC
// from the PR #4850 verification report.
const ESC = '\x1b';
const BEL = '\x07';
vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue(
config(`Hostile${ESC}[2K`, [
{
name: `evil${ESC}[31m-plugin${ESC}[2K`,
version: `9.9.9${ESC}[5m`,
description: `Totally safe${ESC}[1A${ESC}]0;PWNED${BEL} — trust me${ESC}[7m`,
author: { name: `mallory${ESC}[0m` },
homepage: `https://e${ESC}[31mvil.com`,
category: `tools${BEL}`,
skills: [`pdf${ESC}]0;t${BEL}-audit`],
lastUpdated: `Jun 5${ESC}[2K, 2026`,
source: 'anthropics/skills',
} as never,
]),
);
const [plugin] = await discoverPlugins(
[{ name: 'Skills', source: 'anthropics/skills', type: 'github' }],
new Set(),
);
expect(plugin.marketplaceName).toBe('Hostile');
expect(plugin.name).toBe('evil-plugin');
expect(plugin.version).toBe('9.9.9');
expect(plugin.description).toBe('Totally safe — trust me');
expect(plugin.author).toBe('mallory');
expect(plugin.homepage).toBe('https://evil.com');
expect(plugin.category).toBe('tools');
expect(plugin.lastUpdated).toBe('Jun 5, 2026');
expect(plugin.components?.skills).toEqual(['pdf-audit']);
// Belt-and-braces: no escape/control byte survives in any rendered field.
const rendered = [
plugin.marketplaceName,
plugin.name,
plugin.version,
plugin.description,
plugin.author,
plugin.homepage,
plugin.category,
plugin.lastUpdated,
...(plugin.components?.skills ?? []),
].join('|');
expect(rendered).not.toContain(ESC);
expect(rendered).not.toContain(BEL);
});
it('derives install source from per-plugin source for http sources', async () => {
vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue(
config('Remote', [
{
name: 'gh-plugin',
version: '1.0.0',
source: { source: 'github', repo: 'someone/repo' },
},
{
name: 'url-plugin',
version: '1.0.0',
source: { source: 'url', url: 'https://example.com/p.tgz' },
},
]),
);
const discovered = await discoverPlugins(
[{ name: 'Remote', source: 'https://x/m.json', type: 'http' }],
new Set(),
);
expect(discovered.find((p) => p.name === 'gh-plugin')!.installSource).toBe(
'someone/repo:gh-plugin',
);
expect(discovered.find((p) => p.name === 'url-plugin')!.installSource).toBe(
'https://example.com/p.tgz',
);
});
it('rejects local-path sources from a remote (http) marketplace', async () => {
// A hostile remote marketplace must not be able to point the installer at a
// local filesystem path — via either the bare-string source or the
// structured { source: 'url' } form. Both fall back to the plugin name.
vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue(
config('Remote', [
{ name: 'abs', version: '1.0.0', source: '/etc/passwd' },
{ name: 'rel', version: '1.0.0', source: '../../secret' },
{ name: 'home', version: '1.0.0', source: '~/.ssh/id_rsa' },
{
name: 'urlabs',
version: '1.0.0',
source: { source: 'url', url: '/etc/shadow' },
},
{
name: 'urlrel',
version: '1.0.0',
source: { source: 'url', url: '../escape' },
},
{
name: 'urlok',
version: '1.0.0',
source: { source: 'url', url: 'https://example.com/p.tgz' },
},
] as never),
);
const discovered = await discoverPlugins(
[{ name: 'Remote', source: 'https://x/m.json', type: 'http' }],
new Set(),
);
const src = (name: string) =>
discovered.find((p) => p.name === name)!.installSource;
// String local paths fall back to the bare plugin name (no redirect).
expect(src('abs')).toBe('abs');
expect(src('rel')).toBe('rel');
expect(src('home')).toBe('home');
// { source: 'url' } local paths are rejected too (previously bypassed).
expect(src('urlabs')).toBe('urlabs');
expect(src('urlrel')).toBe('urlrel');
// A genuine remote URL is preserved.
expect(src('urlok')).toBe('https://example.com/p.tgz');
});
it('skips sources that fail to load without throwing', async () => {
vi.mocked(loadMarketplaceConfigFromSource).mockImplementation(
async (source: string) => {
if (source === 'good/repo') {
return config('Good', [
{ name: 'ok', version: '1.0.0', source: 'good/repo' },
]);
}
throw new Error('network down');
},
);
const discovered = await discoverPlugins(
[
{ name: 'Bad', source: 'bad/repo', type: 'github' },
{ name: 'Good', source: 'good/repo', type: 'github' },
],
new Set(),
);
expect(discovered).toHaveLength(1);
expect(discovered[0].name).toBe('ok');
});
});

View file

@ -0,0 +1,367 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { atomicWriteFileSync } from '../utils/atomicFileWrite.js';
import { stripAnsiAndControl } from '../utils/textUtils.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { redactUrlCredentials } from './redaction.js';
import { loadMarketplaceConfigFromSource } from './marketplace.js';
import { quarantineCorruptFile } from './corruptFile.js';
import type {
ClaudeMarketplaceConfig,
ClaudeMarketplacePluginConfig,
} from './claude-converter.js';
const debugLogger = createDebugLogger('SOURCE_REGISTRY');
export type ExtensionSourceType = 'github' | 'git' | 'http' | 'local';
/**
* A persisted marketplace source the user has added (Marketplaces tab).
*/
export interface ExtensionSource {
/** Display name (from the marketplace config `name`, or derived). */
name: string;
/** Original input string used to add the source. */
source: string;
type: ExtensionSourceType;
/** ISO timestamp recorded when the source was added. */
addedAt?: string;
/** ISO timestamp of the last successful (re)fetch / update. */
lastUpdatedAt?: string;
}
/**
* A single installable plugin surfaced by the Discover view.
*/
/** Components a plugin declares in its marketplace entry ("Will install"). */
export interface DiscoveredPluginComponents {
skills?: string[];
commands?: string[];
agents?: string[];
mcpServers?: string[];
}
export interface DiscoveredPlugin {
/** Name of the marketplace this plugin came from. */
marketplaceName: string;
name: string;
description?: string;
version?: string;
author?: string;
homepage?: string;
category?: string;
/** Best-effort last-updated string when the marketplace entry provides one. */
lastUpdated?: string;
/** Best-effort install/download count when the marketplace entry provides one. */
installs?: number;
/** Components the plugin declares (for the "Will install" summary). */
components?: DiscoveredPluginComponents;
/** Source string suitable for `parseInstallSource`. */
installSource: string;
/** Whether an extension with this name is already installed. */
installed: boolean;
}
function asNameList(
value: string | string[] | undefined,
): string[] | undefined {
return Array.isArray(value) && value.length > 0 ? value : undefined;
}
function asMcpNames(
value: string | Record<string, unknown> | undefined,
): string[] | undefined {
if (value && typeof value === 'object') {
const names = Object.keys(value);
return names.length > 0 ? names : undefined;
}
return undefined;
}
function pluginComponents(
plugin: ClaudeMarketplacePluginConfig,
): DiscoveredPluginComponents | undefined {
// Component names render raw in the Discover "Will install" summary, so scrub
// them like the other untrusted marketplace fields to block ANSI injection.
const components: DiscoveredPluginComponents = {
skills: asNameList(plugin.skills)?.map((s) => sanitizeDisplay(s)),
commands: asNameList(plugin.commands)?.map((s) => sanitizeDisplay(s)),
agents: asNameList(plugin.agents)?.map((s) => sanitizeDisplay(s)),
mcpServers: asMcpNames(plugin.mcpServers)?.map((s) => sanitizeDisplay(s)),
};
return Object.values(components).some(Boolean) ? components : undefined;
}
function pluginLastUpdated(
plugin: ClaudeMarketplacePluginConfig,
): string | undefined {
const record = plugin as unknown as Record<string, unknown>;
const value =
record['lastUpdated'] ?? record['updatedAt'] ?? record['updated'];
return typeof value === 'string' ? value : undefined;
}
function pluginInstalls(
plugin: ClaudeMarketplacePluginConfig,
): number | undefined {
const record = plugin as unknown as Record<string, unknown>;
const value =
record['installs'] ?? record['installCount'] ?? record['downloads'];
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
/**
* Classifies a marketplace source string into a {@link ExtensionSourceType}
* using format heuristics only (no network / filesystem access required for a
* confident answer, beyond an optional existence check the caller may do).
*/
export function parseExtensionSourceType(source: string): ExtensionSourceType {
const trimmed = source.trim();
if (trimmed.startsWith('git@') || trimmed.startsWith('sso://')) {
return 'git';
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return isGitHubHost(trimmed) ? 'github' : 'http';
}
if (isOwnerRepoShorthand(trimmed)) {
return 'github';
}
return 'local';
}
function isGitHubHost(url: string): boolean {
try {
return new URL(url).hostname === 'github.com';
} catch {
return false;
}
}
function isOwnerRepoShorthand(source: string): boolean {
return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(source);
}
/**
* Builds the install-source string fed to `parseInstallSource` for a discovered
* plugin. For repo/local sources this is `<marketplace>:<pluginName>`,
* which the existing installer resolves against the marketplace's
* `marketplace.json`. For direct-JSON (`http`) sources it is derived from
* the per-plugin `source` field.
*/
function resolveInstallSource(
marketplace: ExtensionSource,
plugin: ClaudeMarketplacePluginConfig,
): string {
if (marketplace.type !== 'http') {
return `${marketplace.source}:${plugin.name}`;
}
const src = plugin.source;
if (typeof src === 'string') {
// A remote marketplace must not be able to point the installer at an
// arbitrary local filesystem path (e.g. "/opt/secret" or "../../etc").
if (path.isAbsolute(src) || src.startsWith('.') || src.startsWith('~')) {
debugLogger.warn(
`Ignoring local path source "${src}" from remote marketplace "${marketplace.source}".`,
);
return plugin.name;
}
return src.includes(':') ? src : `${src}:${plugin.name}`;
}
if (src && src.source === 'github') {
return `${src.repo}:${plugin.name}`;
}
if (src && src.source === 'url') {
// Same local-path guard as the string-source branch above: a remote
// marketplace must not be able to redirect the installer at a local
// filesystem path via the structured `{ source: 'url' }` form either.
if (
typeof src.url === 'string' &&
(path.isAbsolute(src.url) ||
src.url.startsWith('.') ||
src.url.startsWith('~'))
) {
debugLogger.warn(
`Ignoring local path source "${src.url}" from remote marketplace "${marketplace.source}".`,
);
return plugin.name;
}
return src.url;
}
return plugin.name;
}
/**
* Strips terminal escape/control sequences from untrusted marketplace text.
* Plugin metadata is rendered in the TUI before the user consents to install,
* so a hostile source could otherwise embed ANSI/OSC sequences to move the
* cursor, clear lines, or spoof UI. Install resolution uses the raw plugin
* fields, so sanitizing the display copy here is safe.
*/
function sanitizeDisplay(text: string): string;
function sanitizeDisplay(text: string | undefined): string | undefined;
function sanitizeDisplay(text: string | undefined): string | undefined {
if (text === undefined) return undefined;
// Delegates to the single shared implementation so the rule can't drift.
return stripAnsiAndControl(text);
}
function pluginsFromConfig(
marketplace: ExtensionSource,
config: ClaudeMarketplaceConfig,
installedNames: ReadonlySet<string>,
): DiscoveredPlugin[] {
return (config.plugins ?? []).map((plugin) => ({
marketplaceName: sanitizeDisplay(config.name || marketplace.name),
name: sanitizeDisplay(plugin.name),
description: sanitizeDisplay(plugin.description),
// `version` and `lastUpdated` render in the pre-consent Discover detail via
// `t()` (no escaping), so they need the same scrubbing as the other
// untrusted display fields. `category` has no sink today but is wrapped for
// consistency / future-proofing.
version: sanitizeDisplay(plugin.version),
author: sanitizeDisplay(plugin.author?.name),
homepage: sanitizeDisplay(plugin.homepage),
category: sanitizeDisplay(plugin.category),
lastUpdated: sanitizeDisplay(pluginLastUpdated(plugin)),
installs: pluginInstalls(plugin),
components: pluginComponents(plugin),
installSource: resolveInstallSource(marketplace, plugin),
installed: installedNames.has(plugin.name),
}));
}
/**
* Persists the list of marketplace sources the user has added.
*/
export class SourceRegistryStore {
constructor(private readonly filePath: string) {}
read(): ExtensionSource[] {
let content: string;
try {
content = fs.readFileSync(this.filePath, 'utf-8');
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return [];
}
// A transient read error (permission/too-many-files/…) — the file may be
// valid, so do NOT quarantine it; only a parse failure below does that.
//
// `debugLogger.error` is gated behind QWEN_DEBUG_LOG_FILE (unset for
// almost all users), so without an stderr line the user's source list
// would appear to vanish with no trail. Mirror the
// `quarantineCorruptFile` pattern and surface it on stderr too.
process.stderr.write(
`[warn] Could not read marketplace registry at ${this.filePath}: ${
error instanceof Error ? error.message : String(error)
}. Using an empty source list for this session.\n`,
);
debugLogger.error('Error reading marketplace registry:', error);
return [];
}
try {
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? (parsed as ExtensionSource[]) : [];
} catch (parseError) {
// Genuine corruption: move the file aside so the next `add`/`remove`
// write can't clobber a recoverable (e.g. truncated) source list with
// the empty default returned below.
debugLogger.error('Corrupt marketplace registry:', parseError);
quarantineCorruptFile(this.filePath);
return [];
}
}
private write(sources: ExtensionSource[]): void {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
atomicWriteFileSync(this.filePath, JSON.stringify(sources, null, 2));
}
/**
* Adds (or replaces, when name/source matches) a marketplace source.
*/
add(source: ExtensionSource): void {
const sources = this.read().filter(
(existing) =>
existing.name !== source.name && existing.source !== source.source,
);
sources.push(source);
this.write(sources);
}
/** Removes a marketplace by name. Returns true if anything was removed. */
remove(name: string): boolean {
const sources = this.read();
const next = sources.filter((s) => s.name !== name);
if (next.length === sources.length) {
return false;
}
this.write(next);
return true;
}
}
/**
* Loads each configured marketplace and flattens their plugin lists into a
* single de-duplicated {@link DiscoveredPlugin} array, tagging which entries are
* already installed. Marketplaces that fail to load are skipped (and logged) so
* one bad source does not break discovery.
*/
export async function discoverPlugins(
sources: readonly ExtensionSource[],
installedNames: ReadonlySet<string>,
): Promise<DiscoveredPlugin[]> {
const results = await Promise.all(
sources.map(async (marketplace) => {
try {
const config = await loadMarketplaceConfigFromSource(
marketplace.source,
);
if (!config) {
debugLogger.debug(
`No marketplace config resolved for ${redactUrlCredentials(
marketplace.source,
)}`,
);
return [];
}
return pluginsFromConfig(marketplace, config, installedNames);
} catch (error) {
debugLogger.error(
`Failed to discover plugins from ${redactUrlCredentials(
marketplace.source,
)}:`,
error,
);
return [];
}
}),
);
// De-duplicate by `${marketplaceName}/${pluginName}` to keep distinct plugins
// that happen to share a name across different sources.
const seen = new Set<string>();
const deduped: DiscoveredPlugin[] = [];
for (const plugin of results.flat()) {
const key = `${plugin.marketplaceName}/${plugin.name}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(plugin);
}
return deduped;
}

View file

@ -815,6 +815,9 @@ export async function connectAndDiscover(
// If we found anything, the server is connected
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTED);
// A successful connect proves authentication works now — clear the sticky
// 401 marker so later unrelated outages aren't mislabeled as auth failures.
mcpServerRequiresOAuth.delete(mcpServerName);
// Register any discovered tools
for (const tool of tools) {

View file

@ -5,7 +5,11 @@
*/
import { describe, it, expect } from 'vitest';
import { safeLiteralReplace, normalizeContent } from './textUtils.js';
import {
safeLiteralReplace,
normalizeContent,
stripAnsiAndControl,
} from './textUtils.js';
describe('safeLiteralReplace', () => {
it('returns original string when oldString empty or not found', () => {
@ -117,3 +121,30 @@ describe('normalizeContent', () => {
expect(normalizeContent('Just a single line')).toBe('Just a single line');
});
});
describe('stripAnsiAndControl', () => {
const ESC = '\x1b';
it('leaves ordinary text untouched', () => {
expect(stripAnsiAndControl('hello world 123')).toBe('hello world 123');
});
it('strips ANSI/VT escape sequences (e.g. clear-screen, color)', () => {
expect(stripAnsiAndControl(`${ESC}[2Jevil`)).toBe('evil');
expect(stripAnsiAndControl(`${ESC}[31mred${ESC}[0m`)).toBe('red');
});
it('strips OSC 8 hyperlink sequences but keeps the link text', () => {
const osc = `${ESC}]8;;http://attacker\u0007click${ESC}]8;;\u0007`;
expect(stripAnsiAndControl(osc)).toBe('click');
});
it('removes residual C0 control chars and DEL', () => {
// NUL, BEL and DEL between letters are dropped (no escape prefix).
expect(stripAnsiAndControl('a\u0000b\u0007c\u007fd')).toBe('abcd');
});
it('removes C1 control chars (the range a drifted local copy missed)', () => {
expect(stripAnsiAndControl('x\u0080y\u0081z')).toBe('xyz');
});
});

View file

@ -4,6 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { stripVTControlCharacters } from 'node:util';
// C0/C1 control chars (incl. DEL) left behind after escape-sequence stripping.
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g;
/**
* Strips terminal escape/control sequences from untrusted text: removes ANSI/VT
* escape sequences (via Node's `stripVTControlCharacters`) and then any residual
* C0/C1 control characters (including DEL).
*
* Use this for ANY untrusted string that may reach a terminal marketplace
* metadata rendered in the TUI, values interpolated into error messages, etc.
* Centralised here so the rule can't drift between call sites: a bypass fixed
* here is fixed everywhere instead of leaving a stale near-duplicate vulnerable.
*/
export function stripAnsiAndControl(text: string): string {
return stripVTControlCharacters(text).replace(CONTROL_CHARS_RE, '');
}
/**
* Safely replaces text with literal strings, avoiding ECMAScript GetSubstitution issues.
* Escapes $ characters to prevent template interpretation.