diff --git a/docs/architecture/agent-plan-task-refactor/acp-plan-reachability.md b/docs/architecture/agent-plan-task-refactor/acp-plan-reachability.md
deleted file mode 100644
index b0bacec4b0..0000000000
--- a/docs/architecture/agent-plan-task-refactor/acp-plan-reachability.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# ACP Plan Reachability Audit
-
-## Summary
-
-- Subsystem A (`llmProviderPresenter/providers/acpProvider.ts`) is the active ACP provider stream
- path. It calls `AcpContentMapper.map(notification)` and pushes `mapped.events` into the provider
- `EventQueue`.
-- Subsystem A previously dropped `mapped.blocks`. This refactor adds an internal `LLMCoreStreamEvent`
- `type:'plan'` variant and accumulator handling that upserts the same shared `type:'plan'` block
- shape, so active ACP provider streams can persist plan blocks without inventing an IPC channel.
-- Subsystem B (`acpClientPresenter/mapper/AcpEventMapper.ts`) maps `mapped.blocks` to
- `content.block` and `mapped.planEntries` to `plan.updated`, but repo grep finds no
- `mapSessionUpdate` call site. It is instantiated by `acpClientPresenter/index.ts`, but not proven
- live end-to-end.
-- `MessageBlockPlan` remains the single renderer. `AcpContentMapper.handlePlanUpdate` now uses the
- shared plan-block builder, so ACP subsystem A, ACP subsystem B, and the agent-runtime path produce
- the same `type:'plan'` block shape.
-
-## Decision For This Refactor
-
-- Do not delete `MessageBlockPlan`.
-- Keep the new internal `plan` stream event scoped to provider-to-accumulator transport.
-- Keep subsystem B's block-capable mapper on the shared builder shape.
-- Do not add a public IPC channel or a dedicated plan table for ACP plans.
diff --git a/docs/architecture/agent-plan-task-refactor/plan.md b/docs/architecture/agent-plan-task-refactor/plan.md
deleted file mode 100644
index 3fced9fb6e..0000000000
--- a/docs/architecture/agent-plan-task-refactor/plan.md
+++ /dev/null
@@ -1,300 +0,0 @@
-# Agent Plan / `update_plan` Task — Plan (v4)
-
-> Active planning doc. Delete after implementation; fold durable facts into
-> `docs/architecture/agent-system.md` or `tool-system.md`. v4 broadens the terminal marker to cover
-> **every** turn-exit incl. the abort-exception early return (AD6), threads `max_steps` via
-> `StreamState`, and clarifies that ACP and agent-runtime share one block-builder helper, not one
-> entry point (AD2).
-
-## Involved modules
-
-| Layer | File | Role today |
-| --- | --- | --- |
-| Backend tool | `src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts` | `update_plan` def, `states` Map, snapshot/revision |
-| Tool wiring | `src/main/presenter/toolPresenter/agentTools/agentToolManager.ts` | agent-mode gating (`isAgentMode`, :373), tool-call routing |
-| Prompt | `src/main/presenter/toolPresenter/index.ts` | `buildProgressPrompt` (:641-657) |
-| Runtime | `src/main/presenter/agentRuntimePresenter/dispatch.ts` | `markInternalPlanToolCallBlock` (:371), `publishPlanUpdated` (:385), `agent_plan` branch (:916), `finalize` (:1475) / `finalizeError` (:1489) |
-| Runtime loop / finalize | `src/main/presenter/agentRuntimePresenter/process.ts` | `while(true)` loop (:327), `MAX_TOOL_CALLS` break→`finalize` (:404), `finalizeError` calls (:95,440,504,512,538), **abort-exception early return (:527-535, bypasses finalize)** |
-| Error finalize | `src/main/presenter/agentRuntimePresenter/messageStore.ts` | `buildTerminalErrorBlocks` (:39) flips block `status`→`error` only — does **not** touch `plan_entries[*].status`; interrupted recovery (:615) |
-| Runtime lifecycle | `src/main/presenter/agentRuntimePresenter/index.ts` | `destroySession` (:528-554), cancel/abort |
-| Runtime state | `src/main/presenter/agentRuntimePresenter/types.ts` | `StreamState` — add `planTerminalReason` |
-| ACP (subsystem A) | `llmProviderPresenter/acp/acpContentMapper.ts`, `providers/acpProvider.ts`, `aiSdk/.../accumulator.ts` | builds `type:'plan'` block; `acpProvider` drops `mapped.blocks` |
-| ACP (subsystem B) | `acpClientPresenter/mapper/AcpEventMapper.ts` (instantiated at `acpClientPresenter/index.ts:18`) | maps `mapped.blocks`→`content.block`, `mapped.planEntries`→`plan.updated`; **`mapSessionUpdate` has no found call site** |
-| Shared types | `src/shared/types/agent-plan.ts`, `src/shared/contracts/events/chat.events.ts`, `src/shared/contracts/acp.ts` | `AgentPlanStepStatus`, snapshot/item, event payloads |
-| Store | `src/renderer/src/stores/ui/agentPlan.ts` | in-memory `snapshots`, persisted `collapsedBySession`, revision gate (:12) |
-| UI (live) | `src/renderer/src/components/chat/AgentProgressFloat.vue` | the float in the screenshot |
-| UI (persisted) | `src/renderer/src/components/message/MessageBlockPlan.vue` | `type:'plan'` renderer (`MessageItemAssistant.vue:69`); spins on `in_progress` (:139) |
-| Wiring | `src/renderer/src/pages/ChatPage.vue` | `onPlanUpdated` (:1860), dismiss (:999), stop/retry/continue (:1736/1746/1797) |
-| i18n | `src/renderer/src/i18n/*/chat.json` (`chat.workspace.plan.*`) | labels incl. dead `failed`/`skipped` |
-
-## Architecture decisions
-
-### AD1 — Single persisted representation = the `type:'plan'` block (D1, D4)
-The persisted, in-history plan is a `type:'plan'` block rendered by `MessageBlockPlan.vue`. The live
-`AgentProgressFloat` is a transient overlay during active generation; on reload it rehydrates from
-the latest persisted plan block of the conversation. The hidden `update_plan` tool-call block stays
-transport/provenance only (`extra.internalTool=true`, not double-rendered).
-
-The agent-runtime path **projects each `update_plan` snapshot into a persisted `type:'plan'`
-block**. This intentionally changes the contract asserted by `dispatch.test.ts:299` ("does not
-insert plan blocks"); that test is rewritten to assert the upsert behavior.
-
-**Upsert identity & position.** There is **at most one `type:'plan'` block per assistant message**.
-The producer locates it by scanning the current turn's block stream (`state.blocks`) for a
-`type:'plan'` block: if present it mutates that block in place; otherwise it inserts one
-**immediately after the first (hidden) `update_plan` tool-call block** of the turn, then mutates that
-same block in place for every later revision. Later revisions never move or duplicate it. Identity is
-"the lone `type:'plan'` block within the active message" — **not** a `toolCallId` (which differs per
-`update_plan` call) and **not** `messageId` lookups across the store. Because each turn (including
-retry/continue) builds a fresh `state.blocks` for a new assistant message, a new turn yields a new
-plan block — there is no cross-turn / cross-message overwrite.
-
-Why not "rehydrate the float from the hidden tool-call params" (rejected D4 alternative): it keeps
-two renderers interpreting different sources and leaves `MessageBlockPlan` half-dead. One
-`type:'plan'` block consumed by one renderer is the lower-divergence design and gives the ACP path a
-home (AD2).
-
-### AD2 — Converge ACP and agent-runtime on one block shape + one builder (D2), after an audit
-`AcpContentMapper.handlePlanUpdate` already builds a `type:'plan'` block. The agent-runtime
-`update_plan` path and the ACP plan-notification path are **necessarily two entry points** — that is
-fine. The constraint is: both must call **one shared plan-block construction/normalization helper**
-that produces **one `type:'plan'` block shape**, rendered by the **single `MessageBlockPlan`**
-renderer. Different entry points, one builder, one shape, one renderer.
-
-The audit (T1) establishes which ACP subsystem is live end-to-end:
-- If subsystem B (`AcpEventMapper` → `content.block`) is the real path, ensure a `content.block`
- carrying a `type:'plan'` block reaches the persisted message stream and renders via
- `MessageBlockPlan`.
-- If subsystem A (`acpProvider`) is the real path, it currently drops `mapped.blocks`; wire the
- `type:'plan'` block through (and add a `'plan'` accumulator case if the persisted stream is rebuilt
- there).
-Either way the **renderer stays `MessageBlockPlan`** and both paths share the builder helper. No
-deletion.
-
-### AD3 — Store models server-state and view-state separately, baseline per-turn (C1)
-- `snapshots[sessionId]` is pure server-state for the **current turn's** live overlay. Add
- `beginTurn(sessionId)` that resets the baseline (clears the live snapshot) — called from
- submit/steer/retry/continue. The revision gate then only orders within-turn updates.
-- Replace the boolean collapse map with per-session view-state `{ collapsed, dismissedRevision }`
- (persisted). `dismiss` sets `dismissedRevision = current.revision` (sticky) instead of deleting.
-- `freezeActive(sessionId)` (for `onStop`): set the live overlay's terminal indicator so the spinner
- stops immediately. This is the **live mirror** of the persisted terminal marker (AD6); the source
- of truth for reload is the stamped block, not this call.
-- Float visibility = `snapshot exists && revision > dismissedRevision && entries.length > 0`.
- Default `collapsed=false` on first appearance (AC19); auto-collapse (not delete) when
- `completedCount === total` (AC6).
-- `purge(sessionId)` removes the live snapshot + persisted view-state key (on conversation delete /
- `destroySession`).
-
-### AD4 — One shared status presentation module (AC13)
-`src/renderer/src/composables/useAgentPlanStatus.ts` exports `STATUS_ICON`, `STATUS_ICON_CLASS`,
-`STATUS_BADGE_CLASS`, `entryAriaLabel(t, status, step)`, and a `resolveStepPresentation(status,
-{ terminal })` helper that returns a **non-spinning** interrupted indicator for `in_progress` when
-the plan is terminal (AD6). Both renderers import it; the completed-step decision is made once (mute
-icon, keep text at `text-foreground` for AA — AC17). `MessageBlockPlan`'s ad-hoc `normalizeStatus`/
-`done`→`completed` tolerance moves here as a shared `normalizePlanEntry`.
-
-### AD5 — Status enum single source (AC12)
-In a shared runtime-capable module: `export const agentPlanStepStatusSchema = z.enum(['pending',
-'in_progress','completed'])`, `agentPlanItemSchema = z.object({ step, status })`,
-`type AgentPlanStepStatus = z.infer<...>`. `agentPlanTool.ts` and `chat.events.ts` import these.
-Remove `failed`/`skipped` i18n (AC21). The step-status enum stays three values (see AD6 for the
-block-level terminal marker).
-
-### AD6 — Persistable terminal marker for abnormal/error termination (AC4)
-The step-status enum is unchanged (AD5). To represent a turn that ended while a step was still
-`in_progress`, add a **block/snapshot-level** field `terminalReason?: 'aborted' | 'max_steps' |
-'error'`, persisted into the `type:'plan'` block `extra` (e.g. `plan_terminal_reason`) — additive, no
-enum change (C3).
-
-**Crucial: cover every turn-exit, not just `finalizeError`/`finalize`.** Three exits can leave an
-open `in_progress` step:
-1. `finalizeError` (`dispatch.ts:1489`) — the error/cancel chokepoint reached from user cancel
- (`process.ts:95`), tool terminal error (`:440`), context-window error (`:504`), no-model-response
- (`:512`), a **non-abort** uncaught exception (`:538`), plus interrupted-session recovery
- (`messageStore.ts:615`). Its `buildTerminalErrorBlocks` (`messageStore.ts:39`) only flips block
- `status`→`error`; it does **not** touch `extra.plan_entries[*].status`.
-2. The normal `finalize` (`dispatch.ts:1475`) after the `MAX_TOOL_CALLS` `break` (`process.ts:404`).
-3. **The abort-exception early-return branch (`process.ts:527-535`)** — when `abortSignal.aborted ||
- isAbortError(err)`, the catch `return`s `{status:'aborted'}` **without calling `finalize` or
- `finalizeError`**. Easy to miss; would leave the plan spinning on reload.
-
-Without handling all three, a persisted plan block keeps its `in_progress` entry and **reloads
-spinning** — violating "no step spins after its turn ended".
-
-Implementation: add `state.planTerminalReason?: 'aborted' | 'max_steps' | 'error'` to `StreamState`
-(`types.ts`); set it `= 'max_steps'` immediately before the `break` at `process.ts:404`. Introduce
-one **idempotent** helper `stampPlanTerminalIfOpen(state, io, reason)` that finds the latest
-`type:'plan'` block and, if any entry is still `in_progress` (and not already stamped), sets
-`extra.plan_terminal_reason` and emits one final `chat.plan.updated`. Call it from: `finalize`
-(reason = `state.planTerminalReason`, i.e. `max_steps`), `finalizeError` (reason `aborted` for
-USER_CANCELED, else `error`), **and the abort-exception catch branch before its `return`** (reason
-`aborted`). Idempotency makes a redundant call (e.g. an outer cancel path also invoking
-`finalizeUserCanceledErrorIfNeeded`, `process.ts:90`) harmless. The shared presentation (AD4) renders
-an `in_progress` step as a **static, non-spinning interrupted indicator** whenever `terminalReason`
-is set. Normal, well-closed completion needs no marker (R7). `freezeActive` mirrors this in the store
-for instant live feedback before persistence round-trips.
-
-**Persistence boundary (so reload, not just live, is fixed).** DB writes happen only in the finalize
-family — `finalize` → `updateAssistantContent` (`dispatch.ts:1449`) / `finalizeAssistantMessage`
-(`:1475`); `finalizeError` → `setMessageError` (`:1504`). Streaming itself only flushes to the
-renderer (`flushBlocksToRenderer`, no DB write). Therefore: (a) at `finalize`/`finalizeError`, call
-`stampPlanTerminalIfOpen` **before** the messageStore write so the stamp is persisted; (b) the
-abort-exception early-return branch (`process.ts:527-535`) runs **no** finalize and **no** DB write —
-after stamping it must itself persist (`messageStore.updateAssistantContent(io.messageId,
-state.blocks)` + `flushBlocksToRenderer`) before returning. Without (b), "live not spinning" is fixed
-but **"reload not spinning" is not**.
-
-Sequencing note: in Increment 1 (pre-persistence) `freezeActive` only stops the **live** spinner —
-acceptable because there is no persisted plan block yet to reload. The persistable stamp + reload
-non-spin lands in Increment 2, after the `type:'plan'` block exists (T5).
-
-## Event & data flow (target)
-
-```
-update_plan(call)
- → AgentPlanTool: validate, build snapshot (revision = within-turn monotonic)
- → onProgress(agent_plan) [single transport; drop toolResult.snapshot]
- → dispatch.applyProgressUpdate (agent_plan, allowProgressUpdates):
- • upsert THE type:'plan' block of this turn (the lone one in state.blocks) [NEW]
- plan_entries / plan_explanation / plan_revision / plan_updated_at
- (inserted right after the first hidden update_plan tool-call block)
- • keep update_plan tool-call block extra.internalTool=true (provenance, hidden)
- • publishDeepchatEvent('chat.plan.updated', payload) (live)
- → ChatPage.onPlanUpdated → agentPlanStore.applySnapshot
-
-turn start (submit/steer/retry/continue):
- → agentPlanStore.beginTurn(sessionId) [NEW: reset per-turn baseline → 0]
-
-any turn-exit with an open in_progress step → stampPlanTerminalIfOpen(state, io, reason) [NEW, idempotent]
- • finalizeError (stop / tool-error / context-window / no-response / non-abort exception / interrupted) → error | aborted
- • finalize after MAX_TOOL_CALLS break (state.planTerminalReason='max_steps' set before break) → max_steps
- • abort-exception catch branch (process.ts:527-535) BEFORE its early return → aborted
- (no finalize on this path → must also persist: updateAssistantContent + flushBlocksToRenderer)
- → stamps latest type:'plan' block extra.plan_terminal_reason + one final chat.plan.updated
- (at finalize/finalizeError: stamp BEFORE the messageStore write so the stamp is persisted)
- → agentPlanStore.freezeActive(sessionId) [live mirror; non-spinning indicator]
-
-session load / reopen / switch:
- → from loaded messages, take the latest type:'plan' block → agentPlanStore.applySnapshot [NEW]
- (history always renders inline via MessageBlockPlan; float overlay optional)
-
-destroySession(sessionId) / conversation delete:
- → planTool.clearState(sessionId) AND agentPlanStore.purge(sessionId) [NEW]
-```
-
-ACP path converges on the same `type:'plan'` block via the shared builder (AD2) after the audit.
-
-## Compatibility & migration
-
-- `type:'plan'` blocks and `block.extra` plan fields (`plan_entries`, `plan_terminal_reason`, …) are
- additive; pre-change conversations have no plan block → rehydrate to "no plan" (C3).
-- `collapsedBySession` localStorage (`agent-plan-collapsed`) gains a richer value shape. Per C4 /
- project preference (no compat shims unless required), the decision is to **rename the key** to
- `agent-plan-view-state` and one-time-prune the legacy `agent-plan-collapsed` key on first read,
- rather than ship a legacy-boolean translation shim.
-- **Backward-compatible typed-event extension** (not "no change"): `chat.plan.updated` gains an
- optional `terminalReason`. Update the event-contract zod payload in `chat.events.ts:47-57` (add
- `terminalReason: z.enum(['aborted','max_steps','error']).optional()`); `AgentPlanViewSnapshot =
- DeepchatEventPayload<'chat.plan.updated'>` (`agentPlan.ts:6`) then derives the new field
- automatically, and the contract's `defineEventContract` test must be updated. No route/IPC channel
- change. If `AgentPlanViewSnapshot` is later re-expressed as `AgentPlanSnapshot + messageId`, that
- is type-only.
-
-## Test strategy
-
-- **Main (Vitest):**
- - `dispatch.test.ts:299` rewritten: `applyProgressUpdate` upserts the single `type:'plan'` block
- of the turn (idempotent across revisions, never duplicated) and still publishes the event.
- - abnormal/error termination: `finalizeError` (cancel, tool error, context-window, no-response,
- non-abort exception), the `MAX_TOOL_CALLS` `finalize`, AND the **abort-exception early-return
- branch** (plan written, then provider throws `AbortError`) each stamp `plan_terminal_reason`
- (`aborted`/`max_steps`/`error`) on the latest plan block when a step is open and emit a final
- event; for the abort-exception branch assert the stamp is **persisted to messageStore** (a
- reload sees the non-spinning state), not just emitted. Assert no `in_progress` entry survives
- unstamped after any such ending, and the helper is idempotent.
- - `agentPlanTool`: clearState wiring; missing-`toolCallId` behavior; revision monotonic within turn.
- - subagent path leaves no orphan state.
- - ACP: per audit outcome, a `type:'plan'` block is produced/renderable through the live path via
- the shared builder.
-- **Renderer (Vitest + VTU):**
- - store: per-turn `beginTurn` baseline (the **C1 guard**: clear → next `revision=1` must render),
- `dismiss` sticky, `freezeActive`, `purge`, auto-collapse.
- - presentation: `in_progress` with `terminalReason` set renders **without** `animate-spin` (live
- float and inline block); completed-step uses `text-foreground` (not 50%-alpha muted); steps
- container has `aria-live`.
- - `AgentProgressFloat`: stop/retry/complete transitions leave no spinning `in_progress`; badge
- i18n renders per-locale (`en`, `ja`) without concatenation artifacts.
- - rehydration: a loaded conversation with a persisted plan block shows the plan (and a frozen one
- does not spin); switching sessions isolates plans.
-
-## Review follow-up fixes
-
-The post-implementation review found one real live-path regression and several missing regression
-tests. These are implemented as a follow-up to this same architecture goal.
-
-- Live terminal updates keep the existing tool revision, but `agentPlanStore.applySnapshot` must
- accept a same-revision snapshot when it adds or changes `terminalReason`; otherwise the live float
- can keep spinning until reload even though the persisted inline block is correct.
-- `dismiss` is sticky for the whole active turn, not just the current revision. `beginTurn` resets
- the dismissed flag.
-- Store read paths (`isVisible`, `isCollapsed`) are pure and do not create persisted view-state keys.
-- Rehydration scans loaded messages from newest to oldest and stops at the first persisted plan
- block.
-- Follow-up tests cover the subagent/no-progress path, process-level `max_steps` and abort-exception
- wiring, same-revision terminal updates, sticky dismiss, pure getters, and session switch
- rehydration isolation.
-
-## Second review follow-up fixes
-
-The second review found a real queue-path regression in the first follow-up: `dismiss` became
-session-scoped while `beginTurn` is only called by renderer user handlers. Main-process automatic
-pending-queue drain starts a new assistant turn without calling `beginTurn`, so the previous turn's
-dismissed state can hide the next turn's live float.
-
-- `dismiss` is keyed to the current snapshot `messageId`, so it remains sticky for the active turn
- but cannot leak into a later auto-drained turn.
-- `agentPlanStore.applySnapshot` treats a changed `messageId` as a new turn boundary and accepts the
- snapshot even when its revision is lower than the previous turn's last revision. Within the same
- `messageId`, revision monotonicity remains intact and only same-revision terminal reason changes
- are accepted.
-- Main exposes a narrow `clearAgentPlanState(sessionId)` path and calls it after creating a new
- assistant message. This resets backend `update_plan` revision state for user-initiated turns and
- main auto-drained queue turns without clearing tool mappings.
-- Tests cover the no-`beginTurn` auto-queue-visible store path, same-revision non-terminal drops,
- direct plan-state reset wiring, auto-queue reset calls, and direct persisted-plan rehydration
- helper behavior.
-
-## Third review follow-up fixes
-
-The third review found no functional blocker, but it identified two test guards that were still too
-weak and two renderer view-state edge cases.
-
-- `finalizeError` tests must capture the `setMessageError` write-time blocks with `structuredClone`
- and assert the persisted plan block already has `plan_terminal_reason: 'error'`.
-- Renderer store tests must assert the actual `useStorage` value shape so `purge(sessionId)` cannot
- become a no-op while tests stay green.
-- `agentPlanStore.applySnapshot` resets collapsed state when a changed `messageId` establishes a new
- live turn, so auto-drained queue turns appear expanded even when the previous turn was dismissed.
-- Auto-collapse runs only when the same message transitions from not-all-completed to all-completed;
- rehydration or repeated completed snapshots must not override a user's manual expansion.
-
-## Risks
-
-- **AD1 contract change** (`dispatch.test.ts:299`) is deliberate but touches a persisted-block
- invariant; keep it isolated to one PR with the rewritten test + a rehydration test. The upsert
- identity ("lone `type:'plan'` block in `state.blocks`") must be covered so revisions never
- duplicate the block.
-- **C1 guard** (fresh-`revision=1`-dropped) is the highest-risk regression; the store test must
- reproduce "beginTurn → revision 1 → renders".
-- **AD6 main-side stamping** must hook **every** turn-exit: `finalizeError`, the `MAX_TOOL_CALLS`
- `finalize`, AND the **abort-exception early-return branch (`process.ts:527-535`) which bypasses
- both** — or a reloaded plan still spins. Three traps: (1) `buildTerminalErrorBlocks` not touching
- `plan_entries` status; (2) the abort branch returning before any finalize; (3) **DB writes living
- only in the finalize family** — so the abort branch must persist (`updateAssistantContent` + flush)
- after stamping, else "live" is fixed but "reload" is not. Cover each trigger (cancel,
- abort-exception, tool-error, exception, max-steps) in tests **including a reload/persistence
- assertion**; make the helper idempotent.
-- **AD2 audit** may reveal subsystems A/B are both partially wired; resolve to one before touching
- the hot path. Audit is its own task (T1), output recorded in this folder.
-- Scope: R6 items are independent low-risk cleanups — separate commits so R1/R2 stay reviewable.
diff --git a/docs/architecture/agent-plan-task-refactor/tasks.md b/docs/architecture/agent-plan-task-refactor/tasks.md
deleted file mode 100644
index fa9645355e..0000000000
--- a/docs/architecture/agent-plan-task-refactor/tasks.md
+++ /dev/null
@@ -1,161 +0,0 @@
-# Agent Plan / `update_plan` Task — Tasks (v4)
-
-> Implemented. Decisions D1–D4 are hard-resolved in `spec.md` (D4: agent-mode history
-> shows an inline `type:'plan'` block — settled). Ordered so the cheap terminal-state wins ship
-> before the persistence refactor. Each task is one reviewable commit/PR.
-
-## Increment 0 — De-risk (must precede Increment 2)
-
-- [x] **T1 — ACP reachability audit (R5/AC14).** Trace both ACP subsystems end-to-end: does a
- `type:'plan'` block today reach the persisted message stream and render via `MessageBlockPlan`?
- Cover subsystem A (`acpProvider.handleSessionUpdate`, which drops `mapped.blocks`) vs subsystem
- B (`AcpEventMapper`, instantiated at `acpClientPresenter/index.ts:18` but whose
- `mapSessionUpdate` has **no found call site**). Record findings in
- `acp-plan-reachability.md`. Output decides AD2 wiring (T8). **No deletion of `MessageBlockPlan`.**
-
-## Increment 1 — Stop the "stuck spinner" (no persistence, immediate value)
-
-- [x] **T2 — Prompt closure discipline (R7/AC22).** Extend `buildProgressPrompt`: reconcile every
- step before finishing, never end a turn with a dangling `in_progress`. Mirrors Codex's
- plan-closure rule. (+ prompt snapshot test.)
-- [x] **T3 — Per-turn baseline + live freeze/rebaseline transitions (R2/AC4 live, AC5; C1).** In the
- store add `beginTurn(sessionId)` (reset baseline → 0) and `freezeActive(sessionId)` (stops the
- **live** spinner). Wire `beginTurn` into `onSubmit`/`onSteer`/`onMessageRetry`/
- `onMessageEditSave`/`onMessageContinue`; wire `freezeActive` into `onStop`. **No blanket
- delete.** Scope: this only fixes the live in-session spinner; the persistable terminal marker
- (reload) is T6. (+ renderer test: stop mid-plan → live float no longer spins; retry → clean
- overlay.)
-- [x] **T4 — Auto-collapse + sticky dismiss + default-expanded (R2/AC6,AC7; AC19).** View-state
- `{ collapsed, dismissedRevision }`; default `collapsed=false` first appearance; auto-collapse
- when all complete; `dismiss` sets `dismissedRevision`. (+ store test.)
-
-## Increment 2 — Persisted plan block + terminal state + rehydration (depends on T1; D1/D2/D4)
-
-- [x] **T5 — Upsert THE `type:'plan'` block per turn (R1/AD1).** In `dispatch.applyProgressUpdate`,
- upsert the single `type:'plan'` block of the turn — locate the lone `type:'plan'` block in
- `state.blocks`, mutate in place, else insert it **immediately after the first (hidden)
- `update_plan` tool-call block**; carry
- `plan_entries/plan_explanation/plan_revision/plan_updated_at`; keep the tool-call block
- `internalTool=true`. **Rewrite `dispatch.test.ts:299`** to assert the upsert (idempotent across
- revisions, never duplicated) + event. (+ test.)
-- [x] **T6 — Persistable terminal marker on every turn-exit (R2/AC4 reload; AD6).** Add
- `state.planTerminalReason?: 'aborted'|'max_steps'|'error'` to `StreamState` (`types.ts`); set
- it `= 'max_steps'` right before the `break` at `process.ts:404`. Add an **idempotent** helper
- `stampPlanTerminalIfOpen(state, io, reason)` that stamps `plan_terminal_reason` onto the latest
- `type:'plan'` block (only when a step is still `in_progress`) and emits one final
- `chat.plan.updated`. Call it from **all three exits**: `finalizeError` (`dispatch.ts:1489` —
- covers cancel `process.ts:95`, tool error `:440`, context-window `:504`, no-response `:512`,
- non-abort exception `:538`, interrupted recovery `messageStore.ts:615`), the `finalize`
- (`:1475`) after `MAX_TOOL_CALLS`, **and the abort-exception catch branch (`process.ts:527-535`)
- before its early `return`** (reason `aborted`). **Persistence boundary:** DB writes live only
- in the finalize family (`updateAssistantContent` :1449 / `finalizeAssistantMessage` :1475 /
- `setMessageError` :1504), so call the helper **before** those writes in `finalize`/
- `finalizeError`, and in the abort-exception branch **persist after stamping**
- (`messageStore.updateAssistantContent` + `flushBlocksToRenderer`) since it has no finalize.
- (`buildTerminalErrorBlocks` flips block status only — never rely on it for entry status.)
- **Extend the event contract** `chat.events.ts:47-57`
- with optional `terminalReason: z.enum(['aborted','max_steps','error'])` (+ update the
- `defineEventContract` test; `AgentPlanViewSnapshot` derives it). Render `in_progress`
- **without** `animate-spin` when terminal — directly in `MessageBlockPlan.vue` +
- `AgentProgressFloat.vue` for now (consolidated into the composable by T13). (+ main tests:
- cancel / **abort-exception (plan written → provider throws `AbortError` → `aborted`,
- asserted persisted to messageStore, not just emitted)** / tool-error / exception / max-steps;
- + renderer test: reload after an error/abort ending → no spin.)
-- [x] **T7 — Rehydrate live float from persisted block on load/switch (R1/AC1–AC3; C1).** On
- `loadMessages` / sessionId switch, take the latest `type:'plan'` block and
- `agentPlanStore.applySnapshot`; rely on `beginTurn` (T3) so a subsequent live turn rebaselines
- cleanly. Per-conversation isolation. (+ renderer rehydration + switch-isolation tests, incl.
- the C1 guard.)
-- [x] **T8 — Converge ACP onto the same block (R5/AC15; AD2).** Per T1's outcome, fix the ACP
- producer/transport so its `type:'plan'` block renders via `MessageBlockPlan`; remove the
- divergent/dead branch (producer side only — renderer stays). (+ test for the live ACP path.)
-
-## Increment 3 — Backend hygiene
-
-- [x] **T9 — Bound `states` + purge renderer (R3/AC8).** Wire `planTool.clearState(sessionId)` into
- `destroySession` and `agentPlanStore.purge` on conversation delete. Backend revision may stay
- process-local (safe under C1). (+ main test.)
-- [x] **T10 — Remove dead surface (R3/AC9).** Drop `rawData.toolResult.snapshot` (only `onProgress`
- consumed); remove `getState`/`clearState` if T9 leaves them unused. Document `onProgress` as
- sole transport.
-- [x] **T11 — Subagent orphan-key + missing-`toolCallId` (R3/AC10,AC11).** Stop subagent
- `update_plan` from polluting the parent `states` Map; treat a missing `toolCallId` as an error
- or logged drop, not silent success. (+ main tests.)
-
-## Increment 4 — Contracts & DRY
-
-- [x] **T12 — Status enum single source (R4/AC12; AD5).** `agentPlanStepStatusSchema` /
- `agentPlanItemSchema` once in shared; import in tool schema + event contract; remove
- re-declarations and the unreachable `failed`/`skipped` i18n (AC21).
-- [x] **T13 — Shared status presentation composable (R4/AC13; AD4).** Extract
- `useAgentPlanStatus.ts` (+ `normalizePlanEntry` + `resolveStepPresentation` incl. the terminal
- non-spin rule from T6); both renderers consume it; unify completed styling.
-
-## Increment 5 — UX / i18n / a11y polish (independent, low-risk)
-
-- [x] **T14 — Parameterized completed counter (R6/AC16).** One pluralizable
- `chat.workspace.plan.completedCount` across locales; float + inline badge consistent.
- (+ per-locale render test.)
-- [x] **T15 — Contrast + a11y (R6/AC17,AC18).** Completed-step text at `text-foreground` (mute icon
- only); `aria-live="polite"`/`role="status"`; single disclosure control with `aria-expanded` +
- `aria-controls`; drop the redundant chevron tab stop.
-- [x] **T16 — Prune persisted view-state (R6/AC20).** GC the renamed `agent-plan-view-state` key on
- conversation deletion (same flow as T9); one-time prune the legacy `agent-plan-collapsed` key.
-
-## Increment 6 — Review follow-up fixes
-
-- [x] **T17 — Accept same-revision terminal updates.** Keep terminal stamps on the existing plan
- revision, but let `agentPlanStore.applySnapshot` accept a same-revision snapshot that adds or
- changes `terminalReason`, so the live float does not keep spinning after `max_steps`/error.
-- [x] **T18 — Make dismiss turn-sticky and store getters pure.** Replace revision-based dismiss
- gating with a turn-scoped `dismissed` flag reset by `beginTurn`; make `isVisible` and
- `isCollapsed` pure reads that do not create localStorage entries.
-- [x] **T19 — Tighten rehydration.** Rename the store clear API to `clearSnapshot`, update
- `ChatPage`, and scan loaded messages from newest to oldest, stopping at the first persisted
- `type:'plan'` block.
-- [x] **T20 — Backend cleanup.** Reduce `AgentPlanState` to the revision value that remains in use
- and share the canonical `update_plan` tool-name constant with the shared block helper.
-- [x] **T21 — Add runtime regression tests.** Cover subagent/no-progress isolation, process-level
- `MAX_TOOL_CALLS` terminal stamping, abort-exception persistence, and terminal-stamp
- idempotency with cloned messageStore write assertions.
-- [x] **T22 — Add renderer regression tests.** Cover same-revision terminal acceptance, backend
- terminal reason overriding optimistic freeze, sticky dismiss through later revisions, pure
- getters, and session switch rehydration isolation.
-
-## Increment 7 — Second review follow-up fixes
-
-- [x] **T23 — Make dismiss message-scoped.** Store the dismissed `messageId` instead of a
- session-level boolean; keep dismiss sticky for the current turn and allow the next auto-drained
- turn to show its live float without requiring renderer `beginTurn`.
-- [x] **T24 — Treat changed `messageId` as a new live plan turn.** Let `agentPlanStore.applySnapshot`
- accept a lower/equal revision when `messageId` changes, while preserving same-message revision
- monotonicity and same-revision terminal-only updates.
-- [x] **T25 — Reset backend plan state at new assistant turn creation.** Add
- `clearAgentPlanState(sessionId)` as a narrow ToolPresenter method and call it after
- `createAssistantMessage`, covering user sends and main auto-drained queue turns without
- clearing tool mappings.
-- [x] **T26 — Add second-review regression tests.** Cover no-`beginTurn` next-message visibility,
- same-revision non-terminal drops, narrow clear-state wiring, auto-queue reset calls, and direct
- `snapshotFromAgentPlanBlock` hydration behavior.
-
-## Increment 8 — Third review follow-up fixes
-
-- [x] **T27 — Strengthen error stamp persistence guard.** Capture `setMessageError` blocks at call
- time and assert ordinary `finalizeError` writes already include `plan_terminal_reason: 'error'`.
-- [x] **T28 — Strengthen renderer purge view-state guard.** Make `agentPlanStore` tests assert the
- real `useStorage` value shape so `purge(sessionId)` must delete persisted view-state.
-- [x] **T29 — Keep new-message live plans expanded.** Reset collapsed state when `applySnapshot`
- accepts a changed `messageId`, covering main auto-drained queue turns that do not call
- `beginTurn`.
-- [x] **T30 — Make auto-collapse transition-only.** Auto-collapse only when the same message moves
- from not-all-completed to all-completed, not during rehydration or repeated completed updates.
-
-## Sequencing notes
-
-- **T1 first** — resolves the only remaining ambiguity (ACP reachability) and unblocks T5/T8.
-- Increment 1 (T2–T4) ships independently, no persistence, fixes the most visible **live** symptom;
- safe before T5 because freezing/rebaselining the live overlay does not touch persisted history.
-- T5 carries a deliberate test-contract change; T6 (terminal marker) and T7 (rehydration) must land
- with it so reload never shows a spinning or vanished plan. T7 must include the C1 guard test.
-- Increments 3–5 are cleanup; interleave once Increment 2's contracts are settled. T13 absorbs the
- inline terminal-render added directly in T6.
diff --git a/docs/features/cua-plugin-icon/spec.md b/docs/features/cua-plugin-icon/spec.md
new file mode 100644
index 0000000000..a69604703f
--- /dev/null
+++ b/docs/features/cua-plugin-icon/spec.md
@@ -0,0 +1,49 @@
+# CUA Plugin Icon
+
+## User Need
+
+The CUA Computer Use official plugin should be easier to recognize in the plugin hub and detail page.
+
+## Goal
+
+Use `lucide:laptop-minimal-check` for `com.deepchat.plugins.cua` instead of the generic puzzle icon.
+
+## Acceptance Criteria
+
+- The added plugins row shows the CUA plugin with `lucide:laptop-minimal-check`.
+- The plugin catalog card shows the CUA plugin with `lucide:laptop-minimal-check`.
+- The CUA plugin detail header shows `lucide:laptop-minimal-check`.
+- Other non-special official plugins keep the generic puzzle icon.
+
+## UI Sketch
+
+Before:
+
+```text
++---------------------------+
+| [puzzle] CUA Computer Use |
++---------------------------+
+```
+
+After:
+
+```text
++-----------------------------------------+
+| [laptop-minimal-check] CUA Computer Use |
++-----------------------------------------+
+```
+
+## Constraints
+
+- Keep the change renderer-only.
+- Do not add a manifest icon field for a single plugin.
+- Do not change plugin runtime behavior.
+
+## Non-Goals
+
+- Redesign plugin cards.
+- Add configurable icon infrastructure.
+
+## Open Questions
+
+- None.
diff --git a/docs/features/deepchat-skills-management/plan.md b/docs/features/deepchat-skills-management/plan.md
new file mode 100644
index 0000000000..98f7ec3f41
--- /dev/null
+++ b/docs/features/deepchat-skills-management/plan.md
@@ -0,0 +1,643 @@
+# DeepChat Skills Management Implementation Plan
+
+## Architecture Fit
+
+Use the existing split:
+
+- Main runtime owner: `src/main/presenter/skillPresenter/index.ts`
+- External scan/conversion owner: `src/main/presenter/skillSyncPresenter/index.ts`
+- Shared types: `src/shared/types/*`
+- Route contracts: `src/shared/contracts/routes/*`
+- Route dispatch: `src/main/routes/index.ts`
+- Renderer API clients: `src/renderer/api/*Client.ts`
+- Settings UI: `src/renderer/settings/components/skills/*`
+
+Do not create a new top-level Presenter for V1. Add small helper modules under the existing
+presenter folders where code size requires it.
+
+## Current Gaps
+
+| Gap | Current state | Needed change |
+| --- | --- | --- |
+| Database state | Runtime extension settings currently live in per-skill files under `.deepchat-meta/.json`. | Move skill management state into the application database and treat `.deepchat-meta` as legacy migration input. |
+| Library disabled state | `getMetadataList()` and `getMetadataPrompt()` expose all visible skills. | Add a Library catalog that includes disabled skills, and filter disabled skills from runtime paths. |
+| Agent ownership | `SkillSyncPresenter` scans external tools but does not classify links or ownership. | Add user-level folder-format agent management scan/classification. |
+| Adoption | Existing import copies external skills into DeepChat, but does not move agent-owned folders or create links. | Add adopt preview/execute with private backups and link creation. |
+| Link repair/remove | No DeepChat-owned link model. | Track created links in database state and only repair/remove those safely. |
+| Git install | `installFromUrl` downloads ZIP only. | Add Git clone scan/install flow with provenance, opened from the top add menu. |
+| Sync directory | Existing import/export targets registered tools, not a user-selected multi-skill repo directory. | Add native sync directory preview/execute APIs, labeled as sync directory instead of agent export. |
+| Skill details | Long descriptions currently expand list/table rows. | Add one reusable detail dialog that renders manifest data and `SKILL.md` Markdown. |
+| Settings UX | The first implementation over-split Library, Agents, Import / Export, Install, and Discover. | Collapse to Library, Agents, and Sync Directory. Folder/ZIP/URL/Git install lives under top Add Skill; install-to-agent lives on each Library row. |
+
+## Data Model
+
+Add `src/shared/types/skillManagement.ts`.
+
+```ts
+export type SkillSourceType =
+ | 'builtin'
+ | 'created'
+ | 'folder-install'
+ | 'zip-install'
+ | 'url-install'
+ | 'git-install'
+ | 'adopted'
+ | 'imported'
+
+export type SkillRepoFormat = 'single-skill' | 'multi-skill'
+
+export interface SkillManagementState {
+ version: 1
+ skills: Record
+ sync?: SkillSyncDirectoryConfig
+}
+
+export interface SkillManagementItem {
+ name: string
+ canonicalPath: string
+ deepchat: {
+ disabled: boolean
+ }
+ extension: SkillExtensionConfig
+ source: SkillSource
+ agentLinks?: Record
+}
+
+export interface SkillSource {
+ type: SkillSourceType
+ repoUrl?: string
+ repoFormat?: SkillRepoFormat
+ agentId?: string
+ originalPath?: string
+ importedFrom?: string
+ installedAt?: string
+ importedAt?: string
+ adoptedAt?: string
+}
+
+export interface AgentLinkInfo {
+ path: string
+ state: 'linked' | 'missing' | 'broken' | 'conflict' | 'permission-denied'
+ createdByDeepChat: boolean
+ linkedAt?: string
+}
+
+export interface SkillSyncDirectoryConfig {
+ skillsDirectory: string
+ layout: 'multi-skill-repo'
+ lastExportAt?: string | null
+ lastImportAt?: string | null
+}
+```
+
+Database state rules:
+
+- Store only durable state that cannot be derived cheaply from files.
+- Store V1 state in the existing application database, preferably through the DB-backed settings
+ path (`app_settings`) unless implementation proves dedicated SQL tables are needed.
+- Rebuild missing database entries from discovered DeepChat skills with `source.type = 'created'`
+ only as a fallback. Keep current built-in install behavior, but mark bundled resources as
+ `builtin` when source can be recognized.
+- Migrate legacy runtime extension sidecars from `/.deepchat-meta/.json` into
+ database state on first load.
+- After successful migration, remove the migrated legacy sidecar files. If migration fails, leave
+ legacy files untouched for retry.
+- New writes go only to the database.
+- The skills path must not be the canonical storage location for management metadata.
+- Use database transactions for multi-skill state writes.
+
+## Presenter Changes
+
+### SkillPresenter
+
+Add helpers:
+
+- `managementState.ts`: load/save/migrate database-backed skill management state.
+- `gitInstall.ts`: clone/scan/install Git repositories.
+- `importExport.ts`: native sync directory import/export.
+
+Add or extend methods on `ISkillPresenter`:
+
+- `getUnifiedSkillCatalog(): Promise`
+- `getSkillDetail(input: { name: string }): Promise`
+- `setSkillDeepChatDisabled(name: string, disabled: boolean): Promise`
+- `getSkillManagementState(): Promise`
+- `scanGitSkillRepo(input): Promise`
+- `installSkillsFromGit(input): Promise`
+- `getSkillsSyncConfig(): Promise`
+- `setSkillsSyncDirectory(input): Promise`
+- `previewSyncDirectoryExport(input): Promise`
+- `executeSyncDirectoryExport(input): Promise`
+- `previewSyncDirectoryImport(input): Promise`
+- `executeSyncDirectoryImport(input): Promise`
+
+Runtime filtering:
+
+- `getMetadataPrompt()` excludes disabled skills.
+- `loadSkillContent(name)` returns `null` for disabled skills unless an explicit internal option is
+ added later.
+- `validateSkillNames()` excludes disabled skills.
+- `getActiveSkillsAllowedTools()` inherits disabled filtering.
+- `getUnifiedSkillCatalog()` includes disabled skills for Library.
+
+Install provenance:
+
+- `installFromFolder`, `installFromZip`, and `installFromUrl` should update database source type.
+- Existing folder/ZIP/URL behavior must remain compatible.
+- Existing overwrite backup under the skills directory should be removed from the target design.
+ Normal install replacement and adoption backups both use private backup/temp locations outside
+ the skills path.
+
+### SkillSyncPresenter
+
+Keep read-only agent scan/classification inside `SkillSyncPresenter` for the first pass. Extract an
+`agentManagement.ts` helper only when adoption, repair, remove, and custom path actions make the
+method set large enough to justify another module.
+
+Methods to add to `ISkillSyncPresenter`:
+
+- `scanSkillAgents(): Promise`
+- `scanSkillAgent(input: { agentId: string }): Promise`
+- `getAgentSkillDetail(input: { agentId: string; name: string }): Promise`
+- `previewAdoptAgentSkill(input): Promise`
+- `executeAdoptAgentSkill(input): Promise`
+- `previewLinkDeepChatSkills(input): Promise`
+- `executeLinkDeepChatSkills(input): Promise`
+- `repairAgentSkillLink(input): Promise`
+- `removeAgentSkillLink(input): Promise`
+- `addCustomSkillAgentPath(input): Promise`
+
+Use `toolScanner.getAllTools()` as the registered tool source, but filter link/adopt targets to
+user-level folder-format tools:
+
+```ts
+const canManageLinks =
+ !tool.isProjectLevel &&
+ tool.filePattern === '*/SKILL.md' &&
+ tool.capabilities.supportsSubfolders
+```
+
+Classification should inspect each entry without writing:
+
+```txt
+symlink -> target missing => broken-link
+symlink -> target under skillsDir => deepchat linked
+symlink -> other target => external-link
+real dir + DeepChat same name + diff => conflict
+real dir + no DeepChat same name => agent-owned
+```
+
+Use content hashes only for conflict detection after verifying both sides have `SKILL.md`.
+
+## Route And Client Changes
+
+Extend route contracts:
+
+- `src/shared/contracts/routes/skills.routes.ts`
+- `src/shared/contracts/routes/skillSync.routes.ts`
+
+Extend Zod schemas in `src/shared/contracts/domainSchemas.ts` only for route payload validation.
+Route dispatch remains in `src/main/routes/index.ts`.
+
+Extend renderer clients:
+
+- `src/renderer/api/SkillClient.ts` for Library, Git, and sync directory calls.
+- `src/renderer/api/SkillSyncClient.ts` for agent management calls.
+
+Add event contracts only where UI needs push refresh:
+
+- `skills.catalog.changed`: add reason values for `disabled-updated`, `management-state-updated`,
+ `git-installed`, and `sync-directory-updated`.
+- Add `skillSync.agentLinks.changed` if link/adopt actions need passive refresh.
+
+Keep scan/import/export progress events unchanged.
+
+### Route API Shape
+
+Library:
+
+```ts
+export interface UnifiedSkillItem {
+ name: string
+ description: string
+ canonicalPath: string
+ sourceType: SkillSourceType
+ deepchatDisabled: boolean
+ agentLinks: Record
+ ownerPluginId?: string
+ mutable: boolean
+}
+
+export interface SkillDetail {
+ name: string
+ description: string
+ sourcePath: string
+ markdown: string
+ mutable: boolean
+}
+```
+
+Agents:
+
+```ts
+export type AgentSkillOwner = 'deepchat' | 'agent' | 'external-link' | 'broken-link' | 'unknown'
+
+export type AgentSkillStatus =
+ | 'linked'
+ | 'agent-owned'
+ | 'linked-out'
+ | 'broken-link'
+ | 'conflict'
+ | 'empty'
+
+export type AgentSkillAction =
+ | 'adopt'
+ | 'resolve-conflict'
+ | 'repair-link'
+ | 'remove-link'
+ | 'open'
+
+export interface InstalledSkillAgent {
+ id: string
+ name: string
+ skillsDir: string
+ isCustom: boolean
+ supportsLinkManagement: boolean
+ skillsCount: number
+ linkedCount: number
+ agentOwnedCount: number
+ conflictCount: number
+ brokenLinkCount: number
+ status: 'ready' | 'detected-no-skills-dir' | 'permission-denied'
+}
+
+export interface AgentSkillItem {
+ name: string
+ description?: string
+ path: string
+ owner: AgentSkillOwner
+ status: AgentSkillStatus
+ action?: AgentSkillAction
+ link?: {
+ isSymlink: boolean
+ targetPath?: string
+ targetExists?: boolean
+ targetInsideDeepChat?: boolean
+ createdByDeepChat?: boolean
+ }
+ deepchat?: {
+ exists: boolean
+ path?: string
+ disabled?: boolean
+ sameContent?: boolean
+ }
+}
+```
+
+Git install:
+
+```ts
+export interface GitSkillRepoScanResult {
+ repoUrl: string
+ repoFormat: 'single-skill' | 'multi-skill'
+ skills: Array<{
+ name: string
+ description: string
+ relativePath: string
+ conflict: boolean
+ valid: boolean
+ error?: string
+ }>
+}
+```
+
+Sync directory:
+
+```ts
+export type SyncDirectorySkillState = 'new' | 'same' | 'modified' | 'conflict' | 'invalid'
+
+export interface SyncDirectorySkillPreview {
+ name: string
+ state: SyncDirectorySkillState
+ sourcePath: string
+ targetPath: string
+ error?: string
+}
+```
+
+## File Operations
+
+Base directories:
+
+```txt
+//
+application database: skill management state
+~/.deepchat/backups/skill-adoptions////
+~/.deepchat/tmp/skill-adoptions//
+~/.deepchat/tmp/skill-installs//
+~/.deepchat/tmp/skill-imports//
+```
+
+The configured skills path is a content root only. It must not contain `.deepchat-meta`, metadata
+files, backup folders, temp folders, or rollback folders in the target design.
+
+Adoption flow:
+
+```txt
+1. Resolve tool and skill row from a fresh scan.
+2. Validate source is inside the selected agent skills directory.
+3. Resolve symlink source when adopting external-link rows.
+4. Validate `SKILL.md` and skill name.
+5. Choose target name, defaulting to `-` on conflict.
+6. Copy source content to private temp.
+7. Validate copied `SKILL.md` and hash.
+8. Move temp to `/`.
+9. Move original agent path to private backup.
+10. Create directory symlink; on Windows fallback to junction.
+11. Write database source provenance and agentLinks.
+12. Rediscover DeepChat skills and rescan the selected agent.
+```
+
+Agent directories must never receive:
+
+```txt
+*.backup
+*.old
+*.deepchat-backup-*
+.deepchat-meta
+tmp
+```
+
+## Git Install
+
+Implementation:
+
+- Use `child_process.execFile` or existing process utility with `git` directly. Do not add a Git
+ dependency.
+- Clone into `~/.deepchat/tmp/skill-installs/`.
+- Detect:
+ - root `SKILL.md` => `single-skill`
+ - `skills//SKILL.md` => `multi-skill`
+- Reuse existing skill validation and copy logic where possible.
+- Support strategies: `rename`, `overwrite`, `skip`.
+- Record `repoUrl`, `repoFormat`, and `installedAt`.
+- Always remove temp clone after install/scan completion.
+
+## Sync Directory
+
+This is separate from existing external tool import/export.
+
+Export:
+
+```txt
+/
+ README.md
+ skills/
+ /
+ SKILL.md
+ assets/
+ references/
+ scripts/
+```
+
+Import:
+
+- Scan only `/skills/*/SKILL.md`.
+- Validate each skill before preview.
+- Show state: `new`, `same`, `modified`, `conflict`, `invalid`.
+- Apply `rename`, `overwrite`, or `skip`.
+- Record `source.type = 'imported'`, `importedFrom`, and `importedAt`.
+
+## Renderer Plan
+
+Convert `SkillsSettings.vue` into three tabs and one top add menu:
+
+```txt
+SettingsPageShell
+ Actions: search where relevant, Add Skill menu
+ Tabs
+ Library
+ Agents
+ Sync Directory
+```
+
+Reuse or adapt:
+
+- Existing `SkillCard` for Library rows.
+- Existing `SkillInstallDialog` folder/ZIP/URL UI from the top Add Skill menu.
+- Existing Git install dialog logic from the top Add Skill menu.
+- Existing link/sync-to-agent backend from a single-skill Library row action.
+
+New components:
+
+- `SkillAgentsTab.vue`
+- `AgentSkillTable.vue`
+- `AdoptSkillDialog.vue`
+- `ResolveSkillConflictDialog.vue`
+- `InstallSkillToAgentDialog.vue`
+- `SkillDetailDialog.vue`
+- `SkillImportExportTab.vue`
+- `InstallFromGitDialog.vue`
+
+Keep user-facing strings in `src/renderer/src/i18n/*/settings.json`.
+
+### Renderer Style Contract
+
+Use current settings UI patterns instead of a new design system:
+
+- Shell: `SettingsPageShell`.
+- Tabs: existing shadcn tabs.
+- Tables/lists: plain bordered row groups with compact spacing.
+- Actions: `Button` with lucide/Iconify icons; destructive actions stay in menus or confirm dialogs.
+- Toggles: `Switch` for DeepChat-only enabled/disabled.
+- Selection: `Checkbox` for skill multi-select.
+- Conflict strategies: `RadioGroup`.
+- Paths: monospace text, truncated with tooltip.
+- Status: badge text plus semantic color.
+
+Recommended tab component shape:
+
+```txt
+SkillsSettings.vue
+ Add Skill menu
+ SkillInstallDialog.vue
+ InstallFromGitDialog.vue
+ SkillCard.vue
+ SkillDetailDialog.vue
+ InstallSkillToAgentDialog.vue
+ SkillAgentsTab.vue
+ AgentSkillTable.vue
+ SkillDetailDialog.vue
+ AdoptSkillDialog.vue
+ ResolveSkillConflictDialog.vue
+ CustomAgentPathDialog.vue
+ SkillImportExportTab.vue as Sync Directory
+```
+
+Description handling:
+
+```txt
+List/table row: one-line clamp or no description.
+Detail dialog: full manifest description plus rendered Markdown from SKILL.md.
+```
+
+Library row interaction:
+
+```txt
+SkillCard.vue
+ non-control area click -> SkillDetailDialog.vue
+ exposed controls:
+ [Install to Agent] InstallSkillToAgentDialog.vue
+ [switch] DeepChat enable/disable
+
+SkillDetailDialog.vue
+ preview mode: rendered SKILL.md body
+ edit mode: name (read-only), description, allowedTools, Markdown content
+ actions: Install to Agent, enable/disable, Edit/Preview, Delete with confirm, Save/Cancel
+```
+
+Loading, empty, and error states:
+
+```txt
+Loading:
+[spinner] Scanning installed agents...
+
+Empty:
+No supported agents found.
+[Refresh]
+
+Permission error:
+Cannot read ~/.claude/skills
+[Open Folder] [Refresh]
+
+Broken link:
+Target missing: ~/.deepchat/skills/foo
+[Repair] [...]
+```
+
+Do not add nested cards. A tab may have one top toolbar and one primary list/table area; dialogs are
+the only framed surfaces that may contain form sections.
+
+### File Change Range
+
+Expected source files across the full feature. Phase 2 keeps read-only agent classification in
+`SkillSyncPresenter.index.ts`; do not add a dedicated agent-management module until write actions
+make that separation useful.
+
+```txt
+src/main/presenter/skillPresenter/
+ index.ts
+ managementState.ts
+ gitInstall.ts
+ importExport.ts
+
+src/main/presenter/skillSyncPresenter/
+ index.ts
+ toolScanner.ts
+
+src/shared/types/
+ skill.ts
+ skillManagement.ts
+ skillSync.ts
+
+src/main/presenter/sqlitePresenter/tables/
+ configTables.ts
+
+src/shared/contracts/routes/
+ skills.routes.ts
+ skillSync.routes.ts
+
+src/shared/contracts/events/
+ skills.events.ts
+ skillSync.events.ts
+
+src/renderer/api/
+ SkillClient.ts
+ SkillSyncClient.ts
+
+src/renderer/settings/components/skills/
+ SkillsSettings.vue
+ SkillAgentsTab.vue
+ AgentSkillTable.vue
+ AdoptSkillDialog.vue
+ ResolveSkillConflictDialog.vue
+ InstallSkillToAgentDialog.vue
+ SkillDetailDialog.vue
+ CustomAgentPathDialog.vue
+ SkillImportExportTab.vue
+ InstallFromGitDialog.vue
+```
+
+## Security And Compatibility
+
+- Reuse `skillSyncPresenter/security.ts` path safety helpers where possible.
+- Add symlink-aware containment checks for existing and not-yet-existing paths.
+- Never follow recursive symlink loops during scan or copy.
+- Skip symlinks during copied skill content unless adopting an external-link target explicitly.
+- Enforce current skill name rules: `^[a-z0-9][a-z0-9._-]*$`.
+- Enforce current file/ZIP/folder size ceilings or stricter ceilings for new flows.
+- Keep `skillsPath` compatibility. All "DeepChat skills path" operations use
+ `configPresenter.getSkillsPath()`, not hard-coded `~/.deepchat/skills`.
+- Migrate legacy `/.deepchat-meta/*.json` sidecars into database state, then stop writing
+ new sidecar files.
+- Scans must ignore legacy `.deepchat-meta` until migration cleanup is implemented.
+- Plugin-contributed skills are read-only catalog entries and are excluded from mutable actions.
+- Detail routes must read only from the selected DeepChat skill path or the freshly scanned supported
+ agent skill path.
+
+## Test Strategy
+
+Main unit tests:
+
+- Database-backed management state load/save/migration.
+- Legacy `.deepchat-meta/.json` sidecar import into database state.
+- Assertion that new runtime extension writes do not create files under the skills path.
+- Disabled filtering in metadata prompt, `loadSkillContent`, `validateSkillNames`, and Library
+ catalog inclusion.
+- Agent scan classification for linked, agent-owned, external-link, broken-link, and conflict.
+- Adoption success, conflict rename, backup location, and no backup residue in agent directory.
+- Link create/repair/remove, including Windows junction fallback mocked path.
+- Git single-skill and multi-skill scan/install with temp cleanup.
+- Sync directory import/export preview and conflict strategies.
+- Skill detail route path safety for DeepChat and agent-owned skills.
+
+Renderer tests:
+
+- Skills tabs render as Library, Agents, and Sync Directory only.
+- Top Add Skill menu exposes folder, ZIP, URL, and Git install choices.
+- Disabled toggle calls typed client and updates UI.
+- Library row Install to Agent opens detected local agents and calls the link client for one skill.
+- Library Install to Agent shows Disconnect and calls the remove-link client when the selected agent
+ is already linked.
+- Skill card body click opens detail while exposed install/toggle controls do not trigger detail.
+- Skill detail dialog renders long `SKILL.md` content without expanding list/table rows.
+- Skill detail dialog owns edit/save and delete-with-confirm controls for mutable DeepChat skills.
+- Agents table row actions map to the right client methods.
+- Agents tab uses icon-leading agent tab buttons and has no bulk "Sync to Agent" button.
+- Git install dialog scan/select/install states from the top add menu.
+- Sync Directory preview states.
+- Discover tab and `find-skills` resource are absent.
+
+Smoke tests:
+
+- Extend existing skills read-only route smoke for new Library routes.
+- Extend skill sync smoke for agent scan routes without mutating user files.
+
+Manual checks:
+
+- macOS/Linux symlink creation.
+- Windows junction fallback.
+- Agent directory remains clean after adoption.
+
+## Delivery Order
+
+1. Database state and Library disabled state.
+2. Agents scan/classification UI with no mutations.
+3. Adoption, link, repair, and remove.
+4. Git install through the top add menu.
+5. Sync directory import/export.
+6. UX consolidation: remove Install and Discover tabs, remove `find-skills`, move install-to-agent
+ to Library rows, and add reusable skill details.
+
+This order produces a useful first slice after step 3: users can take over existing local
+folder-format agent skills without polluting agent directories.
diff --git a/docs/features/deepchat-skills-management/spec.md b/docs/features/deepchat-skills-management/spec.md
new file mode 100644
index 0000000000..debfeccb37
--- /dev/null
+++ b/docs/features/deepchat-skills-management/spec.md
@@ -0,0 +1,736 @@
+# DeepChat Skills Management
+
+## Current-State Corrections
+
+The source draft describes the right product direction, but several parts need to be corrected for
+the current codebase before implementation:
+
+- This is not a greenfield skills system. `SkillPresenter` already owns local skill discovery,
+ install/uninstall, hot reload, built-in skill installation, legacy sidecar runtime config, and
+ session activation.
+- This is not a generic "all agents are sync targets" feature. V1 link/adopt operations are only
+ valid for user-level folder-format tools that use `//SKILL.md`. Project-level
+ and single-file tools remain import/export conversion targets only.
+- Git install is not the same as current `installFromUrl`. The existing URL path downloads ZIP
+ files; Git install needs clone/scan/select/install provenance.
+- The settings UX should stay in the existing `settings-skills` route, but V1.1 must remove the
+ over-split five-tab surface. Library owns add/install-to-agent actions, Agents owns inspection and
+ adoption, and sync directory remains a separate local repository workflow.
+- A command-copy Discover tab is not useful enough to keep. Remove it and do not bundle
+ `find-skills` as a built-in skill.
+
+## User Need
+
+Users need DeepChat to act as the local control center for skills: see local DeepChat skills, disable
+them for DeepChat without deleting files, install new skills from the top add menu, install a
+specific DeepChat skill to a detected local agent from that skill row, inspect existing
+folder-format skills from installed agents, adopt those skills into DeepChat safely, and move skills
+in or out of a user-selected sync directory.
+
+## Goals
+
+- Keep DeepChat runtime skills canonical under the configured skills path, defaulting to
+ `~/.deepchat/skills`.
+- Store source provenance, DeepChat-only disabled state, runtime extension settings, sync directory
+ settings, and DeepChat-created agent links in the application database.
+- Keep the configured DeepChat skills path as a pure content directory: only skill folders and their
+ files belong under it.
+- Preserve the existing `SkillPresenter` runtime behavior while adding a Library catalog that can
+ show disabled skills.
+- Add an Agents tab that scans detected user-level folder-format tools and classifies each skill as
+ DeepChat-linked, agent-owned, external-link, broken-link, or conflict.
+- Adopt agent-owned folder-format skills by copying the skill into DeepChat, backing up the original
+ under `~/.deepchat/backups`, and replacing the agent path with a link to the DeepChat canonical
+ skill.
+- Link a selected DeepChat skill to a supported local agent from the Library row action by creating a
+ symlink or Windows junction.
+- Add Git repository installation for single-skill repos with root `SKILL.md` and multi-skill repos
+ with `skills//SKILL.md` through the top add menu.
+- Add manual import/export to a user-selected multi-skill sync directory.
+- Add a reusable skill detail dialog that clamps list/table descriptions and renders the selected
+ `SKILL.md` body as Markdown.
+- Remove the top-level Install and Discover tabs; remove the old external-tool import block from the
+ Library tab.
+
+## Existing Capabilities To Preserve
+
+- `SkillPresenter` discovers `SKILL.md` files under the configured skills path.
+- `SkillPresenter` installs from folder, ZIP, and ZIP URL.
+- `SkillPresenter` installs built-in skills from `resources/skills`.
+- `SkillPresenter` currently stores per-skill runtime extension config under `.deepchat-meta`; the
+ target design migrates this state into the database and treats `.deepchat-meta` as legacy input.
+- `SkillPresenter` watches skill file changes and publishes `skills.catalog.changed`.
+- `SkillSyncPresenter` scans registered external tools, imports external skills into DeepChat, and
+ exports DeepChat skills to external tool formats.
+- Renderer-main communication uses typed route contracts and renderer API clients.
+- Skills settings currently live at `settings-skills` in `SkillsSettings.vue`.
+
+## Directory Layout
+
+DeepChat-managed skills use the configured skills path. When the user has not changed it, the path
+is:
+
+```txt
+~/.deepchat/
+ skills/
+ skill-a/
+ SKILL.md
+ assets/
+ references/
+ scripts/
+ skill-b/
+ SKILL.md
+ backups/
+ skill-adoptions/
+ claude-code/
+ old-review/
+ 20260626-153000/
+ original/
+ SKILL.md
+ adoption.json
+ tmp/
+ skill-adoptions/
+ skill-installs/
+ skill-imports/
+```
+
+Database-backed management state:
+
+```txt
+application database
+ skill metadata/provenance
+ DeepChat-only disabled flags
+ runtime extension settings
+ agent link ownership
+ sync directory config and timestamps
+```
+
+After adoption or Library install-to-agent, supported agent directories should contain final skills
+or links only:
+
+```txt
+~/.claude/skills/
+ old-review -> ~/.deepchat/skills/old-review
+ guizang-ppt -> ~/.deepchat/skills/guizang-ppt
+```
+
+Manual sync directory layout:
+
+```txt
+~/Documents/deepchat-skills/
+ README.md
+ skills/
+ old-review/
+ SKILL.md
+ assets/
+ references/
+ scripts/
+ guizang-ppt/
+ SKILL.md
+```
+
+## Ownership Rules
+
+| Location | Owner |
+| --- | --- |
+| Real directory under configured DeepChat skills path | DeepChat |
+| Real directory under a supported agent skills path | Agent |
+| Agent path is a symlink/junction to DeepChat skills path | DeepChat |
+| Agent path is a symlink to another location | External link |
+| Agent path is a symlink/junction whose target is missing | Broken link |
+
+DeepChat runtime reads only managed DeepChat skills and plugin-contributed runtime skills. It must
+not require any metadata directory inside the skills path. Legacy `.deepchat-meta`, backups, temp
+directories, and agent backup residue must be ignored during migration/scanning.
+
+## Supported Agent Management Targets
+
+V1 link/adopt supports only user-level folder-format tools:
+
+- `claude-code`
+- `codex`
+- `cursor`
+- `opencode`
+- `goose`
+- `kilocode`
+- `copilot-user`
+
+V1 does not link/adopt project-level or single-file tools:
+
+- `cursor-project`
+- `windsurf`
+- `copilot`
+- `kiro`
+- `antigravity`
+
+Those tools remain available through the existing import/export conversion flow.
+
+## Functional Requirements
+
+### Library
+
+- Users can view all DeepChat-managed skills, including disabled skills.
+- Users can toggle a DeepChat-only disabled state.
+- Users can open a reusable skill detail dialog from each row.
+- Users can install a single DeepChat skill to a detected local agent from that skill row.
+- Users can add skills from folder, ZIP, URL, or Git repository from the top add menu.
+- The Library tab must not show the old external-tool import grid.
+- Disabled skills remain on disk and remain eligible for agent links and manual export when the user
+ explicitly includes them.
+- Disabled skills are excluded from DeepChat runtime prompt injection, automatic validation, and
+ active skill tool permissions.
+
+### Agents
+
+- Users can see detected supported agents as icon tab buttons matching the Library/external tool
+ button style.
+- Users can select an agent and see the agent's skills directory, counts, and skill rows.
+- Agent rows classify ownership and status without mutating files during scan.
+- Agent rows clamp descriptions to a short preview and expose full content through the reusable
+ skill detail dialog.
+- Agent-owned folder skills can be adopted into DeepChat after a preview and confirmation.
+- DeepChat-linked skills show link details and do not offer a primary mutation button.
+- Broken DeepChat-created links can be repaired when the canonical DeepChat skill still exists.
+- DeepChat-created links can be removed without deleting the canonical DeepChat skill.
+- Agents tab must not show a bulk "Sync to Agent" action; installing DeepChat skills to agents is a
+ Library row action.
+
+### Add Skill
+
+- Users can install selected skills from a Git repository.
+- Folder, ZIP, URL, and Git installation share the top add menu instead of a separate Install tab.
+- Git scan detects root `SKILL.md` as `single-skill`.
+- Git scan detects `skills//SKILL.md` entries as `multi-skill`.
+- Git install records source provenance in database state.
+
+### Sync Directory
+
+- Users can set a sync directory.
+- Export writes selected skills to `/skills/`.
+- Import reads selected skills from `/skills/`.
+- Import/export previews show new, same, modified, conflict, skipped, and failed items.
+- Import/export updates database sync timestamps.
+- This workflow is for local multi-skill repository backup/migration, not for installing a skill to
+ an agent.
+
+## UX Shape
+
+The existing `settings-skills` route becomes a smaller tabbed work surface:
+
+```txt
++--------------------------------------------------------------------------+
+| Skills [Search_______] [+ Add Skill] |
+| Manage DeepChat skills and local agent links. |
++--------------------------------------------------------------------------+
+| [ Library ] [ Agents ] [ Sync Directory ] |
++--------------------------------------------------------------------------+
+| active tab content |
++--------------------------------------------------------------------------+
+```
+
+Style contract:
+
+- Use the existing settings shell, shadcn controls, Iconify/lucide icons, and Tailwind utilities.
+- Keep the page dense and operational. No hero, marketing panel, gradient background, or nested
+ cards.
+- Use compact rows, 8px or smaller radius, semantic badges, and icon buttons with tooltips for
+ refresh/open/remove actions.
+- Agent selector buttons use the same icon-leading button style as the Library external tool tiles:
+ icon, name, count badge, selected border.
+- Use semantic color only as a secondary signal:
+ - Enabled/linked/success: green semantic badge.
+ - Disabled/skipped/neutral: muted badge.
+ - Conflict/warning: amber badge.
+ - Broken/failed/destructive: destructive badge.
+- Every status must also have text; color alone is not enough.
+- Long paths and descriptions truncate or clamp instead of wrapping over action controls.
+- Primary action per row goes in the right column; secondary actions go in a row menu.
+
+Top add menu:
+
+```txt
++----------------------------------+
+| + Add Skill |
++----------------------------------+
+| Folder... |
+| ZIP... |
+| URL... |
+| Git repository... |
++----------------------------------+
+```
+
+Git repository install opens from the top add menu, not from a tab:
+
+```txt
++--------------------------------------------------------------------------+
+| Install from Git |
++--------------------------------------------------------------------------+
+| Repository URL |
+| [https://github.com/op7418/guizang-ppt-skill______________] [Scan] |
+| |
+| Detected format: single-skill |
+| [x] guizang-ppt-skill No conflict |
+| |
+| Conflict strategy |
+| (*) Rename new skill ( ) Replace existing ( ) Skip existing |
+| |
+| [Cancel] [Install to DeepChat] |
++--------------------------------------------------------------------------+
+```
+
+Library tab:
+
+```txt
++--------------------------------------------------------------------------+
+| Library [Open Folder] |
++--------------------------------------------------------------------------+
+| Summary: 18 skills - 15 enabled - 3 disabled - 4 agent links |
+| |
+| [wand] guizang-ppt |
+| Create PowerPoint decks from structured plans. |
+| Git install Enabled Claude [Install to Agent] [on] |
+| |
+| [wand] frontend-design |
+| UI and UX implementation guidance. |
+| Built-in Enabled - [Install to Agent] [on] |
+| |
+| [wand] old-review |
+| Review legacy code paths. |
+| Adopted Disabled Codex [Install to Agent] [off]|
+| |
+| Empty: No skills installed. Use Add Skill to add folder, ZIP, URL, Git. |
++--------------------------------------------------------------------------+
+```
+
+Library row interaction:
+
+```txt
+Click a non-control area of a Library row -> open Skill Detail.
+The exposed hot controls are:
+
+[Install to Agent] Install to Agent
+[on/off] Enable or disable in DeepChat
+```
+
+Skill detail:
+
+```txt
++--------------------------------------------------------------------------+
+| G guizang-ppt [Install to Agent] |
+| Create PowerPoint decks from structured plans. Enabled [] |
+| /Users/.../.deepchat/skills/guizang-ppt/SKILL.md [Edit] [Delete] |
+| |
+| +----------------------------------------------------------------------+ |
+| | Rendered Markdown preview of SKILL.md without YAML frontmatter | |
+| +----------------------------------------------------------------------+ |
++--------------------------------------------------------------------------+
+
+Edit mode keeps the same dialog:
+
++--------------------------------------------------------------------------+
+| G guizang-ppt [Install to Agent] |
+| /Users/.../.deepchat/skills/guizang-ppt/SKILL.md [Preview] [Delete] |
+| |
+| Name: guizang-ppt (read-only) |
+| Description: [.........................................................] |
+| Allowed tools: [Read, Bash] |
+| Content: |
+| +----------------------------------------------------------------------+ |
+| | # guizang-ppt | |
+| | ... | |
+| +----------------------------------------------------------------------+ |
+| [Cancel] [Save] |
++--------------------------------------------------------------------------+
+```
+
+Install one skill to a detected local agent:
+
+```txt
++--------------------------------------------------+
+| Install guizang-ppt to Agent |
++--------------------------------------------------+
+| Target agent |
+| [ Claude Code ] [ OpenAI Codex ] [ Cursor ] |
+| [ OpenCode ] [ Goose ] [ Kilo Code ] |
+| |
+| Result |
+| ~/.codex/skills/guizang-ppt -> DeepChat skill |
+| |
+| Conflict strategy |
+| (*) Rename link ( ) Replace DeepChat-owned link |
+| ( ) Skip |
+| |
+| [Cancel] [Install] |
++--------------------------------------------------+
+```
+
+Library row behavior:
+
+- Enabled/disabled toggle changes only DeepChat runtime state.
+- Disabled rows remain visible and editable, but their badge is muted and activation controls are
+ disabled where runtime selection appears.
+- Built-in or plugin-owned rows do not show destructive actions unless the existing system already
+ supports that action.
+- The old external-tool import grid is removed from this tab.
+
+Agents tab:
+
+```txt
++--------------------------------------------------------------------------+
+| Agents [Refresh] |
++--------------------------------------------------------------------------+
+| [ icon Claude Code 0 ] [ icon OpenAI Codex 2 ] [ icon Cursor 0 ] |
+| [ icon OpenCode 0 ] [ icon Goose 0 ] [ icon Kilo Code 0 ] |
++--------------------------------------------------------------------------+
+| OpenAI Codex Available |
+| /Users/me/.codex/skills |
+| 2 skills - 0 linked - 2 agent owned - 0 conflict - 0 broken |
++------------------+--------------+--------------+----------+------------+
+| Skill | Owner | Status | Preview | Action |
++------------------+--------------+--------------+----------+------------+
+| hatch-pet | Codex | Agent owned | View | Adopt |
+| native-feel | Codex | Agent owned | View | Adopt |
++------------------+--------------+--------------+----------+------------+
+```
+
+Agent row rules:
+
+- Description stays clamped to one line or is omitted from the table.
+- Full description and `SKILL.md` body are shown through the reusable detail dialog.
+- The tab does not show "Sync to Agent"; linking DeepChat skills to agents starts from Library.
+
+Agent row states:
+
+```txt
+Agent owned:
++------------------+--------------+--------------+----------+------------+
+| old-review | Claude Code | Agent owned | View | Adopt |
++------------------+--------------+--------------+----------+------------+
+
+DeepChat linked:
++------------------+--------------+--------------+----------+------------+
+| guizang-ppt | DeepChat | Linked | View | ... |
++------------------+--------------+--------------+----------+------------+
+menu: Open in Finder, Remove link
+
+External link:
++------------------+--------------+--------------+----------+------------+
+| docs-writer | External | Linked out | View | Adopt |
++------------------+--------------+--------------+----------+------------+
+
+Conflict:
++------------------+--------------+--------------+----------+------------+
+| frontend-helper | Claude Code | Conflict | View | Resolve |
++------------------+--------------+--------------+----------+------------+
+
+Broken link:
++------------------+--------------+--------------+----------+------------+
+| broken-ppt | DeepChat | Broken link | View | Repair |
++------------------+--------------+--------------+----------+------------+
+```
+
+Adopt confirmation:
+
+```txt
++--------------------------------------------------+
+| Adopt Skill |
++--------------------------------------------------+
+| old-review |
+| |
+| Current location |
+| ~/.claude/skills/old-review |
+| |
+| After adoption |
+| ~/.deepchat/skills/old-review |
+| ~/.claude/skills/old-review -> DeepChat skill |
+| |
+| Backup |
+| ~/.deepchat/backups/skill-adoptions/... |
+| |
+| [Cancel] [Adopt] |
++--------------------------------------------------+
+```
+
+Conflict resolver:
+
+```txt
++--------------------------------------------------+
+| Resolve Conflict |
++--------------------------------------------------+
+| frontend-helper |
+| |
+| Agent |
+| ~/.claude/skills/frontend-helper |
+| |
+| DeepChat |
+| ~/.deepchat/skills/frontend-helper |
+| |
+| Choose action |
+| (*) Adopt as frontend-helper-claude |
+| ( ) Replace DeepChat frontend-helper |
+| ( ) Keep current state |
+| |
+| [Cancel] [Apply] |
++--------------------------------------------------+
+```
+
+Custom path:
+
+```txt
++--------------------------------------------------+
+| Add Custom Agent Path |
++--------------------------------------------------+
+| Display name |
+| [My Agent ] |
+| |
+| Skills directory |
+| [/Users/me/.my-agent/skills ] |
+| |
+| Format |
+| (*) SKILL.md folder format |
+| |
+| [Cancel] [Scan path] |
++--------------------------------------------------+
+```
+
+Reusable skill detail:
+
+```txt
++----------------------------------------------------------+
+| C |
+| ComputerUse skill [Switch] [...]|
+| Drive the user's desktop GUI through ... |
+| |
+| +------------------------------------------------------+ |
+| | Computer Use | |
+| | Rendered Markdown from SKILL.md | |
+| | ... | |
+| +------------------------------------------------------+ |
+| |
+| [Try in Chat] |
++----------------------------------------------------------+
+```
+
+The same dialog is used from Library rows and Agents rows. It receives a source descriptor and
+renders the manifest summary plus sanitized Markdown body.
+
+Sync Directory tab:
+
+```txt
++--------------------------------------------------------------------------+
+| Sync Directory |
++--------------------------------------------------------------------------+
+| Local multi-skill repository |
+| [~/Documents/deepchat-skills____________________________] [Browse] [Save] |
++--------------------------------------------------------------------------+
+| [ Export to directory ] [ Import from directory ] |
++--------------------------------------------------------------------------+
+| Export selected skills |
+| [x] guizang-ppt Enabled Git install |
+| [x] frontend-design Enabled Built-in |
+| [ ] old-review Disabled Adopted |
+| |
+| [Preview Export] [Export Now] |
++--------------------------------------------------------------------------+
+```
+
+Import preview:
+
+```txt
++--------------------------------------------------------------------------+
+| Import from ~/Documents/deepchat-skills |
++----------------------+-------------+---------------+---------------------+
+| Skill | State | Source | Action |
++----------------------+-------------+---------------+---------------------+
+| guizang-ppt | Same | sync dir | Skip |
+| frontend-design | New | sync dir | Import |
+| skill-x | Conflict | sync dir | Rename |
+| broken-skill | Invalid | sync dir | View error |
++----------------------+-------------+---------------+---------------------+
+| Conflict strategy: (*) Rename imported ( ) Replace local ( ) Skip |
+| [Cancel] [Import Selected] |
++--------------------------------------------------------------------------+
+```
+
+## Non-Goals
+
+- No automatic scheduled sync.
+- No built-in Git commit, pull, or push.
+- No marketplace search or command-copy Discover tab.
+- No project-level agent link/adopt.
+- No conversion of single-file prompt formats into linked folder-format skills during adoption.
+- No cloud sync.
+- No separate Install tab; install flows start from the top add menu.
+- No new dependency unless an existing standard library or installed dependency is insufficient.
+
+## Acceptance Criteria
+
+- Database state is created or migrated without deleting existing skills or legacy sidecar runtime
+ configs.
+- Disabling a skill persists across restart, remains visible in Library, and excludes that skill
+ from DeepChat runtime metadata prompt and active-skill validation.
+- The configured DeepChat skills path contains only skill content directories. It must not contain
+ `.deepchat-meta`, metadata files, backups, temp files, or other management metadata after
+ migration.
+- Legacy `.deepchat-meta` runtime config files are migrated into the database and removed only after
+ the database write succeeds.
+- Supported agents are shown only when detected locally.
+- Supported agents use icon-leading tab buttons with counts.
+- Project-level and single-file tools are not offered link/adopt actions.
+- Scanning an agent never creates, deletes, or moves files.
+- Agents table descriptions are clamped or omitted, and full skill content is available through the
+ reusable skill detail dialog with Markdown rendering.
+- Adopting an agent-owned skill creates `~/.deepchat/skills//SKILL.md`, stores the original
+ under `~/.deepchat/backups/skill-adoptions/...`, and replaces the agent path with a link to the
+ canonical DeepChat skill.
+- Agent skills directories do not receive backup, temp, rollback, or metadata folders.
+- Same-name conflicts default to creating a unique adopted skill name instead of overwriting the
+ existing DeepChat skill.
+- Installing one Library skill to an agent creates or repairs only DeepChat-owned links and does not
+ delete agent-owned skill directories unless the user explicitly chooses a conflict strategy.
+- The top add menu exposes folder, ZIP, URL, and Git install paths.
+- Git single-skill and multi-skill repositories install selected skills into DeepChat and write
+ `git-install` provenance.
+- Manual export creates a valid multi-skill repository layout.
+- Manual import handles new, same, modified, and conflict states before writing.
+- The old external-tool import grid, separate Install tab, Discover tab, and `find-skills` bundled
+ skill are removed from the settings surface.
+
+## Critical Acceptance Scenarios
+
+### Agent-Owned Adoption
+
+```txt
+Given ~/.claude/skills/old-review/SKILL.md exists
+And ~/.deepchat/skills/old-review does not exist
+When the user adopts old-review from Claude Code
+Then ~/.deepchat/skills/old-review/SKILL.md exists
+And ~/.claude/skills/old-review links to ~/.deepchat/skills/old-review
+And the original is backed up under ~/.deepchat/backups/skill-adoptions
+And database state source.type is adopted
+```
+
+### Clean Agent Directory
+
+```txt
+Given a user adopts ~/.claude/skills/old-review
+Then ~/.claude/skills contains old-review as a link
+And ~/.claude/skills does not contain old-review.deepchat-backup-*
+And scan shows one old-review row
+```
+
+### DeepChat Linked Display
+
+```txt
+Given ~/.claude/skills/guizang-ppt links to ~/.deepchat/skills/guizang-ppt
+Then the Agents table shows:
+Skill = guizang-ppt
+Owner = DeepChat
+Status = Linked
+Action = row menu only
+```
+
+### Conflict Adoption
+
+```txt
+Given ~/.claude/skills/frontend-helper exists
+And ~/.deepchat/skills/frontend-helper exists
+And their content hashes differ
+When the user chooses "Adopt as frontend-helper-claude"
+Then ~/.deepchat/skills/frontend-helper remains unchanged
+And ~/.deepchat/skills/frontend-helper-claude is created
+And ~/.claude/skills/frontend-helper links to the renamed DeepChat skill
+```
+
+### Git Installation
+
+```txt
+Given a repo root contains SKILL.md
+When the user opens Add Skill -> Git repository, scans, and installs it
+Then the selected skill is copied to the DeepChat skills path
+And database state source.type is git-install
+And database state source.repoFormat is single-skill
+
+Example: `https://github.com/op7418/guizang-ppt-skill` is a root `SKILL.md` repository whose
+frontmatter skill name is `guizang-ppt-skill`.
+
+Given a repo contains skills/a/SKILL.md and skills/b/SKILL.md
+When the user selects a and b
+Then both skills are installed
+And database state source.repoFormat is multi-skill
+```
+
+### Library Install To Agent
+
+```txt
+Given ~/.deepchat/skills/guizang-ppt/SKILL.md exists
+And ~/.codex/skills is detected
+When the user chooses Install to Agent from the guizang-ppt Library row
+Then ~/.codex/skills/guizang-ppt links to ~/.deepchat/skills/guizang-ppt
+And database state records the Codex agent link
+And the Agents tab later shows guizang-ppt as DeepChat linked
+
+Given guizang-ppt is already linked to ~/.codex/skills/guizang-ppt
+When the user opens Install to Agent and selects Codex
+Then the dialog shows a Disconnect action
+And Disconnect removes the DeepChat-owned Agent link
+And database state removes the Codex agent link record
+```
+
+### Library Row And Detail Interaction
+
+```txt
+Given a Library skill row is visible
+When the user clicks any non-control area of the row
+Then the Skill Detail dialog opens
+And the row does not expose a standalone View details action
+And the row keeps Install to Agent and DeepChat enable/disable as visible controls
+
+Given the Skill Detail dialog is open for a mutable skill
+When the user chooses Edit
+Then the dialog switches to editable name, description, allowed tools, and Markdown content fields
+And Delete is next to Edit/Preview inside the same dialog
+And Delete requires a second confirmation before removing the skill
+And Install to Agent and DeepChat enable/disable are also available inside the detail dialog
+```
+
+### Skill Detail Preview
+
+```txt
+Given an agent skill has a long description
+When the user views the agent row
+Then the table does not expand horizontally for the full description
+And clicking the row detail affordance opens a detail dialog
+And the dialog renders the selected SKILL.md body as Markdown
+```
+
+### Manual Export
+
+```txt
+Given sync directory is ~/Documents/deepchat-skills
+And selected skills are a and b
+When the user exports
+Then ~/Documents/deepchat-skills/skills/a/SKILL.md exists
+And ~/Documents/deepchat-skills/skills/b/SKILL.md exists
+And database sync lastExportAt is updated
+```
+
+### DeepChat-Only Disable
+
+```txt
+Given skill a exists in the DeepChat skills path
+When the user disables a in Library
+Then database state marks skill a as DeepChat-disabled
+And Library still shows a
+And getMetadataPrompt excludes a
+And existing agent links remain unchanged
+```
+
+## Resolved Assumptions
+
+- `~/.deepchat/skills` means the configured skills path when the user changed `skillsPath`.
+- Skill management database state is local-only and not automatically synchronized.
+- Existing `.deepchat-meta/.json` runtime config is legacy migration input; new writes go to
+ the database.
+- Plugin-contributed skills remain read-only runtime contributions and are not adopted, linked,
+ exported by default, or moved into database-owned management state.
diff --git a/docs/features/deepchat-skills-management/tasks.md b/docs/features/deepchat-skills-management/tasks.md
new file mode 100644
index 0000000000..7a115e310f
--- /dev/null
+++ b/docs/features/deepchat-skills-management/tasks.md
@@ -0,0 +1,189 @@
+# DeepChat Skills Management Tasks
+
+Status: Phase 1 through Phase 8 are implemented for the supported V1.1 paths. V1.1 keeps the
+working backend paths, removes the over-split Install/Discover UI, moves install-to-agent into
+Library rows, and keeps sync directory as a separate local repository workflow. Adoption still
+defaults conflicts to safe rename; destructive overwrite and custom agent path management remain
+deferred until the agent registry has a durable custom-target model.
+The standalone draft has been absorbed into this SDD folder and removed.
+
+## Phase 0: Design Contract Check
+
+- [x] Keep the `settings-skills` route as one tabbed settings surface.
+- [x] Match the ASCII layouts in `spec.md` before implementing UI.
+- [x] Use existing shadcn settings controls and lucide/Iconify icons.
+- [x] Use compact row/table layouts; do not add hero panels or nested cards.
+- [x] Add loading, empty, permission-error, conflict, broken-link, and invalid-skill states.
+- [x] Ensure every status uses text, not color alone.
+- [x] Truncate long paths and descriptions with tooltips.
+- [x] Keep all user-facing labels in i18n files.
+
+## Phase 1: Database State And Library Disable
+
+- [x] Add `src/shared/types/skillManagement.ts`.
+- [x] Add database-backed management state helper under `src/main/presenter/skillPresenter/`.
+- [x] Store source provenance, disabled state, runtime extension settings, sync config, and agent
+ links in the application database.
+- [x] Migrate legacy `/.deepchat-meta/.json` runtime configs into database state.
+- [x] Remove migrated legacy `.deepchat-meta` files after successful database write.
+- [x] Stop writing new `.deepchat-meta` files under the skills path.
+- [x] Add a test that the configured skills path remains a pure skill content directory.
+- [x] Add `getUnifiedSkillCatalog()` to `ISkillPresenter`.
+- [x] Add `setSkillDeepChatDisabled()` to `ISkillPresenter`.
+- [x] Add typed routes and `SkillClient` methods for unified catalog and disabled toggle.
+- [x] Filter disabled skills from `getMetadataPrompt()`.
+- [x] Filter disabled skills from `loadSkillContent()`.
+- [x] Filter disabled skills from `validateSkillNames()`.
+- [x] Keep disabled skills visible in the Library catalog.
+- [x] Add Library disabled toggle UI and i18n strings.
+- [x] Add unit tests for database migration, persistence, disabled filtering, and restart behavior.
+
+## Phase 2: Agents Scan And Classification
+
+- [x] Add shared agent-management types for installed agents, skill rows, owners, statuses, and
+ actions.
+- [x] Add route contracts for agent scan/list/detail.
+- [x] Keep read-only classification helpers inside `SkillSyncPresenter`; defer a separate
+ `agentManagement.ts` until adoption/repair/remove logic needs it.
+- [x] Reuse `toolScanner.getAllTools()` and filter link/adopt support to user-level
+ `*/SKILL.md` tools.
+- [x] Implement read-only installed-agent detection.
+- [x] Implement read-only skill row classification.
+- [x] Exclude project-level and single-file tools from link/adopt actions.
+- [x] Add `SkillSyncClient` methods for agent scan/list/detail.
+- [x] Add `SkillAgentsTab.vue` and `AgentSkillTable.vue`.
+- [x] Match the read-only Agents tab ASCII layout, row states, and action placement from `spec.md`;
+ keep write actions disabled until Phase 3.
+- [x] Add tests for linked, agent-owned, external-link, broken-link, and conflict classification.
+
+## Phase 3: Adoption And Agent Links
+
+- [x] Add adopt preview route and presenter method.
+- [x] Add adopt execute route and presenter method.
+- [x] Copy adoption sources through `~/.deepchat/tmp/skill-adoptions/`.
+- [x] Move original agent content to `~/.deepchat/backups/skill-adoptions/...`.
+- [x] Replace adopted agent path with a symlink or Windows junction.
+- [x] Record database source provenance and `agentLinks`.
+- [x] Add default conflict strategy: adopt as `-`.
+- [x] Add sync-to-agent preview route and presenter method.
+- [x] Add sync-to-agent execute route and presenter method.
+- [x] Add repair DeepChat-owned link route and presenter method.
+- [x] Add remove DeepChat-owned link route and presenter method.
+- [x] Add `AdoptSkillDialog.vue` and wire adopt/resolve-conflict rows to the existing adoption
+ backend.
+- [x] Match the adopt confirmation ASCII layout from `spec.md`.
+- [ ] Add destructive overwrite/keep adoption conflict strategies after the custom agent ownership
+ model is durable enough to support them safely.
+- [x] Add batch sync-to-agent backend; the original dialog was superseded by the Phase 8 Library
+ row flow.
+- [x] Match sync-to-agent dialog ASCII layout from `spec.md` for the first V1 slice.
+- [ ] Match custom-path dialog ASCII layout when custom agent targets are implemented.
+- [x] Add tests that agent directories never receive backup/temp/meta folders.
+- [x] Add renderer tests for the adopt preview/execute confirmation flow.
+- [x] Add tests for repair/remove refusing links not created by DeepChat.
+
+## Phase 4: Git Install
+
+- [x] Add Git scan route and `SkillClient` method.
+- [x] Add Git install route and `SkillClient` method.
+- [x] Clone repos to `~/.deepchat/tmp/skill-installs/`.
+- [x] Detect root `SKILL.md` as `single-skill`.
+- [x] Detect `skills//SKILL.md` entries as `multi-skill`.
+- [x] Reuse existing skill validation before copy.
+- [x] Support `rename`, `overwrite`, and `skip` conflict strategies.
+- [x] Record `git-install` provenance in database state.
+- [x] Clean temp clones on success and failure.
+- [x] Add `InstallFromGitDialog.vue` and wire it into the Install tab.
+- [x] Match the Install tab Git scan/select/conflict ASCII layout from `spec.md`.
+- [x] Add tests for single-skill, multi-skill, conflict strategy, and temp cleanup.
+
+## Phase 5: Sync Directory Import / Export
+
+- [x] Add sync directory config types and routes.
+- [x] Add `getSkillsSyncConfig()` and `setSkillsSyncDirectory()`.
+- [x] Add export preview and execute routes.
+- [x] Export selected skills to `/skills/`.
+- [x] Write `README.md` when missing.
+- [x] Exclude disabled skills by default and allow explicit inclusion.
+- [x] Add import preview and execute routes.
+- [x] Scan `/skills/*/SKILL.md` only.
+- [x] Preview `new`, `same`, `modified`, `conflict`, and `invalid`.
+- [x] Apply `rename`, `overwrite`, and `skip`.
+- [x] Record `imported` provenance and import/export timestamps.
+- [x] Add `SkillImportExportTab.vue`.
+- [x] Match export and import preview ASCII layouts from `spec.md`.
+- [x] Add tests for export layout and import conflict states.
+
+## Phase 6: Discover
+
+- [x] Add `resources/skills/find-skills/SKILL.md`.
+- [x] Confirm built-in installation picks up the new skill on first run.
+- [x] Add `SkillDiscoverTab.vue`.
+- [x] Match the Discover tab ASCII layout from `spec.md`.
+- [x] Show local `find-skills` status and command-oriented actions.
+- [x] Add i18n strings.
+- [x] Add a renderer test that the Discover tab renders through the five-tab surface coverage.
+
+## Phase 7: Settings Surface And Smoke Coverage
+
+- [x] Convert `SkillsSettings.vue` into Library, Agents, Import / Export, Install, and Discover tabs.
+- [x] Keep existing folder/ZIP/URL install behavior reachable.
+- [x] Keep existing external tool import/export behavior reachable.
+- [x] Keep existing first-launch sync prompt behavior or explicitly remove it from the UX if the new
+ tabs replace it.
+- [x] Extend `test/renderer/api/clients.test.ts` for new typed routes.
+- [x] Extend skills route smoke tests for new read-only routes.
+- [x] Extend skill sync smoke tests for read-only agent scan routes.
+- [x] Run `pnpm run format`.
+- [x] Run `pnpm run i18n`.
+- [x] Run `pnpm run lint`.
+- [x] Run targeted main and renderer tests for touched skills modules.
+
+## Phase 8: V1.1 UX Consolidation
+
+- [x] Reduce `SkillsSettings.vue` tabs to Library, Agents, and Sync Directory.
+- [x] Remove the separate Install tab and `SkillInstallTab.vue`.
+- [x] Remove the Discover tab, `SkillDiscoverTab.vue`, `resources/skills/find-skills/`, and related
+ i18n/tests.
+- [x] Keep folder, ZIP, URL, and Git installs reachable from the top Add Skill menu.
+- [x] Move Git repo install entry into the Add Skill menu and reuse the existing Git scan/install
+ backend.
+- [x] Remove the top Export action; replace that flow with per-skill Library Install to Agent.
+- [x] Remove the old external-tool import grid from the Library tab.
+- [x] Add Library row action: Install to Agent.
+- [x] Let Install to Agent choose from detected local user-level folder-format agents only.
+- [x] Reuse existing link/sync-to-agent backend for the one-skill Library row flow.
+- [x] Let Install to Agent disconnect an already linked Agent via the existing remove-link backend.
+- [x] Remove the bulk Sync to Agent button from the Agents tab.
+- [x] Change Agents selector buttons to icon-leading tab buttons with count badges.
+- [x] Clamp or omit long descriptions in the Agents skill table.
+- [x] Add reusable `SkillDetailDialog.vue` for Library and Agents rows.
+- [x] Render the selected `SKILL.md` body as Markdown inside the detail dialog.
+- [x] Make Library row non-control area open the detail dialog directly.
+- [x] Keep Library row Install to Agent and DeepChat enable/disable as exposed controls.
+- [x] Move mutable skill edit/save into `SkillDetailDialog.vue`.
+- [x] Remove the standalone `SkillEditorSheet.vue` path after merging edit into detail.
+- [x] Move mutable skill delete into `SkillDetailDialog.vue` with second confirmation.
+- [x] Keep Install to Agent and DeepChat enable/disable available inside the detail dialog.
+- [x] Add detail route/client methods for DeepChat skills and scanned agent skills.
+- [x] Rename the manual import/export UI copy to Sync Directory to separate it from agent install.
+- [x] Keep sync directory import/export backend intact for local multi-skill repository backup and
+ migration.
+- [x] Update all user-facing strings in every locale with local-language translations.
+- [x] Update renderer tests for three tabs, Add Skill Git install, Library Install to Agent, agent
+ icon tabs, detail dialog, and absence of Discover/Install tabs.
+- [x] Update main/contract tests for skill detail routes.
+- [x] Run `pnpm run format`.
+- [x] Run `pnpm run i18n`.
+- [x] Run `pnpm run lint`.
+- [x] Run targeted main and renderer tests for touched skills modules.
+
+## Deferred
+
+- [ ] Built-in Git commit/pull/push for the sync directory.
+- [ ] Automatic scheduled sync.
+- [ ] Custom agent path registry and UI.
+- [ ] Destructive overwrite/keep adoption conflict strategies.
+- [ ] Project-level agent adoption.
+- [ ] Single-file prompt adoption into folder-format skills.
+- [ ] Deep external marketplace search integration.
diff --git a/docs/features/plugins-hub/plan.md b/docs/features/plugins-hub/plan.md
new file mode 100644
index 0000000000..a88fc62d5f
--- /dev/null
+++ b/docs/features/plugins-hub/plan.md
@@ -0,0 +1,454 @@
+# Plugins Hub Implementation Plan
+
+## Strategy
+
+Do not build a new plugin platform and do not add a new window. Move the UI ownership boundary into
+the existing main window.
+
+The smallest reliable implementation is:
+
+- Add `/plugins` routes to the existing main renderer router.
+- Keep `WindowSideBar` and `AppBar` as the app shell around the Plugins page.
+- Reuse current clients/stores/components where they already own behavior.
+- Treat Remote channels as renderer-only virtual plugin cards backed by existing `remoteControl.*` routes.
+- Hide Settings navigation entries for Plugins-owned areas, while keeping compatibility redirects.
+- Update the main sidebar expanded layout only; keep collapsed behavior unchanged.
+
+No data migration is required.
+
+## Main Route Architecture
+
+```text
+Main window
+ App.vue
+ AppBar
+ WindowSideBar
+ RouterView
+ /chat -> ChatTabView
+ /welcome -> WelcomePage
+ /plugins -> PluginsHubPage
+ /plugins
+ /plugins/skills
+ /plugins/mcp
+ /plugins/:pluginId
+```
+
+Use the existing `src/renderer/src/router/index.ts`. Do not add `src/renderer/plugins`, a new Vite
+entry, or a new BrowserWindow.
+
+Route names can be:
+
+```text
+plugins
+plugins-skills
+plugins-mcp
+plugins-detail
+```
+
+External/main-process callers should not know UI component internals. Reuse the existing app-runtime event path where possible. Only add a narrow route if a future main-process caller needs generic main-window navigation:
+
+```text
+system.openMainRoute({ routeName: 'plugins-mcp', params? }) -> { focused: boolean }
+```
+
+For the first increment, MCP install deeplinks reuse `DEEPLINK_EVENTS.MCP_INSTALL` and the main app deeplink handler routes the renderer to `/plugins/mcp`. Do not add a Plugins-specific window route.
+
+## Affected Boundaries
+
+| Boundary | Required change |
+| --- | --- |
+| `src/renderer/src/router/index.ts` | Add `/plugins` route family |
+| `src/renderer/src/App.vue` | Keep existing shell; ensure `/plugins` receives same global overlays/theme/i18n |
+| `WindowSideBar.vue` | Add expanded command list and route Plugins row to `/plugins` |
+| `renderer/api` | Reuse existing clients; add a main-window navigation client only if a generic main-process caller appears |
+| `shared/contracts/routes` | Add narrow focus/navigate route only if deeplink/main process cannot use existing event path |
+| Settings renderer | Remove/hide Plugins-owned nav entries and overview links |
+| Deeplink presenter | Route MCP install deeplink to main `/plugins/mcp` page |
+| Plugin presenter | Stop using per-plugin BrowserWindow as primary UI path |
+
+## Data Ownership
+
+Do not create a persisted unified plugin table.
+
+Use a renderer-only union for cards:
+
+```text
+CatalogItem =
+ official plugin item from plugins.list
+ Remote virtual item from remoteControl.listChannels + status
+```
+
+`MCP` and `Skills` are top-level sibling tabs under `/plugins`, not catalog cards. This union only drives plugin catalog rendering and search filtering. Writes go back to the current owner:
+
+| User action | Owner route/client |
+| --- | --- |
+| Enable official plugin | `PluginClient.enablePlugin` |
+| Disable official plugin | `PluginClient.disablePlugin` |
+| CUA runtime/permission action | `PluginClient.invokeAction` existing runtime actions |
+| MCP add/edit/toggle | `McpClient` / `useMcpStore` existing paths |
+| Skill install/edit/delete/sync | `SkillClient` / `SkillSyncClient` / `useSkillsStore` |
+| Remote enable/save/pair/remove binding | `RemoteControlClient` |
+
+## Page Shell
+
+Create the Plugins UI under the existing renderer:
+
+```text
+src/renderer/src/pages/plugins/
+├── PluginsHubPage.vue
+├── PluginsCatalogPage.vue
+├── OfficialPluginDetailPage.vue
+├── McpPluginsPage.vue
+├── SkillsPluginsPage.vue
+├── components/
+│ ├── PluginsTopTabs.vue
+│ ├── PluginCatalogGrid.vue
+│ ├── PluginCatalogCard.vue
+│ ├── AddedPluginsStrip.vue
+│ └── PluginSearchBar.vue
+└── composables/
+ ├── usePluginCatalog.ts
+ └── useRemotePluginItems.ts
+```
+
+Keep this list flexible during implementation; do not split files unless the component becomes hard to read.
+
+Visual baseline:
+
+- Main content starts with top tabs (`Plugins`, `Skills`, `MCP`).
+- Catalog page uses the Codex-like layout: title, subtitle, search, added strip, segmented filters, sectioned list.
+- Catalog cards include official plugins and Remote virtual plugins; MCP and Skills remain reachable through top tabs.
+- Remote does not have a top tab or product list route. Each channel opens as a virtual plugin detail.
+- Avoid settings-style full-width form pages for the catalog. Detail routes may use denser settings sections.
+- Cards are individual repeated items only. Do not put page sections inside floating cards.
+
+## Route and Navigation Behavior
+
+Renderer-side navigation:
+
+| Trigger | Behavior |
+| --- | --- |
+| Sidebar `Plugins` row | `router.push({ name: 'plugins' })` |
+| Top tab `Skills` | `router.push({ name: 'plugins-skills' })` |
+| Top tab `MCP` | `router.push({ name: 'plugins-mcp' })` |
+| Plugin card/detail | `router.push({ name: 'plugins-detail', params: { pluginId } })` |
+| Remote channel card/detail | `router.push({ name: 'plugins-detail', params: { pluginId: 'remote:' } })` |
+| `New Chat` row while on `/plugins` | `router.push({ name: 'chat' })`, then start new conversation |
+
+Main-process initiated navigation:
+
+- MCP install deeplink must focus the main window and route to `/plugins/mcp`.
+- Historical Settings route redirects can focus main and route to the matching `/plugins...` route.
+- If the main window does not exist, create/focus the normal app window, not a Plugins window.
+
+## Official Plugin Detail
+
+Current behavior opens `PluginPresenter.openPluginSettingsWindow(pluginId)`.
+
+Target behavior:
+
+- List page opens `/plugins/:pluginId`.
+- Detail page loads `plugins.get(pluginId)`.
+- Enable/disable remains in detail and list.
+- Runtime status and MCP status remain visible.
+- Known first-party plugin actions are exposed as native detail sections:
+ - `runtime.getStatus`
+ - `runtime.checkPermissions`
+ - `runtime.openPermissionGuide`
+
+Do not add a generic embedded HTML settings host in the first increment. Current shipped plugins are first-party (`cua`, `feishu`), so native Vue detail pages are enough and safer than enabling arbitrary webview/iframe behavior.
+
+Legacy fallback:
+
+- Keep `settingsContributions` in manifests during migration.
+- Keep `settings.open` action available only as a temporary compatibility path if some old package still calls it.
+- The first-party UI must not call `settings.open`.
+
+When to add a generic plugin settings host:
+
+- Only when third-party plugin settings contributions are a supported product requirement.
+- Use an isolated child WebContents/WebContentsView with the plugin-specific preload, not an iframe without preload.
+- Keep external navigation denied.
+
+## MCP Migration
+
+`McpSettings.vue` already owns most behavior. Move by reuse, not rewrite.
+
+Recommended first pass:
+
+- Create `McpPluginsPage.vue`.
+- Import/reuse `McpServers`, `McpBuiltinMarket`, NPM registry controls, guide overlay only if still needed.
+- Preserve current route query shape for market view inside Plugins (`/plugins/mcp?view=market`).
+- Move deeplink handler from Settings bootstrap to main app or Plugins page bootstrap for MCP install.
+
+Compatibility:
+
+- `deepchat://mcp/install` focuses main window and routes to `/plugins/mcp`.
+- Hidden `settings-mcp` route can redirect/open main `/plugins/mcp` during transition.
+
+Settings cleanup:
+
+- Remove visible `settings-mcp` navigation item.
+- Remove MCP Overview metric.
+- Remove `start-mcp` quick task or replace it with a non-Plugins Settings task.
+
+## Skills Migration
+
+`SkillsSettings.vue` can become a Plugins page with minimal changes:
+
+- Rename/wrap visually as `SkillsPluginsPage`.
+- Keep `SkillCard`, `SkillInstallDialog`, `SkillEditorSheet`, `SkillSyncDialog`, `SyncStatusSection`.
+- Keep draft suggestion toggle.
+- Keep first-launch sync prompt if product still relies on it.
+
+Avoid duplicating the skills store or install logic.
+
+Compatibility:
+
+- Hidden `settings-skills` route should route the main window to `/plugins/skills`.
+- Settings Overview search should not list Skills.
+
+## Remote Migration
+
+First increment: reuse `RemoteSettings.vue` inside `/plugins/:pluginId` for virtual plugin ids such as `remote:telegram`. The detail shell owns the plugin-style enable/disable button, while single-channel mode hides the old Remote tab strip and the embedded channel toggle. Feishu/Lark Integration uses the same hide-toggle mode so its top-level plugin enable button controls both the official plugin and Feishu/Lark Remote. This keeps the existing credential, pairing, default agent/workdir, bindings and WeChat iLink behavior intact.
+
+Follow-up refactor: extract channel sections from `RemoteSettings.vue` into reusable components. The file is already large, but splitting it before moving the route would increase regression risk and delay the user-visible entry-point cleanup.
+
+Refactor only around real channel boundaries:
+
+```text
+PluginsCatalogPage
+ -> virtual cards from listRemoteChannels()
+PluginDetailPage(remote:)
+ -> channel header/status/toggle
+ -> credentials section
+ -> default agent/workdir section
+ -> pairing section when supportsPairing
+ -> bindings section
+ -> channel-specific section
+```
+
+Suggested extracted components:
+
+| Component | Scope |
+| --- | --- |
+| `RemotePluginCard` | card summary for one channel |
+| `PluginDetailPage(remote:)` | detail shell and save status |
+| `RemoteCredentialsSection` | token/app secret fields; channel-specific props |
+| `RemoteDefaultsSection` | default agent and default workdir |
+| `RemotePairingSection` | pair code and principals for pairable channels |
+| `RemoteBindingsSection` | bound chats/groups/topics |
+| `WeixinIlinkAccountsSection` | WeChat iLink login/account controls |
+
+Keep shared logic tiny:
+
+- load channel settings
+- save channel settings
+- load channel status
+- load bindings/pairing
+
+Do not invent a generic form schema for all channels.
+
+Virtual item mapping:
+
+```text
+remote:
+ kind: remote
+ title: channel title + ' Remote' when needed
+ description: descriptor.descriptionKey
+ enabled: status.enabled
+ state: status.state
+ detailRoute: /plugins/:pluginId
+```
+
+## Settings Removal and Redirects
+
+Change visible navigation source:
+
+- Remove or mark hidden:
+ - `settings-mcp`
+ - `settings-remote`
+ - `settings-plugins`
+ - `settings-skills`
+
+Because `settingsNavigation.ts` is the single source for Settings sidebar and Overview search, this should remove most visible Settings entries without scattered conditions.
+
+Route compatibility options:
+
+1. Keep hidden route items so old route names still exist.
+2. When entered, focus main window and navigate to the mapped `/plugins...` route.
+3. Do not show these items in Settings sidebar/search.
+
+Mapping:
+
+| Old Settings route | Main window target |
+| --- | --- |
+| `settings-mcp` | `/plugins/mcp` |
+| `settings-remote` | `/plugins` |
+| `settings-plugins` | `/plugins` |
+| `settings-skills` | `/plugins/skills` |
+
+`settings-acp` stays in Settings for this feature. ACP is an agent/provider configuration surface, not part of the four requested Plugins-owned areas.
+
+## Sidebar Implementation Plan
+
+Current `WindowSideBar.vue` should not be rewritten. Modify the expanded right column header area.
+
+Before:
+
+```text
+right column
+├── header row: selectedAgentName + group toggle + plus
+├── search input
+├── pinned section
+└── session groups
+```
+
+After:
+
+```text
+right column
+├── title row: selectedAgentName
+├── command list
+│ ├── New Chat
+│ ├── Search
+│ └── Plugins
+├── blank spacer
+├── pinned section when non-empty
+├── Chat group
+├── 工作区 header + existing group-mode/sort toggle
+└── project groups
+```
+
+Command behavior:
+
+| Row | Existing behavior to call |
+| --- | --- |
+| New Chat | `router.push({ name: 'chat' })` then `sessionStore.startNewConversation({ refresh: true })` |
+| Search | `spotlightStore.toggleSpotlight()` |
+| Plugins | `router.push({ name: 'plugins' })` |
+
+Keep:
+
+- Agent icon rail.
+- Settings/theme/sidebar controls in the existing left rail.
+- collapsed width and transitions.
+- session pagination and fill checks.
+- pinned collapse behavior.
+- project grouping/reorder behavior.
+- existing group-mode/sort behavior, moved to the `工作区` header.
+- shortcut badge logic for sessions.
+
+Question the old inline session search:
+
+- First increment should remove the inline search input from expanded sidebar to match the requested command-list shape.
+- Search row opens Spotlight, which already searches sessions/messages/settings/actions.
+- If users later need local-only filtering, add it inside Spotlight or as a session-list filter command, not as a second persistent input.
+
+Right column ordering:
+
+```text
+所有 Agents
+
+New Chat
+Search
+Plugins
+
+Pinned (only if any)
+...
+Chat
+...
+工作区 [group/sort toggle]
+project groups
+...
+```
+
+Do not add Settings, theme, collapse, remote status or other rail controls into this right column.
+
+## Deeplinks and External Entry Points
+
+Update callers:
+
+| Current caller | New behavior |
+| --- | --- |
+| MCP install deeplink | focus main window, route to `/plugins/mcp`, dispatch MCP install event there |
+| Settings sidebar old MCP/Skills/Plugins/Remote | no visible entry |
+| Settings activity old route | focus main window and route to matching `/plugins...` page |
+| Sidebar remote status button | route to the first enabled `remote:` plugin detail |
+| Chat input MCP indicator `openSettings` text | route to `/plugins/mcp` |
+
+Provider install deeplink stays in Settings Provider. Do not route provider/model setup to Plugins.
+
+## i18n
+
+Add route/page labels:
+
+- `routes.plugins`
+- `pluginsHub.title`
+- `pluginsHub.subtitle`
+- `pluginsHub.searchPlaceholder`
+- `pluginsHub.tabs.plugins`
+- `pluginsHub.tabs.skills`
+- `pluginsHub.tabs.mcp`
+- `pluginsHub.tabs.remote`
+- sidebar command labels if existing `common.newChat` and spotlight labels are not enough.
+
+Avoid moving existing `settings.mcp`, `settings.skills`, `settings.remote` keys in the first increment. Reuse them from Plugins pages to keep the diff smaller. Later cleanup can rename namespaces if the old naming becomes misleading.
+
+## Testing Strategy
+
+Small checks with high signal:
+
+| Area | Tests |
+| --- | --- |
+| Main router | `/plugins` and child routes render inside app shell |
+| Settings navigation | removed entries do not appear in `getSettingsNavigationGroups`; hidden redirects still resolve |
+| Sidebar | expanded command rows render; collapsed state unchanged; Plugins row routes to `/plugins` |
+| Remote virtual items | descriptors + status produce cards; detail saves via `remoteControl.saveChannelSettings` |
+| Official plugin detail | list/detail enable-disable; settings button no longer calls `settings.open` |
+| Deeplink | MCP install focuses main and routes to `/plugins/mcp` |
+
+Manual visual QA:
+
+- macOS light/dark with app sidebar and main content.
+- Windows light/dark with main app shell.
+- Linux opaque backgrounds.
+- Narrow width with collapsed and expanded sidebar.
+- Long remote token/error/path strings.
+- Chinese and English labels.
+
+Final implementation gates:
+
+```bash
+pnpm run format
+pnpm run i18n
+pnpm run lint
+pnpm run typecheck
+```
+
+Renderer tests should be run for touched components. Full app smoke test should open Chat, Plugins and Settings separately.
+
+## Risks and Mitigations
+
+| Risk | Mitigation |
+| --- | --- |
+| Settings routes are used by deeplinks/onboarding | Keep hidden compatibility routes and redirect to main `/plugins...` |
+| RemoteSettings monolith makes migration risky | Reuse it in single-channel mode; extract per-channel sections only when needed |
+| Plugin settings HTML depends on plugin preload | Do not embed arbitrary HTML in first increment; build first-party native details |
+| Feishu official plugin vs Feishu Remote naming collision | Merge Feishu Remote into the Feishu/Lark Integration detail page |
+| Plugins page becomes another Settings | Catalog page stays Codex-like; detail pages are dense only where settings are unavoidable |
+| Main route conflicts with chat internal `pageRouter` | Use Vue router for `/plugins`; keep `pageRouter` scoped to ChatTabView |
+| Search behavior confusion | Sidebar Search row opens existing Spotlight; do not add a new search engine |
+
+## Rollout Plan
+
+1. Land `/plugins` main route skeleton with top tabs and catalog placeholder.
+2. Move visible Settings entries out, with redirects to main Plugins routes.
+3. Move MCP page and deeplink.
+4. Move Skills page.
+5. Add official plugin native list/detail and stop first-party UI from opening plugin settings windows.
+6. Add Remote virtual plugin list/detail.
+7. Update main sidebar expanded command list.
+8. Run visual QA and clean up i18n/tests.
+
+This order keeps each PR reviewable and avoids breaking every surface at once.
diff --git a/docs/features/plugins-hub/spec.md b/docs/features/plugins-hub/spec.md
new file mode 100644
index 0000000000..64376e7a6d
--- /dev/null
+++ b/docs/features/plugins-hub/spec.md
@@ -0,0 +1,368 @@
+# Plugins Hub Specification
+
+## User Need
+
+DeepChat 的 Settings 里混入了高频工具能力、扩展能力和系统偏好。用户想管理 Skills、MCP
+servers、official Plugins 和 Remote control channels 时,不应该打开 Settings,也不应该弹一个新的
+插件设置窗口。
+
+本目标是把插件型能力移动到主窗口的一级页面和子路由中,形态参考 Codex 的主窗口 Plugins 页面:
+左侧仍是主窗口 sidebar,右侧主内容区显示 `Plugins` 页面、顶部 tabs、搜索、已添加项和推荐项。
+
+## Product Position
+
+`Plugins` 是主窗口里的扩展能力页面,不是独立 BrowserWindow,也不是 Settings 的子页面。
+
+| Capability | 在主窗口 Plugins 页面中的定位 | 数据事实源 |
+| --- | --- | --- |
+| Official Plugins | 可启停的 DeepChat first-party plugin package,例如 CUA、Feishu/Lark Integration | `PluginPresenter` + `plugins.*` routes |
+| MCP | 工具 server 管理、market、global MCP enablement | `McpPresenter` / `useMcpStore` |
+| Skills | agent skill 管理、导入导出、sync、draft suggestion | `SkillPresenter` / `SkillSyncPresenter` / `useSkillsStore` |
+| Remote | Telegram、Feishu/Lark、QQBot、Discord、WeChat iLink 作为 virtual plugin card | `RemoteControlPresenter` + `remoteControl.*` routes |
+
+Remote channel 是 Plugins UI 里的 virtual plugin,不是 `.dcplugin` 安装包。这个建模只改变用户入口和
+展示方式,不改变 remote control 的配置存储、runtime 生命周期或消息协议。
+
+## Goals
+
+- 新增主窗口 route:`/plugins`。
+- 在主窗口主内容区集中管理 Official Plugins、MCP、Skills、Remote。
+- 主窗口 sidebar 展开态右栏显示 `New Chat`、`Search`、`Plugins` command list,点击 `Plugins` 进入 `/plugins`。
+- `Plugins` command 下方留出空行,再显示 `Pinned`、`Chat`、`工作区` 和 project groups。
+- Settings 等底部 app controls 继续留在现有左侧 rail,不进入展开右栏。
+- `Plugins` 页面保留左侧 sidebar,右侧内容区切换,不创建新窗口。
+- Remote 每个 implemented channel 都作为 plugin-like card 出现,并能进入该 channel 的详情子路由。
+- Remote 设置页不再在 Settings 中展示;从列表进入详情时使用主窗口 Plugins 子路由。
+- Official plugin 的详情和设置入口不再弹出 per-plugin BrowserWindow;从列表进入详情时使用主窗口 Plugins 子路由。
+- Settings 侧边栏、Settings Overview 搜索和 quick entry 不再展示 Skills、MCP、Plugins、Remote。
+- 保留 Settings 内部旧 route 的兼容能力,避免 deeplink、onboarding 或历史入口直接 404。
+- `所有 Agents` 标题保留。
+- UI 需要在 macOS、Windows、Linux 以及窄窗口下保持可用和美观。
+
+## Non-Goals
+
+- 不做第三方 plugin marketplace。
+- 不把 Remote channel 改造成真实 `.dcplugin` 包。
+- 不迁移 provider、model、DeepChat Agents、ACP Agents、prompt、memory、knowledge、data、shortcut、about 等系统设置。
+- 不新增 Automations 入口;参考截图中有 Automations,但本目标只做 `New Chat`、`Search`、`Plugins`。
+- 不新增独立 Plugins BrowserWindow。
+- 不新增 `src/renderer/plugins` 独立 renderer entry。
+- 不重写 MCP、Skill、Remote、Plugin presenter。
+- 不新增统一持久化表来存一个“大插件模型”。
+- 不改变 existing Remote commands、pairing protocol、channel binding behavior。
+- 不改变 existing MCP server config schema、Skill sidecar schema 或 plugin manifest schema,除非内嵌设置页确实需要最小 route 补充。
+
+## Current State
+
+Relevant current files:
+
+| Area | Current files |
+| --- | --- |
+| Main app shell | `src/renderer/src/App.vue`, `src/renderer/src/router/index.ts` |
+| Main sidebar | `src/renderer/src/components/WindowSideBar.vue`, `src/renderer/src/stores/ui/sidebar.ts` |
+| Chat page internal route state | `src/renderer/src/stores/ui/pageRouter.ts`, `src/renderer/src/views/ChatTabView.vue` |
+| Settings shell and navigation | `src/renderer/settings/App.vue`, `src/renderer/settings/main.ts`, `src/shared/settingsNavigation.ts` |
+| Settings window lifecycle | `src/main/presenter/windowPresenter/index.ts`, `src/shared/contracts/routes/system.routes.ts` |
+| Plugins settings page | `src/renderer/settings/components/PluginsSettings.vue`, `src/renderer/api/PluginClient.ts`, `src/shared/contracts/routes/plugins.routes.ts` |
+| MCP settings page | `src/renderer/settings/components/McpSettings.vue`, `src/renderer/src/components/mcp-config/**`, `src/renderer/src/stores/mcp.ts` |
+| Skills settings page | `src/renderer/settings/components/skills/SkillsSettings.vue`, `src/renderer/src/stores/skillsStore.ts` |
+| Remote settings page | `src/renderer/settings/components/RemoteSettings.vue`, `src/renderer/api/RemoteControlClient.ts` |
+
+Important current constraints:
+
+- Main window Vue router currently exposes `/chat` and `/welcome`.
+- Main shell already keeps `WindowSideBar` outside `RouterView`, so adding `/plugins` naturally preserves the sidebar.
+- Settings navigation is centralized in `src/shared/settingsNavigation.ts`.
+- Settings routes are generated from navigation items in `src/renderer/settings/main.ts`.
+- `system.openSettings` only accepts `SettingsRouteNameSchema`.
+- MCP install deeplinks currently open Settings and send `DEEPLINK_EVENTS.MCP_INSTALL`.
+- Plugin settings currently call `plugins.invokeAction({ actionId: 'settings.open' })`, which opens a per-plugin BrowserWindow.
+- Remote channels already expose `RemoteChannelDescriptor`, status, settings, bindings and pairing through typed routes.
+
+## Proposed Main Route Structure
+
+```text
+src/renderer/src/router/index.ts
+├── /chat
+├── /welcome
+└── /plugins
+ ├── tab=plugins or child /plugins
+ ├── /plugins/skills
+ ├── /plugins/mcp
+ └── /plugins/:pluginId
+```
+
+Implementation can use nested Vue routes or one `/plugins` route with internal tab state. The URL must be shareable enough for internal navigation and redirects:
+
+| Target | Required addressable route |
+| --- | --- |
+| Plugins catalog | `/plugins` |
+| Skills | `/plugins/skills` |
+| MCP | `/plugins/mcp` |
+| Plugin detail | `/plugins/:pluginId` |
+
+Legacy `/plugins/official/:pluginId`, `/plugins/remote` and `/plugins/remote/:channel` paths may redirect for compatibility, but they are not product routes.
+
+## Proposed Information Architecture
+
+Top-level sections:
+
+| Section | User label | Contents |
+| --- | --- | --- |
+| Plugins | Plugins | official plugin packages, added/recommended plugin cards, Remote virtual plugin cards |
+| Skills | Skills | installed skills, install, edit, sync import/export, draft suggestion toggle |
+| MCP | MCP Servers | user MCP servers, plugin-owned MCP status, MCP market/add flow |
+
+The visual top tab row uses `Plugins`, `Skills` and `MCP`. `Remote` is not a top tab; each remote channel is a virtual plugin card in the catalog.
+
+Remote virtual plugin ids use `remote:`. Feishu/Lark is special: when the official Feishu/Lark Integration plugin is installed, the Feishu/Lark Remote card is merged into that official plugin detail page.
+
+Historical remote settings compatibility: if a channel has credentials/accounts from an older configuration and no explicit enabled flag, that virtual plugin starts enabled by default. Explicit `enabled: false` still stays disabled.
+
+## Main Window Plugins UX
+
+### Desktop Layout
+
+```text
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ AppBar │
+├───────────────┬──────────────────────────────────────────────────────────────┤
+│rail│ expanded sidebar │ [Plugins] [Skills] [MCP] + ↻ │
+│ │ 所有 Agents │ │
+│ │ New Chat │ Plugins │
+│ │ Search │ Work with DeepChat across your favorite tools │
+│ │ Plugins │ ┌────────────────────────────────────────────┐ │
+│ │ │ │ Search plugins and remote channels... │ │
+│ │ Pinned │ └────────────────────────────────────────────┘ │
+│ │ ... │ │
+│ │ Chat │ Added Manage │
+│ │ ... │ [CUA] [Feishu] [Telegram] [Skill pack] │
+│ │ 工作区 [sort]│ │
+│ │ project A │ Featured │
+│ │ project B │ Computer Use Add Chrome Add │
+│⚙︎ │ │ Spreadsheets ... Presentations ... │
+└────┴──────────────────┴──────────────────────────────────────────────────────────────┘
+```
+
+### Detail Route Layout
+
+```text
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ AppBar │
+├───────────────┬──────────────────────────────────────────────────────────────┤
+│rail│ expanded sidebar │ [Plugins] [Skills] [MCP] │
+│ │ 所有 Agents │ ← Back to Plugins │
+│ │ New Chat │ Telegram Remote on/off │
+│ │ Search │ Status: running · bindings: 2 · last error: none │
+│ │ Plugins │ │
+│ │ │ ┌ Credentials ────────────────────────────────────┐ │
+│ │ Pinned │ │ Bot token / app credentials │ │
+│ │ ... │ └─────────────────────────────────────────────────┘ │
+│ │ Chat │ ┌ Remote Control ────────────────────────────────┐ │
+│ │ 工作区 [sort]│ │ Default agent · Default workdir · Pairing │ │
+│ │ project A │ └─────────────────────────────────────────────────┘ │
+│⚙︎ │ │ ┌ Bindings ──────────────────────────────────────┐ │
+│ │ │ │ Existing chats/channels and remove actions │ │
+│ │ │ └─────────────────────────────────────────────────┘ │
+└────┴──────────────────┴──────────────────────────────────────────────────────┘
+```
+
+### Narrow Main Window Layout
+
+At constrained widths, keep the same app shell and avoid modal navigation:
+
+```text
+┌────────────────────────────────────┐
+│ AppBar │
+├────┬───────────────────────────────┤
+│rail│ [Plugins][Skills][MCP] │
+│⚙︎ ├───────────────────────────────┤
+│ │ Search │
+│ │ │
+│ │ Card list / Detail page │
+│ │ │
+└────┴───────────────────────────────┘
+```
+
+Narrow behavior:
+
+- If sidebar is collapsed, it stays collapsed.
+- Section navigation becomes a wrapped horizontal tab row.
+- Detail pages keep a top back button.
+- Long tokens, paths and errors must truncate with tooltip or wrap in a controlled block.
+- Forms use one column.
+
+## Sidebar UX
+
+### Target Expanded Shape
+
+```text
+┌────┬──────────────────────────────┐
+│rail│ 所有 Agents │
+│ │ │
+│ │ ┌──────────────────────────┐ │
+│ │ │ ✎ New Chat ⌘N │ │
+│ │ │ 🔍 Search ⌘P │ │
+│ │ │ ⌘ Plugins │ │
+│ │ └──────────────────────────┘ │
+│ │ │
+│ │ Pinned (if any) │
+│ │ ... │
+│ │ Chat │
+│ │ ... │
+│ │ 工作区 [sort] │
+│ │ project groups │
+│⚙︎ │ ... │
+└────┴──────────────────────────────┘
+```
+
+Notes:
+
+- `New Chat` starts a new conversation through the existing session store path and navigates to `/chat` if needed.
+- `Search` opens existing Spotlight/search behavior; it is not a second search implementation.
+- `Plugins` routes the current main window to `/plugins`.
+- There is a blank spacer after `Plugins` before the conversation sections.
+- `Pinned` is rendered only when pinned sessions exist.
+- `Chat` remains the unprojected/default chat group.
+- `工作区` is a section title for project groups; the existing group-mode/sort toggle moves to this row.
+- Shortcut badges display only for existing registered shortcuts. Do not add a new shortcut just to fill the badge.
+- The existing collapsed rail stays visually and behaviorally unchanged.
+- The existing Agent icon rail remains the collapsed-state affordance; no new collapsed Plugins icon is added.
+- Settings, theme and other bottom controls stay in the existing left rail. They are not listed under `Plugins` in the expanded right column.
+- The old header new-chat plus button is removed as a competing primary action. The existing group-mode/sort control moves to the `工作区` header.
+
+### Collapsed Shape
+
+Collapsed state remains current:
+
+```text
+┌────┐
+│ ◎ │ all agents / agent icons
+│ .. │
+│ 🔍 │ existing search affordance
+│ .. │ existing status/theme/sidebar/settings affordances
+└────┘
+```
+
+## Settings UX
+
+Settings remains for system/model/account/data preferences:
+
+```text
+Settings
+├── Overview
+├── Common
+├── Display
+├── Environments
+├── Providers
+├── DeepChat Agents
+├── ACP
+├── Notifications / Hooks
+├── Scheduled Tasks
+├── Prompt
+├── Memory
+├── Knowledge Base
+├── Database
+├── Shortcuts
+└── About
+```
+
+Removed from visible Settings navigation:
+
+- MCP
+- Remote
+- Plugins
+- Skills
+
+Compatibility behavior:
+
+- Existing internal settings routes can remain hidden during migration.
+- Direct navigation to removed route names should focus the main window and route to the matching `/plugins...` page when possible.
+- Settings Overview search should not list hidden Plugins-owned entries.
+- Settings activity records can keep historical `routeName` values; opening them should redirect to Plugins when the route is now Plugins-owned.
+
+## Acceptance Criteria
+
+### Main Window Route
+
+- `/plugins` renders inside the existing main app shell and keeps `WindowSideBar` visible.
+- Opening Plugins from the main sidebar navigates the current main window to `/plugins`.
+- No new Plugins BrowserWindow is created.
+- No new `src/renderer/plugins` renderer entry is added.
+- `AppBar`, sidebar, theme, language direction and global overlays continue to work.
+- The page has stable responsive behavior and remains usable at narrow widths.
+- User-facing strings use i18n keys.
+
+### Official Plugins
+
+- Official plugin list keeps enable/disable/status behavior.
+- Plugin-owned MCP errors remain visible.
+- Opening a plugin settings/detail uses `/plugins/:pluginId`, not Settings and not a per-plugin BrowserWindow.
+- CUA detail includes runtime/MCP status, permission checks and permission guide actions.
+- Remote virtual plugin detail pages use the same top-level enable/disable button style as official plugin details. The embedded remote form must not show a second channel toggle.
+- Feishu/Lark Integration detail includes Feishu/Lark Remote configuration instead of showing a separate Feishu/Lark Remote card.
+- Feishu/Lark Integration has one top-level enable/disable control; it enables/disables both the official plugin and the embedded Feishu/Lark Remote configuration. The embedded remote form must not show a second channel toggle.
+- Legacy `settings.open` plugin action is not used as the primary UI path after migration.
+
+### MCP
+
+- MCP global enablement, server list, add/edit, market view and NPM registry controls remain available from Plugins.
+- Plugin-owned MCP servers remain read-only where their owning plugin controls lifecycle.
+- MCP install deeplinks focus the main window and route to `/plugins/mcp` instead of opening Settings.
+
+### Skills
+
+- Installed Skills list, search, install, edit, delete, sync import/export and draft suggestion toggle remain available from Plugins.
+- First-launch sync prompt remains available if it is still part of the current product flow.
+- Skill drag/drop and URL/zip/folder install behavior remains unchanged.
+
+### Remote
+
+- Every implemented `RemoteChannelDescriptor` appears as a Remote virtual plugin card.
+- Each card shows enabled state, runtime state, binding/pairing summary and last error when present.
+- Each card opens `/plugins/:pluginId`; remote virtual plugin ids use `remote:`.
+- Channel settings render inside the plugin detail page and preserve current behavior:
+ - credentials
+ - enable/disable
+ - default agent
+ - default workdir
+ - pairing
+ - bindings/principals
+ - channel-specific login/account controls for WeChat iLink
+- Saving a channel setting still uses `remoteControl.saveChannelSettings`.
+- Remote status indicator in the main sidebar continues to work.
+
+### Main Sidebar
+
+- Expanded sidebar shows `New Chat`, `Search`, `Plugins` before pinned sessions.
+- Expanded sidebar inserts a blank spacer after `Plugins`.
+- Expanded sidebar shows `Pinned` only when pinned sessions exist, then `Chat`, then `工作区`.
+- The existing group-mode/sort toggle is placed on the `工作区` header row.
+- `所有 Agents` remains visible.
+- Settings and other bottom controls remain in the existing left rail, not in the expanded right column.
+- Collapsed sidebar keeps current visual shape and behavior.
+- No new collapsed icon button is added.
+- Session list pagination, pinned section, project grouping, drag reorder and keyboard shortcut badges continue working.
+
+### Settings Removal
+
+- Settings sidebar no longer shows MCP, Remote, Plugins or Skills.
+- Settings Overview search no longer returns MCP, Remote, Plugins or Skills as settings pages.
+- Settings Overview no longer uses MCP as one of the primary system metrics or quick-start tasks.
+- Existing app code that opens `settings-mcp`, `settings-remote`, `settings-plugins` or `settings-skills` is migrated or redirected to `/plugins...`.
+
+## Platform and Accessibility Requirements
+
+- Keyboard navigation works across top tabs, search, card list, detail forms and back navigation.
+- `Esc` closes transient dialogs only; it does not leave `/plugins`.
+- `Tab` order follows visual order.
+- Buttons and icon-only controls have accessible labels.
+- Status colors are not the only status signal; labels must remain visible.
+- Long file paths, tokens, error strings and command lines do not overflow their container.
+- Remote credentials remain password inputs by default, preserving current reveal behavior.
+- Linux and Windows backgrounds must not rely on macOS-only materials.
+- RTL languages should inherit existing app i18n direction handling.
+
+## Open Questions
+
+None.
diff --git a/docs/features/plugins-hub/tasks.md b/docs/features/plugins-hub/tasks.md
new file mode 100644
index 0000000000..a2133d0fdf
--- /dev/null
+++ b/docs/features/plugins-hub/tasks.md
@@ -0,0 +1,130 @@
+# Plugins Hub Tasks
+
+## 0. Review Gate
+
+- [x] Review `spec.md` with product/maintainers.
+- [x] Review `plan.md` main-route architecture, route compatibility, and sidebar layout.
+- [x] Confirm no unresolved clarification markers exist before implementation.
+- [ ] Keep this SDD folder active until the feature lands or is deliberately abandoned.
+
+## 1. Main Route Skeleton
+
+- [x] Add `/plugins` route family to the existing main renderer router.
+- [x] Add `PluginsHubPage.vue` inside `src/renderer/src/pages/plugins/`.
+- [x] Add top tab navigation for Plugins, Skills and MCP.
+- [x] Add Codex-like catalog placeholder with title, subtitle, search, added strip and featured sections.
+- [x] Keep MCP and Skills as top tabs only, not plugin catalog cards.
+- [x] Keep `WindowSideBar`, `AppBar`, global overlays, theme and i18n behavior intact.
+- [x] Add i18n keys for route, page title, subtitle, tabs and search placeholder.
+- [ ] Add renderer tests proving `/plugins` renders inside the existing app shell.
+
+## 2. Main-Process Navigation Compatibility
+
+- [x] Reuse existing deeplink event handling for main-process initiated MCP navigation.
+- [x] Ensure MCP install deeplink can focus/create the normal main window and navigate to `/plugins/mcp`.
+- [x] Do not add a Plugins BrowserWindow.
+- [x] Do not add `src/renderer/plugins` or a separate renderer entry.
+- [ ] Add tests for focusing main and navigating to `/plugins/mcp`.
+
+## 3. Settings Navigation Cleanup
+
+- [x] Hide or remove visible Settings navigation items for MCP, Remote, Plugins, and Skills.
+- [x] Keep compatibility routes or redirect handlers for old route names.
+- [ ] Map every old route name to main `/plugins...` routes.
+- [x] Remove MCP from Settings Overview primary metric.
+- [x] Remove or replace Settings Overview `start-mcp` quick task.
+- [x] Ensure Settings Overview search does not return hidden Plugins-owned pages.
+- [ ] Update Settings activity click behavior for historical routes.
+- [ ] Add tests for Settings navigation groups and hidden route handling.
+
+## 4. MCP Section
+
+- [x] Create `/plugins/mcp` page using current MCP store/client behavior.
+- [x] Reuse `McpSettings`/current MCP components for list/add/edit/toggle.
+- [x] Reuse MCP market view inside `/plugins/mcp?view=market`.
+- [x] Reuse NPM registry controls.
+- [x] Move MCP install deeplink target from Settings to main `/plugins/mcp`.
+- [x] Move MCP install event handling into the main app or Plugins route bootstrap.
+- [x] Keep plugin-owned MCP server read-only behavior.
+- [ ] Add tests for deeplink route target and MCP page render.
+
+## 5. Skills Section
+
+- [x] Create `/plugins/skills` page from current Skills settings behavior.
+- [x] Reuse skill list, search, install, edit, delete, sync import/export.
+- [x] Preserve draft suggestion toggle.
+- [x] Preserve first-launch sync prompt if still required.
+- [x] Ensure skill dialogs/sheets fit the main Plugins page shell.
+- [ ] Add renderer tests for empty/list/search/install entry behavior.
+
+## 6. Official Plugins Section
+
+- [x] Create official plugin list route from `PluginClient.listPlugins`.
+- [x] Add unified detail route `/plugins/:pluginId`.
+- [x] Keep enable/disable actions.
+- [x] Show runtime status, plugin-owned MCP status and last errors.
+- [ ] Add native CUA detail sections for runtime status, permissions and permission guide actions.
+- [x] Merge Feishu/Lark Remote configuration into the Feishu/Lark Integration detail page.
+- [x] Use the Feishu/Lark Integration top-level enable/disable button to control both the official plugin and Feishu/Lark Remote.
+- [x] Stop first-party Plugins UI from calling `settings.open`.
+- [x] Keep `settings.open` only as temporary compatibility fallback.
+- [ ] Add tests for list/detail action behavior.
+
+## 7. Remote Virtual Plugins
+
+- [x] Build remote virtual cards from `remoteControl.listChannels`.
+- [x] Fetch and display per-channel status.
+- [x] Route remote virtual plugin cards through `/plugins/:pluginId` using `remote:` ids.
+- [x] Remove the Remote top tab/product list route.
+- [x] Reuse `RemoteSettings` in single-channel mode inside plugin detail pages.
+- [x] Use the plugin detail top-level enable/disable button for remote virtual plugin state.
+- [x] Auto-enable configured legacy channels when the explicit enabled flag is missing.
+- [x] Preserve credentials fields and password reveal behavior.
+- [x] Preserve enable/disable save behavior.
+- [x] Preserve default agent and default workdir behavior.
+- [x] Preserve pairing flow for Telegram, Feishu/Lark, QQBot and Discord.
+- [x] Preserve binding/principal removal behavior.
+- [x] Preserve WeChat iLink login/account controls.
+- [x] Route sidebar remote status button to the first enabled remote plugin detail.
+- [ ] Add tests for card mapping, save, pairing and bindings.
+
+## 8. Main Sidebar Layout
+
+- [x] Replace expanded sidebar header/search area with command list.
+- [x] Keep `所有 Agents` title.
+- [x] Wire `New Chat` row to navigate to `/chat` and start a new conversation.
+- [x] Wire `Search` row to existing Spotlight behavior.
+- [x] Localize the `Search` command label for Chinese locales.
+- [x] Wire `Plugins` row to `router.push({ name: 'plugins' })`.
+- [x] Add a blank spacer after the `Plugins` command row.
+- [x] Render `Pinned` only when pinned sessions exist.
+- [x] Keep the `Chat` group after `Pinned`.
+- [x] Add `工作区` header before project groups.
+- [x] Move the existing group-mode/sort toggle to the `工作区` header.
+- [x] Keep Settings/theme/sidebar controls in the existing left rail, not in the expanded right column.
+- [x] Display shortcut badges only for existing shortcuts.
+- [x] Keep collapsed sidebar visual behavior unchanged.
+- [x] Preserve session list pagination, pinned section, project grouping and reorder.
+- [ ] Add renderer tests for expanded rows and collapsed state.
+- [ ] Capture before/after ASCII blocks in PR description.
+
+## 9. Cross-Platform UI QA
+
+- [ ] Verify macOS light/dark with app shell and sidebar.
+- [ ] Verify Windows light/dark with app shell and sidebar.
+- [ ] Verify Linux opaque background.
+- [ ] Verify narrow main window layout with expanded sidebar.
+- [ ] Verify narrow main window layout with collapsed sidebar.
+- [ ] Verify long paths/tokens/errors do not overflow.
+- [ ] Verify keyboard navigation and focus order.
+- [ ] Verify Chinese and English labels.
+
+## 10. Final Quality Gates
+
+- [x] Run `pnpm run format`.
+- [x] Run `pnpm run i18n`.
+- [x] Run `pnpm run lint`.
+- [x] Run `pnpm run typecheck`.
+- [ ] Run targeted renderer tests for Plugins route and sidebar.
+- [ ] Run targeted main tests for navigation/deeplink behavior.
+- [ ] Update durable docs or remove/archive active plan/tasks after implementation lands.
diff --git a/docs/features/remote-feishu-lark-scan-auth/plan.md b/docs/features/remote-feishu-lark-scan-auth/plan.md
deleted file mode 100644
index ef5f1dabf5..0000000000
--- a/docs/features/remote-feishu-lark-scan-auth/plan.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Plan
-
-## Existing Architecture
-
-- Renderer settings UI: `src/renderer/settings/components/RemoteSettings.vue`.
-- Renderer API: `src/renderer/api/RemoteControlClient.ts`.
-- Typed routes: `src/shared/contracts/routes/remote-control.routes.ts`, exported through `src/shared/contracts/routes.ts`.
-- Shared presenter types: `src/shared/types/presenters/remote-control.presenter.d.ts`.
-- Main route dispatch: `src/main/routes/index.ts`.
-- Main presenter/runtime: `src/main/presenter/remoteControlPresenter/index.ts`, `types.ts`, `services/remoteBindingStore.ts`, `feishu/feishuClient.ts`.
-
-## Data Flow
-
-### Official PersonalAgent install
-
-1. Renderer calls `remoteControl.startFeishuInstall({ brand })` from the Feishu/Lark settings section.
-2. Main presenter creates an in-memory install session and calls `https://accounts.feishu.cn/oauth/v1/app/registration` with form data:
- - `action=begin`
- - `archetype=PersonalAgent`
- - `auth_method=client_secret`
- - `request_user_info=open_id tenant_brand`
-3. Main returns a session summary containing a safe session key, official verification URL (`installUrl`), user code, interval, and expiration.
-4. Renderer chooses one of two UI modes:
- - web mode: open `installUrl` with `openRuntimeExternal`;
- - QR mode: render an in-app dialog with a locally generated QR code whose payload is exactly `installUrl`.
-5. Renderer waits with `remoteControl.waitForFeishuInstall({ sessionKey })` for both modes.
-6. Main polls the registration endpoint with `action=poll` and the stored device code.
-7. If polling on Feishu reports `tenant_brand=lark` without a secret, main switches that session to `accounts.larksuite.com` and polls again.
-8. When `client_id` and `client_secret` are returned, main updates existing Feishu settings:
- - `brand` from detected tenant domain;
- - `appId` from `client_id`;
- - `appSecret` from `client_secret`;
- - app-specific verification/encrypt manual fields cleared for the installed PersonalAgent;
- - `pairedUserOpenIds` extended with returned `user_info.open_id` when present.
-9. Main discards all transient registration session data and rebuilds the Feishu runtime if enabled.
-10. Renderer refreshes settings/status and shows success/failure status; QR dialog is closed on success.
-
-### Feishu/Lark user authorization
-
-- Keep `/pair ` as the main bot-command authorization path for Feishu/Lark remote control.
-- Keep the local-callback OAuth scan authorization as a fallback for users who already configured a self-built app and want to add the current user's Open ID without typing `/pair`.
-- Present both in one Feishu/Lark authorization section so users understand both update the same authorized-principal list.
-
-### Existing local OAuth pairing fallback
-
-- Keep the already implemented loopback OAuth helper and routes as a fallback for users who already configured a self-built app and explicitly need OAuth user pairing.
-- Do not present the loopback redirect URI as the primary setup path.
-
-### Manual fallback setup
-
-- Keep manual fields visible and editable.
-- Keep Echo Bot/developer console links as advanced/fallback guidance.
-- Continue supporting `/pair ` exactly as before.
-
-## Interfaces
-
-Shared install types:
-
-- `FeishuInstallSession`
-- `FeishuInstallResult`
-- `FeishuInstallStartInput`
-- `FeishuInstallWaitInput`
-
-New routes:
-
-- `remoteControl.startFeishuInstall`
-- `remoteControl.waitForFeishuInstall`
-- `remoteControl.cancelFeishuInstall`
-
-Existing routes remain:
-
-- `remoteControl.startFeishuAuth`
-- `remoteControl.waitForFeishuAuth`
-- `remoteControl.cancelFeishuAuth`
-
-Renderer client methods mirror all typed routes.
-
-## Renderer QR Implementation
-
-- Generate the QR in the renderer from `installUrl` without a third-party web service.
-- Prefer a small local QR implementation or an existing dependency if present; keep the API contained in `RemoteSettings.vue` unless shared reuse appears.
-- Expose stable test selectors:
- - `feishu-install-open-web-button`
- - `feishu-install-show-qr-button`
- - `feishu-install-qr-dialog`
- - `feishu-install-qr-code`
-- Add a `data-qr-value` attribute to the QR container/image for deterministic tests and accessibility/debugging without decoding the QR bitmap.
-
-## Compatibility
-
-- `FeishuRemoteSettings` remains the persisted settings shape; no user OAuth token fields are added.
-- Existing `/pair` state and `pairedUserOpenIds` remain the source of remote-control authorization.
-- Existing manual save/rebuild behavior remains unchanged.
-- Existing WebSocket Feishu runtime continues to use App ID/App Secret.
-- Existing scan OAuth routes continue to work for self-built apps.
-
-## Security
-
-- Do not expose or log `client_secret`, registration device codes, OAuth tokens, or provider raw error bodies.
-- Keep registration session state in memory and expire/cancel it.
-- Check session completion after every async boundary before persisting credentials, paired users, or rebuilding runtime so cancelled/timed-out sessions have no late side effects.
-- Use request-level timeouts or abort signals for Feishu/Lark OAuth and registration network calls.
-- Return generic i18n message keys for provider/network failures.
-- Store only the installed bot credentials already required by the Feishu runtime and the authorized user's `open_id`.
-- Do not persist user OAuth access tokens or refresh tokens.
-- Avoid adding the full install URL to logs; showing it in the user-triggered QR dialog is acceptable because it is the scanned payload.
-
-## Test Strategy
-
-- Main unit tests:
- - official install begins on Feishu for both Feishu and Lark selections;
- - Lark tenant detection switches polling to Lark accounts domain;
- - successful install stores App ID/App Secret and pairs returned Open ID;
- - pending/expired/error results are sanitized.
-- Existing OAuth callback tests remain to cover fallback behavior.
-- Renderer tests:
- - official install controls render as two buttons in Feishu tab;
- - web install opens the returned install URL and refreshes credentials on success;
- - QR install shows a dialog generated from the returned install URL, does not open external browser, waits for success, and refreshes settings/status;
- - Feishu/Lark user-authorization UI explains `/pair` and scan authorization together;
- - manual fields still render and save payload stays cloneable.
-
-## Validation Commands
-
-After implementation:
-
-- targeted Vitest tests for touched main/renderer files;
-- `pnpm run format`;
-- `pnpm run i18n`;
-- `pnpm run lint`.
diff --git a/docs/features/remote-feishu-lark-scan-auth/tasks.md b/docs/features/remote-feishu-lark-scan-auth/tasks.md
deleted file mode 100644
index 459d3b0f87..0000000000
--- a/docs/features/remote-feishu-lark-scan-auth/tasks.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Tasks
-
-- [x] Inspect existing remote Feishu/Lark settings, runtime, storage, routes, and tests.
-- [x] Review initial Feishu OAuth callback approach and preserve it as a fallback.
-- [x] Investigate Kun's Feishu/Lark install flow and identify the PersonalAgent app registration endpoint.
-- [x] Update spec and plan to make official PersonalAgent install the primary setup path.
-- [x] Add shared install session/result types and typed route contracts.
-- [x] Implement Feishu/Lark PersonalAgent registration helper in main process.
-- [x] Add RemoteControlPresenter start/wait/cancel install methods and route dispatch.
-- [x] Add renderer API client methods.
-- [x] Split Feishu/Lark install UI into web-page install and in-app QR install buttons.
-- [x] Generate an in-app QR code directly from the returned install URL.
-- [x] Combine Feishu/Lark `/pair` and OAuth scan authorization guidance in the UI.
-- [x] Add or update i18n keys for web install, QR install, and combined authorization guidance.
-- [x] Add or update renderer tests for the two install modes and combined authorization guidance.
-- [x] Fix review findings for Feishu/Lark auth/install cancellation, timeout, and late async side effects.
-- [x] Add regression tests for cancelling in-flight OAuth auth and PersonalAgent install polling.
-- [x] Run targeted tests, format, i18n, and lint.
diff --git a/docs/issues/cua-plugin-description-copy/spec.md b/docs/issues/cua-plugin-description-copy/spec.md
new file mode 100644
index 0000000000..4acca69481
--- /dev/null
+++ b/docs/issues/cua-plugin-description-copy/spec.md
@@ -0,0 +1,46 @@
+# CUA Plugin Description Copy
+
+## User Need
+
+The CUA plugin should describe what it is instead of showing `DeepChat · com.deepchat.plugins.cua`.
+
+## Goal
+
+Show localized copy meaning: DeepChat's ComputerUse plugin implemented based on `trycua/cua`.
+
+## Acceptance Criteria
+
+- CUA plugin detail subtitle uses the localized description.
+- CUA plugin catalog card uses the same localized description.
+- Non-CUA plugins keep their existing description behavior.
+- Completed SDD folders keep only `spec.md`.
+
+## UI Sketch
+
+Before:
+
+```text
+CUA Computer Use Runtime
+DeepChat · com.deepchat.plugins.cua
+```
+
+After:
+
+```text
+CUA Computer Use Runtime
+DeepChat 基于 trycua/cua 项目实现的 ComputerUse 插件
+```
+
+## Constraints
+
+- Use i18n for user-facing copy.
+- Do not add a manifest field for one plugin.
+
+## Non-Goals
+
+- Redesigning the plugin detail header.
+- Changing plugin runtime metadata.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/feishu-plugin-description-copy/spec.md b/docs/issues/feishu-plugin-description-copy/spec.md
new file mode 100644
index 0000000000..cf3601ae47
--- /dev/null
+++ b/docs/issues/feishu-plugin-description-copy/spec.md
@@ -0,0 +1,47 @@
+# Feishu Plugin Description Copy
+
+## User Need
+
+The Feishu/Lark plugin should describe its actual remote-control purpose instead of showing `DeepChat · com.deepchat.plugins.feishu`.
+
+## Goal
+
+Use the existing Feishu remote-control description for the official Feishu/Lark plugin in catalog and detail views.
+
+## Acceptance Criteria
+
+- Feishu/Lark plugin catalog description uses `settings.remote.feishu.description`.
+- Feishu/Lark plugin detail subtitle uses `settings.remote.feishu.description`.
+- zh-CN Feishu remote description names `飞书 / Lark Bot`.
+- Other non-special official plugins keep the publisher/id fallback.
+- Completed SDD folders keep only `spec.md`.
+
+## UI Sketch
+
+Before:
+
+```text
+飞书 / Lark
+DeepChat · com.deepchat.plugins.feishu
+```
+
+After:
+
+```text
+飞书 / Lark
+接入飞书 / Lark Bot,支持私聊、群聊和会话远程控制。
+```
+
+## Constraints
+
+- Reuse existing i18n copy instead of adding duplicate plugin-specific Feishu strings.
+- Do not change plugin manifest metadata.
+
+## Non-Goals
+
+- Redesigning plugin cards.
+- Changing Feishu runtime behavior.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/plugin-hub-available-title/spec.md b/docs/issues/plugin-hub-available-title/spec.md
new file mode 100644
index 0000000000..37d71b2112
--- /dev/null
+++ b/docs/issues/plugin-hub-available-title/spec.md
@@ -0,0 +1,50 @@
+# Plugin Hub Available Title
+
+## User Need
+
+The plugin hub should not show Official, Workspace, and Personal tabs before those plugin categories are supported.
+
+## Goal
+
+Replace the unused category tabs with a single `可用插件` heading.
+
+## Acceptance Criteria
+
+- The plugin catalog section shows a localized "Available plugins" heading.
+- Official, Workspace, and Personal filter tabs are not rendered.
+- Plugin search still filters the available catalog items.
+- Completed SDD folders keep only `spec.md`.
+
+## UI Sketch
+
+Before:
+
+```text
+[DeepChat 官方] [工作区] [个人]
++--------------------+ +--------------------+
+| CUA | | 飞书 / Lark |
++--------------------+ +--------------------+
+```
+
+After:
+
+```text
+可用插件
++--------------------+ +--------------------+
+| CUA | | 飞书 / Lark |
++--------------------+ +--------------------+
+```
+
+## Constraints
+
+- Keep this renderer-only.
+- Do not add workspace/personal plugin category behavior.
+
+## Non-Goals
+
+- Redesigning plugin cards.
+- Adding plugin source classification.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/plugin-hub-single-list/spec.md b/docs/issues/plugin-hub-single-list/spec.md
new file mode 100644
index 0000000000..c901695b4c
--- /dev/null
+++ b/docs/issues/plugin-hub-single-list/spec.md
@@ -0,0 +1,58 @@
+# Plugin Hub Single List
+
+## User Need
+
+The plugin hub should avoid a separate Added section and search field while the plugin catalog is small.
+
+## Goal
+
+Show one available plugin list, with enabled plugins first, clear action labels, and highlighted enabled state.
+
+## Acceptance Criteria
+
+- The standalone Added section is removed.
+- The search input is removed.
+- Enabled plugins sort before disabled plugins.
+- Enabled plugin action button says `管理`.
+- Disabled plugin action button says `添加`.
+- Enabled status badges use a highlighted color.
+- Completed SDD folders keep only `spec.md`.
+
+## UI Sketch
+
+Before:
+
+```text
+[Search plugins] [Refresh]
+
+已添加
+[CUA icon]
+
+可用插件
+[CUA] [管理] [已启用]
+[Telegram] [管理] [未启用]
+```
+
+After:
+
+```text
+插件 [Refresh]
+
+可用插件
+[CUA] [管理] [已启用 highlighted]
+[Telegram] [添加] [已停用]
+```
+
+## Constraints
+
+- Keep the change renderer-only.
+- Do not add new plugin category support.
+
+## Non-Goals
+
+- Redesigning plugin cards.
+- Adding search back behind a feature flag.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/plugin-remote-icon-consistency/spec.md b/docs/issues/plugin-remote-icon-consistency/spec.md
new file mode 100644
index 0000000000..d2c44bef53
--- /dev/null
+++ b/docs/issues/plugin-remote-icon-consistency/spec.md
@@ -0,0 +1,30 @@
+# Plugin Remote Detail Consistency
+
+## User Need
+
+Remote-control plugins should keep the same icon and localized title treatment when moving between the plugin list and detail page.
+
+## Goal
+
+Make remote virtual plugins and the official Feishu/Lark plugin use the same icon, color, and localized title shown by remote channel metadata.
+
+## Acceptance Criteria
+
+- Feishu/Lark catalog and detail both show the message-circle icon with the blue remote color.
+- Remote virtual plugin details keep their catalog icon color.
+- In Chinese, Feishu/Lark keeps the localized `飞书 / Lark` title instead of flashing to the plugin manifest name.
+- Non-remote official plugins still use the generic puzzle icon.
+
+## Constraints
+
+- Keep the fix in the renderer plugin detail page.
+- Do not change plugin manifest data or remote-control settings behavior.
+
+## Non-Goals
+
+- Redesign the plugin hub layout.
+- Add new icon configuration infrastructure.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/pr1818-plugin-review-fixes/spec.md b/docs/issues/pr1818-plugin-review-fixes/spec.md
new file mode 100644
index 0000000000..9969ab51db
--- /dev/null
+++ b/docs/issues/pr1818-plugin-review-fixes/spec.md
@@ -0,0 +1,37 @@
+# PR 1818 Plugin Review Fixes
+
+## User Need
+
+Review comments on PR #1818 identify a few plugin-page issues that should be fixed before merge when they are directly related to the recent Plugins Hub work.
+
+## Goal
+
+Apply the low-risk plugin-page review fixes that keep the current PR focused:
+
+- Refresh the embedded Feishu remote settings after the official Feishu plugin is enabled or disabled from the detail page header.
+- Make plugin component tests assert concrete localized strings instead of raw i18n keys for titles, headings, and action labels.
+
+## Acceptance Criteria
+
+- The official Feishu plugin detail page remounts its embedded `RemoteSettings` after the top enable or disable action updates Feishu remote settings.
+- `PluginsCatalogPage` tests use distinct translated values for asserted catalog title, heading, status, and action label keys.
+- `OfficialPluginDetailPage` tests use distinct translated values for asserted title and action button keys.
+- Existing plugin catalog and official plugin detail tests pass.
+- `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` pass.
+
+## Constraints
+
+- Keep the diff scoped to PR #1818 plugin-page review comments.
+- Preserve existing Vue 3 Composition API and i18n patterns.
+- Do not mix larger SkillPresenter, SkillSyncPresenter, or git-install hardening work into this fix.
+
+## Non-Goals
+
+- No changes to skill install rollback behavior.
+- No changes to agent skill adoption/link cleanup.
+- No changes to git repository validation.
+- No layout redesign.
+
+## Open Questions
+
+None.
diff --git a/docs/issues/remote-topic-thread-copy/spec.md b/docs/issues/remote-topic-thread-copy/spec.md
new file mode 100644
index 0000000000..3b5b2d6e5b
--- /dev/null
+++ b/docs/issues/remote-topic-thread-copy/spec.md
@@ -0,0 +1,44 @@
+# Remote Topic Thread Copy
+
+## User Need
+
+The Simplified Chinese remote-control copy should not use the awkward phrase `话题线程`.
+
+## Goal
+
+Replace `话题线程` with `会话` in the zh-CN remote-control descriptions and access rule.
+
+## Acceptance Criteria
+
+- Telegram remote description says `私聊、群聊和会话远程控制`.
+- Feishu remote description says `私聊、群聊和会话远程控制`.
+- The group access rule says `群聊和会话里`.
+- Topic ID field labels remain unchanged.
+
+## UI Sketch
+
+Before:
+
+```text
+接入 Telegram Bot,支持私聊、群聊和话题线程远程控制。
+```
+
+After:
+
+```text
+接入 Telegram Bot,支持私聊、群聊和会话远程控制。
+```
+
+## Constraints
+
+- zh-CN copy only.
+- No behavior or schema changes.
+
+## Non-Goals
+
+- Renaming topic/thread IDs.
+- Updating non-Chinese locales.
+
+## Open Questions
+
+- None.
diff --git a/docs/issues/sidebar-chat-workspace-sort/spec.md b/docs/issues/sidebar-chat-workspace-sort/spec.md
new file mode 100644
index 0000000000..4b7181b139
--- /dev/null
+++ b/docs/issues/sidebar-chat-workspace-sort/spec.md
@@ -0,0 +1,52 @@
+# Sidebar Chat Workspace Sort Spec
+
+## User Need
+
+The expanded sidebar must keep Chat and Workspace as separate sections. The Workspace sort toggle
+must not move workspace sessions into Chat or make the Workspace section disappear.
+
+## Goal
+
+- Chat sessions remain under Chat and can be collapsed.
+- Workspace sessions remain under Workspace.
+- The Workspace toggle only changes Workspace grouping between project/date modes.
+
+## Acceptance Criteria
+
+- Clicking Chat collapses and expands Chat sessions.
+- The Chat section icon matches the chat icon used by the new-thread project selector.
+- In date mode, date groups for workspace sessions render under Workspace, not Chat.
+- Workspace stays visible in the same sidebar position after toggling grouping.
+- Pinned sessions stay independent.
+
+## Constraints
+
+- Keep the fix local to the renderer sidebar.
+- Do not add dependencies or new persistent settings.
+- Do not touch unrelated Skills work already dirty in the worktree.
+
+## Non-Goals
+
+- Redesign the sidebar.
+- Change session storage, pagination, or pin behavior.
+
+## UI Shape
+
+Before:
+
+```text
+Pinned
+Chat
+ Recent / Earlier workspace groups
+Workspace
+```
+
+After:
+
+```text
+Pinned
+Chat [collapsible]
+ chat sessions only
+Workspace [project/date toggle]
+ workspace groups only
+```
diff --git a/src/main/presenter/configPresenter/configDbStores.ts b/src/main/presenter/configPresenter/configDbStores.ts
index 51314a70a7..1fab300a2c 100644
--- a/src/main/presenter/configPresenter/configDbStores.ts
+++ b/src/main/presenter/configPresenter/configDbStores.ts
@@ -13,7 +13,8 @@ export const SENSITIVE_APP_SETTING_KEYS = [
'hooksNotifications',
'knowledgeConfigs',
'customPrompts',
- 'systemPrompts'
+ 'systemPrompts',
+ 'skills.managementState'
] as const
const SENSITIVE_APP_SETTING_KEY_SET = new Set(SENSITIVE_APP_SETTING_KEYS)
diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts
index a0a05d6746..c63624f9a9 100644
--- a/src/main/presenter/deeplinkPresenter/index.ts
+++ b/src/main/presenter/deeplinkPresenter/index.ts
@@ -384,13 +384,14 @@ export class DeeplinkPresenter implements IDeeplinkPresenter {
return
}
- const settingsWindowId = await presenter.windowPresenter.createSettingsWindow()
- if (!settingsWindowId) {
- console.error('Failed to open Settings window for MCP install deeplink')
+ const targetWindow = await this.resolveChatWindow()
+ if (!targetWindow) {
+ console.error('Failed to resolve main window for MCP install deeplink')
return
}
- presenter.windowPresenter.sendToWindow(settingsWindowId, DEEPLINK_EVENTS.MCP_INSTALL, {
+ await this.ensureChatWindowReady(targetWindow.id)
+ presenter.windowPresenter.sendToWindow(targetWindow.id, DEEPLINK_EVENTS.MCP_INSTALL, {
mcpConfig: JSON.stringify(completeMcpConfig)
})
diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts
index 1309a0dce1..c77310dc8c 100644
--- a/src/main/presenter/remoteControlPresenter/types.ts
+++ b/src/main/presenter/remoteControlPresenter/types.ts
@@ -1110,7 +1110,7 @@ const extractLegacyTelegramConfig = (input: unknown): LegacyTelegramRemoteConfig
const record = input as Record
if (
- !hasAnyOwn(record, ['allowlist', 'streamMode', 'pollOffset', 'lastFatalError']) &&
+ !hasAnyOwn(record, ['botToken', 'allowlist', 'streamMode', 'pollOffset', 'lastFatalError']) &&
!hasBindingPrefix(record, 'telegram:')
) {
return null
@@ -1351,6 +1351,9 @@ const normalizeBindings = (
return bindings
}
+const resolveRemoteEnabled = (enabled: boolean | undefined, configured: boolean): boolean =>
+ typeof enabled === 'boolean' ? enabled : configured
+
export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => {
const defaults = createDefaultRemoteControlConfig()
const parsed = RemoteControlConfigSchema.safeParse(input)
@@ -1363,11 +1366,12 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi
const qqbot = parsed.data.qqbot ?? extractLegacyQQBotConfig(input) ?? {}
const discord = parsed.data.discord ?? extractLegacyDiscordConfig(input) ?? {}
const weixinIlink = parsed.data.weixinIlink ?? extractLegacyWeixinIlinkConfig(input) ?? {}
+ const weixinIlinkAccounts = normalizeWeixinIlinkRuntimeAccounts(weixinIlink.accounts)
return {
telegram: {
botToken: telegram.botToken?.trim() || '',
- enabled: Boolean(telegram.enabled),
+ enabled: resolveRemoteEnabled(telegram.enabled, Boolean(telegram.botToken?.trim())),
allowlist: normalizeTelegramUserIds(telegram.allowlist),
streamMode: telegram.streamMode === 'final' ? 'final' : defaults.telegram.streamMode,
defaultAgentId: telegram.defaultAgentId?.trim() || defaults.telegram.defaultAgentId,
@@ -1395,7 +1399,10 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi
appSecret: feishu.appSecret?.trim() || '',
verificationToken: feishu.verificationToken?.trim() || '',
encryptKey: feishu.encryptKey?.trim() || '',
- enabled: Boolean(feishu.enabled),
+ enabled: resolveRemoteEnabled(
+ feishu.enabled,
+ Boolean(feishu.appId?.trim() && feishu.appSecret?.trim())
+ ),
enableStreamingCards: Boolean(feishu.enableStreamingCards),
defaultAgentId: feishu.defaultAgentId?.trim() || defaults.feishu.defaultAgentId,
defaultWorkdir: feishu.defaultWorkdir?.trim() || '',
@@ -1414,7 +1421,10 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi
qqbot: {
appId: qqbot.appId?.trim() || '',
clientSecret: qqbot.clientSecret?.trim() || '',
- enabled: Boolean(qqbot.enabled),
+ enabled: resolveRemoteEnabled(
+ qqbot.enabled,
+ Boolean(qqbot.appId?.trim() && qqbot.clientSecret?.trim())
+ ),
defaultAgentId: qqbot.defaultAgentId?.trim() || defaults.qqbot.defaultAgentId,
defaultWorkdir: qqbot.defaultWorkdir?.trim() || '',
pairedUserIds: normalizeQQBotUserIds(qqbot.pairedUserIds),
@@ -1432,7 +1442,7 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi
},
discord: {
botToken: discord.botToken?.trim() || '',
- enabled: Boolean(discord.enabled),
+ enabled: resolveRemoteEnabled(discord.enabled, Boolean(discord.botToken?.trim())),
defaultAgentId: discord.defaultAgentId?.trim() || defaults.discord.defaultAgentId,
defaultWorkdir: discord.defaultWorkdir?.trim() || '',
pairedChannelIds: normalizeDiscordChannelIds(discord.pairedChannelIds),
@@ -1449,10 +1459,10 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi
bindings: normalizeBindings(discord.bindings, 'discord')
},
weixinIlink: {
- enabled: Boolean(weixinIlink.enabled),
+ enabled: resolveRemoteEnabled(weixinIlink.enabled, weixinIlinkAccounts.length > 0),
defaultAgentId: weixinIlink.defaultAgentId?.trim() || defaults.weixinIlink.defaultAgentId,
defaultWorkdir: weixinIlink.defaultWorkdir?.trim() || '',
- accounts: normalizeWeixinIlinkRuntimeAccounts(weixinIlink.accounts)
+ accounts: weixinIlinkAccounts
}
}
}
diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts
index d3d7fdb736..5d555df09b 100644
--- a/src/main/presenter/skillPresenter/index.ts
+++ b/src/main/presenter/skillPresenter/index.ts
@@ -1,7 +1,9 @@
import { app, shell } from 'electron'
import path from 'path'
import fs from 'fs'
+import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
+import { promisify } from 'node:util'
import matter from 'gray-matter'
import { unzipSync } from 'fflate'
import type { IConfigPresenter } from '@shared/presenter'
@@ -20,7 +22,18 @@ import {
SkillInstallResult,
SkillFolderNode,
SkillInstallOptions,
+ GitSkillInstallInput,
+ GitSkillRepoScanItem,
+ GitSkillRepoScanResult,
+ SkillAdoptionRegistration,
+ SkillAgentLinkRegistration,
SkillExtensionConfig,
+ SkillSyncDirectoryExportInput,
+ SkillSyncDirectoryExportPreview,
+ SkillSyncDirectoryImportInput,
+ SkillSyncDirectoryImportPreview,
+ SkillSyncDirectoryPreviewItem,
+ SkillSyncDirectoryResult,
SkillManageRequest,
SkillManageResult,
SkillDraftActionResult,
@@ -30,11 +43,21 @@ import {
SkillViewResult,
SkillLinkedFile
} from '@shared/types/skill'
+import type {
+ SkillManagementItem,
+ SkillManagementState,
+ SkillSyncDirectoryConfig,
+ SkillSource,
+ SkillSourceType,
+ UnifiedSkillItem
+} from '@shared/types/skillManagement'
import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent'
import logger from '@shared/logger'
import { normalizeSkillAllowedTools } from './toolNameMapping'
import { discoverSkillMetadataInWorker, logSkillDiscoveryWorkerWarnings } from './discoveryWorker'
+const execFileAsync = promisify(execFile)
+
/**
* Skill system configuration constants
*/
@@ -112,6 +135,7 @@ const DRAFT_ALLOWED_TOP_LEVEL_DIRS = new Set(['references', 'templates', 'script
const DRAFT_CONVERSATION_ID_PATTERN = /^[A-Za-z0-9._-]+$/
const DRAFT_ID_PATTERN = /^[A-Za-z0-9._-]+$/
const DRAFT_ACTIVITY_MARKER = '.lastActivity'
+const SKILL_MANAGEMENT_STATE_KEY = 'skills.managementState'
const DRAFT_INJECTION_PATTERNS = [
/ignore\s+previous\s+instructions/i,
/disregard\s+all\s+prior/i,
@@ -282,9 +306,6 @@ export class SkillPresenter implements ISkillPresenter {
if (!fs.existsSync(this.skillsDir)) {
fs.mkdirSync(this.skillsDir, { recursive: true })
}
- if (!fs.existsSync(this.sidecarDir)) {
- fs.mkdirSync(this.sidecarDir, { recursive: true })
- }
}
/**
@@ -482,7 +503,181 @@ export class SkillPresenter implements ISkillPresenter {
}
private isSkillVisible(metadata: SkillMetadata): boolean {
- return Boolean(metadata)
+ return Boolean(metadata) && !this.isSkillDeepChatDisabled(metadata.name)
+ }
+
+ private createDefaultManagementState(): SkillManagementState {
+ return {
+ version: 1,
+ skills: {}
+ }
+ }
+
+ private getStoredManagementState(): SkillManagementState {
+ const stored = this.configPresenter.getSetting(SKILL_MANAGEMENT_STATE_KEY)
+ if (!stored || typeof stored !== 'object') {
+ return this.createDefaultManagementState()
+ }
+
+ const candidate = stored as Partial
+ const skills: Record = {}
+ for (const [name, item] of Object.entries(candidate.skills ?? {})) {
+ if (!this.isSafeSkillName(name) || !item || typeof item !== 'object') {
+ continue
+ }
+ const raw = item as Partial
+ skills[name] = {
+ name,
+ canonicalPath:
+ typeof raw.canonicalPath === 'string' && raw.canonicalPath.trim()
+ ? raw.canonicalPath
+ : path.join(this.skillsDir, name),
+ deepchat: {
+ disabled: raw.deepchat?.disabled === true
+ },
+ extension: sanitizeSkillExtensionConfig(raw.extension),
+ source: this.sanitizeSkillSource(raw.source),
+ agentLinks:
+ raw.agentLinks && typeof raw.agentLinks === 'object'
+ ? (raw.agentLinks as SkillManagementItem['agentLinks'])
+ : undefined
+ }
+ }
+
+ return {
+ version: 1,
+ skills,
+ sync: this.sanitizeSyncDirectoryConfig(candidate.sync)
+ }
+ }
+
+ private sanitizeSyncDirectoryConfig(value: unknown): SkillSyncDirectoryConfig | undefined {
+ const raw =
+ value && typeof value === 'object' ? (value as Partial) : {}
+ if (typeof raw.skillsDirectory !== 'string' || !raw.skillsDirectory.trim()) {
+ return undefined
+ }
+
+ return {
+ skillsDirectory: path.resolve(raw.skillsDirectory),
+ layout: 'multi-skill-repo',
+ lastExportAt: typeof raw.lastExportAt === 'string' ? raw.lastExportAt : null,
+ lastImportAt: typeof raw.lastImportAt === 'string' ? raw.lastImportAt : null
+ }
+ }
+
+ private saveManagementState(state: SkillManagementState): void {
+ this.configPresenter.setSetting(SKILL_MANAGEMENT_STATE_KEY, state)
+ }
+
+ private sanitizeSkillSource(value: unknown): SkillSource {
+ const raw = value && typeof value === 'object' ? (value as Partial) : {}
+ const source: SkillSource = {
+ type: this.normalizeSkillSourceType(raw.type)
+ }
+ if (typeof raw.repoUrl === 'string') source.repoUrl = raw.repoUrl
+ if (raw.repoFormat === 'single-skill' || raw.repoFormat === 'multi-skill') {
+ source.repoFormat = raw.repoFormat
+ }
+ if (typeof raw.agentId === 'string') source.agentId = raw.agentId
+ if (typeof raw.originalPath === 'string') source.originalPath = raw.originalPath
+ if (typeof raw.importedFrom === 'string') source.importedFrom = raw.importedFrom
+ if (typeof raw.installedAt === 'string') source.installedAt = raw.installedAt
+ if (typeof raw.importedAt === 'string') source.importedAt = raw.importedAt
+ if (typeof raw.adoptedAt === 'string') source.adoptedAt = raw.adoptedAt
+ return source
+ }
+
+ private normalizeSkillSourceType(value: unknown): SkillSourceType {
+ const allowed: SkillSourceType[] = [
+ 'builtin',
+ 'created',
+ 'folder-install',
+ 'zip-install',
+ 'url-install',
+ 'git-install',
+ 'adopted',
+ 'imported'
+ ]
+ return typeof value === 'string' && allowed.includes(value as SkillSourceType)
+ ? (value as SkillSourceType)
+ : 'created'
+ }
+
+ private createDefaultManagementItem(name: string): SkillManagementItem {
+ return {
+ name,
+ canonicalPath: path.join(this.skillsDir, name),
+ deepchat: {
+ disabled: false
+ },
+ extension: createDefaultSkillExtensionConfig(),
+ source: {
+ type: 'created'
+ }
+ }
+ }
+
+ private updateSkillManagementItem(
+ name: string,
+ updater: (item: SkillManagementItem) => SkillManagementItem
+ ): SkillManagementItem {
+ const state = this.getStoredManagementState()
+ const nextItem = updater(state.skills[name] ?? this.createDefaultManagementItem(name))
+ state.skills[name] = nextItem
+ this.saveManagementState(state)
+ return nextItem
+ }
+
+ private isSkillDeepChatDisabled(name: string): boolean {
+ return this.getStoredManagementState().skills[name]?.deepchat.disabled === true
+ }
+
+ async getSkillManagementState(): Promise {
+ return this.getStoredManagementState()
+ }
+
+ async setSkillDeepChatDisabled(name: string, disabled: boolean): Promise {
+ if (this.metadataCache.size === 0) {
+ await this.discoverSkills()
+ }
+ if (!this.metadataCache.has(name)) {
+ throw new Error(`Skill "${name}" not found`)
+ }
+
+ this.updateSkillManagementItem(name, (item) => ({
+ ...item,
+ canonicalPath: this.metadataCache.get(name)?.skillRoot ?? item.canonicalPath,
+ deepchat: {
+ ...item.deepchat,
+ disabled
+ }
+ }))
+ this.contentCache.delete(name)
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'disabled-updated',
+ name,
+ version: Date.now()
+ })
+ }
+
+ async getUnifiedSkillCatalog(): Promise {
+ if (this.metadataCache.size === 0) {
+ await this.discoverSkills()
+ }
+
+ const state = this.getStoredManagementState()
+ return this.sortSkillMetadata(Array.from(this.metadataCache.values())).map((skill) => {
+ const item = state.skills[skill.name] ?? this.createDefaultManagementItem(skill.name)
+ return {
+ ...skill,
+ canonicalPath: item.canonicalPath || skill.skillRoot,
+ sourceType: item.source.type,
+ deepchatDisabled: item.deepchat.disabled,
+ agentLinks: item.agentLinks ?? {},
+ mutable: !skill.ownerPluginId
+ }
+ })
}
private sortSkillMetadata(skills: SkillMetadata[]): SkillMetadata[] {
@@ -1080,7 +1275,7 @@ export class SkillPresenter implements ISkillPresenter {
continue
}
- const result = await this.installFromDirectory(skillDir, { overwrite: false })
+ const result = await this.installFromDirectory(skillDir, { overwrite: false }, 'builtin')
if (!result.success && result.error?.includes('already exists')) {
continue
}
@@ -1140,7 +1335,7 @@ export class SkillPresenter implements ISkillPresenter {
folderPath: string,
options?: SkillInstallOptions
): Promise {
- return this.installFromDirectory(folderPath, options)
+ return this.installFromDirectory(folderPath, options, 'folder-install')
}
/**
@@ -1161,7 +1356,7 @@ export class SkillPresenter implements ISkillPresenter {
if (!skillDir) {
return { success: false, error: 'SKILL.md not found in zip archive' }
}
- return await this.installFromDirectory(skillDir, options)
+ return await this.installFromDirectory(skillDir, options, 'zip-install')
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMsg, errorCode: 'io_error' }
@@ -1177,7 +1372,17 @@ export class SkillPresenter implements ISkillPresenter {
const tempZipPath = path.join(app.getPath('temp'), `deepchat-skill-${Date.now()}.zip`)
try {
await this.downloadSkillZip(url, tempZipPath)
- return await this.installFromZip(tempZipPath, options)
+ const result = await this.installFromZip(tempZipPath, options)
+ if (result.success && result.skillName) {
+ this.updateSkillManagementItem(result.skillName, (item) => ({
+ ...item,
+ source: {
+ type: 'url-install',
+ installedAt: new Date().toISOString()
+ }
+ }))
+ }
+ return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMsg, errorCode: 'io_error' }
@@ -1188,6 +1393,277 @@ export class SkillPresenter implements ISkillPresenter {
}
}
+ async scanGitSkillRepo(repoUrl: string): Promise {
+ const normalizedRepoUrl = repoUrl.trim()
+ if (!normalizedRepoUrl) {
+ throw new Error('Git repository URL is required')
+ }
+
+ const cloneDir = await this.cloneGitSkillRepo(normalizedRepoUrl)
+ try {
+ return await this.scanGitSkillRepoDirectory(normalizedRepoUrl, cloneDir)
+ } finally {
+ fs.rmSync(cloneDir, { recursive: true, force: true })
+ }
+ }
+
+ async installSkillsFromGit(input: GitSkillInstallInput): Promise {
+ const repoUrl = input.repoUrl.trim()
+ const selected = new Set(input.skillNames)
+ const strategy = input.strategy ?? 'rename'
+ if (!repoUrl || selected.size === 0) {
+ return []
+ }
+
+ const cloneDir = await this.cloneGitSkillRepo(repoUrl)
+ try {
+ const scan = await this.scanGitSkillRepoDirectory(repoUrl, cloneDir)
+ const selectedItems = scan.skills.filter((item) => selected.has(item.name))
+ const results: SkillInstallResult[] = []
+
+ for (const item of selectedItems) {
+ if (!item.valid) {
+ results.push({
+ success: false,
+ skillName: item.name,
+ error: item.error ?? 'Invalid skill',
+ errorCode: 'invalid_skill'
+ })
+ continue
+ }
+
+ if (item.conflict && strategy === 'skip') {
+ results.push({
+ success: false,
+ skillName: item.name,
+ existingSkillName: item.name,
+ error: `Skill "${item.name}" already exists`,
+ errorCode: 'conflict'
+ })
+ continue
+ }
+
+ const sourceDir =
+ scan.repoFormat === 'single-skill'
+ ? cloneDir
+ : path.join(cloneDir, item.relativePath.replace(/\/SKILL\.md$/, ''))
+ const targetName =
+ item.conflict && strategy === 'rename' ? this.createUniqueSkillName(item.name) : item.name
+ const result = await this.installFromDirectory(
+ sourceDir,
+ { overwrite: item.conflict && strategy === 'overwrite' },
+ 'git-install',
+ {
+ repoUrl,
+ repoFormat: scan.repoFormat,
+ installedAt: new Date().toISOString()
+ },
+ targetName
+ )
+ results.push(result)
+ }
+
+ if (results.some((result) => result.success)) {
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'git-installed',
+ version: Date.now()
+ })
+ }
+
+ return results
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ return [{ success: false, error: errorMsg, errorCode: 'io_error' }]
+ } finally {
+ fs.rmSync(cloneDir, { recursive: true, force: true })
+ }
+ }
+
+ async getSkillsSyncConfig(): Promise {
+ return this.getStoredManagementState().sync ?? null
+ }
+
+ async setSkillsSyncDirectory(input: {
+ skillsDirectory: string
+ }): Promise {
+ const skillsDirectory = path.resolve(input.skillsDirectory.trim())
+ const config: SkillSyncDirectoryConfig = {
+ skillsDirectory,
+ layout: 'multi-skill-repo',
+ lastExportAt: null,
+ lastImportAt: null
+ }
+
+ fs.mkdirSync(path.join(skillsDirectory, 'skills'), { recursive: true })
+ const state = this.getStoredManagementState()
+ state.sync = {
+ ...state.sync,
+ ...config
+ }
+ this.saveManagementState(state)
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'sync-directory-updated',
+ version: Date.now()
+ })
+ return state.sync
+ }
+
+ async previewSyncDirectoryExport(
+ input: SkillSyncDirectoryExportInput
+ ): Promise {
+ const config = this.requireSyncDirectoryConfig()
+ const selected = new Set(input.skillNames)
+ const skills = (await this.getUnifiedSkillCatalog()).filter((skill) => {
+ if (!selected.has(skill.name)) return false
+ return input.includeDisabled === true || !skill.deepchatDisabled
+ })
+
+ return {
+ skillsDirectory: config.skillsDirectory,
+ items: skills.map((skill) => {
+ const targetPath = path.join(config.skillsDirectory, 'skills', skill.name)
+ if (!skill.mutable || !fs.existsSync(path.join(skill.skillRoot, 'SKILL.md'))) {
+ return {
+ name: skill.name,
+ state: 'invalid',
+ sourcePath: skill.skillRoot,
+ targetPath,
+ error: 'Skill cannot be exported'
+ }
+ }
+ return {
+ name: skill.name,
+ state: this.resolveExportPreviewState(skill.skillRoot, targetPath),
+ sourcePath: skill.skillRoot,
+ targetPath
+ }
+ })
+ }
+ }
+
+ async executeSyncDirectoryExport(
+ input: SkillSyncDirectoryExportInput
+ ): Promise {
+ const preview = await this.previewSyncDirectoryExport(input)
+ let exported = 0
+ let skipped = 0
+ const failed: Array<{ skillName: string; reason: string }> = []
+
+ fs.mkdirSync(path.join(preview.skillsDirectory, 'skills'), { recursive: true })
+ this.ensureSyncDirectoryReadme(preview.skillsDirectory)
+
+ for (const item of preview.items) {
+ if (item.state === 'invalid') {
+ skipped += 1
+ failed.push({ skillName: item.name, reason: item.error ?? 'Invalid skill' })
+ continue
+ }
+
+ try {
+ fs.rmSync(item.targetPath, { recursive: true, force: true })
+ this.copyDirectory(item.sourcePath, item.targetPath)
+ exported += 1
+ } catch (error) {
+ failed.push({
+ skillName: item.name,
+ reason: error instanceof Error ? error.message : String(error)
+ })
+ }
+ }
+
+ if (exported > 0) {
+ this.updateSyncDirectoryConfig({ lastExportAt: new Date().toISOString() })
+ }
+
+ return {
+ success: failed.length === 0,
+ exported,
+ skipped,
+ failed
+ }
+ }
+
+ async previewSyncDirectoryImport(): Promise {
+ const config = this.requireSyncDirectoryConfig()
+ const skillsRoot = path.join(config.skillsDirectory, 'skills')
+ const items: SkillSyncDirectoryPreviewItem[] = []
+ if (!fs.existsSync(skillsRoot)) {
+ return { skillsDirectory: config.skillsDirectory, items }
+ }
+
+ for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue
+ const sourcePath = path.join(skillsRoot, entry.name)
+ const targetPath = path.join(this.skillsDir, entry.name)
+ items.push(this.createImportPreviewItem(sourcePath, targetPath))
+ }
+
+ return {
+ skillsDirectory: config.skillsDirectory,
+ items: items.sort((left, right) => left.name.localeCompare(right.name))
+ }
+ }
+
+ async executeSyncDirectoryImport(
+ input: SkillSyncDirectoryImportInput
+ ): Promise {
+ const preview = await this.previewSyncDirectoryImport()
+ const selected = new Set(input.skillNames)
+ const strategy = input.strategy ?? 'rename'
+ let imported = 0
+ let skipped = 0
+ const failed: Array<{ skillName: string; reason: string }> = []
+
+ for (const item of preview.items.filter((candidate) => selected.has(candidate.name))) {
+ if (item.state === 'invalid' || item.state === 'same') {
+ skipped += 1
+ if (item.state === 'invalid') {
+ failed.push({ skillName: item.name, reason: item.error ?? 'Invalid skill' })
+ }
+ continue
+ }
+
+ if ((item.state === 'conflict' || item.state === 'modified') && strategy === 'skip') {
+ skipped += 1
+ continue
+ }
+
+ const targetName =
+ (item.state === 'conflict' || item.state === 'modified') && strategy === 'rename'
+ ? this.createUniqueSkillName(item.name)
+ : item.name
+ const result = await this.installFromDirectory(
+ item.sourcePath,
+ { overwrite: strategy === 'overwrite' },
+ 'imported',
+ {
+ importedFrom: item.sourcePath,
+ importedAt: new Date().toISOString()
+ },
+ targetName
+ )
+ if (result.success) {
+ imported += 1
+ } else {
+ failed.push({
+ skillName: item.name,
+ reason: result.error ?? 'Import failed'
+ })
+ }
+ }
+
+ if (imported > 0) {
+ this.updateSyncDirectoryConfig({ lastImportAt: new Date().toISOString() })
+ }
+
+ return {
+ success: failed.length === 0,
+ imported,
+ skipped,
+ failed
+ }
+ }
+
async registerPluginSkill(input: {
ownerPluginId: string
id: string
@@ -1212,6 +1688,90 @@ export class SkillPresenter implements ISkillPresenter {
}
}
+ async registerAdoptedSkill(input: SkillAdoptionRegistration): Promise {
+ const skillRoot = path.resolve(input.canonicalPath)
+ const metadata = await this.parseSkillMetadata(path.join(skillRoot, 'SKILL.md'), input.name)
+ if (!metadata || metadata.name !== input.name) {
+ throw new Error(`Adopted skill "${input.name}" is invalid`)
+ }
+
+ this.metadataCache.set(input.name, metadata)
+ this.contentCache.delete(input.name)
+ this.updateSkillManagementItem(input.name, (item) => ({
+ ...item,
+ canonicalPath: skillRoot,
+ source: {
+ type: 'adopted',
+ agentId: input.agentId,
+ originalPath: input.originalPath,
+ adoptedAt: new Date().toISOString()
+ },
+ agentLinks: {
+ ...item.agentLinks,
+ [input.agentId]: {
+ path: input.agentPath,
+ state: 'linked',
+ createdByDeepChat: true,
+ linkedAt: new Date().toISOString()
+ }
+ }
+ }))
+
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'installed',
+ name: input.name,
+ skill: metadata,
+ version: Date.now()
+ })
+ }
+
+ async registerAgentSkillLink(input: SkillAgentLinkRegistration): Promise {
+ if (this.metadataCache.size === 0) {
+ await this.discoverSkills()
+ }
+ const metadata = this.metadataCache.get(input.skillName)
+ if (!metadata) {
+ throw new Error(`Skill "${input.skillName}" not found`)
+ }
+
+ this.updateSkillManagementItem(input.skillName, (item) => ({
+ ...item,
+ canonicalPath: metadata.skillRoot,
+ agentLinks: {
+ ...item.agentLinks,
+ [input.agentId]: {
+ path: input.agentPath,
+ state: 'linked',
+ createdByDeepChat: true,
+ linkedAt: new Date().toISOString()
+ }
+ }
+ }))
+
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'management-state-updated',
+ name: input.skillName,
+ version: Date.now()
+ })
+ }
+
+ async removeAgentSkillLink(input: { skillName: string; agentId: string }): Promise {
+ this.updateSkillManagementItem(input.skillName, (item) => {
+ const agentLinks = { ...item.agentLinks }
+ delete agentLinks[input.agentId]
+ return {
+ ...item,
+ agentLinks: Object.keys(agentLinks).length > 0 ? agentLinks : undefined
+ }
+ })
+
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'management-state-updated',
+ name: input.skillName,
+ version: Date.now()
+ })
+ }
+
async unregisterPluginSkillsByOwner(ownerPluginId: string): Promise {
let changed = false
for (const [key, contribution] of this.pluginSkillContributions.entries()) {
@@ -1233,7 +1793,10 @@ export class SkillPresenter implements ISkillPresenter {
private async installFromDirectory(
folderPath: string,
- options?: SkillInstallOptions
+ options?: SkillInstallOptions,
+ sourceType: SkillSourceType = 'folder-install',
+ sourcePatch: Partial = {},
+ targetName?: string
): Promise {
try {
this.ensureSkillsDir()
@@ -1285,15 +1848,24 @@ export class SkillPresenter implements ISkillPresenter {
}
}
- const targetDir = path.join(this.skillsDir, skillName)
+ const finalSkillName = targetName?.trim() || skillName
+ if (!this.isSafeSkillName(finalSkillName)) {
+ return {
+ success: false,
+ error: 'Invalid target skill name',
+ errorCode: 'invalid_skill'
+ }
+ }
+
+ const targetDir = path.join(this.skillsDir, finalSkillName)
const resolvedTarget = path.resolve(targetDir)
if (resolvedSource === resolvedTarget) {
return {
success: false,
- error: `Skill "${skillName}" already exists`,
+ error: `Skill "${finalSkillName}" already exists`,
errorCode: 'conflict',
- existingSkillName: skillName
+ existingSkillName: finalSkillName
}
}
@@ -1313,36 +1885,51 @@ export class SkillPresenter implements ISkillPresenter {
if (!options?.overwrite) {
return {
success: false,
- error: `Skill "${skillName}" already exists`,
+ error: `Skill "${finalSkillName}" already exists`,
errorCode: 'conflict',
- existingSkillName: skillName
+ existingSkillName: finalSkillName
}
}
- const replaceResult = this.prepareExistingSkillTargetForInstall(skillName, resolvedTarget)
+ const replaceResult = this.prepareExistingSkillTargetForInstall(
+ finalSkillName,
+ resolvedTarget
+ )
if (replaceResult) {
return replaceResult
}
- this.metadataCache.delete(skillName)
- this.contentCache.delete(skillName)
+ this.metadataCache.delete(finalSkillName)
+ this.contentCache.delete(finalSkillName)
}
this.copyDirectory(resolvedSource, resolvedTarget)
+ if (finalSkillName !== skillName) {
+ this.rewriteSkillManifestName(resolvedTarget, finalSkillName)
+ }
const metadata = await this.parseSkillMetadata(
path.join(resolvedTarget, 'SKILL.md'),
- skillName
+ finalSkillName
)
if (metadata) {
- this.metadataCache.set(skillName, metadata)
- }
+ this.metadataCache.set(finalSkillName, metadata)
+ }
+ this.updateSkillManagementItem(finalSkillName, (item) => ({
+ ...item,
+ canonicalPath: resolvedTarget,
+ source: {
+ type: sourceType,
+ installedAt: new Date().toISOString(),
+ ...sourcePatch
+ }
+ }))
publishDeepchatEvent('skills.catalog.changed', {
reason: 'installed',
- name: skillName,
+ name: finalSkillName,
version: Date.now()
})
- return { success: true, skillName }
+ return { success: true, skillName: finalSkillName, targetPath: resolvedTarget }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMsg, errorCode: 'io_error' }
@@ -1372,16 +1959,25 @@ export class SkillPresenter implements ISkillPresenter {
private backupExistingSkill(skillName: string): string {
const sourceDir = path.join(this.skillsDir, skillName)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
- let backupDir = path.join(this.skillsDir, `${skillName}.backup-${timestamp}`)
+ const backupRoot = path.join(app.getPath('home'), '.deepchat', 'backups', 'skill-installs')
+ fs.mkdirSync(backupRoot, { recursive: true })
+ let backupDir = path.join(backupRoot, `${skillName}-${timestamp}`)
let counter = 0
while (fs.existsSync(backupDir)) {
counter += 1
- backupDir = path.join(this.skillsDir, `${skillName}.backup-${timestamp}-${counter}`)
+ backupDir = path.join(backupRoot, `${skillName}-${timestamp}-${counter}`)
}
fs.renameSync(sourceDir, backupDir)
return backupDir
}
+ private rewriteSkillManifestName(skillDir: string, name: string): void {
+ const skillPath = path.join(skillDir, 'SKILL.md')
+ const raw = fs.readFileSync(skillPath, 'utf-8')
+ const parsed = matter(raw)
+ fs.writeFileSync(skillPath, matter.stringify(parsed.content, { ...parsed.data, name }), 'utf-8')
+ }
+
private createTargetLockedFailure(
skillName: string,
targetPath: string,
@@ -1545,6 +2141,235 @@ export class SkillPresenter implements ISkillPresenter {
}
}
+ private async cloneGitSkillRepo(repoUrl: string): Promise {
+ const operationRoot = path.join(app.getPath('home'), '.deepchat', 'tmp', 'skill-installs')
+ fs.mkdirSync(operationRoot, { recursive: true })
+ const cloneDir = path.join(operationRoot, `${Date.now()}-${randomUUID()}`)
+ try {
+ await execFileAsync('git', ['clone', '--depth', '1', repoUrl, cloneDir], {
+ timeout: SKILL_CONFIG.DOWNLOAD_TIMEOUT
+ })
+ return cloneDir
+ } catch (error) {
+ fs.rmSync(cloneDir, { recursive: true, force: true })
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ throw new Error(`Failed to clone Git repository: ${errorMsg}`)
+ }
+ }
+
+ private async scanGitSkillRepoDirectory(
+ repoUrl: string,
+ repoRoot: string
+ ): Promise {
+ const rootSkill = path.join(repoRoot, 'SKILL.md')
+ if (fs.existsSync(rootSkill)) {
+ return {
+ repoUrl,
+ repoFormat: 'single-skill',
+ skills: [this.createGitScanItem(repoRoot, 'SKILL.md')]
+ }
+ }
+
+ const skillsRoot = path.join(repoRoot, 'skills')
+ const skills = fs.existsSync(skillsRoot)
+ ? fs
+ .readdirSync(skillsRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) =>
+ this.createGitScanItem(
+ path.join(skillsRoot, entry.name),
+ path.join('skills', entry.name, 'SKILL.md')
+ )
+ )
+ : []
+
+ return {
+ repoUrl,
+ repoFormat: 'multi-skill',
+ skills: skills.sort((left, right) => left.name.localeCompare(right.name))
+ }
+ }
+
+ private createGitScanItem(skillDir: string, relativePath: string): GitSkillRepoScanItem {
+ const summary = this.readSkillManifestSummary(skillDir)
+ if (!summary.valid) {
+ return {
+ name: path.basename(skillDir),
+ description: '',
+ relativePath,
+ conflict: false,
+ valid: false,
+ error: summary.error
+ }
+ }
+
+ return {
+ name: summary.name,
+ description: summary.description,
+ relativePath,
+ conflict: fs.existsSync(path.join(this.skillsDir, summary.name)),
+ valid: true
+ }
+ }
+
+ private readSkillManifestSummary(
+ skillDir: string
+ ): { valid: true; name: string; description: string } | { valid: false; error: string } {
+ const skillPath = path.join(skillDir, 'SKILL.md')
+ if (!fs.existsSync(skillPath)) {
+ return { valid: false, error: 'SKILL.md not found' }
+ }
+
+ try {
+ const content = fs.readFileSync(skillPath, 'utf-8')
+ const { data } = matter(content)
+ const name = typeof data.name === 'string' ? data.name.trim() : ''
+ const description = typeof data.description === 'string' ? data.description.trim() : ''
+ if (!name || !description || !this.isSafeSkillName(name)) {
+ return { valid: false, error: 'Invalid SKILL.md frontmatter' }
+ }
+ return { valid: true, name, description }
+ } catch (error) {
+ return { valid: false, error: error instanceof Error ? error.message : String(error) }
+ }
+ }
+
+ private createUniqueSkillName(baseName: string): string {
+ let counter = 1
+ let candidate = `${baseName}-${counter}`
+ while (fs.existsSync(path.join(this.skillsDir, candidate))) {
+ counter += 1
+ candidate = `${baseName}-${counter}`
+ }
+ return candidate
+ }
+
+ private requireSyncDirectoryConfig(): SkillSyncDirectoryConfig {
+ const config = this.getStoredManagementState().sync
+ if (!config) {
+ throw new Error('Skills sync directory is not configured')
+ }
+ return config
+ }
+
+ private updateSyncDirectoryConfig(patch: Partial): void {
+ const state = this.getStoredManagementState()
+ if (!state.sync) {
+ throw new Error('Skills sync directory is not configured')
+ }
+ state.sync = {
+ ...state.sync,
+ ...patch
+ }
+ this.saveManagementState(state)
+ publishDeepchatEvent('skills.catalog.changed', {
+ reason: 'sync-directory-updated',
+ version: Date.now()
+ })
+ }
+
+ private ensureSyncDirectoryReadme(syncDirectory: string): void {
+ const readmePath = path.join(syncDirectory, 'README.md')
+ if (!fs.existsSync(readmePath)) {
+ fs.writeFileSync(
+ readmePath,
+ '# DeepChat Skills\n\nThis directory stores portable DeepChat skills under `skills/`.\n',
+ 'utf-8'
+ )
+ }
+ }
+
+ private resolveExportPreviewState(
+ sourcePath: string,
+ targetPath: string
+ ): SkillSyncDirectoryPreviewItem['state'] {
+ if (!fs.existsSync(targetPath)) {
+ return 'new'
+ }
+ return this.areSkillDirectoriesSame(sourcePath, targetPath) ? 'same' : 'modified'
+ }
+
+ private createImportPreviewItem(
+ sourcePath: string,
+ fallbackTargetPath: string
+ ): SkillSyncDirectoryPreviewItem {
+ const summary = this.readSkillManifestSummary(sourcePath)
+ if (!summary.valid) {
+ return {
+ name: path.basename(sourcePath),
+ state: 'invalid',
+ sourcePath,
+ targetPath: fallbackTargetPath,
+ error: summary.error
+ }
+ }
+
+ const targetPath = path.join(this.skillsDir, summary.name)
+ if (!fs.existsSync(targetPath)) {
+ return {
+ name: summary.name,
+ state: 'new',
+ sourcePath,
+ targetPath
+ }
+ }
+
+ if (this.areSkillDirectoriesSame(sourcePath, targetPath)) {
+ return {
+ name: summary.name,
+ state: 'same',
+ sourcePath,
+ targetPath
+ }
+ }
+
+ const existingSource = this.getStoredManagementState().skills[summary.name]?.source
+ const state =
+ existingSource?.type === 'imported' && existingSource.importedFrom === sourcePath
+ ? 'modified'
+ : 'conflict'
+ return {
+ name: summary.name,
+ state,
+ sourcePath,
+ targetPath
+ }
+ }
+
+ private areSkillDirectoriesSame(left: string, right: string): boolean {
+ try {
+ return this.createSkillDirectorySnapshot(left) === this.createSkillDirectorySnapshot(right)
+ } catch {
+ return false
+ }
+ }
+
+ private createSkillDirectorySnapshot(root: string): string {
+ return this.collectSkillDirectoryFiles(root)
+ .sort()
+ .map((relativePath) => {
+ const content = fs.readFileSync(path.join(root, relativePath)).toString('base64')
+ return `${relativePath}\0${content}`
+ })
+ .join('\0')
+ }
+
+ private collectSkillDirectoryFiles(root: string, current: string = root): string[] {
+ const files: string[] = []
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ if (entry.isSymbolicLink() || entry.name === SKILL_CONFIG.SIDECAR_DIR) {
+ continue
+ }
+ const fullPath = path.join(current, entry.name)
+ if (entry.isDirectory()) {
+ files.push(...this.collectSkillDirectoryFiles(root, fullPath))
+ } else {
+ files.push(path.relative(root, fullPath))
+ }
+ }
+ return files
+ }
+
/**
* Uninstall a skill
*/
@@ -1584,9 +2409,9 @@ export class SkillPresenter implements ISkillPresenter {
private cleanupUninstalledSkillState(name: string): void {
if (this.isSafeSkillName(name)) {
try {
- this.deleteSkillExtension(name)
+ this.deleteSkillManagementItem(name)
} catch (error) {
- logger.warn('[SkillPresenter] Failed to delete skill sidecar after uninstall', {
+ logger.warn('[SkillPresenter] Failed to delete skill management state after uninstall', {
name,
error
})
@@ -1642,15 +2467,17 @@ export class SkillPresenter implements ISkillPresenter {
return { success: false, error: `Skill "${name}" not found` }
}
- const sidecarPath = this.getSidecarPath(name)
const previousSkillContent = fs.readFileSync(metadata.path, 'utf-8')
- const hadSidecar = fs.existsSync(sidecarPath)
- const previousSidecarContent = hadSidecar ? fs.readFileSync(sidecarPath, 'utf-8') : null
+ const previousState = this.getStoredManagementState()
const sanitized = sanitizeSkillExtensionConfig(config)
try {
fs.writeFileSync(metadata.path, content, 'utf-8')
- fs.writeFileSync(sidecarPath, JSON.stringify(sanitized, null, 2), 'utf-8')
+ this.updateSkillManagementItem(name, (item) => ({
+ ...item,
+ canonicalPath: metadata.skillRoot,
+ extension: sanitized
+ }))
this.contentCache.delete(name)
const newMetadata = await this.parseSkillMetadata(metadata.path, name)
@@ -1664,11 +2491,7 @@ export class SkillPresenter implements ISkillPresenter {
try {
fs.writeFileSync(metadata.path, previousSkillContent, 'utf-8')
- if (hadSidecar && previousSidecarContent !== null) {
- fs.writeFileSync(sidecarPath, previousSidecarContent, 'utf-8')
- } else if (fs.existsSync(sidecarPath)) {
- fs.rmSync(sidecarPath, { force: true })
- }
+ this.saveManagementState(previousState)
} catch (rollbackError) {
const rollbackMessage =
rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
@@ -1776,14 +2599,36 @@ export class SkillPresenter implements ISkillPresenter {
async getSkillExtension(name: string): Promise {
this.ensureSkillsDir()
+ const item = this.getStoredManagementState().skills[name]
+ if (item) {
+ return sanitizeSkillExtensionConfig(item.extension)
+ }
+
+ return await this.migrateLegacySkillExtension(name)
+ }
+
+ private async migrateLegacySkillExtension(name: string): Promise {
const sidecarPath = this.getSidecarPath(name)
if (!(await this.pathExists(sidecarPath))) {
return createDefaultSkillExtensionConfig()
}
-
try {
const content = await fs.promises.readFile(sidecarPath, 'utf-8')
- return sanitizeSkillExtensionConfig(JSON.parse(content))
+ const config = sanitizeSkillExtensionConfig(JSON.parse(content))
+ this.updateSkillManagementItem(name, (item) => ({
+ ...item,
+ extension: config
+ }))
+ try {
+ fs.rmSync(sidecarPath, { force: true })
+ this.removeLegacySidecarDirIfEmpty()
+ } catch (cleanupError) {
+ logger.warn('[SkillPresenter] Failed to remove migrated skill sidecar', {
+ name,
+ error: cleanupError
+ })
+ }
+ return config
} catch (error) {
logger.warn('[SkillPresenter] Failed to read skill sidecar, using defaults', {
name,
@@ -1793,6 +2638,16 @@ export class SkillPresenter implements ISkillPresenter {
}
}
+ private removeLegacySidecarDirIfEmpty(): void {
+ try {
+ if (fs.existsSync(this.sidecarDir) && fs.readdirSync(this.sidecarDir).length === 0) {
+ fs.rmSync(this.sidecarDir, { force: true, recursive: false })
+ }
+ } catch {
+ // Keep legacy residue for the next migration attempt.
+ }
+ }
+
async saveSkillExtension(name: string, config: SkillExtensionConfig): Promise {
this.ensureSkillsDir()
if (this.metadataCache.size === 0) {
@@ -1804,7 +2659,12 @@ export class SkillPresenter implements ISkillPresenter {
}
const sanitized = sanitizeSkillExtensionConfig(config)
- fs.writeFileSync(this.getSidecarPath(name), JSON.stringify(sanitized, null, 2), 'utf-8')
+ const metadata = this.metadataCache.get(name)
+ this.updateSkillManagementItem(name, (item) => ({
+ ...item,
+ canonicalPath: metadata?.skillRoot ?? item.canonicalPath,
+ extension: sanitized
+ }))
this.contentCache.delete(name)
}
@@ -2236,10 +3096,11 @@ export class SkillPresenter implements ISkillPresenter {
return path.join(this.sidecarDir, `${name}.json`)
}
- private deleteSkillExtension(name: string): void {
- const sidecarPath = this.getSidecarPath(name)
- if (fs.existsSync(sidecarPath)) {
- fs.rmSync(sidecarPath, { force: true })
+ private deleteSkillManagementItem(name: string): void {
+ const state = this.getStoredManagementState()
+ if (state.skills[name]) {
+ delete state.skills[name]
+ this.saveManagementState(state)
}
}
diff --git a/src/main/presenter/skillSyncPresenter/index.ts b/src/main/presenter/skillSyncPresenter/index.ts
index 292aa2e48e..05ba17e129 100644
--- a/src/main/presenter/skillSyncPresenter/index.ts
+++ b/src/main/presenter/skillSyncPresenter/index.ts
@@ -11,7 +11,9 @@ import logger from '@shared/logger'
import * as fs from 'fs'
import * as path from 'path'
+import { randomUUID } from 'node:crypto'
import { app } from 'electron'
+import matter from 'gray-matter'
import type {
ISkillSyncPresenter,
ExternalToolConfig,
@@ -22,17 +24,38 @@ import type {
CanonicalSkill,
ExternalSkillInfo,
ScanCache,
- NewDiscovery
+ NewDiscovery,
+ InstalledSkillAgent,
+ InstalledSkillAgentDetail,
+ AgentSkillItem,
+ AdoptAgentSkillInput,
+ AdoptAgentSkillPreview,
+ AdoptAgentSkillResult,
+ AgentSkillLinkInput,
+ LinkDeepChatSkillResult,
+ LinkDeepChatSkillsInput,
+ LinkDeepChatSkillsPreview,
+ LinkDeepChatSkillsResult,
+ SkillDetail
} from '@shared/types/skillSync'
import { ConflictStrategy } from '@shared/types/skillSync'
+import type { UnifiedSkillItem } from '@shared/types/skillManagement'
import type { ISkillPresenter, IConfigPresenter } from '@shared/presenter'
import { toolScanner, resolveSkillsDir } from './toolScanner'
import { formatConverter } from './formatConverter'
import type { SyncContext } from './types'
import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent'
-import { isValidToolId, isValidConflictStrategy, checkWritePermission } from './security'
+import {
+ isValidToolId,
+ isValidConflictStrategy,
+ checkWritePermission,
+ checkReadPermission,
+ isFilenameSafe
+} from './security'
import { scanAndDetectDiscoveriesInWorker, scanExternalToolsInWorker } from './scanWorker'
+const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/
+
type SkillSyncEventName =
| 'skillSync.discoveries.changed'
| 'skillSync.scan.started'
@@ -734,6 +757,354 @@ export class SkillSyncPresenter implements ISkillSyncPresenter {
return toolScanner.getAllTools()
}
+ async scanSkillAgents(): Promise {
+ const results = await this.scanExternalToolsWithFallback()
+ const resultByTool = new Map(results.map((result) => [result.toolId, result]))
+ const agents: InstalledSkillAgent[] = []
+
+ for (const tool of this.getManageableAgentTools()) {
+ const result =
+ resultByTool.get(tool.id) ??
+ (await toolScanner.scanTool(tool.id, this.syncContext.projectRoot))
+ const detail = await this.buildAgentDetail(tool, result)
+ const { skills: _skills, ...summary } = detail
+ agents.push(summary)
+ }
+
+ return agents
+ }
+
+ async scanSkillAgent(input: { agentId: string }): Promise {
+ const tool = toolScanner.getTool(input.agentId)
+ if (!tool || !this.canManageAgentLinks(tool)) {
+ return {
+ id: input.agentId,
+ name: input.agentId,
+ skillsDir: '',
+ isCustom: false,
+ supportsLinkManagement: false,
+ skillsCount: 0,
+ linkedCount: 0,
+ agentOwnedCount: 0,
+ conflictCount: 0,
+ brokenLinkCount: 0,
+ status: 'detected-no-skills-dir',
+ skills: []
+ }
+ }
+
+ return this.buildAgentDetail(
+ tool,
+ await toolScanner.scanTool(tool.id, this.syncContext.projectRoot)
+ )
+ }
+
+ async getAgentSkillDetail(input: { agentId: string; skillName: string }): Promise {
+ const detail = await this.scanSkillAgent({ agentId: input.agentId })
+ const skill = detail.skills.find((item) => item.name === input.skillName)
+ if (!skill) {
+ throw new Error(`Skill "${input.skillName}" not found in ${detail.name}`)
+ }
+
+ const markdownPath = path.join(skill.path, 'SKILL.md')
+ const markdown = await fs.promises.readFile(markdownPath, 'utf-8')
+ return {
+ name: skill.name,
+ description: skill.description ?? '',
+ sourcePath: markdownPath,
+ markdown,
+ mutable: skill.owner !== 'broken-link'
+ }
+ }
+
+ async previewAdoptAgentSkill(input: AdoptAgentSkillInput): Promise {
+ const adoption = await this.resolveAdoptionSource(input)
+ const source = await this.readAdoptableSkill(adoption.sourcePath)
+ if (source.name !== adoption.skill.name) {
+ throw new Error(`SKILL.md name "${source.name}" does not match "${adoption.skill.name}"`)
+ }
+
+ const skillsDir = path.resolve(await this.skillPresenter.getSkillsDir())
+ const deepchatSkills = await this.skillPresenter.getUnifiedSkillCatalog()
+ const deepchatNames = new Set(deepchatSkills.map((skill) => skill.name))
+ const hasConflict =
+ deepchatNames.has(source.name) || (await this.pathExists(path.join(skillsDir, source.name)))
+ const targetName =
+ input.targetName ??
+ (hasConflict
+ ? await this.generateAdoptionTargetName(
+ `${source.name}-${input.agentId}`,
+ skillsDir,
+ deepchatNames
+ )
+ : source.name)
+
+ this.assertValidDeepChatSkillName(targetName)
+ if (
+ deepchatNames.has(targetName) ||
+ (await this.pathExists(path.join(skillsDir, targetName)))
+ ) {
+ throw new Error(`Skill "${targetName}" already exists`)
+ }
+
+ const dataRoot = path.dirname(skillsDir)
+ const targetPath = path.join(skillsDir, targetName)
+
+ return {
+ agentId: input.agentId,
+ agentName: adoption.agent.name,
+ skillName: adoption.skill.name,
+ targetName,
+ sourcePath: adoption.sourcePath,
+ agentPath: adoption.agentPath,
+ targetPath,
+ backupRoot: path.join(
+ dataRoot,
+ 'backups',
+ 'skill-adoptions',
+ input.agentId,
+ adoption.skill.name
+ ),
+ conflict: hasConflict,
+ warnings: targetName === source.name ? [] : [`Skill will be adopted as "${targetName}"`]
+ }
+ }
+
+ async executeAdoptAgentSkill(input: AdoptAgentSkillInput): Promise {
+ let tempPath = ''
+ let targetCreated = false
+ let originalMoved = false
+ let preview: AdoptAgentSkillPreview | undefined
+ let backupPath = ''
+
+ try {
+ preview = await this.previewAdoptAgentSkill(input)
+ const operationId = `${Date.now()}-${randomUUID()}`
+ const dataRoot = path.dirname(path.resolve(await this.skillPresenter.getSkillsDir()))
+ tempPath = path.join(dataRoot, 'tmp', 'skill-adoptions', operationId)
+ backupPath = path.join(preview.backupRoot, operationId)
+
+ await fs.promises.mkdir(path.dirname(tempPath), { recursive: true })
+ await fs.promises.mkdir(path.dirname(backupPath), { recursive: true })
+ await this.prepareAdoptionTemp(preview.sourcePath, tempPath, preview.targetName)
+
+ if (await this.pathExists(preview.targetPath)) {
+ throw new Error(`Skill "${preview.targetName}" already exists`)
+ }
+
+ await fs.promises.mkdir(path.dirname(preview.targetPath), { recursive: true })
+ await fs.promises.rename(tempPath, preview.targetPath)
+ targetCreated = true
+
+ try {
+ await fs.promises.rename(preview.agentPath, backupPath)
+ originalMoved = true
+ await this.createDirectoryLink(preview.targetPath, preview.agentPath)
+ } catch (error) {
+ if (originalMoved && !(await this.pathExists(preview.agentPath))) {
+ await fs.promises.rename(backupPath, preview.agentPath).catch(() => undefined)
+ }
+ if (targetCreated) {
+ await fs.promises.rm(preview.targetPath, { recursive: true, force: true })
+ }
+ throw error
+ }
+
+ await this.skillPresenter.registerAdoptedSkill({
+ name: preview.targetName,
+ canonicalPath: preview.targetPath,
+ agentId: preview.agentId,
+ agentPath: preview.agentPath,
+ originalPath: preview.sourcePath
+ })
+
+ return {
+ success: true,
+ skillName: preview.targetName,
+ targetPath: preview.targetPath,
+ agentPath: preview.agentPath,
+ backupPath
+ }
+ } catch (error) {
+ if (tempPath) {
+ await fs.promises.rm(tempPath, { recursive: true, force: true }).catch(() => undefined)
+ }
+ return {
+ success: false,
+ skillName: preview?.targetName,
+ targetPath: preview?.targetPath,
+ agentPath: preview?.agentPath,
+ backupPath: backupPath || undefined,
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ }
+
+ async previewLinkDeepChatSkills(
+ input: LinkDeepChatSkillsInput
+ ): Promise {
+ const tool = this.resolveManageableAgentTool(input.agentId)
+ const detail = await this.scanSkillAgent({ agentId: input.agentId })
+ const skillsDir = detail.skillsDir || resolveSkillsDir(tool, this.syncContext.projectRoot)
+ const existingByName = new Map(detail.skills.map((skill) => [skill.name, skill]))
+ const deepchatByName = new Map(
+ (await this.skillPresenter.getUnifiedSkillCatalog()).map((skill) => [skill.name, skill])
+ )
+
+ return {
+ agentId: input.agentId,
+ agentName: tool.name,
+ skillsDir,
+ items: await Promise.all(
+ [...new Set(input.skillNames)].map(async (skillName) => {
+ this.assertValidDeepChatSkillName(skillName)
+ const deepchat = deepchatByName.get(skillName)
+ const targetPath = path.join(skillsDir, skillName)
+ if (!deepchat) {
+ return {
+ skillName,
+ targetPath,
+ status: 'missing',
+ message: `Skill "${skillName}" not found in DeepChat`
+ }
+ }
+
+ const existing = existingByName.get(skillName)
+ if (!existing) {
+ return {
+ skillName,
+ sourcePath: deepchat.skillRoot,
+ targetPath,
+ status: 'ready'
+ }
+ }
+
+ if (
+ existing.status === 'linked' &&
+ existing.link?.targetPath &&
+ path.resolve(existing.link.targetPath) === path.resolve(deepchat.skillRoot)
+ ) {
+ return {
+ skillName,
+ sourcePath: deepchat.skillRoot,
+ targetPath,
+ status: 'already-linked'
+ }
+ }
+
+ return {
+ skillName,
+ sourcePath: deepchat.skillRoot,
+ targetPath,
+ status: 'conflict',
+ message: `Agent path already exists: ${targetPath}`
+ }
+ })
+ )
+ }
+ }
+
+ async executeLinkDeepChatSkills(
+ input: LinkDeepChatSkillsInput
+ ): Promise {
+ const preview = await this.previewLinkDeepChatSkills(input)
+ const result: LinkDeepChatSkillsResult = {
+ success: true,
+ linked: 0,
+ skipped: 0,
+ failed: []
+ }
+
+ await fs.promises.mkdir(preview.skillsDir, { recursive: true })
+ if (!(await checkWritePermission(preview.skillsDir))) {
+ return {
+ success: false,
+ linked: 0,
+ skipped: 0,
+ failed: input.skillNames.map((skillName) => ({
+ skillName,
+ reason: `No write permission for: ${preview.skillsDir}`
+ }))
+ }
+ }
+
+ for (const item of preview.items) {
+ if (item.status === 'already-linked') {
+ result.skipped += 1
+ continue
+ }
+ if (item.status !== 'ready' || !item.sourcePath) {
+ result.skipped += 1
+ continue
+ }
+
+ try {
+ await this.createDirectoryLink(item.sourcePath, item.targetPath)
+ await this.skillPresenter.registerAgentSkillLink({
+ skillName: item.skillName,
+ agentId: input.agentId,
+ agentPath: item.targetPath
+ })
+ result.linked += 1
+ } catch (error) {
+ result.failed.push({
+ skillName: item.skillName,
+ reason: error instanceof Error ? error.message : String(error)
+ })
+ }
+ }
+
+ result.success = result.failed.length === 0
+ return result
+ }
+
+ async repairAgentSkillLink(input: AgentSkillLinkInput): Promise {
+ try {
+ const link = await this.resolveDeepChatOwnedAgentLink(input)
+ await this.assertAgentPathIsLinkOrMissing(link.agentPath)
+ await fs.promises.rm(link.agentPath, { recursive: true, force: true })
+ await this.createDirectoryLink(link.targetPath, link.agentPath)
+ await this.skillPresenter.registerAgentSkillLink({
+ skillName: input.skillName,
+ agentId: input.agentId,
+ agentPath: link.agentPath
+ })
+ return {
+ success: true,
+ skillName: input.skillName,
+ agentPath: link.agentPath,
+ targetPath: link.targetPath
+ }
+ } catch (error) {
+ return {
+ success: false,
+ skillName: input.skillName,
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ }
+
+ async removeAgentSkillLink(input: AgentSkillLinkInput): Promise {
+ try {
+ const link = await this.resolveDeepChatOwnedAgentLink(input)
+ await this.assertAgentPathIsLinkOrMissing(link.agentPath)
+ await fs.promises.rm(link.agentPath, { recursive: true, force: true })
+ await this.skillPresenter.removeAgentSkillLink(input)
+ return {
+ success: true,
+ skillName: input.skillName,
+ agentPath: link.agentPath,
+ targetPath: link.targetPath
+ }
+ } catch (error) {
+ return {
+ success: false,
+ skillName: input.skillName,
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ }
+
/**
* Check if a tool's directory exists
*/
@@ -753,6 +1124,440 @@ export class SkillSyncPresenter implements ISkillSyncPresenter {
// Private Helper Methods
// ============================================================================
+ private async resolveAdoptionSource(input: AdoptAgentSkillInput): Promise<{
+ agent: InstalledSkillAgentDetail
+ skill: AgentSkillItem
+ sourcePath: string
+ agentPath: string
+ }> {
+ const tool = toolScanner.getTool(input.agentId)
+ if (!tool || !this.canManageAgentLinks(tool)) {
+ throw new Error(`Agent "${input.agentId}" does not support skill adoption`)
+ }
+
+ const agent = await this.scanSkillAgent({ agentId: input.agentId })
+ const skill = agent.skills.find((item) => item.name === input.skillName)
+ if (!skill) {
+ throw new Error(`Skill "${input.skillName}" not found in ${agent.name}`)
+ }
+ if (!['agent-owned', 'linked-out', 'conflict'].includes(skill.status)) {
+ throw new Error(`Skill "${input.skillName}" cannot be adopted from status "${skill.status}"`)
+ }
+ if (!this.isInsideDirectory(skill.path, agent.skillsDir)) {
+ throw new Error(`Agent path escapes skills directory: ${skill.path}`)
+ }
+
+ const sourcePath = skill.status === 'linked-out' ? skill.link?.targetPath : skill.path
+ if (!sourcePath) {
+ throw new Error(`Skill "${input.skillName}" source path is unavailable`)
+ }
+ if (!(await checkReadPermission(sourcePath))) {
+ throw new Error(`No read permission for: ${sourcePath}`)
+ }
+
+ return {
+ agent,
+ skill,
+ sourcePath,
+ agentPath: skill.path
+ }
+ }
+
+ private resolveManageableAgentTool(agentId: string): ExternalToolConfig {
+ const tool = toolScanner.getTool(agentId)
+ if (!tool || !this.canManageAgentLinks(tool)) {
+ throw new Error(`Agent "${agentId}" does not support skill links`)
+ }
+ return tool
+ }
+
+ private async resolveDeepChatOwnedAgentLink(input: AgentSkillLinkInput): Promise<{
+ agentPath: string
+ targetPath: string
+ }> {
+ this.assertValidDeepChatSkillName(input.skillName)
+ const tool = this.resolveManageableAgentTool(input.agentId)
+ const skillsDir = resolveSkillsDir(tool, this.syncContext.projectRoot)
+ const state = await this.skillPresenter.getSkillManagementState()
+ const link = state.skills[input.skillName]?.agentLinks?.[input.agentId]
+ if (!link?.createdByDeepChat) {
+ throw new Error(`Link for "${input.skillName}" was not created by DeepChat`)
+ }
+
+ const deepchat = (await this.skillPresenter.getUnifiedSkillCatalog()).find(
+ (skill) => skill.name === input.skillName
+ )
+ if (!deepchat || !(await this.pathExists(deepchat.skillRoot))) {
+ throw new Error(`DeepChat skill "${input.skillName}" not found`)
+ }
+
+ if (!this.isInsideDirectory(link.path, skillsDir)) {
+ throw new Error(`Agent link path escapes skills directory: ${link.path}`)
+ }
+
+ return {
+ agentPath: link.path,
+ targetPath: deepchat.skillRoot
+ }
+ }
+
+ private async assertAgentPathIsLinkOrMissing(agentPath: string): Promise {
+ try {
+ await fs.promises.readlink(agentPath)
+ return
+ } catch {
+ if (await this.pathExists(agentPath)) {
+ throw new Error(`Agent path is not a link: ${agentPath}`)
+ }
+ }
+ }
+
+ private async readAdoptableSkill(skillRoot: string): Promise<{
+ name: string
+ description: string
+ parsed: matter.GrayMatterFile
+ }> {
+ const skillPath = path.join(skillRoot, 'SKILL.md')
+ const content = await fs.promises.readFile(skillPath, 'utf-8')
+ const parsed = matter(content)
+ const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : ''
+ const description =
+ typeof parsed.data.description === 'string' ? parsed.data.description.trim() : ''
+ this.assertValidDeepChatSkillName(name)
+ if (!description) {
+ throw new Error('Skill description not found in SKILL.md frontmatter')
+ }
+ return { name, description, parsed }
+ }
+
+ private assertValidDeepChatSkillName(name: string): void {
+ if (!SKILL_NAME_PATTERN.test(name) || name.includes('/') || name.includes('\\')) {
+ throw new Error(`Invalid skill name: ${name}`)
+ }
+ }
+
+ private async generateAdoptionTargetName(
+ baseName: string,
+ skillsDir: string,
+ existingNames: Set
+ ): Promise {
+ this.assertValidDeepChatSkillName(baseName)
+ let candidate = baseName
+ let counter = 2
+ while (
+ existingNames.has(candidate) ||
+ (await this.pathExists(path.join(skillsDir, candidate)))
+ ) {
+ candidate = `${baseName}-${counter}`
+ counter += 1
+ }
+ return candidate
+ }
+
+ private async prepareAdoptionTemp(
+ sourcePath: string,
+ tempPath: string,
+ targetName: string
+ ): Promise {
+ await fs.promises.rm(tempPath, { recursive: true, force: true })
+ await this.copyDirectoryWithoutSymlinks(sourcePath, tempPath)
+ const copied = await this.readAdoptableSkill(tempPath)
+ if (copied.name !== targetName) {
+ copied.parsed.data.name = targetName
+ await fs.promises.writeFile(
+ path.join(tempPath, 'SKILL.md'),
+ matter.stringify(copied.parsed.content, copied.parsed.data),
+ 'utf-8'
+ )
+ }
+ }
+
+ private async copyDirectoryWithoutSymlinks(
+ sourcePath: string,
+ targetPath: string
+ ): Promise {
+ await fs.promises.mkdir(targetPath, { recursive: true })
+ const entries = await fs.promises.readdir(sourcePath, { withFileTypes: true })
+ for (const entry of entries) {
+ if (entry.isSymbolicLink() || entry.name === '.deepchat-meta') {
+ continue
+ }
+ const sourceEntry = path.join(sourcePath, entry.name)
+ const targetEntry = path.join(targetPath, entry.name)
+ if (entry.isDirectory()) {
+ await this.copyDirectoryWithoutSymlinks(sourceEntry, targetEntry)
+ } else if (entry.isFile()) {
+ await fs.promises.copyFile(sourceEntry, targetEntry)
+ }
+ }
+ }
+
+ private async createDirectoryLink(targetPath: string, linkPath: string): Promise {
+ await fs.promises.symlink(
+ targetPath,
+ linkPath,
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+ }
+
+ private getManageableAgentTools(): ExternalToolConfig[] {
+ return toolScanner.getAllTools().filter((tool) => this.canManageAgentLinks(tool))
+ }
+
+ private canManageAgentLinks(tool: ExternalToolConfig): boolean {
+ return (
+ !tool.isProjectLevel &&
+ tool.filePattern === '*/SKILL.md' &&
+ tool.capabilities.supportsSubfolders
+ )
+ }
+
+ private async buildAgentDetail(
+ tool: ExternalToolConfig,
+ result: ScanResult
+ ): Promise {
+ if (!result.available) {
+ return this.createAgentDetail(
+ tool,
+ result.skillsDir || tool.skillsDir,
+ 'detected-no-skills-dir',
+ []
+ )
+ }
+
+ const skills = await this.classifyAgentSkills(result)
+ const status = skills.some((skill) => skill.status === 'empty') ? 'permission-denied' : 'ready'
+ return this.createAgentDetail(
+ tool,
+ result.skillsDir,
+ status,
+ skills.filter((skill) => skill.status !== 'empty')
+ )
+ }
+
+ private createAgentDetail(
+ tool: ExternalToolConfig,
+ skillsDir: string,
+ status: InstalledSkillAgent['status'],
+ skills: AgentSkillItem[]
+ ): InstalledSkillAgentDetail {
+ return {
+ id: tool.id,
+ name: tool.name,
+ skillsDir,
+ isCustom: false,
+ supportsLinkManagement: this.canManageAgentLinks(tool),
+ skillsCount: skills.length,
+ linkedCount: skills.filter((skill) => skill.status === 'linked').length,
+ agentOwnedCount: skills.filter((skill) => skill.status === 'agent-owned').length,
+ conflictCount: skills.filter((skill) => skill.status === 'conflict').length,
+ brokenLinkCount: skills.filter((skill) => skill.status === 'broken-link').length,
+ status,
+ skills
+ }
+ }
+
+ private async classifyAgentSkills(result: ScanResult): Promise {
+ const deepchatSkills = await this.skillPresenter.getUnifiedSkillCatalog()
+ const deepchatByName = new Map(deepchatSkills.map((skill) => [skill.name, skill]))
+ const deepchatSkillsDir = path.resolve(await this.skillPresenter.getSkillsDir())
+ const scannedByPath = new Map(result.skills.map((skill) => [path.resolve(skill.path), skill]))
+
+ let entries: fs.Dirent[]
+ try {
+ entries = await fs.promises.readdir(result.skillsDir, { withFileTypes: true })
+ } catch (error) {
+ const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null
+ if (code === 'EACCES' || code === 'EPERM') {
+ return [
+ {
+ name: result.toolId,
+ path: result.skillsDir,
+ owner: 'unknown',
+ status: 'empty'
+ }
+ ]
+ }
+ return []
+ }
+
+ const skills: AgentSkillItem[] = []
+ for (const entry of entries) {
+ if (!isFilenameSafe(entry.name) || (!entry.isDirectory() && !entry.isSymbolicLink())) {
+ continue
+ }
+
+ const entryPath = path.join(result.skillsDir, entry.name)
+ if (entry.isSymbolicLink()) {
+ skills.push(
+ await this.classifyAgentSkillLink(
+ result.toolId,
+ entry.name,
+ entryPath,
+ deepchatSkillsDir,
+ deepchatByName
+ )
+ )
+ continue
+ }
+
+ const scanInfo = scannedByPath.get(path.resolve(entryPath))
+ if (!scanInfo) {
+ continue
+ }
+ skills.push(await this.classifyAgentSkillDirectory(scanInfo, deepchatByName))
+ }
+
+ return skills.sort((left, right) => left.name.localeCompare(right.name))
+ }
+
+ private async classifyAgentSkillDirectory(
+ skill: ExternalSkillInfo,
+ deepchatByName: Map
+ ): Promise {
+ const deepchat = deepchatByName.get(skill.name)
+ if (!deepchat) {
+ return {
+ name: skill.name,
+ description: skill.description,
+ path: skill.path,
+ owner: 'agent',
+ status: 'agent-owned',
+ action: 'adopt',
+ deepchat: { exists: false }
+ }
+ }
+
+ const sameContent = await this.hasSameSkillContent(skill.path, deepchat.skillRoot)
+ return {
+ name: skill.name,
+ description: skill.description || deepchat.description,
+ path: skill.path,
+ owner: 'agent',
+ status: sameContent ? 'agent-owned' : 'conflict',
+ action: sameContent ? 'adopt' : 'resolve-conflict',
+ deepchat: {
+ exists: true,
+ path: deepchat.skillRoot,
+ disabled: deepchat.deepchatDisabled,
+ sameContent
+ }
+ }
+ }
+
+ private async classifyAgentSkillLink(
+ agentId: string,
+ name: string,
+ linkPath: string,
+ deepchatSkillsDir: string,
+ deepchatByName: Map
+ ): Promise {
+ const targetPath = await this.readResolvedLinkTarget(linkPath)
+ const targetExists = targetPath ? await this.pathExists(targetPath) : false
+ const targetInsideDeepChat = Boolean(
+ targetPath && this.isInsideDirectory(targetPath, deepchatSkillsDir)
+ )
+ const deepchat = deepchatByName.get(name)
+ const createdByDeepChat =
+ deepchat?.agentLinks[agentId]?.createdByDeepChat === true &&
+ path.resolve(deepchat.agentLinks[agentId].path) === path.resolve(linkPath)
+
+ if (!targetExists) {
+ return {
+ name,
+ path: linkPath,
+ owner: 'broken-link',
+ status: 'broken-link',
+ action: createdByDeepChat ? 'repair-link' : undefined,
+ link: {
+ isSymlink: true,
+ targetPath,
+ targetExists: false,
+ targetInsideDeepChat,
+ createdByDeepChat
+ },
+ deepchat: deepchat
+ ? { exists: true, path: deepchat.skillRoot, disabled: deepchat.deepchatDisabled }
+ : { exists: false }
+ }
+ }
+
+ if (targetInsideDeepChat) {
+ return {
+ name,
+ description: deepchat?.description,
+ path: linkPath,
+ owner: 'deepchat',
+ status: 'linked',
+ action: createdByDeepChat ? 'remove-link' : undefined,
+ link: {
+ isSymlink: true,
+ targetPath,
+ targetExists: true,
+ targetInsideDeepChat: true,
+ createdByDeepChat
+ },
+ deepchat: deepchat
+ ? { exists: true, path: deepchat.skillRoot, disabled: deepchat.deepchatDisabled }
+ : { exists: false }
+ }
+ }
+
+ return {
+ name,
+ path: linkPath,
+ owner: 'external-link',
+ status: 'linked-out',
+ action: 'adopt',
+ link: {
+ isSymlink: true,
+ targetPath,
+ targetExists: true,
+ targetInsideDeepChat: false
+ },
+ deepchat: deepchat
+ ? { exists: true, path: deepchat.skillRoot, disabled: deepchat.deepchatDisabled }
+ : { exists: false }
+ }
+ }
+
+ private async readResolvedLinkTarget(linkPath: string): Promise {
+ try {
+ const rawTarget = await fs.promises.readlink(linkPath)
+ return path.isAbsolute(rawTarget)
+ ? path.resolve(rawTarget)
+ : path.resolve(path.dirname(linkPath), rawTarget)
+ } catch {
+ return undefined
+ }
+ }
+
+ private async pathExists(targetPath: string): Promise {
+ try {
+ await fs.promises.access(targetPath, fs.constants.F_OK)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ private isInsideDirectory(targetPath: string, parentPath: string): boolean {
+ const relative = path.relative(parentPath, path.resolve(targetPath))
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
+ }
+
+ private async hasSameSkillContent(leftRoot: string, rightRoot: string): Promise {
+ try {
+ const [left, right] = await Promise.all([
+ fs.promises.readFile(path.join(leftRoot, 'SKILL.md'), 'utf-8'),
+ fs.promises.readFile(path.join(rightRoot, 'SKILL.md'), 'utf-8')
+ ])
+ return left === right
+ } catch {
+ return false
+ }
+ }
+
/**
* Parse an external skill file
*/
diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts
index 3dc52a097b..ec3ce24c95 100644
--- a/src/main/routes/index.ts
+++ b/src/main/routes/index.ts
@@ -270,25 +270,44 @@ import {
skillsGetDirectoryRoute,
skillsGetExtensionRoute,
skillsGetFolderTreeRoute,
+ skillsGetSyncConfigRoute,
+ skillsExecuteSyncDirectoryExportRoute,
+ skillsExecuteSyncDirectoryImportRoute,
+ skillsInstallFromGitRoute,
skillsInstallFromFolderRoute,
skillsInstallFromUrlRoute,
skillsInstallFromZipRoute,
+ skillsListCatalogRoute,
skillsListMetadataRoute,
skillsListScriptsRoute,
skillsOpenFolderRoute,
+ skillsPreviewSyncDirectoryExportRoute,
+ skillsPreviewSyncDirectoryImportRoute,
skillsReadFileRoute,
+ skillsScanGitRepoRoute,
skillsSaveExtensionRoute,
skillsSaveWithExtensionRoute,
skillsSetActiveRoute,
+ skillsSetDisabledRoute,
+ skillsSetSyncDirectoryRoute,
skillsUninstallRoute,
skillsUpdateFileRoute,
skillSyncAcknowledgeDiscoveriesRoute,
+ skillSyncExecuteAdoptAgentSkillRoute,
skillSyncExecuteExportRoute,
skillSyncExecuteImportRoute,
+ skillSyncExecuteLinkDeepChatSkillsRoute,
+ skillSyncGetAgentDetailRoute,
+ skillSyncGetAgentSkillDetailRoute,
skillSyncGetNewDiscoveriesRoute,
skillSyncGetRegisteredToolsRoute,
+ skillSyncPreviewAdoptAgentSkillRoute,
skillSyncPreviewExportRoute,
skillSyncPreviewImportRoute,
+ skillSyncPreviewLinkDeepChatSkillsRoute,
+ skillSyncRemoveAgentSkillLinkRoute,
+ skillSyncRepairAgentSkillLinkRoute,
+ skillSyncScanAgentsRoute,
skillSyncScanExternalToolsRoute,
syncGetBackupStatusRoute,
syncImportRoute,
@@ -2986,6 +3005,21 @@ export async function dispatchDeepchatRoute(
})
}
+ case skillsListCatalogRoute.name: {
+ return await runTrackedRouteTask(runtime, routeName, context, async () => {
+ skillsListCatalogRoute.input.parse(rawInput)
+ const skills = await runtime.skillPresenter.getUnifiedSkillCatalog()
+ return skillsListCatalogRoute.output.parse({ skills })
+ })
+ }
+
+ case skillsSetDisabledRoute.name: {
+ const input = skillsSetDisabledRoute.input.parse(rawInput)
+ await runtime.skillPresenter.setSkillDeepChatDisabled(input.name, input.disabled)
+ recordSkillUpdatedActivity(runtime, input.name, 'skill-disabled-state')
+ return skillsSetDisabledRoute.output.parse({ saved: true })
+ }
+
case skillsGetDirectoryRoute.name: {
skillsGetDirectoryRoute.input.parse(rawInput)
const path = await runtime.skillPresenter.getSkillsDir()
@@ -3019,6 +3053,57 @@ export async function dispatchDeepchatRoute(
return skillsInstallFromUrlRoute.output.parse({ result })
}
+ case skillsScanGitRepoRoute.name: {
+ const input = skillsScanGitRepoRoute.input.parse(rawInput)
+ const result = await runtime.skillPresenter.scanGitSkillRepo(input.repoUrl)
+ return skillsScanGitRepoRoute.output.parse({ result })
+ }
+
+ case skillsInstallFromGitRoute.name: {
+ const input = skillsInstallFromGitRoute.input.parse(rawInput)
+ const results = await runtime.skillPresenter.installSkillsFromGit(input)
+ if (results.some(didSkillOperationSucceed)) {
+ recordSkillSettingsActivity(runtime, 'created', 'skill Git source')
+ }
+ return skillsInstallFromGitRoute.output.parse({ results })
+ }
+
+ case skillsGetSyncConfigRoute.name: {
+ skillsGetSyncConfigRoute.input.parse(rawInput)
+ const config = await runtime.skillPresenter.getSkillsSyncConfig()
+ return skillsGetSyncConfigRoute.output.parse({ config })
+ }
+
+ case skillsSetSyncDirectoryRoute.name: {
+ const input = skillsSetSyncDirectoryRoute.input.parse(rawInput)
+ const config = await runtime.skillPresenter.setSkillsSyncDirectory(input)
+ return skillsSetSyncDirectoryRoute.output.parse({ config })
+ }
+
+ case skillsPreviewSyncDirectoryExportRoute.name: {
+ const input = skillsPreviewSyncDirectoryExportRoute.input.parse(rawInput)
+ const preview = await runtime.skillPresenter.previewSyncDirectoryExport(input)
+ return skillsPreviewSyncDirectoryExportRoute.output.parse({ preview })
+ }
+
+ case skillsExecuteSyncDirectoryExportRoute.name: {
+ const input = skillsExecuteSyncDirectoryExportRoute.input.parse(rawInput)
+ const result = await runtime.skillPresenter.executeSyncDirectoryExport(input)
+ return skillsExecuteSyncDirectoryExportRoute.output.parse({ result })
+ }
+
+ case skillsPreviewSyncDirectoryImportRoute.name: {
+ skillsPreviewSyncDirectoryImportRoute.input.parse(rawInput)
+ const preview = await runtime.skillPresenter.previewSyncDirectoryImport()
+ return skillsPreviewSyncDirectoryImportRoute.output.parse({ preview })
+ }
+
+ case skillsExecuteSyncDirectoryImportRoute.name: {
+ const input = skillsExecuteSyncDirectoryImportRoute.input.parse(rawInput)
+ const result = await runtime.skillPresenter.executeSyncDirectoryImport(input)
+ return skillsExecuteSyncDirectoryImportRoute.output.parse({ result })
+ }
+
case skillsUninstallRoute.name: {
const input = skillsUninstallRoute.input.parse(rawInput)
const result = await runtime.skillPresenter.uninstallSkill(input.name)
@@ -3143,6 +3228,69 @@ export async function dispatchDeepchatRoute(
})
}
+ case skillSyncScanAgentsRoute.name: {
+ skillSyncScanAgentsRoute.input.parse(rawInput)
+ return skillSyncScanAgentsRoute.output.parse({
+ agents: await runtime.skillSyncPresenter.scanSkillAgents()
+ })
+ }
+
+ case skillSyncGetAgentDetailRoute.name: {
+ const input = skillSyncGetAgentDetailRoute.input.parse(rawInput)
+ return skillSyncGetAgentDetailRoute.output.parse({
+ agent: await runtime.skillSyncPresenter.scanSkillAgent({ agentId: input.agentId })
+ })
+ }
+
+ case skillSyncGetAgentSkillDetailRoute.name: {
+ const input = skillSyncGetAgentSkillDetailRoute.input.parse(rawInput)
+ return skillSyncGetAgentSkillDetailRoute.output.parse({
+ detail: await runtime.skillSyncPresenter.getAgentSkillDetail(input)
+ })
+ }
+
+ case skillSyncPreviewAdoptAgentSkillRoute.name: {
+ const input = skillSyncPreviewAdoptAgentSkillRoute.input.parse(rawInput)
+ return skillSyncPreviewAdoptAgentSkillRoute.output.parse({
+ preview: await runtime.skillSyncPresenter.previewAdoptAgentSkill(input)
+ })
+ }
+
+ case skillSyncExecuteAdoptAgentSkillRoute.name: {
+ const input = skillSyncExecuteAdoptAgentSkillRoute.input.parse(rawInput)
+ return skillSyncExecuteAdoptAgentSkillRoute.output.parse({
+ result: await runtime.skillSyncPresenter.executeAdoptAgentSkill(input)
+ })
+ }
+
+ case skillSyncPreviewLinkDeepChatSkillsRoute.name: {
+ const input = skillSyncPreviewLinkDeepChatSkillsRoute.input.parse(rawInput)
+ return skillSyncPreviewLinkDeepChatSkillsRoute.output.parse({
+ preview: await runtime.skillSyncPresenter.previewLinkDeepChatSkills(input)
+ })
+ }
+
+ case skillSyncExecuteLinkDeepChatSkillsRoute.name: {
+ const input = skillSyncExecuteLinkDeepChatSkillsRoute.input.parse(rawInput)
+ return skillSyncExecuteLinkDeepChatSkillsRoute.output.parse({
+ result: await runtime.skillSyncPresenter.executeLinkDeepChatSkills(input)
+ })
+ }
+
+ case skillSyncRepairAgentSkillLinkRoute.name: {
+ const input = skillSyncRepairAgentSkillLinkRoute.input.parse(rawInput)
+ return skillSyncRepairAgentSkillLinkRoute.output.parse({
+ result: await runtime.skillSyncPresenter.repairAgentSkillLink(input)
+ })
+ }
+
+ case skillSyncRemoveAgentSkillLinkRoute.name: {
+ const input = skillSyncRemoveAgentSkillLinkRoute.input.parse(rawInput)
+ return skillSyncRemoveAgentSkillLinkRoute.output.parse({
+ result: await runtime.skillSyncPresenter.removeAgentSkillLink(input)
+ })
+ }
+
case skillSyncPreviewImportRoute.name: {
const input = skillSyncPreviewImportRoute.input.parse(rawInput)
return skillSyncPreviewImportRoute.output.parse({
diff --git a/src/renderer/api/SkillClient.ts b/src/renderer/api/SkillClient.ts
index 498a849e1a..3ab0d3a0df 100644
--- a/src/renderer/api/SkillClient.ts
+++ b/src/renderer/api/SkillClient.ts
@@ -5,20 +5,36 @@ import {
skillsGetDirectoryRoute,
skillsGetExtensionRoute,
skillsGetFolderTreeRoute,
+ skillsGetSyncConfigRoute,
+ skillsExecuteSyncDirectoryExportRoute,
+ skillsExecuteSyncDirectoryImportRoute,
+ skillsInstallFromGitRoute,
skillsInstallFromFolderRoute,
skillsInstallFromUrlRoute,
skillsInstallFromZipRoute,
+ skillsListCatalogRoute,
skillsListMetadataRoute,
skillsListScriptsRoute,
skillsOpenFolderRoute,
+ skillsPreviewSyncDirectoryExportRoute,
+ skillsPreviewSyncDirectoryImportRoute,
skillsReadFileRoute,
+ skillsScanGitRepoRoute,
skillsSaveExtensionRoute,
skillsSaveWithExtensionRoute,
skillsSetActiveRoute,
+ skillsSetDisabledRoute,
+ skillsSetSyncDirectoryRoute,
skillsUninstallRoute,
skillsUpdateFileRoute
} from '@shared/contracts/routes'
-import type { SkillExtensionConfig, SkillInstallOptions } from '@shared/types/skill'
+import type {
+ GitSkillInstallInput,
+ SkillExtensionConfig,
+ SkillInstallOptions,
+ SkillSyncDirectoryExportInput,
+ SkillSyncDirectoryImportInput
+} from '@shared/types/skill'
import { getDeepchatBridge } from './core'
export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge()) {
@@ -27,6 +43,11 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
return result.skills
}
+ async function getUnifiedSkillCatalog() {
+ const result = await bridge.invoke(skillsListCatalogRoute.name, {})
+ return result.skills
+ }
+
async function getSkillsDir() {
const result = await bridge.invoke(skillsGetDirectoryRoute.name, {})
return result.path
@@ -56,6 +77,46 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
return result.result
}
+ async function scanGitSkillRepo(repoUrl: string) {
+ const result = await bridge.invoke(skillsScanGitRepoRoute.name, { repoUrl })
+ return result.result
+ }
+
+ async function installFromGit(input: GitSkillInstallInput) {
+ const result = await bridge.invoke(skillsInstallFromGitRoute.name, input)
+ return result.results
+ }
+
+ async function getSkillsSyncConfig() {
+ const result = await bridge.invoke(skillsGetSyncConfigRoute.name, {})
+ return result.config
+ }
+
+ async function setSkillsSyncDirectory(skillsDirectory: string) {
+ const result = await bridge.invoke(skillsSetSyncDirectoryRoute.name, { skillsDirectory })
+ return result.config
+ }
+
+ async function previewSyncDirectoryExport(input: SkillSyncDirectoryExportInput) {
+ const result = await bridge.invoke(skillsPreviewSyncDirectoryExportRoute.name, input)
+ return result.preview
+ }
+
+ async function executeSyncDirectoryExport(input: SkillSyncDirectoryExportInput) {
+ const result = await bridge.invoke(skillsExecuteSyncDirectoryExportRoute.name, input)
+ return result.result
+ }
+
+ async function previewSyncDirectoryImport() {
+ const result = await bridge.invoke(skillsPreviewSyncDirectoryImportRoute.name, {})
+ return result.preview
+ }
+
+ async function executeSyncDirectoryImport(input: SkillSyncDirectoryImportInput) {
+ const result = await bridge.invoke(skillsExecuteSyncDirectoryImportRoute.name, input)
+ return result.result
+ }
+
async function uninstallSkill(name: string) {
const result = await bridge.invoke(skillsUninstallRoute.name, { name })
return result.result
@@ -102,6 +163,10 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
await bridge.invoke(skillsSaveExtensionRoute.name, { name, config })
}
+ async function setSkillDisabled(name: string, disabled: boolean) {
+ await bridge.invoke(skillsSetDisabledRoute.name, { name, disabled })
+ }
+
async function listSkillScripts(name: string) {
const result = await bridge.invoke(skillsListScriptsRoute.name, { name })
return result.scripts
@@ -122,7 +187,15 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
function onCatalogChanged(
listener: (payload: {
- reason: 'discovered' | 'installed' | 'uninstalled' | 'metadata-updated'
+ reason:
+ | 'discovered'
+ | 'installed'
+ | 'uninstalled'
+ | 'metadata-updated'
+ | 'disabled-updated'
+ | 'management-state-updated'
+ | 'git-installed'
+ | 'sync-directory-updated'
name?: string
version: number
}) => void
@@ -143,10 +216,19 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
return {
getMetadataList,
+ getUnifiedSkillCatalog,
getSkillsDir,
installFromFolder,
installFromZip,
installFromUrl,
+ scanGitSkillRepo,
+ installFromGit,
+ getSkillsSyncConfig,
+ setSkillsSyncDirectory,
+ previewSyncDirectoryExport,
+ executeSyncDirectoryExport,
+ previewSyncDirectoryImport,
+ executeSyncDirectoryImport,
uninstallSkill,
readSkillFile,
updateSkillFile,
@@ -155,6 +237,7 @@ export function createSkillClient(bridge: DeepchatBridge = getDeepchatBridge())
openSkillsFolder,
getSkillExtension,
saveSkillExtension,
+ setSkillDisabled,
listSkillScripts,
getActiveSkills,
setActiveSkills,
diff --git a/src/renderer/api/SkillSyncClient.ts b/src/renderer/api/SkillSyncClient.ts
index 6e984fd588..86e452376b 100644
--- a/src/renderer/api/SkillSyncClient.ts
+++ b/src/renderer/api/SkillSyncClient.ts
@@ -13,21 +13,41 @@ import {
import {
type DeepchatRouteInput,
skillSyncAcknowledgeDiscoveriesRoute,
+ skillSyncExecuteAdoptAgentSkillRoute,
skillSyncExecuteExportRoute,
skillSyncExecuteImportRoute,
+ skillSyncExecuteLinkDeepChatSkillsRoute,
+ skillSyncGetAgentDetailRoute,
+ skillSyncGetAgentSkillDetailRoute,
skillSyncGetNewDiscoveriesRoute,
skillSyncGetRegisteredToolsRoute,
+ skillSyncPreviewAdoptAgentSkillRoute,
skillSyncPreviewExportRoute,
skillSyncPreviewImportRoute,
+ skillSyncPreviewLinkDeepChatSkillsRoute,
+ skillSyncRemoveAgentSkillLinkRoute,
+ skillSyncRepairAgentSkillLinkRoute,
+ skillSyncScanAgentsRoute,
skillSyncScanExternalToolsRoute
} from '@shared/contracts/routes'
import type {
+ AgentSkillLinkInput,
ConflictStrategy,
+ AdoptAgentSkillInput,
+ AdoptAgentSkillPreview,
+ AdoptAgentSkillResult,
ExportPreview,
ExternalToolConfig,
ImportPreview,
+ InstalledSkillAgent,
+ InstalledSkillAgentDetail,
+ LinkDeepChatSkillResult,
+ LinkDeepChatSkillsInput,
+ LinkDeepChatSkillsPreview,
+ LinkDeepChatSkillsResult,
NewDiscovery,
ScanResult,
+ SkillDetail,
SyncResult
} from '@shared/types/skillSync'
import { getDeepchatBridge } from './core'
@@ -53,6 +73,66 @@ export function createSkillSyncClient(bridge: DeepchatBridge = getDeepchatBridge
return result.tools as ExternalToolConfig[]
}
+ async function scanAgents(): Promise {
+ const result = await bridge.invoke(skillSyncScanAgentsRoute.name, {})
+ return result.agents as InstalledSkillAgent[]
+ }
+
+ async function getAgentDetail(agentId: string): Promise {
+ const result = await bridge.invoke(skillSyncGetAgentDetailRoute.name, { agentId })
+ return result.agent as InstalledSkillAgentDetail
+ }
+
+ async function getAgentSkillDetail(agentId: string, skillName: string): Promise {
+ const result = await bridge.invoke(skillSyncGetAgentSkillDetailRoute.name, {
+ agentId,
+ skillName
+ })
+ return result.detail as SkillDetail
+ }
+
+ async function previewAdoptAgentSkill(
+ input: AdoptAgentSkillInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncPreviewAdoptAgentSkillRoute.name, input)
+ return result.preview as AdoptAgentSkillPreview
+ }
+
+ async function executeAdoptAgentSkill(
+ input: AdoptAgentSkillInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncExecuteAdoptAgentSkillRoute.name, input)
+ return result.result as AdoptAgentSkillResult
+ }
+
+ async function previewLinkDeepChatSkills(
+ input: LinkDeepChatSkillsInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncPreviewLinkDeepChatSkillsRoute.name, input)
+ return result.preview as LinkDeepChatSkillsPreview
+ }
+
+ async function executeLinkDeepChatSkills(
+ input: LinkDeepChatSkillsInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncExecuteLinkDeepChatSkillsRoute.name, input)
+ return result.result as LinkDeepChatSkillsResult
+ }
+
+ async function repairAgentSkillLink(
+ input: AgentSkillLinkInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncRepairAgentSkillLinkRoute.name, input)
+ return result.result as LinkDeepChatSkillResult
+ }
+
+ async function removeAgentSkillLink(
+ input: AgentSkillLinkInput
+ ): Promise {
+ const result = await bridge.invoke(skillSyncRemoveAgentSkillLinkRoute.name, input)
+ return result.result as LinkDeepChatSkillResult
+ }
+
async function previewImport(toolId: string, skillNames: string[]): Promise {
const result = await bridge.invoke(skillSyncPreviewImportRoute.name, {
toolId,
@@ -163,6 +243,15 @@ export function createSkillSyncClient(bridge: DeepchatBridge = getDeepchatBridge
getNewDiscoveries,
acknowledgeDiscoveries,
getRegisteredTools,
+ scanAgents,
+ getAgentDetail,
+ getAgentSkillDetail,
+ previewAdoptAgentSkill,
+ executeAdoptAgentSkill,
+ previewLinkDeepChatSkills,
+ executeLinkDeepChatSkills,
+ repairAgentSkillLink,
+ removeAgentSkillLink,
previewImport,
executeImport,
previewExport,
diff --git a/src/renderer/settings/components/McpSettings.vue b/src/renderer/settings/components/McpSettings.vue
index b0f925ac66..39bad89f9f 100644
--- a/src/renderer/settings/components/McpSettings.vue
+++ b/src/renderer/settings/components/McpSettings.vue
@@ -497,15 +497,23 @@ const closeMarketView = async () => {
const nextQuery = { ...route.query }
delete nextQuery.view
+ const routeName =
+ typeof router.hasRoute === 'function' && router.hasRoute('plugins-mcp')
+ ? 'plugins-mcp'
+ : 'settings-mcp'
await router.replace({
- name: 'settings-mcp',
+ name: routeName,
query: nextQuery
})
}
const openMarketView = async () => {
+ const routeName =
+ typeof router.hasRoute === 'function' && router.hasRoute('plugins-mcp')
+ ? 'plugins-mcp'
+ : 'settings-mcp'
await router.push({
- name: 'settings-mcp',
+ name: routeName,
query: {
...route.query,
view: 'market'
diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue
index 883572b923..59f94dde9c 100644
--- a/src/renderer/settings/components/RemoteSettings.vue
+++ b/src/renderer/settings/components/RemoteSettings.vue
@@ -1,5 +1,5 @@
-
+
@@ -25,20 +25,27 @@
{{ t('common.error.requestFailed') }}
-
+
-
{{ t('settings.remote.title') }}
+
+ {{ singleChannelMode ? channelTitle(activeChannel) : t('settings.remote.title') }}
+
{{ t('common.saving') }}
- {{ t('settings.remote.description') }}
+ {{
+ singleChannelMode
+ ? channelDescription(activeChannel)
+ : t('settings.remote.description')
+ }}
@@ -84,7 +91,10 @@
{{ telegramStatus.lastError }}
-
-
+
{{
feishuSettings.remoteEnabled ? t('common.enabled') : t('common.disabled')
}}
@@ -751,7 +764,10 @@
{{ qqbotStatus.lastError }}
-
+
{{
qqbotSettings.remoteEnabled ? t('common.enabled') : t('common.disabled')
}}
@@ -961,7 +977,10 @@
{{ discordStatus.lastError }}
-
+
{{
discordSettings.remoteEnabled ? t('common.enabled') : t('common.disabled')
}}
@@ -1181,7 +1200,10 @@
{{ weixinIlinkStatus.lastError }}
-
+
{{
weixinIlinkSettings.remoteEnabled ? t('common.enabled') : t('common.disabled')
}}
@@ -1458,7 +1480,7 @@
-
+