diff --git a/.opencode/skill/opencode-bridge/SKILL.md b/.opencode/skills/opencode-bridge/SKILL.md similarity index 100% rename from .opencode/skill/opencode-bridge/SKILL.md rename to .opencode/skills/opencode-bridge/SKILL.md diff --git a/.opencode/skill/opencode-mirror/SKILL.md b/.opencode/skills/opencode-mirror/SKILL.md similarity index 100% rename from .opencode/skill/opencode-mirror/SKILL.md rename to .opencode/skills/opencode-mirror/SKILL.md diff --git a/.opencode/skill/opencode-primitives/SKILL.md b/.opencode/skills/opencode-primitives/SKILL.md similarity index 100% rename from .opencode/skill/opencode-primitives/SKILL.md rename to .opencode/skills/opencode-primitives/SKILL.md diff --git a/.opencode/skill/openwork-core/SKILL.md b/.opencode/skills/openwork-core/SKILL.md similarity index 100% rename from .opencode/skill/openwork-core/SKILL.md rename to .opencode/skills/openwork-core/SKILL.md diff --git a/.opencode/skill/solidjs-patterns/SKILL.md b/.opencode/skills/solidjs-patterns/SKILL.md similarity index 100% rename from .opencode/skill/solidjs-patterns/SKILL.md rename to .opencode/skills/solidjs-patterns/SKILL.md diff --git a/.opencode/skill/tauri-solidjs/SKILL.md b/.opencode/skills/tauri-solidjs/SKILL.md similarity index 100% rename from .opencode/skill/tauri-solidjs/SKILL.md rename to .opencode/skills/tauri-solidjs/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index e6e1c750..ddc5cdcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ OpenWork is an open-source alternative to Claude Cowork. ## Repository Guidance -- Write new PRDs under `packages/app/pr/.md` (see `.opencode/skill/prd-conventions/SKILL.md`). +- Write new PRDs under `packages/app/pr/.md` (see `.opencode/skills/prd-conventions/SKILL.md`). - Use MOTIVATIONS-PHILOSOPHY.md to understand the "why" of OpenWork so you can guide your decisions. @@ -90,7 +90,7 @@ Key primitives to expose: When editing SolidJS UI (`packages/app/src/**/*.tsx`), consult: -- `.opencode/skill/solidjs-patterns/SKILL.md` +- `.opencode/skills/solidjs-patterns/SKILL.md` This captures OpenWork’s preferred reactivity + UI state patterns (avoid global `busy()` deadlocks; use scoped async state). diff --git a/MOTIVATIONS-PHILOSOPHY.md b/MOTIVATIONS-PHILOSOPHY.md index 0ea8e4f5..a16d5b09 100644 --- a/MOTIVATIONS-PHILOSOPHY.md +++ b/MOTIVATIONS-PHILOSOPHY.md @@ -228,7 +228,7 @@ OpenWork’s settings pages use: OpenWork exposes two extension surfaces: 1. **Skills (OpenPackage)** - - Installed into `.opencode/skill/*`. + - Installed into `.opencode/skills/*`. - OpenWork can run `opkg install` to pull packages from the registry or GitHub. 2. **Plugins (OpenCode)** diff --git a/README.md b/README.md index a3ba230d..b8b6cf8c 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ OpenWork is designed to be: - **Permissions**: surface permission requests and reply (allow once / always / deny). - **Templates**: save and re-run common workflows (stored locally). - **Skills manager**: - - list installed `.opencode/skill` folders + - list installed `.opencode/skills` folders - install from OpenPackage (`opkg install ...`) - - import a local skill folder into `.opencode/skill/` + - import a local skill folder into `.opencode/skills/` ## Skill Manager @@ -177,7 +177,7 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 openwork - Review `AGENTS.md` and `MOTIVATIONS-PHILOSOPHY.md` to understand the product goals before making changes. - Ensure Node.js, `pnpm`, the Rust toolchain, and `opencode` are installed before working inside the repo. - Run `pnpm install` once per checkout, then verify your change with `pnpm typecheck` plus `pnpm test:e2e` (or the targeted subset of scripts) before opening a PR. -- Add new PRDs to `packages/app/pr/.md` following the `.opencode/skill/prd-conventions/SKILL.md` conventions described in `AGENTS.md`. +- Add new PRDs to `packages/app/pr/.md` following the `.opencode/skills/prd-conventions/SKILL.md` conventions described in `AGENTS.md`. ## License diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 37b43511..42234bc2 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -153,6 +153,7 @@ export default function App() { const [busyLabel, setBusyLabel] = createSignal(null); const [busyStartedAt, setBusyStartedAt] = createSignal(null); const [error, setError] = createSignal(null); + const [booting, setBooting] = createSignal(true); const [developerMode, setDeveloperMode] = createSignal(false); let markReloadRequiredRef: (reason: ReloadReason) => void = () => {}; @@ -1632,7 +1633,7 @@ export default function App() { } } - void workspaceStore.bootstrapOnboarding(); + void workspaceStore.bootstrapOnboarding().finally(() => setBooting(false)); }); createEffect(() => { @@ -1936,6 +1937,7 @@ export default function App() { }); const workspaceSwitchOpen = createMemo(() => { + if (booting()) return true; if (workspaceStore.connectingWorkspaceId()) return true; if (!busy() || !busyLabel()) return false; const label = busyLabel(); @@ -1954,6 +1956,7 @@ export default function App() { } if (label === "status.loading_session") return "workspace.switching_status_loading"; if (workspaceStore.connectingWorkspaceId()) return "workspace.switching_status_loading"; + if (booting()) return "workspace.switching_status_preparing"; return "workspace.switching_status_preparing"; }); diff --git a/packages/app/src/app/components/session/context-panel.tsx b/packages/app/src/app/components/session/context-panel.tsx new file mode 100644 index 00000000..d1899189 --- /dev/null +++ b/packages/app/src/app/components/session/context-panel.tsx @@ -0,0 +1,115 @@ +import { For, Show } from "solid-js"; +import { ChevronDown, Circle, File, Folder } from "lucide-solid"; + +export type ContextPanelProps = { + activePlugins: string[]; + activePluginStatus: string | null; + authorizedDirs: string[]; + workingFiles: string[]; + expanded: boolean; + onToggle: () => void; +}; + +const humanizePlugin = (name: string) => { + const cleaned = name + .replace(/^@[^/]+\//, "") + .replace(/[-_]+/g, " ") + .replace(/\b(opencode|plugin)\b/gi, "") + .trim(); + return cleaned + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + .trim(); +}; + +export default function ContextPanel(props: ContextPanelProps) { + return ( +
+
+ +
+ +
+ + + ); +} diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 38d47692..fb05c639 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -1,21 +1,18 @@ import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"; import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; -import { Check, ChevronDown, Circle, Copy, File, FileText } from "lucide-solid"; +import { Check, ChevronDown, Circle, Copy, File } from "lucide-solid"; -import type { ArtifactItem, MessageGroup, MessageWithParts } from "../../types"; +import type { MessageGroup, MessageWithParts } from "../../types"; import { groupMessageParts, summarizeStep } from "../../utils"; -import Button from "../button"; import PartView from "../part-view"; export type MessageListProps = { messages: MessageWithParts[]; - artifacts: ArtifactItem[]; developerMode: boolean; showThinking: boolean; expandedStepIds: Set; setExpandedStepIds: (updater: (current: Set) => Set) => void; - onOpenArtifact: (artifact: ArtifactItem) => void; footer?: JSX.Element; }; @@ -26,7 +23,6 @@ type StepClusterBlock = { partsGroups: Part[][]; messageIds: string[]; isUser: boolean; - artifacts: ArtifactItem[]; }; type MessageBlock = { @@ -36,7 +32,6 @@ type MessageBlock = { groups: MessageGroup[]; isUser: boolean; messageId: string; - artifacts: ArtifactItem[]; }; type MessageBlockItem = MessageBlock | StepClusterBlock; @@ -103,24 +98,8 @@ export default function MessageList(props: MessageListProps) { return props.developerMode; }); - const artifactsByMessage = createMemo(() => { - const map = new Map(); - for (const artifact of props.artifacts) { - const key = artifact.messageId?.trim(); - if (!key) continue; - const current = map.get(key); - if (current) { - current.push(artifact); - } else { - map.set(key, [artifact]); - } - } - return map; - }); - const messageBlocks = createMemo(() => { const blocks: MessageBlockItem[] = []; - const artifactMap = artifactsByMessage(); for (const message of props.messages) { const renderableParts = renderablePartsForMessage(message); @@ -130,7 +109,6 @@ export default function MessageList(props: MessageListProps) { const groupId = String((message.info as any).id ?? "message"); const groups = groupMessageParts(renderableParts, groupId); const isUser = (message.info as any).role === "user"; - const messageArtifacts = artifactMap.get(messageId) ?? []; const isStepsOnly = groups.length === 1 && groups[0].kind === "steps"; if (isStepsOnly) { @@ -140,9 +118,6 @@ export default function MessageList(props: MessageListProps) { lastBlock.partsGroups.push(stepGroup.parts); lastBlock.stepIds.push(stepGroup.id); lastBlock.messageIds.push(messageId); - if (messageArtifacts.length) { - lastBlock.artifacts.push(...messageArtifacts); - } } else { blocks.push({ kind: "steps-cluster", @@ -151,7 +126,6 @@ export default function MessageList(props: MessageListProps) { partsGroups: [stepGroup.parts], messageIds: [messageId], isUser, - artifacts: [...messageArtifacts], }); } continue; @@ -164,7 +138,6 @@ export default function MessageList(props: MessageListProps) { groups, isUser, messageId, - artifacts: messageArtifacts, }); } @@ -261,36 +234,6 @@ export default function MessageList(props: MessageListProps) { - -
-
Artifacts
- - {(artifact) => ( -
-
-
- -
-
-
{artifact.name}
-
Document
-
-
- -
- )} -
-
-
); @@ -322,8 +265,8 @@ export default function MessageList(props: MessageListProps) { renderMarkdown={!block.isUser} /> - - {() => { + {group.kind === "steps" && + (() => { const stepGroup = group as { kind: "steps"; id: string; parts: Part[] }; const expanded = () => isStepsExpanded(stepGroup.id); return ( @@ -355,42 +298,10 @@ export default function MessageList(props: MessageListProps) { ); - }} - + })()} )} - -
-
Artifacts
- - {(artifact) => ( -
-
-
- -
-
-
{artifact.name}
-
Document
-
-
- -
- )} -
-
-
-
- -
- - -
- No artifacts yet.
} - > - - {(artifact) => ( - - )} - -
-
- - - -
- - -
- -
-
- Active plugins -
-
- - {props.activePluginStatus ?? "No plugins loaded."} -
- } - > - - {(plugin) => ( -
- - {humanizePlugin(plugin) || plugin} -
- )} -
- -
-
-
- -
-
- Authorized folders -
-
- - {(folder) => ( -
- - - {folder.split(/[/\\]/).pop()} - -
- )} -
-
-
- -
-
- Working files -
-
- None yet.
} - > - - {(file) => ( -
- - {file} -
- )} -
- -
-
- - - diff --git a/packages/app/src/app/components/workspace-switch-overlay.tsx b/packages/app/src/app/components/workspace-switch-overlay.tsx index 72f46119..deade90d 100644 --- a/packages/app/src/app/components/workspace-switch-overlay.tsx +++ b/packages/app/src/app/components/workspace-switch-overlay.tsx @@ -1,7 +1,7 @@ import { Show, createMemo } from "solid-js"; import { Dynamic } from "solid-js/web"; -import { Folder, Globe, Loader2, Zap } from "lucide-solid"; +import { Folder, Globe, Zap } from "lucide-solid"; import { t, currentLocale } from "../../i18n"; import type { WorkspaceInfo } from "../lib/tauri"; @@ -64,37 +64,83 @@ export default function WorkspaceSwitchOverlay(props: { return ( -
-
-
-
- +
+
+
+
+
+
+
+ +
+
+
+ + OpenWork +
-
-
-
-

{title()}

- - - {translate("dashboard.remote")} - - -
-

{subtitle()}

+ +
+
+
+
+
+
-
- +
+ +
+
+

{title()}

+ + + {translate("dashboard.remote")} + + +
+

{subtitle()}

+
+ +
+
+ + + + {statusLine()}
+
+
+
+
+ +
-
{metaPrimary()}
+
{metaPrimary()}
-
{metaSecondary()}
+
{metaSecondary()}
diff --git a/packages/app/src/app/context/extensions.ts b/packages/app/src/app/context/extensions.ts index e74dfe9a..9361583d 100644 --- a/packages/app/src/app/context/extensions.ts +++ b/packages/app/src/app/context/extensions.ts @@ -79,8 +79,8 @@ export function createExtensionsStore(options: { return; } - // Host/Tauri mode: read directly from `.opencode/skill(s)` so the UI still works - // even if the OpenCode engine is stopped or unreachable. + // Host/Tauri mode: read directly from `.opencode/skills` or `.claude/skills` + // so the UI still works even if the OpenCode engine is stopped or unreachable. if (options.mode() === "host" && isTauriRuntime()) { if (root !== skillsRoot) { skillsLoaded = false; @@ -432,8 +432,9 @@ export function createExtensionsStore(options: { try { const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); - const plural = await join(root, ".opencode", "skills"); - const singular = await join(root, ".opencode", "skill"); + const opencodeSkills = await join(root, ".opencode", "skills"); + const claudeSkills = await join(root, ".claude", "skills"); + const legacySkills = await join(root, ".opencode", "skill"); const tryOpen = async (target: string) => { try { @@ -445,9 +446,10 @@ export function createExtensionsStore(options: { }; // Prefer opening the folder. `revealItemInDir` expects a file path on macOS. - if (await tryOpen(plural)) return; - if (await tryOpen(singular)) return; - await revealItemInDir(singular); + if (await tryOpen(opencodeSkills)) return; + if (await tryOpen(claudeSkills)) return; + if (await tryOpen(legacySkills)) return; + await revealItemInDir(opencodeSkills); } catch (e) { setSkillsStatus(e instanceof Error ? e.message : translate("skills.reveal_failed")); } diff --git a/packages/app/src/app/data/skill-creator.md b/packages/app/src/app/data/skill-creator.md index d1f59689..745fe8e3 100644 --- a/packages/app/src/app/data/skill-creator.md +++ b/packages/app/src/app/data/skill-creator.md @@ -9,7 +9,7 @@ This skill is a template + checklist for creating skills in a workspace. ## What is a skill? -A skill is a folder under `.opencode/skill//` (or `.opencode/skills//`) anchored by `SKILL.md`. +A skill is a folder under `.opencode/skills//` or `.claude/skills//` anchored by `SKILL.md`. ## Design goals @@ -22,7 +22,7 @@ A skill is a folder under `.opencode/skill//` (or `.opencode/skills/ ``` .opencode/ - skill/ + skills/ my-skill/ SKILL.md README.md diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 048a5d00..24837385 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -24,12 +24,11 @@ import Button from "../components/button"; import RenameSessionModal from "../components/rename-session-modal"; import WorkspaceChip from "../components/workspace-chip"; import ProviderAuthModal from "../components/provider-auth-modal"; -import { isTauriRuntime, isWindowsPlatform } from "../utils"; import MessageList from "../components/session/message-list"; -import Composer, { type CommandItem } from "../components/session/composer"; +import Composer from "../components/session/composer"; import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar"; -import Minimap from "../components/session/minimap"; +import ContextPanel from "../components/session/context-panel"; import FlyoutItem from "../components/flyout-item"; export type SessionViewProps = { @@ -100,7 +99,6 @@ export default function SessionView(props: SessionViewProps) { let messagesEndEl: HTMLDivElement | undefined; let chatContainerEl: HTMLDivElement | undefined; - const [artifactToast, setArtifactToast] = createSignal(null); const [commandToast, setCommandToast] = createSignal(null); const [providerAuthActionBusy, setProviderAuthActionBusy] = createSignal(false); const [renameModalOpen, setRenameModalOpen] = createSignal(false); @@ -116,7 +114,6 @@ export default function SessionView(props: SessionViewProps) { }; const [flyouts, setFlyouts] = createSignal([]); const [prevTodoCount, setPrevTodoCount] = createSignal(0); - const [prevArtifactCount, setPrevArtifactCount] = createSignal(0); const [prevFileCount, setPrevFileCount] = createSignal(0); const [isInitialLoad, setIsInitialLoad] = createSignal(true); const [runStartedAt, setRunStartedAt] = createSignal(null); @@ -127,8 +124,6 @@ export default function SessionView(props: SessionViewProps) { partCount: 0, }); const [thinkingExpanded, setThinkingExpanded] = createSignal(false); - - const pendingArtifactRafIds = new Set(); const lastAssistantSnapshot = createMemo(() => { for (let i = props.messages.length - 1; i >= 0; i -= 1) { @@ -405,31 +400,6 @@ export default function SessionView(props: SessionViewProps) { setPrevTodoCount(count); }); - createEffect(() => { - const artifacts = props.artifacts; - const count = artifacts.length; - const prev = prevArtifactCount(); - if (count > prev && prev > 0) { - const last = artifacts[artifacts.length - 1]; - const scheduleAttempt = (attempts: number) => { - const rafId = requestAnimationFrame(() => { - pendingArtifactRafIds.delete(rafId); - const card = document.querySelector(`[data-artifact-id="${last.id}"]`); - if (card) { - triggerFlyout(card, "sidebar-artifacts", last.name, "file"); - return; - } - if (attempts > 0) { - scheduleAttempt(attempts - 1); - } - }); - pendingArtifactRafIds.add(rafId); - }; - scheduleAttempt(3); - } - setPrevArtifactCount(count); - }); - createEffect(() => { const files = props.workingFiles; const count = files.length; @@ -441,61 +411,12 @@ export default function SessionView(props: SessionViewProps) { setPrevFileCount(count); }); - createEffect(() => { - if (!artifactToast()) return; - const id = window.setTimeout(() => setArtifactToast(null), 3000); - return () => window.clearTimeout(id); - }); - createEffect(() => { if (!commandToast()) return; const id = window.setTimeout(() => setCommandToast(null), 2400); return () => window.clearTimeout(id); }); - const artifactActionToast = () => (isWindowsPlatform() ? "Opened in default app." : "Revealed in file manager."); - - const resolveArtifactPath = (artifact: ArtifactItem) => { - const rawPath = artifact.path?.trim(); - if (!rawPath) return null; - if (/^(?:[a-zA-Z]:[\\/]|~[\\/]|\/)/.test(rawPath)) { - return rawPath; - } - - const root = props.activeWorkspaceDisplay.path?.trim(); - if (!root) return rawPath; - - const separator = root.includes("\\") ? "\\" : "/"; - const trimmedRoot = root.replace(/[\\/]+$/, ""); - const trimmedPath = rawPath.replace(/^[\\/]+/, ""); - return `${trimmedRoot}${separator}${trimmedPath}`; - }; - - const handleOpenArtifact = async (artifact: ArtifactItem) => { - const resolvedPath = resolveArtifactPath(artifact); - if (!resolvedPath) { - setArtifactToast("Artifact path missing."); - return; - } - - if (!isTauriRuntime()) { - setArtifactToast("Open is only available in the desktop app."); - return; - } - - try { - const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener"); - if (isWindowsPlatform()) { - await openPath(resolvedPath); - } else { - await revealItemInDir(resolvedPath); - } - setArtifactToast(artifactActionToast()); - } catch (error) { - setArtifactToast(error instanceof Error ? error.message : "Could not open artifact."); - } - }; - const selectedSessionTitle = createMemo(() => { const id = props.selectedSessionId; if (!id) return ""; @@ -786,16 +707,10 @@ export default function SessionView(props: SessionViewProps) {
(chatContainerEl = el)} > - -
@@ -839,12 +742,10 @@ export default function SessionView(props: SessionViewProps) { @@ -904,14 +805,22 @@ export default function SessionView(props: SessionViewProps) {
(messagesEndEl = el)} />
- - chatContainerEl} messages={props.messages} /> - -
- {artifactToast()} -
-
+
let dest = std::path::PathBuf::from(&project_dir) .join(".opencode") - .join("skill") + .join("skills") .join(name); if dest.exists() { diff --git a/packages/desktop/src-tauri/src/commands/skills.rs b/packages/desktop/src-tauri/src/commands/skills.rs index 6d158f4e..41f540bf 100644 --- a/packages/desktop/src-tauri/src/commands/skills.rs +++ b/packages/desktop/src-tauri/src/commands/skills.rs @@ -3,16 +3,99 @@ use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use crate::paths::{candidate_xdg_config_dirs, home_dir}; use crate::types::ExecResult; -fn resolve_skill_root(project_dir: &str) -> Result { +fn ensure_project_skill_root(project_dir: &str) -> Result { + let project_dir = project_dir.trim(); + if project_dir.is_empty() { + return Err("projectDir is required".to_string()); + } + let base = PathBuf::from(project_dir).join(".opencode"); - let plural = base.join("skills"); - let singular = base.join("skill"); - let root = if plural.exists() { plural } else { singular }; - fs::create_dir_all(&root) - .map_err(|e| format!("Failed to create {}: {e}", root.display()))?; - Ok(root) + let legacy = base.join("skill"); + let modern = base.join("skills"); + + if legacy.is_dir() && !modern.exists() { + fs::rename(&legacy, &modern) + .map_err(|e| format!("Failed to move {} -> {}: {e}", legacy.display(), modern.display()))?; + } + + fs::create_dir_all(&modern) + .map_err(|e| format!("Failed to create {}: {e}", modern.display()))?; + Ok(modern) +} + +fn collect_project_skill_roots(project_dir: &Path) -> Vec { + let mut roots = Vec::new(); + let mut current = Some(project_dir); + + while let Some(dir) = current { + let opencode_root = dir.join(".opencode").join("skills"); + if opencode_root.is_dir() { + roots.push(opencode_root); + } else { + let legacy_root = dir.join(".opencode").join("skill"); + if legacy_root.is_dir() { + roots.push(legacy_root); + } + } + + let claude_root = dir.join(".claude").join("skills"); + if claude_root.is_dir() { + roots.push(claude_root); + } + + if dir.join(".git").exists() { + break; + } + + current = dir.parent(); + } + + roots +} + +fn collect_global_skill_roots() -> Vec { + let mut roots = Vec::new(); + for dir in candidate_xdg_config_dirs() { + let opencode_root = dir.join("opencode").join("skills"); + if opencode_root.is_dir() { + roots.push(opencode_root); + } + } + + if let Some(home) = home_dir() { + let claude_root = home.join(".claude").join("skills"); + if claude_root.is_dir() { + roots.push(claude_root); + } + } + + roots +} + +fn collect_skill_roots(project_dir: &str) -> Result, String> { + let project_dir = project_dir.trim(); + if project_dir.is_empty() { + return Err("projectDir is required".to_string()); + } + + let mut roots = Vec::new(); + let project_path = PathBuf::from(project_dir); + roots.extend(collect_project_skill_roots(&project_path)); + roots.extend(collect_global_skill_roots()); + + let mut seen = HashSet::new(); + let mut unique = Vec::new(); + for root in roots { + let key = root.to_string_lossy().to_string(); + if seen.insert(key) { + unique.push(root); + } + } + + Ok(unique) } fn validate_skill_name(name: &str) -> Result { @@ -114,10 +197,12 @@ pub fn list_local_skills(project_dir: String) -> Result, Str return Err("projectDir is required".to_string()); } - let skill_root = resolve_skill_root(project_dir)?; + let skill_roots = collect_skill_roots(project_dir)?; let mut found: Vec = Vec::new(); let mut seen = HashSet::new(); - gather_skills(&skill_root, &mut seen, &mut found)?; + for root in skill_roots { + gather_skills(&root, &mut seen, &mut found)?; + } let mut out = Vec::new(); for path in found { @@ -154,7 +239,7 @@ pub fn install_skill_template( } let name = validate_skill_name(&name)?; - let skill_root = resolve_skill_root(project_dir)?; + let skill_root = ensure_project_skill_root(project_dir)?; let dest = skill_root.join(&name); if dest.exists() { @@ -192,21 +277,29 @@ pub fn uninstall_skill(project_dir: String, name: String) -> Result Result<(), String> { pub fn ensure_workspace_files(workspace_path: &str, preset: &str) -> Result<(), String> { let root = PathBuf::from(workspace_path); - let skill_root = root.join(".opencode").join("skill"); + let skill_root = root.join(".opencode").join("skills"); fs::create_dir_all(&skill_root) - .map_err(|e| format!("Failed to create .opencode/skill: {e}"))?; + .map_err(|e| format!("Failed to create .opencode/skills: {e}"))?; seed_workspace_guide(&skill_root)?; let templates_dir = root.join(".openwork").join("templates");