Release/v0.0.7 (#151)
* chore(assets): update wechat_group image file * Refactor code structure for improved readability and maintainability * feat(backends): enhance MergedReadOnlyBackend with improved ls, grep, and glob methods * fix(docs): update WeChat QR code image link in README files * feat(skills): enhance skill management to support global and workspace tiers * style: apply ruff format to skills_cmd and commands/implementation/skills Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(skills): improve uninstall_skill to prevent removal of built-in skills * fix(docs): update skill installation documentation for clarity on global and user directories * fix(skills): enhance uninstall_skill to validate skill directory before removal * fix(skills): improve error handling in install_skill and uninstall_skill for directory creation and validation --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,5 +5,5 @@
|
||||
<rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/>
|
||||
<text x="85" y="22" text-anchor="middle"
|
||||
font-family="Inter, -apple-system, system-ui, sans-serif"
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.0.6</text>
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.0.7</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 555 B |
@@ -5,5 +5,5 @@
|
||||
<rect x="54" y="5" width="62" height="24" rx="6" fill="#2563eb"/>
|
||||
<text x="85" y="22" text-anchor="middle"
|
||||
font-family="Inter, -apple-system, system-ui, sans-serif"
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.0.6</text>
|
||||
font-size="13" font-weight="700" fill="#ffffff">v0.0.7</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 555 B |
Binary file not shown.
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 433 KiB |
@@ -259,6 +259,7 @@ def _get_default_backend():
|
||||
set_active_workspace(workspace_dir)
|
||||
memory_dir = str(_paths_mod.MEMORY_DIR)
|
||||
user_skills_dir = str(_paths_mod.USER_SKILLS_DIR)
|
||||
global_skills_dir = str(_paths_mod.GLOBAL_SKILLS_DIR)
|
||||
|
||||
ws_backend = CustomSandboxBackend(
|
||||
root_dir=workspace_dir,
|
||||
@@ -267,6 +268,7 @@ def _get_default_backend():
|
||||
)
|
||||
sk_backend = MergedReadOnlyBackend(
|
||||
primary_dir=user_skills_dir,
|
||||
global_dir=global_skills_dir,
|
||||
secondary_dir=SKILLS_DIR,
|
||||
)
|
||||
mem_backend = FilesystemBackend(
|
||||
@@ -395,6 +397,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
|
||||
# Read paths dynamically so runtime set_workspace_root() changes are picked up
|
||||
_mem_dir = str(_paths.MEMORY_DIR)
|
||||
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
|
||||
_global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR)
|
||||
|
||||
# Always construct fresh backends from current paths (avoids stale
|
||||
# module-level backend when workspace root changed at runtime).
|
||||
@@ -406,6 +409,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config
|
||||
)
|
||||
sk_backend = MergedReadOnlyBackend(
|
||||
primary_dir=_usr_skills_dir,
|
||||
global_dir=_global_skills_dir,
|
||||
secondary_dir=SKILLS_DIR,
|
||||
)
|
||||
# Memory always uses SHARED directory (not per-session) for cross-session persistence
|
||||
|
||||
+77
-46
@@ -13,6 +13,9 @@ from deepagents.backends.protocol import (
|
||||
ExecuteResponse,
|
||||
FileDownloadResponse,
|
||||
FileUploadResponse,
|
||||
GlobResult,
|
||||
GrepResult,
|
||||
LsResult,
|
||||
WriteResult,
|
||||
)
|
||||
|
||||
@@ -284,69 +287,93 @@ class ReadOnlyFilesystemBackend(FilesystemBackend):
|
||||
|
||||
|
||||
class MergedReadOnlyBackend(BackendProtocol):
|
||||
"""Read-only backend that merges two directories.
|
||||
"""Read-only backend that merges up to three skill directories.
|
||||
|
||||
Reads from *primary* first (user skills in workspace/skills/),
|
||||
falls back to *secondary* (system skills in ./skills/).
|
||||
User skills override system skills with the same name.
|
||||
Priority (high → low):
|
||||
1. primary — workspace/skills/ (project-local)
|
||||
2. global — ~/.config/evoscientist/skills/ (user global, optional)
|
||||
3. secondary — EvoScientist/skills/ (built-in, PyPI)
|
||||
|
||||
Both directories share the same virtual path namespace — the agent
|
||||
sees all skills under /skills/ regardless of which backend serves them.
|
||||
Higher-priority skills override lower-priority skills with the same name.
|
||||
All directories share the same virtual path namespace (/skills/).
|
||||
"""
|
||||
|
||||
def __init__(self, primary_dir: str, secondary_dir: str):
|
||||
def __init__(
|
||||
self,
|
||||
primary_dir: str,
|
||||
secondary_dir: str,
|
||||
global_dir: str | None = None,
|
||||
):
|
||||
self._primary = ReadOnlyFilesystemBackend(
|
||||
root_dir=primary_dir, virtual_mode=True
|
||||
)
|
||||
self._global = (
|
||||
ReadOnlyFilesystemBackend(root_dir=global_dir, virtual_mode=True)
|
||||
if global_dir
|
||||
else None
|
||||
)
|
||||
self._secondary = ReadOnlyFilesystemBackend(
|
||||
root_dir=secondary_dir, virtual_mode=True
|
||||
)
|
||||
|
||||
# -- read: try primary first, fall back to secondary --
|
||||
def _backends(self):
|
||||
"""Yield backends in priority order: primary → global → secondary."""
|
||||
yield self._primary
|
||||
if self._global:
|
||||
yield self._global
|
||||
yield self._secondary
|
||||
|
||||
# -- read: try each tier in priority order --
|
||||
|
||||
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
||||
try:
|
||||
result = self._primary.read(file_path, offset, limit)
|
||||
if not result.startswith("Error:"):
|
||||
return result
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
pass
|
||||
for backend in list(self._backends())[:-1]:
|
||||
try:
|
||||
result = backend.read(file_path, offset, limit)
|
||||
if hasattr(result, "error"):
|
||||
if result.error is None:
|
||||
return result
|
||||
elif not str(result).startswith("Error:"):
|
||||
return result
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
pass
|
||||
return self._secondary.read(file_path, offset, limit)
|
||||
|
||||
# -- ls_info: merge both, primary wins on name conflicts --
|
||||
# -- ls: merge all tiers, higher priority wins on name conflicts --
|
||||
|
||||
def ls_info(self, path: str = "/") -> list:
|
||||
secondary_items = {item["path"]: item for item in self._secondary.ls_info(path)}
|
||||
primary_items = {item["path"]: item for item in self._primary.ls_info(path)}
|
||||
secondary_items.update(primary_items) # primary overrides
|
||||
return sorted(secondary_items.values(), key=lambda x: x["path"])
|
||||
def ls(self, path: str = "/") -> LsResult:
|
||||
merged: dict = {}
|
||||
for backend in reversed(list(self._backends())):
|
||||
result = backend.ls(path)
|
||||
for item in result.entries or []:
|
||||
merged[item["path"]] = item
|
||||
return LsResult(entries=sorted(merged.values(), key=lambda x: x["path"]))
|
||||
|
||||
# -- grep_raw: search both, deduplicate --
|
||||
# -- grep: search all tiers --
|
||||
|
||||
def grep_raw(
|
||||
def grep(
|
||||
self, pattern: str, path: str | None = None, glob: str | None = None
|
||||
) -> list:
|
||||
results = self._secondary.grep_raw(pattern, path, glob)
|
||||
try:
|
||||
results += self._primary.grep_raw(pattern, path, glob)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
) -> GrepResult:
|
||||
matches = []
|
||||
for backend in self._backends():
|
||||
try:
|
||||
result = backend.grep(pattern, path, glob)
|
||||
matches.extend(result.matches or [])
|
||||
except Exception:
|
||||
pass
|
||||
return GrepResult(matches=matches)
|
||||
|
||||
# -- glob_info: merge both --
|
||||
# -- glob: merge all tiers, higher priority wins on name conflicts --
|
||||
|
||||
def glob_info(self, pattern: str, path: str = "/") -> list:
|
||||
secondary = {
|
||||
item["path"]: item for item in self._secondary.glob_info(pattern, path)
|
||||
}
|
||||
try:
|
||||
primary = {
|
||||
item["path"]: item for item in self._primary.glob_info(pattern, path)
|
||||
}
|
||||
secondary.update(primary)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(secondary.values(), key=lambda x: x["path"])
|
||||
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
||||
merged: dict = {}
|
||||
for backend in reversed(list(self._backends())):
|
||||
try:
|
||||
result = backend.glob(pattern, path)
|
||||
for item in result.matches or []:
|
||||
merged[item["path"]] = item
|
||||
except Exception:
|
||||
pass
|
||||
return GlobResult(matches=sorted(merged.values(), key=lambda x: x["path"]))
|
||||
|
||||
# -- write / edit: blocked --
|
||||
|
||||
@@ -369,12 +396,16 @@ class MergedReadOnlyBackend(BackendProtocol):
|
||||
# -- download / upload --
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
"""Download files, trying primary then secondary."""
|
||||
"""Download files, trying each tier in priority order."""
|
||||
backends = list(self._backends())
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for path in paths:
|
||||
resp = self._primary.download_files([path])[0]
|
||||
if resp.error is not None:
|
||||
resp = self._secondary.download_files([path])[0]
|
||||
resp = backends[-1].download_files([path])[0]
|
||||
for backend in backends[:-1]:
|
||||
candidate = backend.download_files([path])[0]
|
||||
if candidate.error is None:
|
||||
resp = candidate
|
||||
break
|
||||
responses.append(resp)
|
||||
return responses
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from .agent import _shorten_path
|
||||
|
||||
|
||||
def _cmd_list_skills() -> None:
|
||||
"""List all available skills (user and system)."""
|
||||
from ..paths import USER_SKILLS_DIR
|
||||
"""List all available skills (workspace, global, and built-in)."""
|
||||
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
from ..tools.skills_manager import list_skills
|
||||
|
||||
skills = list_skills(include_system=True)
|
||||
@@ -17,57 +17,82 @@ def _cmd_list_skills() -> None:
|
||||
console.print("[dim]No skills available.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] /install-skill <path-or-url>")
|
||||
console.print(
|
||||
f"[dim]Skills directory:[/dim] [cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan]"
|
||||
f"[dim]Global skills:[/dim] [cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan]"
|
||||
)
|
||||
console.print()
|
||||
return
|
||||
|
||||
user_skills = [s for s in skills if s.source == "user"]
|
||||
system_skills = [s for s in skills if s.source == "system"]
|
||||
workspace_skills = [s for s in skills if s.source == "workspace"]
|
||||
global_skills = [s for s in skills if s.source == "global"]
|
||||
builtin_skills = [s for s in skills if s.source == "builtin"]
|
||||
|
||||
if user_skills:
|
||||
console.print(f"[bold]User Skills[/bold] ({len(user_skills)}):")
|
||||
for skill in user_skills:
|
||||
sections = [
|
||||
("Workspace Skills", workspace_skills, "green"),
|
||||
("Global Skills", global_skills, "cyan"),
|
||||
("Built-in Skills", builtin_skills, "blue"),
|
||||
]
|
||||
|
||||
printed = False
|
||||
for title, group, color in sections:
|
||||
if not group:
|
||||
continue
|
||||
if printed:
|
||||
console.print()
|
||||
console.print(f"[bold]{title}[/bold] ({len(group)}):")
|
||||
for skill in group:
|
||||
tags_str = f" [dim]({', '.join(skill.tags)})[/dim]" if skill.tags else ""
|
||||
console.print(
|
||||
f" [green]{skill.name}[/green] - {skill.description}{tags_str}"
|
||||
)
|
||||
|
||||
if user_skills and system_skills:
|
||||
console.print()
|
||||
|
||||
if system_skills:
|
||||
console.print(f"[bold]Built-in Skills[/bold] ({len(system_skills)}):")
|
||||
for skill in system_skills:
|
||||
tags_str = f" [dim]({', '.join(skill.tags)})[/dim]" if skill.tags else ""
|
||||
console.print(
|
||||
f" [cyan]{skill.name}[/cyan] - {skill.description}{tags_str}"
|
||||
f" [{color}]{skill.name}[/{color}] - {skill.description}{tags_str}"
|
||||
)
|
||||
printed = True
|
||||
|
||||
console.print(
|
||||
f"\n[dim]User skills folder:[/dim] [green]{_shorten_path(str(USER_SKILLS_DIR))}[/green]"
|
||||
f"\n[dim]Global skills:[/dim] [cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan]"
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Workspace skills:[/dim] [green]{_shorten_path(str(USER_SKILLS_DIR))}[/green]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _cmd_install_skill(source: str) -> None:
|
||||
"""Install a skill from local path or GitHub URL."""
|
||||
def _cmd_install_skill(args: str) -> None:
|
||||
"""Install a skill from local path or GitHub URL.
|
||||
|
||||
By default, installs to the global skills directory (~/.config/evoscientist/skills/).
|
||||
Append --local to install to the current workspace instead.
|
||||
|
||||
Usage: /install-skill <path-or-url> [--local]
|
||||
"""
|
||||
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
from ..tools.skills_manager import install_skill
|
||||
|
||||
# Parse --local flag out of the args string
|
||||
local = "--local" in args.split()
|
||||
source = args.replace("--local", "").strip()
|
||||
|
||||
if not source:
|
||||
console.print("[red]Usage:[/red] /install-skill <path-or-url>")
|
||||
console.print("[red]Usage:[/red] /install-skill <path-or-url> [--local]")
|
||||
console.print("[dim]Examples:[/dim]")
|
||||
console.print(" /install-skill ./my-skill")
|
||||
console.print(
|
||||
" /install-skill https://github.com/user/repo/tree/main/skill-name"
|
||||
)
|
||||
console.print(" /install-skill user/repo@skill-name")
|
||||
console.print(
|
||||
" /install-skill ./my-skill --local [dim](workspace only)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
return
|
||||
|
||||
dest_label = (
|
||||
f"[cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan] [dim](workspace)[/dim]"
|
||||
if local
|
||||
else f"[cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan] [dim](global)[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Installing skill from:[/dim] {source}")
|
||||
console.print(f"[dim]Destination:[/dim] {dest_label}")
|
||||
|
||||
result = install_skill(source)
|
||||
result = install_skill(source, global_install=not local)
|
||||
|
||||
if result.get("batch"):
|
||||
# Batch install — multiple skills
|
||||
@@ -128,7 +153,7 @@ def _cmd_install_skills(args: str = "") -> None:
|
||||
from prompt_toolkit.styles import Style as PtStyle
|
||||
from questionary import Choice
|
||||
|
||||
from ..paths import USER_SKILLS_DIR
|
||||
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
from ..tools.skills_manager import fetch_remote_skill_index, install_skill
|
||||
|
||||
_PICKER_STYLE = PtStyle.from_dict(
|
||||
@@ -189,11 +214,11 @@ def _cmd_install_skills(args: str = "") -> None:
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Detect already-installed skills
|
||||
skills_dir = Path(USER_SKILLS_DIR)
|
||||
# Detect already-installed skills (both global and workspace tiers)
|
||||
installed_names: set[str] = set()
|
||||
if skills_dir.exists():
|
||||
installed_names = {e.name for e in skills_dir.iterdir() if e.is_dir()}
|
||||
for skills_dir in (Path(GLOBAL_SKILLS_DIR), Path(USER_SKILLS_DIR)):
|
||||
if skills_dir.exists():
|
||||
installed_names.update(e.name for e in skills_dir.iterdir() if e.is_dir())
|
||||
|
||||
pre_filter_tag = args.strip().lower() if args else ""
|
||||
|
||||
@@ -288,10 +313,10 @@ def _cmd_install_skills(args: str = "") -> None:
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Step 4: Install selected skills
|
||||
# Step 4: Install selected skills (default: global)
|
||||
installed_count = 0
|
||||
for source in selected:
|
||||
result = install_skill(source)
|
||||
result = install_skill(source, global_install=True)
|
||||
if result.get("batch"):
|
||||
for item in result.get("installed", []):
|
||||
console.print(f"[green]Installed:[/green] {item['name']}")
|
||||
|
||||
@@ -16,7 +16,7 @@ class SkillsCommand(Command):
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
from ...cli.agent import _shorten_path
|
||||
from ...paths import USER_SKILLS_DIR
|
||||
from ...paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
from ...tools.skills_manager import list_skills
|
||||
|
||||
skills = list_skills(include_system=True)
|
||||
@@ -26,38 +26,36 @@ class SkillsCommand(Command):
|
||||
"Install with: /install-skill <path-or-url>", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(
|
||||
f"Skills directory: {_shorten_path(str(USER_SKILLS_DIR))}",
|
||||
f"Global skills: {_shorten_path(str(GLOBAL_SKILLS_DIR))}",
|
||||
style="dim",
|
||||
)
|
||||
return
|
||||
|
||||
user_skills = [s for s in skills if s.source == "user"]
|
||||
system_skills = [s for s in skills if s.source == "system"]
|
||||
sections = [
|
||||
(
|
||||
"Workspace Skills",
|
||||
[s for s in skills if s.source == "workspace"],
|
||||
"green",
|
||||
),
|
||||
("Global Skills", [s for s in skills if s.source == "global"], "cyan"),
|
||||
("Built-in Skills", [s for s in skills if s.source == "builtin"], "blue"),
|
||||
]
|
||||
|
||||
if user_skills:
|
||||
table = Table(title=f"User Skills ({len(user_skills)})", show_header=True)
|
||||
table.add_column("Name", style="green")
|
||||
for title, group, color in sections:
|
||||
if not group:
|
||||
continue
|
||||
table = Table(title=f"{title} ({len(group)})", show_header=True)
|
||||
table.add_column("Name", style=color)
|
||||
table.add_column("Description", style="dim")
|
||||
table.add_column("Tags", style="dim")
|
||||
for s in user_skills:
|
||||
tags = "\n".join(f"· {t}" for t in s.tags[:4]) if s.tags else ""
|
||||
table.add_row(s.name, s.description, tags)
|
||||
ctx.ui.mount_renderable(table)
|
||||
|
||||
if system_skills:
|
||||
table = Table(
|
||||
title=f"Built-in Skills ({len(system_skills)})", show_header=True
|
||||
)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description", style="dim")
|
||||
table.add_column("Tags", style="dim")
|
||||
for s in system_skills:
|
||||
for s in group:
|
||||
tags = "\n".join(f"· {t}" for t in s.tags[:4]) if s.tags else ""
|
||||
table.add_row(s.name, s.description, tags)
|
||||
ctx.ui.mount_renderable(table)
|
||||
|
||||
ctx.ui.append_system(
|
||||
f"User skills folder: {_shorten_path(str(USER_SKILLS_DIR))}",
|
||||
f"Global: {_shorten_path(str(GLOBAL_SKILLS_DIR))} "
|
||||
f"Workspace: {_shorten_path(str(USER_SKILLS_DIR))}",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
@@ -80,9 +78,14 @@ class InstallSkill(Command):
|
||||
from ...cli.agent import _shorten_path
|
||||
from ...tools.skills_manager import install_skill
|
||||
|
||||
source = args[0] if args else ""
|
||||
raw = " ".join(args)
|
||||
local = "--local" in args
|
||||
source = raw.replace("--local", "").strip()
|
||||
|
||||
if not source:
|
||||
ctx.ui.append_system("Usage: /install-skill <path-or-url>", style="yellow")
|
||||
ctx.ui.append_system(
|
||||
"Usage: /install-skill <path-or-url> [--local]", style="yellow"
|
||||
)
|
||||
ctx.ui.append_system("Examples:", style="dim")
|
||||
ctx.ui.append_system(" /install-skill ./my-skill", style="dim")
|
||||
ctx.ui.append_system(
|
||||
@@ -90,17 +93,41 @@ class InstallSkill(Command):
|
||||
style="dim",
|
||||
)
|
||||
ctx.ui.append_system(" /install-skill user/repo@skill-name", style="dim")
|
||||
ctx.ui.append_system(
|
||||
" /install-skill ./my-skill --local (workspace only)", style="dim"
|
||||
)
|
||||
return
|
||||
|
||||
from ...paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
|
||||
|
||||
dest = USER_SKILLS_DIR if local else GLOBAL_SKILLS_DIR
|
||||
ctx.ui.append_system(f"Installing skill from: {source}", style="dim")
|
||||
# For simplicity, calling install_skill directly (might block loop if slow?
|
||||
# But install_skill doesn't seem to be async)
|
||||
result = install_skill(source)
|
||||
if result["success"]:
|
||||
ctx.ui.append_system(
|
||||
f"Destination: {_shorten_path(str(dest))} "
|
||||
f"({'workspace' if local else 'global'})",
|
||||
style="dim",
|
||||
)
|
||||
result = install_skill(source, global_install=not local)
|
||||
if result.get("batch"):
|
||||
for item in result.get("installed", []):
|
||||
ctx.ui.append_system(f"Installed: {item['name']}", style="green")
|
||||
ctx.ui.append_system(
|
||||
f" Description: {item.get('description', '(none)')}", style="dim"
|
||||
)
|
||||
for item in result.get("failed", []):
|
||||
ctx.ui.append_system(
|
||||
f"Failed: {item['name']} — {item['error']}", style="red"
|
||||
)
|
||||
installed_count = len(result.get("installed", []))
|
||||
if installed_count:
|
||||
ctx.ui.append_system(
|
||||
f"{installed_count} skill(s) installed. Reload with /new to apply.",
|
||||
style="dim",
|
||||
)
|
||||
elif result.get("success"):
|
||||
ctx.ui.append_system(f"Installed: {result['name']}", style="green")
|
||||
ctx.ui.append_system(
|
||||
f"Description: {result.get('description', '(none)')}",
|
||||
style="dim",
|
||||
f"Description: {result.get('description', '(none)')}", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(f"Path: {_shorten_path(result['path'])}", style="dim")
|
||||
ctx.ui.append_system("Reload with /new to apply.", style="dim")
|
||||
@@ -145,11 +172,15 @@ class InstallSkills(Command):
|
||||
ctx.ui.append_system("No skills found.", style="yellow")
|
||||
return
|
||||
|
||||
# Detect installed skills
|
||||
skills_dir = _Path(USER_SKILLS_DIR)
|
||||
# Detect installed skills (both global and workspace tiers)
|
||||
from ...paths import GLOBAL_SKILLS_DIR
|
||||
|
||||
installed_names: set[str] = set()
|
||||
if skills_dir.exists():
|
||||
installed_names = {e.name for e in skills_dir.iterdir() if e.is_dir()}
|
||||
for skills_dir in (_Path(GLOBAL_SKILLS_DIR), _Path(USER_SKILLS_DIR)):
|
||||
if skills_dir.exists():
|
||||
installed_names.update(
|
||||
e.name for e in skills_dir.iterdir() if e.is_dir()
|
||||
)
|
||||
|
||||
selected_sources: list[str] | None = None
|
||||
|
||||
@@ -190,7 +221,7 @@ class InstallSkills(Command):
|
||||
# Install selected skills
|
||||
installed_count = 0
|
||||
for source in selected_sources:
|
||||
result = install_skill(source)
|
||||
result = install_skill(source, global_install=True)
|
||||
if result.get("batch"):
|
||||
for item in result.get("installed", []):
|
||||
ctx.ui.append_system(f"Installed: {item['name']}", style="green")
|
||||
|
||||
@@ -27,6 +27,16 @@ USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "ski
|
||||
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
|
||||
|
||||
|
||||
def _global_skills_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||
return base / "evoscientist" / "skills"
|
||||
|
||||
|
||||
# Global skills: shared across all workspaces (~/.config/evoscientist/skills/)
|
||||
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
|
||||
|
||||
|
||||
def set_workspace_root(path: str | Path) -> None:
|
||||
"""Update workspace root and re-derive dependent directories.
|
||||
|
||||
@@ -59,6 +69,10 @@ def ensure_dirs() -> None:
|
||||
"""
|
||||
for path in (MEMORY_DIR, USER_SKILLS_DIR):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
GLOBAL_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
pass # read-only environments — skip silently
|
||||
|
||||
|
||||
def default_workspace_dir() -> Path:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Skill installation and management for EvoScientist.
|
||||
|
||||
This module provides functions for installing, listing, and uninstalling user skills.
|
||||
Skills are installed to USER_SKILLS_DIR (defaults to <workspace>/skills/).
|
||||
Skills are installed to GLOBAL_SKILLS_DIR by default (~/.config/evoscientist/skills/).
|
||||
Pass global_install=False to install to USER_SKILLS_DIR (<workspace>/skills/) instead.
|
||||
|
||||
Supported installation sources:
|
||||
- Local directory paths
|
||||
@@ -11,15 +12,18 @@ Supported installation sources:
|
||||
Usage:
|
||||
from EvoScientist.tools.skills_manager import install_skill, list_skills, uninstall_skill
|
||||
|
||||
# Install from local path
|
||||
# Install from local path (global by default)
|
||||
install_skill("./my-skill")
|
||||
|
||||
# Install to workspace only
|
||||
install_skill("./my-skill", global_install=False)
|
||||
|
||||
# Install from GitHub
|
||||
install_skill("https://github.com/user/repo/tree/main/my-skill")
|
||||
|
||||
# List installed skills
|
||||
# List installed skills (source: "workspace", "global", or "builtin")
|
||||
for skill in list_skills():
|
||||
print(skill["name"], skill["description"])
|
||||
print(skill.name, skill.source, skill.description)
|
||||
|
||||
# Uninstall a skill
|
||||
uninstall_skill("my-skill")
|
||||
@@ -51,7 +55,7 @@ class SkillInfo:
|
||||
name: str
|
||||
description: str
|
||||
path: Path
|
||||
source: str # "user" or "system"
|
||||
source: str # "workspace", "global", or "builtin"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -80,7 +84,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
|
||||
|
||||
Args:
|
||||
skill_md_path: Path to the SKILL.md file.
|
||||
source: Origin label (e.g. "user", "system").
|
||||
source: Origin label (e.g. "workspace", "global", "builtin").
|
||||
|
||||
Returns:
|
||||
SkillInfo with path set to the skill's parent directory.
|
||||
@@ -263,12 +267,19 @@ def _sanitize_name(name: str) -> str | None:
|
||||
return name
|
||||
|
||||
|
||||
def install_skill(source: str, dest_dir: str | None = None) -> dict:
|
||||
def install_skill(
|
||||
source: str,
|
||||
dest_dir: str | None = None,
|
||||
global_install: bool = True,
|
||||
) -> dict:
|
||||
"""Install a skill from a local path or GitHub URL.
|
||||
|
||||
Args:
|
||||
source: Local directory path or GitHub URL/shorthand.
|
||||
dest_dir: Destination directory (defaults to USER_SKILLS_DIR).
|
||||
dest_dir: Explicit destination directory (overrides global_install).
|
||||
global_install: If True (default), install to GLOBAL_SKILLS_DIR
|
||||
(~/.config/evoscientist/skills/). If False, install to the
|
||||
workspace-local USER_SKILLS_DIR.
|
||||
|
||||
Returns:
|
||||
Dictionary with installation result:
|
||||
@@ -277,8 +288,16 @@ def install_skill(source: str, dest_dir: str | None = None) -> dict:
|
||||
- path: installed path (if successful)
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
dest_dir = dest_dir or str(paths.USER_SKILLS_DIR)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dest_dir = dest_dir or (
|
||||
str(paths.GLOBAL_SKILLS_DIR) if global_install else str(paths.USER_SKILLS_DIR)
|
||||
)
|
||||
try:
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cannot create install directory '{dest_dir}': {e}",
|
||||
}
|
||||
|
||||
if _is_github_url(source):
|
||||
return _install_from_github(source, dest_dir)
|
||||
@@ -351,7 +370,7 @@ def _install_single_local(source_path: Path, dest_dir: str, *, ignore_fn=None) -
|
||||
}
|
||||
|
||||
target_path = (Path(dest_dir) / skill_name).resolve()
|
||||
if not str(target_path).startswith(str(Path(dest_dir).resolve())):
|
||||
if not target_path.is_relative_to(Path(dest_dir).resolve()):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Skill name escapes destination: {skill_info.name!r}",
|
||||
@@ -454,41 +473,50 @@ def _install_from_github(source: str, dest_dir: str) -> dict:
|
||||
|
||||
|
||||
def list_skills(include_system: bool = False) -> list[SkillInfo]:
|
||||
"""List all installed user skills.
|
||||
"""List all installed skills across all tiers.
|
||||
|
||||
Priority order: workspace > global > builtin.
|
||||
Higher-priority skills shadow lower-priority skills with the same name.
|
||||
|
||||
Args:
|
||||
include_system: If True, also include system (built-in) skills.
|
||||
include_system: If True, also include built-in (PyPI) skills.
|
||||
|
||||
Returns:
|
||||
List of SkillInfo objects for each installed skill.
|
||||
List of SkillInfo objects for each skill, deduplicated by name.
|
||||
"""
|
||||
skills: list[SkillInfo] = []
|
||||
seen: set[str] = set() # dedup by parsed skill name (not directory name)
|
||||
|
||||
# User skills
|
||||
user_dir = Path(paths.USER_SKILLS_DIR)
|
||||
if user_dir.exists():
|
||||
for entry in sorted(user_dir.iterdir()):
|
||||
def _add_tier(skill_dir: Path, source: str, check_seen: bool = True) -> None:
|
||||
if not skill_dir.exists():
|
||||
return
|
||||
for entry in sorted(skill_dir.iterdir()):
|
||||
if entry.is_dir() and _validate_skill_dir(entry):
|
||||
skills.append(_parse_skill_md(entry / "SKILL.md", source="user"))
|
||||
info = _parse_skill_md(entry / "SKILL.md", source=source)
|
||||
if check_seen and info.name in seen:
|
||||
continue
|
||||
skills.append(info)
|
||||
seen.add(info.name)
|
||||
|
||||
# System skills (optional)
|
||||
# Tier 1: workspace-local skills (always highest priority, no dedup needed)
|
||||
_add_tier(Path(paths.USER_SKILLS_DIR), source="workspace", check_seen=False)
|
||||
|
||||
# Tier 2: global skills (~/.config/evoscientist/skills/)
|
||||
_add_tier(Path(paths.GLOBAL_SKILLS_DIR), source="global")
|
||||
|
||||
# Tier 3: built-in skills (optional)
|
||||
if include_system:
|
||||
from ..EvoScientist import SKILLS_DIR
|
||||
|
||||
system_dir = Path(SKILLS_DIR)
|
||||
if system_dir.exists():
|
||||
for entry in sorted(system_dir.iterdir()):
|
||||
if entry.is_dir() and _validate_skill_dir(entry):
|
||||
# Skip if user has overridden this skill
|
||||
if any(s.name == entry.name for s in skills):
|
||||
continue
|
||||
skills.append(_parse_skill_md(entry / "SKILL.md", source="system"))
|
||||
_add_tier(Path(SKILLS_DIR), source="builtin")
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
def uninstall_skill(name: str) -> dict:
|
||||
"""Uninstall a user-installed skill.
|
||||
"""Uninstall a skill from workspace or global tier.
|
||||
|
||||
Searches workspace first, then global. Built-in skills cannot be uninstalled.
|
||||
|
||||
Args:
|
||||
name: Name of the skill to uninstall.
|
||||
@@ -498,38 +526,56 @@ def uninstall_skill(name: str) -> dict:
|
||||
- success: bool
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
user_dir = Path(paths.USER_SKILLS_DIR).resolve()
|
||||
|
||||
# Validate name to prevent path traversal
|
||||
clean_name = _sanitize_name(name)
|
||||
if not clean_name:
|
||||
return {"success": False, "error": f"Invalid skill name: {name!r}"}
|
||||
|
||||
target_path = (user_dir / clean_name).resolve()
|
||||
# Search workspace tier first, then global tier
|
||||
search_dirs = [
|
||||
Path(paths.USER_SKILLS_DIR).resolve(),
|
||||
Path(paths.GLOBAL_SKILLS_DIR).resolve(),
|
||||
]
|
||||
|
||||
if not target_path.exists():
|
||||
# Try to find by directory name (in case name differs from dir name)
|
||||
found = None
|
||||
if user_dir.exists():
|
||||
for entry in user_dir.iterdir():
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
|
||||
target_path = (search_dir / clean_name).resolve()
|
||||
|
||||
if not target_path.exists() or not _validate_skill_dir(target_path):
|
||||
# Try to find by skill name in SKILL.md (dir name may differ)
|
||||
for entry in search_dir.iterdir():
|
||||
if entry.is_dir() and _validate_skill_dir(entry):
|
||||
info = _parse_skill_md(entry / "SKILL.md")
|
||||
if info.name == clean_name:
|
||||
found = entry.resolve()
|
||||
target_path = entry.resolve()
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
if not found:
|
||||
return {"success": False, "error": f"Skill not found: {name}"}
|
||||
target_path = found
|
||||
# Safety: resolved path must still be inside the search dir
|
||||
if not target_path.is_relative_to(search_dir):
|
||||
return {"success": False, "error": f"Invalid skill path: {name}"}
|
||||
|
||||
# Check resolved path is still inside user_dir
|
||||
if not str(target_path).startswith(str(user_dir)):
|
||||
return {"success": False, "error": f"Cannot uninstall system skill: {name}"}
|
||||
shutil.rmtree(target_path)
|
||||
return {"success": True, "name": name}
|
||||
|
||||
# Remove the skill directory
|
||||
shutil.rmtree(target_path)
|
||||
# Check if it's a built-in skill (read-only, cannot be uninstalled)
|
||||
from ..EvoScientist import SKILLS_DIR
|
||||
|
||||
return {"success": True, "name": name}
|
||||
builtin_dir = Path(SKILLS_DIR)
|
||||
if builtin_dir.exists():
|
||||
for entry in builtin_dir.iterdir():
|
||||
if entry.is_dir() and _validate_skill_dir(entry):
|
||||
info = _parse_skill_md(entry / "SKILL.md")
|
||||
if info.name == clean_name or entry.name == clean_name:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"'{name}' is a built-in skill and cannot be uninstalled.",
|
||||
}
|
||||
|
||||
return {"success": False, "error": f"Skill not found: {name}"}
|
||||
|
||||
|
||||
def get_skill_info(name: str) -> SkillInfo | None:
|
||||
|
||||
@@ -566,7 +566,7 @@ We welcome contributions from developers, researchers, and AI coding agents at a
|
||||
- [Discord](https://discord.gg/AZ9ZMXkunY) — Ask questions, share findings, and collaborate with researchers and developers in real-time.
|
||||
- [WeChat](https://github.com/EvoScientist/EvoScientist/blob/main/.github/assets/cn_info.md) — Connect with our Chinese-speaking research community.
|
||||
|
||||
<img src=".github/assets/wechat_group.jpeg" alt="WeChat QR Code" width="200"/>
|
||||
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/wechat_group.jpeg" alt="WeChat QR Code" width="200"/>
|
||||
|
||||
Every contribution brings us one step closer to a future where AI accelerates scientific breakthroughs for all of humanity.
|
||||
|
||||
|
||||
+1
-1
@@ -575,7 +575,7 @@ Jan Piotrowski, Wiktor Cupiał, Jakub Kaliski, Jakub Filipiuk, Xinhao Yi, Shuyu
|
||||
- [Discord](https://discord.gg/AZ9ZMXkunY) — 实时提问、分享发现,与研究者和开发者协作交流。
|
||||
- [微信](https://github.com/EvoScientist/EvoScientist/blob/main/.github/assets/cn_info.md) — 加入中文社区,与国内研究者和开发者交流。
|
||||
|
||||
<img src=".github/assets/wechat_group.jpeg" alt="微信群二维码" width="200"/>
|
||||
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/wechat_group.jpeg" alt="微信群二维码" width="200"/>
|
||||
|
||||
每一份贡献,都让我们离 AI 驱动科学突破、造福全人类的未来更近一步。
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "EvoScientist"
|
||||
version = "0.0.6"
|
||||
version = "0.0.7"
|
||||
description = "EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -16,7 +16,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
dependencies = [
|
||||
"deepagents>=0.4.11",
|
||||
"deepagents>=0.5.1",
|
||||
"langchain>=1.2.12",
|
||||
"langchain-anthropic>=1.4.0",
|
||||
"langchain-openai>=1.1",
|
||||
|
||||
@@ -25,10 +25,13 @@ from EvoScientist.tools.skills_manager import (
|
||||
|
||||
@pytest.fixture
|
||||
def temp_skills_dir(tmp_path):
|
||||
"""Create a temporary skills directory."""
|
||||
"""Create a temporary skills directory, isolated from the real global tier."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
return skills_dir
|
||||
empty_global = tmp_path / "global_skills"
|
||||
empty_global.mkdir()
|
||||
with patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_global):
|
||||
yield skills_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -288,7 +291,7 @@ class TestListSkills:
|
||||
assert len(skills) == 1
|
||||
assert skills[0].name == "sample-skill"
|
||||
assert skills[0].description == "A sample skill for testing"
|
||||
assert skills[0].source == "user"
|
||||
assert skills[0].source == "workspace"
|
||||
|
||||
def test_list_multiple_skills(self, tmp_path, temp_skills_dir):
|
||||
# Create and install multiple skills
|
||||
|
||||
Reference in New Issue
Block a user