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 @@ diff --git a/src/renderer/settings/components/skills/AgentSkillTable.vue b/src/renderer/settings/components/skills/AgentSkillTable.vue new file mode 100644 index 0000000000..11f7ac2c3b --- /dev/null +++ b/src/renderer/settings/components/skills/AgentSkillTable.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/renderer/settings/components/skills/InstallFromGitDialog.vue b/src/renderer/settings/components/skills/InstallFromGitDialog.vue new file mode 100644 index 0000000000..3be781e13c --- /dev/null +++ b/src/renderer/settings/components/skills/InstallFromGitDialog.vue @@ -0,0 +1,251 @@ + + + diff --git a/src/renderer/settings/components/skills/InstallSkillToAgentDialog.vue b/src/renderer/settings/components/skills/InstallSkillToAgentDialog.vue new file mode 100644 index 0000000000..2c575d00cd --- /dev/null +++ b/src/renderer/settings/components/skills/InstallSkillToAgentDialog.vue @@ -0,0 +1,305 @@ + + + diff --git a/src/renderer/settings/components/skills/SkillAgentsTab.vue b/src/renderer/settings/components/skills/SkillAgentsTab.vue new file mode 100644 index 0000000000..5e89e3bdf1 --- /dev/null +++ b/src/renderer/settings/components/skills/SkillAgentsTab.vue @@ -0,0 +1,351 @@ + + + diff --git a/src/renderer/settings/components/skills/SkillCard.vue b/src/renderer/settings/components/skills/SkillCard.vue index 177d79b8c0..b9c5beb492 100644 --- a/src/renderer/settings/components/skills/SkillCard.vue +++ b/src/renderer/settings/components/skills/SkillCard.vue @@ -1,8 +1,11 @@ diff --git a/src/renderer/settings/components/skills/SkillEditorSheet.vue b/src/renderer/settings/components/skills/SkillEditorSheet.vue deleted file mode 100644 index ec2a37e941..0000000000 --- a/src/renderer/settings/components/skills/SkillEditorSheet.vue +++ /dev/null @@ -1,566 +0,0 @@ -