diff --git a/docs/design/daemon-extension-skill-catalog.md b/docs/design/daemon-extension-skill-catalog.md new file mode 100644 index 00000000000..f87f1d8ec38 --- /dev/null +++ b/docs/design/daemon-extension-skill-catalog.md @@ -0,0 +1,83 @@ +# Daemon extension Skill catalog + +[English](daemon-extension-skill-catalog.md) | [简体中文](daemon-extension-skill-catalog.zh-CN.md) + +This implements stage 2 of #11274. Previously the daemon-local workspace Skill +provider supplied an empty active-extension list, so its first response omitted +installed extension Skills when no child snapshot existed. + +Use an unbound `ExtensionManager` for the selected workspace to load installed +extensions through the existing consistent store reader. Supply active +extensions to `SkillManager`, preserving project > user > extension > bundled +precedence. Append inactive extension Skills as management entries with the +existing `inactive_extension` status, retaining their identity and metadata. +Resolve settings and extension Skill defaults/overrides with the existing +parsers. A settings opt-in does not enable an inactive parent extension. +Resolve each extension's Skill defaults from its own manifest, with workspace +Skill overrides from the same consistent store snapshot used to load it. Cache +those booleans by the loaded extension object and normalized Skill name. This +avoids selecting the wrong owner through a colliding ID; it does not create a +new identity namespace or change store policy. Explicit opt-ins and hard +settings disablements retain their existing precedence. +Resolve localized extension names with the existing language setting and locale +helpers on every response, without changing the daemon process language or +rebuilding the directory cache when only the language changes. + +Keep the lightweight Config surface: do not construct a runtime Config, start a +child, initialize MCP, execute hooks, or install watchers. Honor safe mode, +disabled discovery levels and workspace trust; inert untrusted inventory must +not load workspace settings or extension runtime context. Directory failures +continue to return an uninitialized error status. + +The implementation and collocated regressions live in the daemon-local provider. +Tests cover real manifests, active/inactive state, source collisions, persisted +Skill settings, safe/untrusted contexts and explicit cache invalidation. E2E +evidence uses an isolated home and a daemon with no child session. + +The facade still prefers child snapshots in this stage. Replacing that source, +changing toggle/refresh semantics, adding configured-state fields and changing +Web Shell projections belong to later PRs. No public schema changes are needed. + +Discovery-level disabling suppresses active extension Skills through +`SkillManager`; inactive extension management entries are still appended, as in +the child producer. Safe mode and untrusted contexts never load extensions. + +An absent extensions root is an empty inventory; no extension store is created. +With a present root the read reconciles through the shared store: a missing or +drifted store is initialized in place under the store's exclusive lock, +rewriting `extension-store/state.json`, creating its `state.previous.json` +rollback copy, and rewriting the legacy `extension-enablement.json` projection. +Cache hits reuse the loaded managers. A later rebuild still takes the lock and +may perform directory/permission maintenance even if policy is unchanged. +Unreadable roots and errors propagated by the shared store/loader return +`initialized: false` with explicit errors. That failure is deliberately +all-or-nothing: one bad extension +artifact (for example a dangling directory entry) fails the entire catalog, +project, user and bundled Skills included, until the artifact is repaired. +Individual artifact handling remains owned by the shared loader: malformed +manifests are skipped with its diagnostic, whereas a dangling extension entry +propagates an error. This stage does not add per-artifact diagnostics or +per-level degradation to the response, or change the loader's failure policy. +Existing facade caching, source preference and invalidation behavior remain +unchanged; the tracking issue assigns cache lifecycle and concurrency changes +to stage 4. + +**Known later-stage items (recorded during review, deliberately not in this +stage):** + +- The sibling `/workspace/extensions` route resolves its locale through + `loadSettings` without `skipLoadEnvironment`, so a workspace `.env` + `QWEN_CODE_LANG` can diverge from this provider's language resolution. +- The inactive-entry append and sort assembly duplicates the child producer's + (`acpAgent.ts`). Here a per-extension name set removes duplicate inactive + Skill names; it provides the same source separation without that string key. +- Extension mutations do not invalidate the config-catalog providers this stage + populates, so a committed install, update, enable/disable or uninstall can + leave stale extension Skill state on the skills config routes until an + unrelated skill mutation, a workspace removal, or a restart. The + invalidation wiring and `refreshCacheIfSourcesChanged` revalidation belong + to stage 4. +- The active-Skill `enabled` judgment mirrors `Config.isSkillEnabled` by hand. +- Shared store/API handling of duplicate extension IDs remains separate. This + provider preserves per-manifest defaults instead of querying an ambiguous + ID for its owner; it still consumes workspace overrides by the store's ID key. diff --git a/docs/design/daemon-extension-skill-catalog.zh-CN.md b/docs/design/daemon-extension-skill-catalog.zh-CN.md new file mode 100644 index 00000000000..7717c294e76 --- /dev/null +++ b/docs/design/daemon-extension-skill-catalog.zh-CN.md @@ -0,0 +1,69 @@ +# Daemon extension Skill 目录 + +[English](daemon-extension-skill-catalog.md) | [简体中文](daemon-extension-skill-catalog.zh-CN.md) + +本设计实现 #11274 的第 2 阶段。此前 daemon 本地 workspace Skill provider +提供空的 active extension 列表,因此没有 child 快照时,首次响应缺少已安装 +extension 的 Skill。 + +为选定 workspace 使用不绑定运行时 Config 的 `ExtensionManager`,通过现有 +store 一致性读取加载扩展。将 active extension 交给 `SkillManager`,保留 +project > user > extension > bundled 优先级。将 inactive extension 的 Skill +作为管理条目追加,使用既有 `inactive_extension` 状态,保留身份和元数据。 +设置、extension Skill 默认值与覆盖沿用现有解析器。settings 显式启用不会启用 +inactive 父扩展。 + +每个 extension 的 Skill 默认值来自其自身 manifest;workspace Skill 覆盖来自 +加载扩展时使用的同一份 store 一致性快照。按已加载 extension 对象和归一化 +Skill 名称缓存布尔结果,避免通过冲突 ID 再次选错 owner。这不新增身份命名空间, +也不修改 store 策略。显式启用与 settings 硬禁用保持原有优先级。 + +每次响应通过现有语言设置和 locale helper 解析本地化扩展名,不修改 daemon +进程语言,也不因单独的语言变化重建目录缓存。 + +保持轻量 Config 接口:不构造运行时 Config、不启动 child、不初始化 MCP、 +不执行 hooks、不安装 watcher。遵守 safe mode、发现层级禁用及 workspace 信任 +规则;未信任目录的静态盘点不加载 workspace 设置或 extension 运行时上下文。 +目录读取失败继续返回未初始化错误状态。 + +实现与相邻回归测试位于 daemon 本地 provider。测试覆盖真实 manifest、 +active/inactive 状态、来源冲突、持久化 Skill 设置、safe/untrusted 上下文及 +显式缓存失效。E2E 使用隔离 home 和无 child 会话的 daemon。 + +本阶段 facade 仍优先使用 child 快照。替换来源、修改开关或刷新语义、增加配置 +状态字段及修改 Web Shell 投影属于后续 PR。不修改公共响应 schema。 + +发现层级禁用通过 `SkillManager` 隐藏 active extension Skill;与 child producer +一致,仍追加 inactive extension 管理条目。safe mode 和未信任上下文不加载扩展。 + +extensions 根目录不存在代表空目录,不创建 extension store。根目录存在时, +通过共享 store 协调读取:缺失或发生漂移的 store 在独占锁内初始化,写入 +`extension-store/state.json`、生成 `state.previous.json` 回滚副本,并写入旧格式 +`extension-enablement.json` 投影。命中缓存时复用已加载 manager;后续重建仍会 +获取锁,即使策略未变也可能维护目录与权限。 + +不可读根目录和共享 store/loader 抛出的错误返回 `initialized: false` 及明确 +错误。该失败有意影响整份目录:例如一个悬空 extension 目录条目会使 project、 +user 和 bundled Skill 一同不可用,直到制品被修复。单个制品的处理仍由共享 +loader 决定:损坏 manifest 被跳过并记录其诊断,悬空条目则向外抛错。本阶段不 +增加逐制品诊断或逐层降级,不修改 loader 失败策略。 + +现有 facade 缓存、来源优先级及失效行为不变;跟踪 issue 将缓存生命周期和并发 +改造安排在第 4 阶段。 + +**评审中记录、明确不在本阶段实现的后续事项:** + +- 相邻 `/workspace/extensions` 路由调用 `loadSettings` 时未设置 + `skipLoadEnvironment`,workspace `.env` 的 `QWEN_CODE_LANG` 可能使其语言 + 解析与本 provider 不同。 +- inactive 条目追加及排序与 child producer(`acpAgent.ts`)存在重复。这里按 + extension 分别对 Skill 名去重,与字符串 `level:extensionName:name` key + 提供相同的来源区分,并未使用该字符串 key。 +- extension 变更没有失效本阶段填充的 config-catalog provider,因此安装、更新、 + 启停或卸载提交后,skills config 路由可能保留旧状态,直到无关 Skill 变更、 + workspace 移除或重启。失效接线与 `refreshCacheIfSourcesChanged` 重新验证 + 属于第 4 阶段。 +- active Skill 的 `enabled` 判断沿用 `Config.isSkillEnabled` 的规则,尚未共享 + 判定实现。 +- 共享 store/API 的重复 extension ID 处理另行跟进。本 provider 从各自 manifest + 保留默认值,不通过歧义 ID 查询 owner;workspace 覆盖仍使用 store 的 ID key。 diff --git a/packages/cli/src/serve/workspace-skills-status.test.ts b/packages/cli/src/serve/workspace-skills-status.test.ts index ba4e2bb1e28..728d1b3e3d7 100644 --- a/packages/cli/src/serve/workspace-skills-status.test.ts +++ b/packages/cli/src/serve/workspace-skills-status.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import * as fsp from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -14,7 +14,11 @@ vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: mockWriteStderrLine, })); -import { SkillManager } from '@qwen-code/qwen-code-core'; +import { + ExtensionManager, + ExtensionStore, + SkillManager, +} from '@qwen-code/qwen-code-core'; import { ENV_CORRUPTED_PATH, ENV_WAS_RECOVERED, @@ -23,7 +27,14 @@ import { import { createWorkspaceSkillsStatusProvider } from './workspace-skills-status.js'; describe('createWorkspaceSkillsStatusProvider', () => { - afterEach(() => { + let qwenHome: string; + beforeEach(async () => { + qwenHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-catalog-home-')); + vi.stubEnv('QWEN_HOME', qwenHome); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await fsp.rm(qwenHome, { recursive: true, force: true }); vi.restoreAllMocks(); mockWriteStderrLine.mockClear(); }); @@ -137,17 +148,17 @@ describe('createWorkspaceSkillsStatusProvider', () => { const status = await provider(workspace); expect(status.skills).toMatchObject([ - { - name: 'enabled', - status: 'ok', - installedPath: '/skills/enabled/SKILL.md', - }, { name: 'disabled', status: 'disabled', disabledReason: 'default', installedPath: '/skills/disabled/SKILL.md', }, + { + name: 'enabled', + status: 'ok', + installedPath: '/skills/enabled/SKILL.md', + }, ]); }); @@ -185,17 +196,17 @@ describe('createWorkspaceSkillsStatusProvider', () => { const status = await provider(workspace); expect(status.skills).toMatchObject([ - { - name: 'enabled', - status: 'ok', - installedPath: '/skills/enabled/SKILL.md', - }, { name: 'disabled', status: 'disabled', disabledReason: 'hard', installedPath: '/skills/disabled/SKILL.md', }, + { + name: 'enabled', + status: 'ok', + installedPath: '/skills/enabled/SKILL.md', + }, ]); // A workspace-scope hard disable is not locked by a higher scope. const hardDisabled = status.skills.find((s) => s.name === 'disabled'); @@ -411,4 +422,565 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect(listSpy).toHaveBeenCalledTimes(2); expect(listSpy.mock.instances[0]).toBe(listSpy.mock.instances[1]); }); + + async function writeExtension( + name: string, + skillNames: string[], + skillStates: Record = {}, + ) { + const directory = path.join(qwenHome, 'extensions', name); + await fsp.mkdir(directory, { recursive: true }); + await fsp.writeFile( + path.join(directory, 'qwen-extension.json'), + JSON.stringify({ + name, + version: '1.0.0', + displayName: `${name} display`, + skillStates, + mcpServers: { sentinel: { command: 'must-not-execute' } }, + }), + ); + for (const skill of skillNames) { + const skillDir = path.join(directory, 'skills', skill); + await fsp.mkdir(skillDir, { recursive: true }); + await fsp.writeFile( + path.join(skillDir, 'SKILL.md'), + `---\nname: ${skill}\ndescription: ${skill} description\nargument-hint: \nuser-invocable: false\n---\nInstructions`, + ); + } + return directory; + } + + it('lists active and inactive extension Skills without a runtime Config', async () => { + const active = await writeExtension('active', ['active-skill']); + await writeExtension('inactive', ['inactive-skill']); + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + // A settings opt-in must not enable a Skill whose parent extension is + // inactive. + await fsp.mkdir(path.join(qwenHome, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(qwenHome, '.qwen', 'settings.json'), + JSON.stringify({ skills: { enabled: ['inactive-skill'] } }), + ); + const refreshRuntime = vi.spyOn(ExtensionManager.prototype, 'refreshTools'); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + expect(status.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'active-skill', + status: 'ok', + level: 'extension', + extensionName: 'active', + extensionDisplayName: 'active display', + installedPath: path.join( + active, + 'skills', + 'active-skill', + 'SKILL.md', + ), + argumentHint: '', + userInvocable: false, + }), + expect.objectContaining({ + name: 'inactive-skill', + status: 'disabled', + disabledReason: 'inactive_extension', + extensionName: 'inactive', + extensionDisplayName: 'inactive display', + }), + ]), + ); + expect(refreshRuntime).not.toHaveBeenCalled(); + }); + + it('preserves project precedence and appends same-name inactive sources', async () => { + await writeExtension('active', ['shared']); + await writeExtension('inactive', ['shared']); + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + const projectSkill = path.join(qwenHome, '.qwen', 'skills', 'shared'); + await fsp.mkdir(projectSkill, { recursive: true }); + await fsp.writeFile( + path.join(projectSkill, 'SKILL.md'), + '---\nname: shared\ndescription: Project wins\n---\nBody', + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.skills.filter((s) => s.name === 'shared')).toMatchObject([ + { level: 'project', status: 'ok' }, + { + level: 'extension', + extensionName: 'inactive', + disabledReason: 'inactive_extension', + }, + ]); + }); + + it('uses persisted workspace activation and Skill overrides from the store', async () => { + await writeExtension( + 'suite', + ['blocked', 'opt-in', 'overridden', 'default-on'], + { + blocked: false, + 'opt-in': false, + overridden: false, + }, + ); + const workspace = path.join(qwenHome, 'workspace'); + const other = path.join(qwenHome, 'other'); + await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ + skills: { enabled: ['OPT-IN'], disabled: ['blocked'] }, + }), + ); + const manager = new ExtensionManager({ + workspaceDir: workspace, + isWorkspaceTrusted: true, + }); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const store = new ExtensionStore(); + await store.setWorkspaceActivation(extension, other, 'disabled'); + await store.setSkillWorkspaceOverrides( + extension, + workspace, + { overridden: true, 'default-on': false }, + 0, + ); + const provider = createWorkspaceSkillsStatusProvider(); + const status = await provider(workspace); + expect( + status.skills.filter((s) => s.extensionName === 'suite'), + ).toMatchObject([ + { name: 'blocked', disabledReason: 'hard' }, + // Manifest default is ON, but the workspace override turns it off. + { name: 'default-on', status: 'disabled', disabledReason: 'default' }, + { name: 'opt-in', status: 'ok' }, + { name: 'overridden', status: 'ok' }, + ]); + const otherStatus = await provider(other); + expect( + otherStatus.skills.filter((s) => s.extensionName === 'suite'), + ).toHaveLength(4); + expect( + otherStatus.skills + .filter((s) => s.extensionName === 'suite') + .every((s) => s.disabledReason === 'inactive_extension'), + ).toBe(true); + }); + + it('maps manifest defaults, reloads settings, and rebuilds after invalidation', async () => { + await writeExtension('suite', ['default-off'], { 'default-off': false }); + const workspace = path.join(qwenHome, 'workspace'); + await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true }); + const provider = createWorkspaceSkillsStatusProvider(); + const readSkill = async () => + (await provider(workspace)).skills.find((s) => s.name === 'default-off'); + expect(await readSkill()).toMatchObject({ + status: 'disabled', + disabledReason: 'default', + }); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ skills: { enabled: ['default-off'] } }), + ); + expect(await readSkill()).toMatchObject({ status: 'ok' }); + await fsp.rm(path.join(qwenHome, 'extensions', 'suite'), { + recursive: true, + }); + provider.invalidate?.(workspace); + expect(await readSkill()).toBeUndefined(); + }); + + it.each(['safe', 'untrusted', 'inert-untrusted'] as const)( + 'does not load extensions in %s mode', + async (mode) => { + await writeExtension('suite', ['hidden']); + if (mode === 'safe') vi.stubEnv('QWEN_CODE_SAFE_MODE', '1'); + const refresh = vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ); + const status = await createWorkspaceSkillsStatusProvider({ + workspaceTrusted: mode !== 'untrusted' && mode !== 'inert-untrusted', + includeUntrustedSkills: mode === 'inert-untrusted', + })(qwenHome); + expect(status.initialized).toBe(true); + expect(status.skills.some((s) => s.level === 'extension')).toBe(false); + expect(refresh).not.toHaveBeenCalled(); + }, + ); + + it('returns an error for an unreadable extension directory', async () => { + await fsp.writeFile(path.join(qwenHome, 'extensions'), 'not a directory'); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status).toMatchObject({ + initialized: false, + skills: [], + errors: [{ kind: 'skills', status: 'error' }], + }); + expect(status.errors?.[0]?.error).toContain('ENOTDIR'); + }); + + it('loads linked extensions and Agent Plugin manifests through the shared loader', async () => { + const source = await writeExtension('linked', ['linked-skill']); + const relocated = path.join(qwenHome, 'linked-source'); + await fsp.rename(source, relocated); + await fsp.mkdir(source); + await fsp.writeFile( + path.join(source, '.qwen-extension-install.json'), + JSON.stringify({ type: 'link', source: relocated }), + ); + const plugin = await writeExtension('portable', ['portable-skill']); + await fsp.rm(path.join(plugin, 'qwen-extension.json')); + await fsp.writeFile( + path.join(plugin, 'plugin.json'), + JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'portable', + version: '1.0.0', + }), + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + expect(status.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'linked-skill', + extensionName: 'linked', + installedPath: path.join( + relocated, + 'skills', + 'linked-skill', + 'SKILL.md', + ), + }), + expect.objectContaining({ + name: 'portable-skill', + extensionName: 'portable', + }), + ]), + ); + }); + + it('does not cache a failed store read as an initialized empty catalog', async () => { + await writeExtension('suite', ['visible']); + vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ).mockRejectedValueOnce(new Error('store unavailable')); + const provider = createWorkspaceSkillsStatusProvider(); + expect(await provider(qwenHome)).toMatchObject({ + initialized: false, + errors: [{ kind: 'skills', error: 'store unavailable' }], + }); + expect((await provider(qwenHome)).skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'visible', status: 'ok' }), + ]), + ); + }); + + it.each([ + { language: 'zh', envLanguage: '', expected: '扩展' }, + { language: 'zh-CN', envLanguage: '', expected: '扩展' }, + { language: 'en', envLanguage: 'zh', expected: '扩展' }, + { language: 'auto', envLanguage: '', expected: '扩展' }, + { language: 'en', envLanguage: '', expected: 'Extension' }, + ])( + 'resolves extension names for $language with env $envLanguage', + async ({ language, envLanguage, expected }) => { + vi.stubEnv('QWEN_CODE_LANG', envLanguage); + vi.stubEnv('LANG', 'zh_CN.UTF-8'); + for (const name of ['active', 'inactive']) { + const directory = await writeExtension(name, [`${name}-skill`]); + const manifestPath = path.join(directory, 'qwen-extension.json'); + const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')); + await fsp.writeFile( + manifestPath, + JSON.stringify({ + ...manifest, + displayName: { en: 'Extension', zh: '扩展' }, + }), + ); + } + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + await fsp.mkdir(path.join(qwenHome, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(qwenHome, '.qwen', 'settings.json'), + JSON.stringify({ general: { language } }), + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + expect( + status.skills.filter((s) => s.level === 'extension'), + ).toMatchObject([ + { name: 'active-skill', extensionDisplayName: expected, status: 'ok' }, + { + name: 'inactive-skill', + extensionDisplayName: expected, + disabledReason: 'inactive_extension', + }, + ]); + }, + ); + + it('gates extension discovery but still lists inactive entries when the extension level is disabled', async () => { + await writeExtension('active', ['active-skill']); + await writeExtension('inactive', ['inactive-skill']); + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + await fsp.mkdir(path.join(qwenHome, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(qwenHome, '.qwen', 'settings.json'), + JSON.stringify({ skills: { disabledLevels: ['extension'] } }), + ); + const refresh = vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + // Discovery is gated: no active extension Skill is listed as usable... + expect(status.skills.some((s) => s.name === 'active-skill')).toBe(false); + // ...but inactive management entries still appear, matching the child + // producer, which appends them unconditionally. + expect(status.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'inactive-skill', + level: 'extension', + disabledReason: 'inactive_extension', + }), + ]), + ); + expect(refresh).toHaveBeenCalled(); + }); + + it('re-resolves the extension locale when the configured language changes', async () => { + vi.stubEnv('QWEN_CODE_LANG', ''); + for (const name of ['active', 'inactive']) { + const directory = await writeExtension(name, [`${name}-skill`]); + const manifestPath = path.join(directory, 'qwen-extension.json'); + const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')); + await fsp.writeFile( + manifestPath, + JSON.stringify({ + ...manifest, + displayName: { en: 'Extension', zh: '扩展' }, + }), + ); + } + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + const workspace = path.join(qwenHome, 'workspace'); + await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ general: { language: 'en' } }), + ); + const refresh = vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ); + const provider = createWorkspaceSkillsStatusProvider(); + const readNames = async () => + (await provider(workspace)).skills + .filter((s) => s.level === 'extension') + .map((s) => `${s.name}:${s.extensionDisplayName ?? ''}`); + expect(await readNames()).toEqual([ + 'active-skill:Extension', + 'inactive-skill:Extension', + ]); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ general: { language: 'zh' } }), + ); + expect(await readNames()).toEqual([ + 'active-skill:扩展', + 'inactive-skill:扩展', + ]); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('ignores a non-string general.language instead of failing the catalog', async () => { + // Pin the env out of the way ('' loses to settings): an ambient + // QWEN_CODE_LANG would shadow the invalid setting and leave the + // non-string guard unexercised. + vi.stubEnv('QWEN_CODE_LANG', ''); + const workspace = path.join(qwenHome, 'workspace'); + await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ general: { language: 1 } }), + ); + const status = await createWorkspaceSkillsStatusProvider()(workspace); + expect(status.initialized).toBe(true); + expect(status.skills.some((s) => s.name === 'review')).toBe(true); + }); + + it('lists a duplicate Skill name within one inactive extension only once', async () => { + const directory = path.join(qwenHome, 'extensions', 'inactive'); + for (const dir of ['a', 'b']) { + const skillDir = path.join(directory, 'skills', dir); + await fsp.mkdir(skillDir, { recursive: true }); + await fsp.writeFile( + path.join(skillDir, 'SKILL.md'), + '---\nname: dup\ndescription: dup description\n---\nInstructions', + ); + } + await fsp.writeFile( + path.join(directory, 'qwen-extension.json'), + JSON.stringify({ name: 'inactive', version: '1.0.0' }), + ); + await fsp.writeFile( + path.join(qwenHome, 'extensions', 'extension-enablement.json'), + JSON.stringify({ inactive: { overrides: ['!*'] } }), + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + const duplicates = status.skills.filter((s) => s.name === 'dup'); + expect(duplicates).toHaveLength(1); + expect(duplicates[0]).not.toHaveProperty('extensionDisplayName'); + }); + + it.skipIf(process.platform === 'win32')( + 'fails closed for a dangling symlink at the extensions root', + async () => { + await fsp.symlink( + path.join(qwenHome, 'missing-target'), + path.join(qwenHome, 'extensions'), + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(false); + expect(status.skills).toEqual([]); + expect(status.errors?.[0]?.error).toContain('ENOENT'); + }, + ); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'fails closed for a searchable but unlistable extensions root', + async () => { + const extensionsRoot = path.join(qwenHome, 'extensions'); + await fsp.mkdir(extensionsRoot); + await fsp.writeFile( + path.join(extensionsRoot, 'extension-enablement.json'), + JSON.stringify({}), + ); + await fsp.chmod(extensionsRoot, 0o111); + try { + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(false); + expect(status.errors?.[0]?.error).toContain('EACCES'); + } finally { + await fsp.chmod(extensionsRoot, 0o755); + } + }, + ); + + it('does not create an extension store when no extensions directory exists', async () => { + const refresh = vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + expect(status.skills.some((skill) => skill.name === 'review')).toBe(true); + expect(refresh).not.toHaveBeenCalled(); + await expect( + fsp.stat(path.join(qwenHome, 'extensions')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fsp.stat(path.join(qwenHome, 'extension-store')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps the shared loader behavior for malformed extension manifests', async () => { + await writeExtension('healthy', ['healthy-skill']); + const broken = await writeExtension('broken', ['broken-skill']); + await fsp.writeFile(path.join(broken, 'qwen-extension.json'), '{invalid'); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(true); + expect(status.errors).toBeUndefined(); + expect( + status.skills + .filter((skill) => skill.level === 'extension') + .map((skill) => skill.name), + ).toEqual(['healthy-skill']); + }); + + it.skipIf(process.platform === 'win32')( + 'reports propagated errors for a dangling extension entry', + async () => { + await writeExtension('healthy', ['healthy-skill']); + await fsp.symlink( + path.join(qwenHome, 'missing'), + path.join(qwenHome, 'extensions', 'dangling'), + ); + const status = await createWorkspaceSkillsStatusProvider()(qwenHome); + expect(status.initialized).toBe(false); + expect(status.skills).toEqual([]); + expect(status.errors?.[0]?.error).toContain('ENOENT'); + }, + ); + + it('preserves each manifest default and store override when extension ids collide', async () => { + for (const [name, type, source] of [ + ['first', 'git', 'https://github.com/example/suite'], + ['second', 'github-release', 'https://github.com/example/suite.git'], + ]) { + const directory = await writeExtension(name!, [`${name}-skill`], { + [`${name}-skill`]: name !== 'second', + }); + await fsp.writeFile( + path.join(directory, '.qwen-extension-install.json'), + JSON.stringify({ type, source }), + ); + } + const manager = new ExtensionManager({ + workspaceDir: qwenHome, + isWorkspaceTrusted: true, + }); + await manager.refreshCache(); + const [first, second] = manager.getLoadedExtensions(); + expect(first!.id).toBe(second!.id); + const provider = createWorkspaceSkillsStatusProvider(); + for (let i = 0; i < 2; i++) { + const status = await provider(qwenHome); + expect(status.initialized).toBe(true); + expect( + status.skills.filter((s) => s.level === 'extension'), + ).toMatchObject([ + { name: 'first-skill', status: 'ok' }, + { name: 'second-skill', status: 'disabled', disabledReason: 'default' }, + ]); + } + const store = new ExtensionStore(); + await store.setSkillWorkspaceOverrides( + second!, + qwenHome, + { 'first-skill': false, 'second-skill': true }, + 0, + ); + provider.invalidate?.(qwenHome); + const status = await provider(qwenHome); + expect(status.initialized).toBe(true); + expect(status.skills.filter((s) => s.level === 'extension')).toMatchObject([ + { name: 'first-skill', status: 'disabled', disabledReason: 'default' }, + { name: 'second-skill', status: 'ok' }, + ]); + }); }); diff --git a/packages/cli/src/serve/workspace-skills-status.ts b/packages/cli/src/serve/workspace-skills-status.ts index d677554465a..5225319c0d8 100644 --- a/packages/cli/src/serve/workspace-skills-status.ts +++ b/packages/cli/src/serve/workspace-skills-status.ts @@ -25,17 +25,25 @@ * shim is sufficient — no full `Config` construction (and no `initialize()` * side effects) required. The live child, when present, stays authoritative: * the facade only falls back here after a real child answer and the cached - * last answer are both unavailable, and this daemon-local view intentionally - * omits extension-provided skills (there is no active-extension context - * outside the child) — those still surface once a session exists. + * last answer are both unavailable. This daemon-local view includes installed + * extension Skills using the persistent extension store without binding a + * runtime Config to the ExtensionManager. */ -import { SkillManager, isSafeModeEnv } from '@qwen-code/qwen-code-core'; -import type { Config, SkillLevel } from '@qwen-code/qwen-code-core'; +import { + ExtensionManager, + ExtensionStore, + SkillManager, + Storage, + isSafeModeEnv, + getExtensionDisplayName, +} from '@qwen-code/qwen-code-core'; +import type { Config, Extension, SkillLevel } from '@qwen-code/qwen-code-core'; import type { ServeWorkspaceSkillsStatus } from '@qwen-code/acp-bridge/status'; import { STATUS_SCHEMA_VERSION } from '@qwen-code/acp-bridge/status'; import * as fs from 'node:fs/promises'; import { loadSettings } from '../config/settings.js'; +import { resolveLanguage, resolveLanguageSetting } from '../i18n/index.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { mapSkillConfigToStatus } from '../runtime/workspace-skills-mapping.js'; import { resolveSkillSettings } from '../config/skill-settings.js'; @@ -76,6 +84,12 @@ type SkillManagerConfigShim = Pick< | 'getDisabledSkillLevels' >; +interface WorkspaceSkillManagers { + skillManager: SkillManager; + extensionManager?: ExtensionManager; + extensionSkillStates: Map>; +} + export function createWorkspaceSkillsStatusProvider( options: WorkspaceSkillsStatusProviderOptions = {}, ): WorkspaceSkillsStatusProvider { @@ -84,7 +98,7 @@ export function createWorkspaceSkillsStatusProvider( // globs for) every level on each call. This is a best-effort pre-child // fallback, so slight staleness between explicit invalidation points is // acceptable: the live child re-lists authoritatively once a session exists. - const managers = new Map(); + const managers = new Map(); const provider = ((workspaceCwd: string) => buildWorkspaceSkillsStatus( workspaceCwd, @@ -98,7 +112,7 @@ export function createWorkspaceSkillsStatusProvider( async function buildWorkspaceSkillsStatus( workspaceCwd: string, - managers: Map, + managers: Map, workspaceTrusted: boolean, includeUntrustedSkills: boolean, ): Promise { @@ -109,8 +123,14 @@ async function buildWorkspaceSkillsStatus( skipWorkspaceSettings: !workspaceTrusted, workspaceTrusted, }); - let skillManager = managers.get(workspaceCwd); - if (!skillManager) { + const rawLanguage = settings.merged.general?.language; + const locale = resolveLanguage( + resolveLanguageSetting( + typeof rawLanguage === 'string' ? rawLanguage : undefined, + ), + ); + let cached = managers.get(workspaceCwd); + if (!cached) { // Mirror the CLI guard in loadCliConfig: safe mode nullifies // disabledSkillLevels so the child session loads all bundled skills. const rawLevels = @@ -127,6 +147,49 @@ async function buildWorkspaceSkillsStatus( ); const safeMode = (!workspaceTrusted && !includeUntrustedSkills) || isSafeModeEnv(); + let extensionManager: ExtensionManager | undefined; + const extensionSkillStates = new Map>(); + if (workspaceTrusted && !safeMode) { + const directory = Storage.getUserExtensionsDir(); + const entry = await fs + .lstat(directory) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (entry) { + await fs.readdir(directory); + const extensionStore = new ExtensionStore(); + extensionManager = new ExtensionManager({ + extensionStore, + workspaceDir: workspaceCwd, + isWorkspaceTrusted: workspaceTrusted, + locale, + }); + const snapshot = await extensionManager.refreshCacheWithSnapshot(); + for (const extension of extensionManager.getLoadedExtensions()) { + const states = new Map(); + for (const skill of extension.skills ?? []) { + const name = skill.name.trim().toLowerCase(); + const defaults = extension.config.skillStates; + const defaultEnabled = + defaults && Object.hasOwn(defaults, name) + ? defaults[name]! + : true; + states.set( + name, + extensionStore.getSkillWorkspaceOverride( + snapshot, + extension.id, + workspaceCwd, + name, + ) ?? defaultEnabled, + ); + } + extensionSkillStates.set(extension, states); + } + } + } const shim: SkillManagerConfigShim = { // Honor the safe-mode env the same way `Config` does when no explicit // flag is passed, so an operator running in safe mode gets the same @@ -136,12 +199,12 @@ async function buildWorkspaceSkillsStatus( // bare, so it is always off here. getBareMode: () => false, getProjectRoot: () => workspaceCwd, - // Extension skills need active-extension context that only the child - // has; omit them here and let the session snapshot surface them. - getActiveExtensions: () => [], + getActiveExtensions: () => + extensionManager?.getLoadedExtensions().filter((e) => e.isActive) ?? + [], getDisabledSkillLevels: () => disabledLevels, }; - skillManager = new SkillManager(shim as Config); + const skillManager = new SkillManager(shim as Config); if (!safeMode) { for (const level of ['project', 'user'] as const) { if (disabledLevels.has(level)) continue; @@ -156,17 +219,65 @@ async function buildWorkspaceSkillsStatus( } } } - managers.set(workspaceCwd, skillManager); + cached = { skillManager, extensionManager, extensionSkillStates }; + managers.set(workspaceCwd, cached); } - const disablements = resolveSkillSettings(settings).disablements; + const { disablements, enabledNames } = resolveSkillSettings(settings); + const { skillManager, extensionManager, extensionSkillStates } = cached; + const extensions = extensionManager?.getLoadedExtensions() ?? []; const skills = await skillManager.listSkills(); + const statuses = skills.map((skill) => { + const extension = + skill.level === 'extension' + ? extensions.find((e) => e.name === skill.extensionName) + : undefined; + const enabled = extension + ? extensionSkillStates + .get(extension) + ?.get(skill.name.trim().toLowerCase()) + : undefined; + // Preserve missing display names; the helper otherwise falls back to the name. + const localizedSkill = + extension?.displayName !== undefined + ? { + ...skill, + extensionDisplayName: getExtensionDisplayName(extension, locale), + } + : skill; + return mapSkillConfigToStatus(localizedSkill, disablements, { + enabled: + enabledNames.has(skill.name.trim().toLowerCase()) || + enabled !== false, + }); + }); + for (const extension of extensions) { + if (extension.isActive) continue; + const seenNames = new Set(); + for (const skill of extension.skills ?? []) { + if (seenNames.has(skill.name)) continue; + seenNames.add(skill.name); + statuses.push( + mapSkillConfigToStatus( + { + ...skill, + level: 'extension', + extensionName: extension.name, + extensionDisplayName: + extension.displayName === undefined + ? undefined + : getExtensionDisplayName(extension, locale), + }, + disablements, + { disabled: true }, + ), + ); + } + } return { v: STATUS_SCHEMA_VERSION, workspaceCwd, initialized: true, - skills: skills.map((skill) => - mapSkillConfigToStatus(skill, disablements), - ), + skills: statuses.sort((a, b) => a.name.localeCompare(b.name)), }; } catch (error) { const message = error instanceof Error ? error.message : String(error);