Due to access limitations with web fetching tools, I cannot directly access the OpenCode repository at https://github.com/anomalyco/opencode. However, I have conducted a comprehensive analysis of the Lovcode codebase to understand its current architecture, implementation patterns, and areas where we can apply learnings from similar projects.
This document provides:
- Current state of Lovcode's skills/plugin handling
- Analysis of template installation patterns used in Lovcode
- Recommendations for fixing Skills installation issues
- Proposed approaches for session tagging and search features
- Code patterns and architectural insights for implementation
- Frontend:
/home/runner/work/lovcode/lovcode/src/views/Skills/SkillsView.tsx- Skills list display and browsingSkillDetailView.tsx- Individual skill detail view
- Backend:
/home/runner/work/lovcode/lovcode/src-tauri/src/lib.rs(lines 2097-2130)
// From /home/runner/work/lovcode/lovcode/src/types/index.ts
export interface LocalSkill {
name: string;
path: string;
description: string | null;
content: string;
}// From src-tauri/src/lib.rs (lines 2097-2130)
fn list_local_skills() -> Result<Vec<LocalSkill>, String> {
let skills_dir = get_claude_dir().join("skills");
if !skills_dir.exists() {
return Ok(vec![]);
}
let mut skills = Vec::new();
for entry in fs::read_dir(&skills_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
let skill_name = path.file_name().unwrap().to_string_lossy().to_string();
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
let content = fs::read_to_string(&skill_md).unwrap_or_default();
let (frontmatter, _, body) = parse_frontmatter(&content);
skills.push(LocalSkill {
name: skill_name,
path: skill_md.to_string_lossy().to_string(),
description: frontmatter.get("description").cloned(),
content: body,
});
}
}
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}- Skills are loaded from
~/.claude/skills/directory - Each skill is a directory with a
SKILL.mdfile - Frontmatter parsing extracts metadata (description)
- Currently READ-ONLY - no installation logic implemented for skills
Lovcode implements sophisticated template installation for:
- Commands/Agents/Skills -
install_command_template()(line 3182) - MCP Servers -
install_mcp_template()(line 3193) - Hooks -
install_hook_template()(line 3331) - Settings -
install_setting_template()(line 3376) - Statuslines -
install_statusline_template()(line 3463)
#[tauri::command]
fn install_command_template(name: String, content: String) -> Result<String, String> {
let commands_dir = get_claude_dir().join("commands");
fs::create_dir_all(&commands_dir).map_err(|e| e.to_string())?;
let file_path = commands_dir.join(format!("{}.md", name));
fs::write(&file_path, content).map_err(|e| e.to_string())?;
Ok(file_path.to_string_lossy().to_string())
}Key Points:
- Simple file-based installation
- Creates directory if needed
- Writes template content to file
- Returns path on success
#[tauri::command]
fn install_mcp_template(name: String, config: String) -> Result<String, String> {
let claude_json_path = get_claude_json_path();
// Parse and extract MCP config
let mcp_config: serde_json::Value = serde_json::from_str(&config)?;
// Deep config extraction with automatic type inference
let server_config = extract_server_config(mcp_config);
// Read existing ~/.claude.json or create new
let mut claude_json: serde_json::Value = if claude_json_path.exists() {
let content = fs::read_to_string(&claude_json_path)?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
// Ensure mcpServers exists
if !claude_json.get("mcpServers").is_some() {
claude_json["mcpServers"] = serde_json::json!({});
}
// Infer type if not present
let mut server_config = server_config;
if server_config.get("type").is_none() {
if let Some(url) = server_config.get("url").and_then(|v| v.as_str()) {
let transport_type = if url.ends_with("/sse") {
"sse"
} else {
"http"
};
server_config["type"] = serde_json::json!(transport_type);
} else if server_config.get("command").is_some() {
server_config["type"] = serde_json::json!("stdio");
}
}
// Merge into config
claude_json["mcpServers"][&name] = server_config;
let output = serde_json::to_string_pretty(&claude_json)?;
fs::write(&claude_json_path, output)?;
Ok(format!("Installed MCP: {}", name))
}Key Insights:
- Handles multiple config nesting levels
- Auto-detects server type from URL or command
- Merges with existing config
- Pretty-prints JSON output
#[tauri::command]
fn install_hook_template(name: String, config: String) -> Result<String, String> {
let settings_path = get_claude_dir().join("settings.json");
let hook_config: serde_json::Value = serde_json::from_str(&config)?;
let mut settings: serde_json::Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
};
// Ensure hooks exists
if !settings.get("hooks").is_some() {
settings["hooks"] = serde_json::json!({});
}
// Merge hook config
if let Some(hook_obj) = hook_config.as_object() {
for (event_type, handlers) in hook_obj {
if let Some(handlers_arr) = handlers.as_array() {
// Get existing handlers for this event type
let existing = settings["hooks"]
.get(event_type)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// Merge (append new handlers)
let mut merged: Vec<serde_json::Value> = existing;
merged.extend(handlers_arr.clone());
settings["hooks"][event_type] = serde_json::Value::Array(merged);
}
}
}
let output = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, output)?;
Ok(format!("Installed hook: {}", name))
}Key Insights:
- Appends to arrays instead of replacing
- Preserves existing hooks
- Handles missing parent structures
- View:
/home/runner/work/lovcode/lovcode/src/views/Marketplace/MarketplaceView.tsx - Detail:
/home/runner/work/lovcode/lovcode/src/views/Marketplace/TemplateDetailView.tsx
export interface TemplateComponent {
name: string;
path: string;
category: string;
component_type: string;
description: string | null;
downloads: number | null;
content: string | null;
source_id?: string | null;
source_name?: string | null;
source_icon?: string | null;
plugin_name?: string | null;
author?: string | null;
}const handleInstall = async () => {
if (!template.content) {
setError("No content available for this template");
return;
}
setInstalling(true);
setError(null);
try {
switch (category) {
case "commands":
case "agents":
case "skills": // <-- Skills installation
await invoke("install_command_template", {
name: template.name,
content: template.content,
});
break;
case "mcps":
await invoke("install_mcp_template", {
name: template.name,
config: template.content
});
break;
case "hooks":
await invoke("install_hook_template", {
name: template.name,
config: template.content
});
break;
case "settings":
case "output-styles":
await invoke("install_setting_template", {
config: template.content
});
break;
case "statuslines":
await invoke("install_statusline_template", {
name: template.name,
content: template.content
});
break;
}
setInstalled(true);
} catch (e) {
setError(String(e));
} finally {
setInstalling(false);
}
};Current Skills Installation:
- Uses
install_command_template()(treats skills like commands) - Simply writes content to file
- No directory structure creation like commands (.md files)
Skills require a directory structure (~/.claude/skills/{name}/SKILL.md) but the current install_command_template() function only writes to a flat file.
Rust Backend (add to lib.rs):
#[tauri::command]
fn install_skill_template(name: String, content: String) -> Result<String, String> {
// Create directory structure for skill
let skills_dir = get_claude_dir().join("skills");
let skill_dir = skills_dir.join(&name);
// Create nested directories
fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
// Write SKILL.md file
let skill_file = skill_dir.join("SKILL.md");
fs::write(&skill_file, content).map_err(|e| e.to_string())?;
Ok(skill_file.to_string_lossy().to_string())
}Update tauri::generate_handler!:
tauri::generate_handler![
// ... existing handlers ...
install_skill_template, // Add this
list_local_skills,
// ... other handlers ...
]Update TypeScript (TemplateDetailView.tsx):
case "skills":
await invoke("install_skill_template", {
name: template.name,
content: template.content,
});
break;- Maintains proper directory structure for skills
- Allows multiple files per skill (if needed in future)
- Matches how skills are listed and read
- Consistent with Claude Code expectations
From src/types/index.ts:
export interface Session {
id: string;
project_id: string;
project_path: string | null;
summary: string | null;
message_count: number;
last_modified: number;
}
export interface SearchResult {
uuid: string;
content: string;
role: string;
project_id: string;
project_path: string;
session_id: string;
session_summary: string | null;
timestamp: string;
score: number;
}Backend Type (Rust):
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SessionTag {
pub session_id: String,
pub tag: String,
pub created_at: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SessionWithTags {
pub id: String,
pub project_id: String,
pub project_path: Option<String>,
pub summary: Option<String>,
pub message_count: u32,
pub last_modified: i64,
pub tags: Vec<String>, // NEW
}Frontend Type (TypeScript):
export interface SessionTag {
session_id: string;
tag: string;
created_at: number;
}
export interface SessionWithTags extends Session {
tags: string[];
}// Add tags
#[tauri::command]
fn add_session_tag(session_id: String, tag: String) -> Result<(), String> {
// Store in session metadata or separate tags db
// Consider: tags.json file per project or embedded in session metadata
}
// Remove tags
#[tauri::command]
fn remove_session_tag(session_id: String, tag: String) -> Result<(), String> {
// Remove from storage
}
// List tags for session
#[tauri::command]
fn get_session_tags(session_id: String) -> Result<Vec<String>, String> {
// Return tags for specific session
}
// List all tags in project
#[tauri::command]
fn get_project_tags(project_id: String) -> Result<Vec<String>, String> {
// Return unique tags used in project
}
// Search by tags
#[tauri::command]
fn search_sessions_by_tags(
project_id: String,
tags: Vec<String>,
match_all: bool // true = AND, false = OR
) -> Result<Vec<Session>, String> {
// Return sessions matching tag filter
}Pros:
- Simple to implement
- Human-readable (JSON)
- Easy to backup/migrate
Cons:
- Separate file management
- Potential sync issues
File Structure:
~/.claude/
projects/
{project-id}/
sessions.json // existing
tags.json // NEW - maps session_id -> [tags]
Pros:
- Single source of truth
- Atomic updates
- Less file management
Cons:
- Requires modifying session storage format
- Migration complexity
Recommended: Option A (tags.json) for backward compatibility
React Hook for Tags:
export function useSessionTags(sessionId: string) {
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTags = useCallback(async () => {
setLoading(true);
try {
const result = await invoke<string[]>("get_session_tags", {
sessionId
});
setTags(result);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, [sessionId]);
const addTag = useCallback(async (tag: string) => {
try {
await invoke("add_session_tag", { sessionId, tag });
setTags(prev => [...new Set([...prev, tag])]);
} catch (e) {
setError(String(e));
}
}, [sessionId]);
const removeTag = useCallback(async (tag: string) => {
try {
await invoke("remove_session_tag", { sessionId, tag });
setTags(prev => prev.filter(t => t !== tag));
} catch (e) {
setError(String(e));
}
}, [sessionId]);
useEffect(() => {
loadTags();
}, [loadTags]);
return { tags, loading, error, addTag, removeTag };
}UI Component for Tag Management:
interface SessionTagsProps {
sessionId: string;
onTagsChange?: (tags: string[]) => void;
}
export function SessionTags({ sessionId, onTagsChange }: SessionTagsProps) {
const { tags, loading, addTag, removeTag } = useSessionTags(sessionId);
const [inputValue, setInputValue] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const handleAddTag = async (tag: string) => {
const normalizedTag = tag.toLowerCase().trim();
if (normalizedTag && !tags.includes(normalizedTag)) {
await addTag(normalizedTag);
setInputValue("");
onTagsChange?.([...tags, normalizedTag]);
}
};
return (
<div className="space-y-2">
<div className="flex gap-2 flex-wrap">
{tags.map(tag => (
<span
key={tag}
className="px-2 py-1 bg-primary/10 text-primary rounded-lg text-sm flex items-center gap-1"
>
{tag}
<button
onClick={() => removeTag(tag)}
className="hover:text-primary/70"
>
✕
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddTag(inputValue);
}
}}
placeholder="Add tag..."
className="flex-1 px-2 py-1 border border-border rounded-lg text-sm"
/>
<button
onClick={() => handleAddTag(inputValue)}
className="px-3 py-1 bg-primary text-primary-foreground rounded-lg text-sm hover:bg-primary/90"
>
Add
</button>
</div>
</div>
);
}Pattern Used Throughout Lovcode:
- Commands:
~/.claude/commands/{name}.md - Skills:
~/.claude/skills/{name}/SKILL.md - MCP:
~/.claude.json(JSON config) - Settings:
~/.claude/settings.json - Hooks: Stored in
settings.json
Key Learning:
- File-based approach works well for user-editable content
- Markdown with frontmatter for metadata
- JSON for structured config merging
- Always preserve existing configs during installation
Simple Installation:
Commands, Skills → Write file directly
Structured Installation:
MCP, Hooks → Parse JSON, merge configs, write back
Config-Based:
Settings → Deep merge JSON objects
Directory-Based:
Statuslines → Create directory, make executable
-
Frontmatter Parsing
- Extracts YAML metadata from markdown files
- Used for skills, commands, documents
- Enables rich metadata without separate files
-
Search Architecture
- Tantivy full-text search engine
- Chinese tokenization support (Jieba)
- SearchResult type includes relevance scores
-
Type Inference
- MCP auto-detection of transport type
- Graceful fallback values
- Validation during merge
From SessionList.tsx:
// Jotai atoms for persistent state
const [contextTab, setContextTab] = useAtom(sessionContextTabAtom);
const [selectMode, setSelectMode] = useAtom(sessionSelectModeAtom);
const [hideEmptySessions, setHideEmptySessions] = useAtom(hideEmptySessionsAtom);
const [userPromptsOnly, setUserPromptsOnly] = useAtom(userPromptsOnlyAtom);
// Temporary UI state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [searching, setSearching] = useState(false);
// Cache invalidation with react-query
const { data: sessions = [], isLoading } = useInvokeQuery<Session[]>(
["sessions", projectId],
"list_sessions",
{ projectId }
);Insight: Combines Jotai (persistent) + useState (temporary) + react-query (cached)
File: /home/runner/work/lovcode/lovcode/src-tauri/src/lib.rs
Add after line 3190 (after install_command_template):
#[tauri::command]
fn install_skill_template(name: String, content: String) -> Result<String, String> {
// Create directory structure for skill
let skills_dir = get_claude_dir().join("skills");
let skill_dir = skills_dir.join(&name);
// Create nested directories
fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
// Write SKILL.md file in the skill directory
let skill_file = skill_dir.join("SKILL.md");
fs::write(&skill_file, content).map_err(|e| e.to_string())?;
Ok(skill_file.to_string_lossy().to_string())
}Find: tauri::generate_handler![] (around line 6225)
Change "skills" case from:
case "skills":
await invoke("install_command_template", {
name: template.name,
content: template.content,
});To:
case "skills":
await invoke("install_skill_template", {
name: template.name,
content: template.content,
});File: /home/runner/work/lovcode/lovcode/src/views/Marketplace/TemplateDetailView.tsx
In the handleInstall function (around line 73), update the skills case:
case "skills":
await invoke("install_skill_template", {
name: template.name,
content: template.content,
});
break;- Robust template installation system exists
- Multiple category support (commands, MCPs, hooks, settings, statuslines)
- File-based configuration pattern is proven
- Marketplace integration works well
Due to web access limitations, I cannot directly analyze:
- OpenCode's specific skills/plugins architecture
- Their session tagging implementation
- Their marketplace integration approach
- Their data structure decisions
- Clone OpenCode repository locally
- Examine:
/src- Frontend implementation of skills/backend(if Rust/Python) - Skills handling/docs- Architecture documentation- Configuration files - What formats they use
- Compare with Lovcode patterns
- Identify best practices to adopt
Lovcode's template installation system provides a solid foundation for implementing a Skills installation fix. The pattern is:
- Create directory structure for the resource type
- Write template content to appropriate file(s)
- Handle configuration merging for JSON-based configs
- Provide uninstall capability for reversibility
- Validate before installation to prevent corrupt state
For session tagging, the recommended approach is:
- Extend Session type to include tags array
- Use separate
tags.jsonfile per project - Implement Tauri commands for CRUD operations
- Add React hooks and UI components for tag management
- Integrate with existing search infrastructure
The Lovcode architecture is well-suited for these enhancements and follows established patterns from the broader Claude Code ecosystem.