Skip to content

fix(extension): auto-detect agents/skills/commands directories for Gemini extensions - #4859

Closed
callmeYe wants to merge 5 commits into
QwenLM:mainfrom
callmeYe:worktree-fix-gemini-agents
Closed

callmeYe wants to merge 5 commits into
QwenLM:mainfrom
callmeYe:worktree-fix-gemini-agents

Conversation

@callmeYe

@callmeYe callmeYe commented Jun 8, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Automatically detects and adds references to agents/, skills/, and commands/ directories when converting Gemini extensions to Qwen Code format. The detection happens during the convertGeminiExtensionPackage process and only adds directory references if they exist but are not explicitly declared in gemini-extension.json.

Why it's needed

When installing Gemini extensions via qwen extension install, the conversion function only performed direct field mapping from gemini-extension.json to qwen-extension.json. Extensions like gsd-core that have agents/ directories but don't explicitly declare them in their config file would have their agents unavailable after installation.

Problem example: gsd-core has 33 agent files in agents/ but only 2 were loaded because the config didn't declare agents: "agents".

This fix ensures directory conventions are respected without requiring explicit declarations, aligning with how Qwen Code extensions are expected to work.

Reviewer Test Plan

How to verify

  1. Before fix: Install gsd-core extension and check that only 2 agents appear in /agents manage dialog
  2. After fix: Reinstall gsd-core extension and verify all 33 agents are loaded
# Clean up existing installation
rm -rf ~/.qwen/extensions/gsd-core

# Reinstall the extension
qwen extension install https://github.com/open-gsd/gsd-core

# Check that agents are loaded (should see 33 agents from gsd-core)
# In Qwen Code, run: /agents manage
  1. Unit tests: Run cd packages/core && npx vitest run src/extension/gemini-converter.test.ts — all 9 tests pass
  2. Build: Run npm run build — no errors

Evidence (Before & After)

Before: gsd-core extension installed but only 2 agents visible
After: gsd-core extension installed with all 33 agents visible in /agents manage dialog

Tested on

OS Status
🍏 macOS ✅
🪟 Windows N/A
🐧 Linux N/A

Risk & Scope

  • Main risk: Low — auto-detection only adds directory references when directories exist and are not already declared. No breaking changes to existing extensions that already declare them explicitly.
  • Not validated: Did not test with Claude marketplace extensions or npm-published extensions — only git-based Gemini extensions.
  • Breaking changes: None — backward compatible with existing extensions.

Linked Issues

中文说明

这个 PR 做了什么

在将 Gemini 扩展转换为 Qwen Code 格式时,自动检测并添加 agents/、skills/ 和 commands/ 目录的引用。检测发生在 convertGeminiExtensionPackage 过程中,仅当这些目录存在但未在 gemini-extension.json 中显式声明时才会添加。

为什么需要这个

通过 qwen extension install 安装 Gemini 扩展时,转换函数只执行从 gemini-extension.json 到 qwen-extension.json 的直接字段映射。像 gsd-core 这样在 agents/ 目录中有文件但未在配置中显式声明的扩展,安装后其 agents 将不可用。

问题示例:gsd-core 在 agents/ 中有 33 个 agent 文件,但只加载了 2 个,因为配置没有声明 agents: "agents"。

此修复确保目录约定得到尊重,无需显式声明,符合 Qwen Code 扩展的预期工作方式。

审查者测试计划

如何验证

  1. 修复前:安装 gsd-core 扩展并检查 /agents manage 对话框中只显示 2 个 agents
  2. 修复后:重新安装 gsd-core 扩展并验证所有 33 个 agents 都被加载
# 清理现有安装
rm -rf ~/.qwen/extensions/gsd-core

# 重新安装扩展
qwen extension install https://github.com/open-gsd/gsd-core

# 检查 agents 是否加载(应该看到来自 gsd-core 的 33 个 agents)
# 在 Qwen Code 中运行:/agents manage
  1. 单元测试:运行 cd packages/core && npx vitest run src/extension/gemini-converter.test.ts — 所有 9 个测试通过
  2. 构建:运行 npm run build — 无错误

证据(前后对比)

修复前:gsd-core 扩展已安装但只显示 2 个 agents
修复后:gsd-core 扩展已安装,在 /agents manage 对话框中显示所有 33 个 agents

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows N/A
🐧 Linux N/A

风险与范围

  • 主要风险:低 — 自动检测仅在目录存在且未显式声明时添加引用。对已显式声明的现有扩展无破坏性变更。
  • 未验证:未测试 Claude marketplace 扩展或 npm 发布的扩展 — 仅测试了基于 git 的 Gemini 扩展。
  • 破坏性变更:无 — 与现有扩展向后兼容。

关联 Issue

…mini extensions

When converting a Gemini extension to Qwen Code format, automatically detect
and add references to agents/, skills/, and commands/ directories if they
exist but are not declared in gemini-extension.json.

This fixes an issue where Gemini extensions like gsd-core that have
agents/ directories but don't explicitly declare them in their config
would have their agents unavailable after installation.
@callmeYe
callmeYe requested a review from qwen-code-ci-bot June 8, 2026 12:58
mcpServers: geminiConfig.mcpServers as ExtensionConfig['mcpServers'],
contextFileName: geminiConfig.contextFileName,
settings,
agents: geminiConfig.agents,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing these fields through is not enough for non-default paths. ExtensionManager still loads extension skills and agents from ${extensionPath}/skills and ${extensionPath}/agents unconditionally, so a Gemini extension that explicitly declares "agents": "custom-agents" or "skills": "custom-skills" will get those paths written to qwen-extension.json but the resources still will not be loaded. This needs either collection into the default folders during conversion (similar to the Claude converter) or loader support for the configured paths before exposing the fields.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the passthrough of agents/skills/commands from convertGeminiToQwenConfig entirely. Since ExtensionManager always loads from hardcoded ${extensionPath}/agents etc., exposing custom paths in the config would be misleading. The auto-detection in convertGeminiExtensionPackage still handles the standard directory case correctly.

settings,
agents: geminiConfig.agents,
skills: geminiConfig.skills,
commands: geminiConfig.commands,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passthrough has a conversion gap for explicitly declared command paths. The package converter only runs TOML-to-Markdown conversion for the hard-coded commands/ directory, but after this line Qwen will respect a custom commands value from gemini-extension.json. For "commands": "custom-cmds", any Gemini TOML commands in custom-cmds remain unconverted, so FileCommandLoader will scan that directory for .md files and load nothing. Please convert the declared command path(s), or only emit commands when the target has been converted into a Qwen-loadable Markdown directory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same as above, removed the commands passthrough. Custom command paths would bypass TOML-to-Markdown conversion and fail to load. Auto-detection now only emits "commands": "commands" when the default directory exists as a non-empty directory.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Missing test coverage for auto-detection logic

The PR adds ~30 lines of new behavior in convertGeminiExtensionPackage() but the test file (gemini-converter.test.ts) has no coverage for any of the auto-detection paths. Key untested branches:

  1. agents/ exists + not declared → should auto-set
  2. skills/ exists + not declared → should auto-set
  3. commands/ exists + non-empty + not declared → should auto-set
  4. Directory exists + already declared → should NOT override
  5. Empty commands/ directory → should NOT auto-set
  6. Directory does not exist → should NOT auto-set

Please add unit tests for convertGeminiExtensionPackage covering these scenarios.

— qwen3.7-max via Qwen Code /review

const commandsDirAfterConvert = path.join(tmpDir, 'commands');

if (fs.existsSync(agentsDir) && !geminiConfig.agents) {
geminiConfig.agents = 'agents';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Inconsistent empty-directory guard across the three auto-detection blocks. The commands check (below) includes fs.readdirSync(...).length > 0, but agents and skills only check fs.existsSync(). An empty agents/ or skills/ directory (e.g., scaffolding placeholder) would be falsely detected and registered in the config.

Suggested change
geminiConfig.agents = 'agents';
if (fs.existsSync(agentsDir) && !geminiConfig.agents && fs.readdirSync(agentsDir).length > 0) {

Apply the same guard to the skills block as well.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added fs.readdirSync(agentsDir).length > 0 guard to both agents and skills blocks, matching the existing commands check. All three now consistently skip empty directories.

const agentsDir = path.join(tmpDir, 'agents');
const skillsDir = path.join(tmpDir, 'skills');
const commandsDirAfterConvert = path.join(tmpDir, 'commands');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] commandsDirAfterConvert is path.join(tmpDir, 'commands') — identical to commandsDir from Step 2 (line 91). Both resolve to the same path. The redundant variable hurts readability and creates mild confusion about whether the path could differ post-conversion. Reuse commandsDir instead.

Suggested change
// Remove commandsDirAfterConvert; reuse commandsDir from Step 2 above
const agentsDir = path.join(tmpDir, 'agents');
const skillsDir = path.join(tmpDir, 'skills');

Then use commandsDir in the check below.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed commandsDirAfterConvert and reused commandsDir from Step 2.

const settings: ExtensionSetting[] | undefined = geminiConfig.settings;

// Direct field mapping
// Direct field mapping, including auto-detected fields

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The comment "including auto-detected fields" is misleading in the context of convertGeminiToQwenConfig. This function simply passes fields through from gemini-extension.json — auto-detection happens later in convertGeminiExtensionPackage (Step 3). A reader of this function in isolation would incorrectly conclude that auto-detection logic exists inside it. Revert to // Direct field mapping.

Suggested change
// Direct field mapping, including auto-detected fields
// Direct field mapping

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — reverted to // Direct field mapping. The auto-detection fields passthrough has been removed from this function entirely.

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR adds auto-detection of agents/, skills/, and commands/ directories during Gemini extension conversion. The overall approach is sound, but there are a few issues worth addressing:

  1. Directory validation - fs.existsSync alone doesn't verify the path is actually a directory (could be a regular file).
  2. Inconsistent empty-directory handling - commands has a non-empty check but agents/skills don't.
  3. Missing test coverage - The new auto-detection behavior has no tests.

See inline comments for details and suggested fixes.

// if they exist but are not declared in the config
const agentsDir = path.join(tmpDir, 'agents');
const skillsDir = path.join(tmpDir, 'skills');
const commandsDirAfterConvert = path.join(tmpDir, 'commands');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] fs.existsSync returns true for regular files as well as directories. If an extension has a regular file named agents (not a directory), this would incorrectly add "agents": "agents" to the config. Downstream, loadSubagentFromDir calls fs.readdir which would throw ENOTDIR.

The codebase uses statSync().isDirectory() elsewhere (e.g. extensionManager.ts:581, extensionManager.ts:630). Consider applying the same pattern here and for skillsDir/commandsDirAfterConvert below.

Suggested change
const commandsDirAfterConvert = path.join(tmpDir, 'commands');
if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory() && !geminiConfig.agents) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added fs.statSync(agentsDir).isDirectory() checks to all three auto-detection blocks, consistent with the pattern used in extensionManager.ts.

if (
fs.existsSync(commandsDirAfterConvert) &&
!geminiConfig.commands &&
fs.readdirSync(commandsDirAfterConvert).length > 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Inconsistent empty-directory handling: the commands auto-detection checks fs.readdirSync(...).length > 0 to skip empty directories, but the agents and skills checks above (lines 99, 107) don't. This means an empty agents/ or skills/ directory will be declared in the config while an empty commands/ won't.

If the intent is to skip empty directories for all three, add the same check to agents and skills. If the intent is different, a comment explaining why commands gets the extra guard would help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — all three blocks now have consistent guards: existsSync + isDirectory() + readdirSync().length > 0.

// Step 3: Create qwen-extension.json with converted config
// Step 3: Auto-detect and add agents/skills/commands directory references
// if they exist but are not declared in the config
const agentsDir = path.join(tmpDir, 'agents');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] commandsDirAfterConvert is the same path as commandsDir declared above at line 89 (path.join(tmpDir, 'commands')). The variable could be reused to avoid the redundant declaration.

Suggested change
const agentsDir = path.join(tmpDir, 'agents');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the redundant variable, now reusing commandsDir from Step 2.

- Remove passthrough of agents/skills/commands from convertGeminiToQwenConfig
  since ExtensionManager loads from hardcoded paths, making custom paths inert
- Add fs.statSync().isDirectory() checks to prevent false detection on files
- Add consistent non-empty directory guards across agents/skills/commands
- Remove redundant commandsDirAfterConvert variable, reuse commandsDir
- Revert misleading comment back to "Direct field mapping"
- Add 7 unit tests covering all auto-detection scenarios

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@callmeYe

callmeYe commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

All review comments have been addressed in commit 6c51eae. Summary of changes:

@BZ-D — Removed the passthrough of agents/skills/commands from convertGeminiToQwenConfig. Since ExtensionManager loads from hardcoded paths (${extensionPath}/agents, etc.), exposing custom paths in the config was misleading and non-functional. Auto-detection in convertGeminiExtensionPackage continues to handle the standard directory case.

@wenshao —

  • Reverted comment to // Direct field mapping
  • Removed redundant commandsDirAfterConvert, reusing commandsDir
  • Added consistent readdirSync().length > 0 guard to agents and skills blocks
  • Added 7 unit tests for convertGeminiExtensionPackage covering: auto-detect non-empty dirs, skip empty dirs, skip non-existent dirs, skip regular files, skip custom paths from gemini config

@DragonnZhang —

  • Added fs.statSync().isDirectory() checks to all three auto-detection blocks
  • Same consistency and redundancy fixes as above

All 16 tests pass, type check clean.

@callmeYe
callmeYe requested review from BZ-D, DragonnZhang and wenshao June 9, 2026 03:56
BZ-D
BZ-D previously approved these changes Jun 9, 2026

@BZ-D BZ-D left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found in the latest revision. The auto-detection logic is scoped to existing non-empty default directories, avoids regular-file false positives, and has focused unit coverage.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I noticed: the agents, skills, and commands fields written into the manifest by this PR are not actually consumed by ExtensionManager.

The loader unconditionally scans hardcoded directory paths regardless of what the manifest declares:

// extensionManager.ts:694-709
extension.commands = await loadCommandsFromDir(`${effectiveExtensionPath}/commands`);
extension.skills = await loadSkillsFromDir(`${effectiveExtensionPath}/skills`);
extension.agents = await loadSubagentFromDir(`${effectiveExtensionPath}/agents`);

These calls don't read config.agents / config.skills / config.commands at all — they go straight to the directory. So the new tests prove manifest mutation, but not that previously-missing agents actually become available.

If gsd-core really had 33 agent files but only 2 loaded, the root cause is more likely in loadSubagentFromDir rejecting files that don't match the expected format (valid .md with YAML frontmatter). Could you check whether the missing agents have the right file structure?

Suggestion: either add an ExtensionManager-level integration test that verifies the agents actually load, or reframe this as a metadata-only change (which is fine, but then the PR description should reflect that).

…ng defaults

ExtensionManager previously ignored config.commands/skills/agents and
always scanned hardcoded directory names. Now it reads the manifest
first and only falls back to the default directory when no declaration
is present.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI failing (Windows test). Code review looks good — all prior findings addressed, resolveExtensionResourceDir helper is clean, tests cover the key cases. However, the Windows CI failure may be related to path handling in the new directory resolution. Please investigate. — qwen3-coder via Qwen Code /review

defaultDir: string,
): string {
if (typeof configValue === 'string') {
return path.join(extensionPath, configValue);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] path.join(extensionPath, configValue) does not validate that the resolved path stays within extensionPath. A malicious extension can set "agents": "../../.ssh" in its manifest to escape the extension directory. loadSubagentFromDir and loadSkillsFromDir then read .md file contents from arbitrary filesystem locations and inject them as LLM system prompts.

Before this PR, paths were hardcoded (${extensionPath}/agents), so this is a new attack surface. The codebase already has isPathWithinRoot (workspaceContext.ts:274) that can be reused.

Suggested change
return path.join(extensionPath, configValue);
export function resolveExtensionResourceDir(
extensionPath: string,
configValue: string | string[] | undefined,
defaultDir: string,
): string {
const dirName = typeof configValue === 'string' ? configValue : defaultDir;
const resolved = path.resolve(extensionPath, dirName);
const normalizedBase = path.resolve(extensionPath);
if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
throw new Error(
`Extension resource path "${dirName}" escapes extension directory "${extensionPath}"`,
);
}
return resolved;
}

— qwen3.7-max via Qwen Code /review

it('should use manifest-declared directory when config value is a string', () => {
expect(
resolveExtensionResourceDir('/ext', 'custom-agents', 'agents'),
).toBe('/ext/custom-agents');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] These three tests compare path.join output against POSIX-separator string literals. On Windows, path.join('/ext', 'custom-agents') returns \ext\custom-agents, so all three assertions in this describe block fail on win32 — this is the root cause of the failing Test (windows-latest, Node 22.x) CI job on this PR. Build the expectations with path.join here and in the other two tests (lines 1127–1129 and 1133–1135):

Suggested change
).toBe('/ext/custom-agents');
).toBe(path.join('/ext', 'custom-agents'));

— claude-fable-5 via Claude Code /qreview

}

// Step 3: Create qwen-extension.json with converted config
// Step 3: Auto-detect agents/skills/commands directories and add references

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This auto-detection step is behaviorally inert for the bug the PR claims to fix. It only ever writes the literal default values ("agents": "agents", etc.), and resolveExtensionResourceDir(p, 'agents', 'agents') resolves to exactly the same path as the undefined fallback — which is also exactly the path the loader hardcoded on main before this PR (${effectiveExtensionPath}/agents, scanned unconditionally). So for every converted Gemini extension, load behavior is identical with or without these manifest fields. gsd-core's 33 agents are flat top-level .md files in agents/ — precisely the layout main already scans — so the "before: 2 of 33, after: 33" evidence cannot be produced by this diff; the real root cause of that symptom (if it reproduces on current main) is likely elsewhere, e.g. per-file frontmatter validation in loadSubagentFromDir.

Please either (a) reproduce the 2-of-33 symptom on a build of current main and add a regression test that fails without this diff, or (b) re-scope the PR to "honor manifest-declared resource dirs in ExtensionManager" (the genuinely new behavior here) and drop this auto-detection step as dead weight.

— claude-fable-5 via Claude Code /qreview


extension.commands = await loadCommandsFromDir(
`${effectiveExtensionPath}/commands`,
resolveExtensionResourceDir(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The PR's headline behavior — the loader honoring manifest-declared dirs (here at lines 705–732 and in the install/update consent flow at lines 1003–1027) — has zero behavioral test coverage. The only new tests are three pure path-arithmetic unit tests of resolveExtensionResourceDir, which would still pass if the helper were never called from these call sites. That is exactly the written-but-never-consumed gap this PR's first iteration shipped (caught by human review, not tests). The existing fixtures make this cheap: write a manifest with "agents": "custom-agents" / "skills" / "commands", create files in the custom dirs (and decoys in the default dirs), then assert via refreshCache()/getLoadedExtensions() that extension.agents/skills/commands come from the declared dirs.

— claude-fable-5 via Claude Code /qreview

configValue: string | string[] | undefined,
defaultDir: string,
): string {
if (typeof configValue === 'string') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two unvalidated value classes silently change behavior here:

  1. Empty string: "commands": "" passes the typeof check and path.join(p, '') returns p itself, so the loaders scan the extension root — loadCommandsFromDir globs **/*.md with dot: true, turning README.md/docs into phantom commands, and loadSubagentFromDir parses top-level .md files as agents. Pre-PR, '' fell through to the default dir, and FileCommandLoader (truthiness check) still treats '' as unset — so the two consumers of the same field now disagree.
  2. Declared-but-missing dir: a stale "agents": "my-agents" in an existing manifest (these fields were documented but inert for agents/skills until this PR) now resolves with no existence check, and all three loaders swallow the miss silently (loadCommandsFromDir suppresses ENOENT, loadSubagentFromDir bare-catches, loadSkillsFromDir debug-logs only) — resources silently vanish after upgrade with nothing in default logs.

Suggest treating empty/whitespace strings as unset, and emitting a non-debug warning when an explicitly declared dir does not exist:

Suggested change
if (typeof configValue === 'string') {
if (typeof configValue === 'string' && configValue.trim() !== '') {

— claude-fable-5 via Claude Code /qreview


export function resolveExtensionResourceDir(
extensionPath: string,
configValue: string | string[] | undefined,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This helper and FileCommandLoader.getExtensionCommandsPaths (packages/cli/src/services/FileCommandLoader.ts:209–225) now resolve the same manifest field with conflicting semantics: FileCommandLoader honors string[] values (every entry) and absolute paths (path.isAbsolute check); this helper silently ignores arrays (falls back to the default dir) and mangles absolute strings (path.join('/ext', '/abs') → /ext/abs). Note also that loadExtensionConfig (line 834) runs recursivelyHydrateStrings over the config, so a documented-style value like "commands": "${extensionPath}/commands" hydrates to an absolute path and resolves to <ext>/<ext>/commands here — loading nothing.

Consequence: for array/absolute/hydrated manifests, the install-consent prompt and extension.commands listing show a different command set than what FileCommandLoader actually registers at runtime — the user consents to a list that doesn't match what loads. Suggest a single shared resolver used by both consumers: expand arrays, handle path.isAbsolute, and apply the containment validation from the open comment at line 240 in that one place.

— claude-fable-5 via Claude Code /qreview

mcpServers?: Record<string, unknown>;
contextFileName?: string | string[];
settings?: ExtensionSetting[];
agents?: string | string[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These three fields are dead code — nothing reads them: convertGeminiToQwenConfig deliberately drops them (per the earlier review threads), and the auto-detect mutations below operate on the returned Qwen ExtensionConfig, which already declares these fields. Worse, they advertise support that doesn't exist: a Gemini extension declaring "agents": "my-agents" has its files copied into the converted package, the declaration silently dropped, auto-detection probing only the default agents/ — so those agents vanish with zero diagnostics, the same symptom class this PR sets out to fix. The original rationale for dropping the passthrough ("ExtensionManager loads from hardcoded paths") was invalidated by this same PR's loader change.

Suggest either removing the three fields, or passing through string-valued agents/skills now that the loader honors them (commands must stay dropped — TOML→MD conversion only processes the default commands/ dir — and deserves a comment saying so).

— claude-fable-5 via Claude Code /qreview

await convertGeminiExtensionPackage(testDir);

try {
expect(config.agents).toBe('agents');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These tests assert the in-memory config return value, but the production pipeline discards it — convertGeminiOrClaudeExtension keeps only .convertedDir, and installExtension re-reads qwen-extension.json from disk (extensionManager.ts:965). Coverage currently holds only by statement ordering (the file is written after the mutations); swapping Step 3/Step 4 would break production while these tests stay green. Add at least one assertion against the persisted manifest:

const persisted = JSON.parse(
  actualFs.readFileSync(path.join(convertedDir, 'qwen-extension.json'), 'utf-8'),
);
expect(persisted.agents).toBe('agents');

— claude-fable-5 via Claude Code /qreview

callmeYe and others added 2 commits June 10, 2026 14:29
…ibility

The resolveExtensionResourceDir tests hardcoded forward-slash paths in
expectations, but the implementation uses path.join which produces
backslashes on Windows. Use path.join in expectations to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…nt frontmatter

Many Gemini extensions (e.g. gsd-core) declare tools as a comma-separated
string in YAML frontmatter (`tools: Read, Bash, Grep`), which YAML parses
as a single string. The SubagentValidator then rejects it because it
expects an array, causing 31 of 33 agents to be silently skipped.

Split comma-separated strings into arrays during parsing, consistent with
the existing scalar-to-array normalization for disallowedTools. This is
the actual root cause of the "only 2 subagents loaded" symptom.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@callmeYe

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #4935.

Investigation revealed the root cause is not missing auto-detection in the Gemini converter — it's that parseSubagentContent rejects comma-separated tool strings (tools: Read, Bash, Grep) which YAML parses as a single string instead of an array. The SubagentValidator then fails with "Tools must be an array of strings", silently skipping 31 of 33 agents.

The auto-detection code in this PR was effectively dead — it only writes default values ("agents": "agents") that resolve to the same paths as the existing fallback behavior.

#4935 fixes the actual root cause with a minimal 2-file change.

@callmeYe callmeYe closed this Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope/extensions Extension configuration type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants