agencode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from agencli.skills.manager import (
6
+ SKILL_FILE_NAME,
7
+ installed_skills_root,
8
+ named_skill_source_root,
9
+ skills_dir_has_legacy_layout,
10
+ )
11
+
12
+ _INSTALLED_ALIASES = {"*", "all", "installed", "local"}
13
+
14
+
15
+ def resolve_skill_sources(skills: list[str], skills_dir: str | Path) -> list[str]:
16
+ base_dir = Path(skills_dir).expanduser().resolve()
17
+ resolved: list[str] = []
18
+ seen: set[str] = set()
19
+
20
+ def add(source: str) -> None:
21
+ if source not in seen:
22
+ seen.add(source)
23
+ resolved.append(source)
24
+
25
+ for raw in skills:
26
+ token = raw.strip()
27
+ if not token:
28
+ continue
29
+ lowered = token.lower()
30
+ if lowered in _INSTALLED_ALIASES:
31
+ installed_root = installed_skills_root(base_dir)
32
+ if installed_root.exists():
33
+ add(installed_root.as_posix())
34
+ if skills_dir_has_legacy_layout(base_dir):
35
+ add(base_dir.as_posix())
36
+ continue
37
+ if _is_backend_virtual_source(token):
38
+ add(_normalize_virtual_source(token))
39
+ continue
40
+ if not _looks_like_path(token):
41
+ named_root = named_skill_source_root(base_dir) / token
42
+ if named_root.exists():
43
+ add(named_root.resolve().as_posix())
44
+ continue
45
+ add(_resolve_filesystem_source(token))
46
+
47
+ return resolved
48
+
49
+
50
+ def _is_backend_virtual_source(value: str) -> bool:
51
+ return value.startswith("/") and ":" not in value and "\\" not in value
52
+
53
+
54
+ def _looks_like_path(value: str) -> bool:
55
+ return ":" in value or "\\" in value or "/" in value or value.startswith(".")
56
+
57
+
58
+ def _normalize_virtual_source(value: str) -> str:
59
+ normalized = value.rstrip("/")
60
+ return f"{normalized}/" if normalized else "/"
61
+
62
+
63
+ def _resolve_filesystem_source(value: str) -> str:
64
+ path = Path(value).expanduser()
65
+ if not path.exists():
66
+ raise ValueError(
67
+ f"Unknown skill source `{value}`. Use `installed`, an installed skill name, or a filesystem path."
68
+ )
69
+
70
+ resolved = path.resolve()
71
+ if resolved.is_file():
72
+ if resolved.name != SKILL_FILE_NAME:
73
+ raise ValueError(f"Skill path `{value}` must point to a directory or `{SKILL_FILE_NAME}` file.")
74
+ return resolved.parent.parent.as_posix()
75
+ if (resolved / SKILL_FILE_NAME).exists():
76
+ return resolved.parent.as_posix()
77
+ return resolved.as_posix()
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import re
6
+ import shutil
7
+
8
+ import yaml
9
+
10
+ SKILL_FILE_NAME = "SKILL.md"
11
+ _INSTALLED_DIR_NAME = "installed"
12
+ _BY_NAME_DIR_NAME = "by-name"
13
+ _FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.DOTALL)
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class InstalledSkill:
18
+ name: str
19
+ description: str
20
+ path: Path
21
+ allowed_tools: list[str] = field(default_factory=list)
22
+ license: str | None = None
23
+ compatibility: str | None = None
24
+
25
+
26
+ def install_skill(source: str | Path, skills_dir: str | Path, *, overwrite: bool = False) -> InstalledSkill:
27
+ source_dir = _coerce_skill_directory(source)
28
+ skill = read_skill(source_dir)
29
+ base_dir = Path(skills_dir).expanduser().resolve()
30
+ installed_dir = installed_skills_root(base_dir)
31
+ named_dir = named_skill_source_root(base_dir)
32
+ installed_dir.mkdir(parents=True, exist_ok=True)
33
+ named_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ canonical_target = installed_dir / skill.name
36
+ named_target = named_dir / skill.name / skill.name
37
+ _copy_skill_tree(source_dir, canonical_target, overwrite=overwrite)
38
+ _copy_skill_tree(source_dir, named_target, overwrite=overwrite)
39
+ return read_skill(canonical_target)
40
+
41
+
42
+ def list_installed_skills(skills_dir: str | Path) -> list[InstalledSkill]:
43
+ base_dir = Path(skills_dir).expanduser().resolve()
44
+ skills: dict[str, InstalledSkill] = {}
45
+ for skill_dir in _iter_installed_skill_dirs(base_dir):
46
+ skill = read_skill(skill_dir)
47
+ skills[skill.name] = skill
48
+ return [skills[name] for name in sorted(skills)]
49
+
50
+
51
+ def read_skill(path: str | Path) -> InstalledSkill:
52
+ skill_dir = _coerce_skill_directory(path)
53
+ skill_file = skill_dir / SKILL_FILE_NAME
54
+ raw = skill_file.read_text(encoding="utf-8")
55
+ metadata = _parse_frontmatter(raw, skill_file)
56
+
57
+ name = str(metadata.get("name", "")).strip() or skill_dir.name
58
+ description = str(metadata.get("description", "")).strip()
59
+ if not description:
60
+ raise ValueError(f"Skill `{skill_file}` is missing a description in YAML frontmatter.")
61
+
62
+ raw_tools = metadata.get("allowed-tools") or metadata.get("allowed_tools") or ""
63
+ if isinstance(raw_tools, str):
64
+ allowed_tools = [tool.strip(",") for tool in raw_tools.split() if tool.strip(",")]
65
+ elif isinstance(raw_tools, list):
66
+ allowed_tools = [str(tool).strip() for tool in raw_tools if str(tool).strip()]
67
+ else:
68
+ allowed_tools = []
69
+
70
+ return InstalledSkill(
71
+ name=name,
72
+ description=description,
73
+ path=skill_dir.resolve(),
74
+ allowed_tools=allowed_tools,
75
+ license=str(metadata.get("license", "")).strip() or None,
76
+ compatibility=str(metadata.get("compatibility", "")).strip() or None,
77
+ )
78
+
79
+
80
+ def installed_skills_root(skills_dir: str | Path) -> Path:
81
+ return Path(skills_dir).expanduser().resolve() / _INSTALLED_DIR_NAME
82
+
83
+
84
+ def named_skill_source_root(skills_dir: str | Path) -> Path:
85
+ return Path(skills_dir).expanduser().resolve() / _BY_NAME_DIR_NAME
86
+
87
+
88
+ def skills_dir_has_legacy_layout(skills_dir: str | Path) -> bool:
89
+ base_dir = Path(skills_dir).expanduser().resolve()
90
+ if not base_dir.exists():
91
+ return False
92
+ for child in base_dir.iterdir():
93
+ if not child.is_dir() or child.name in {_INSTALLED_DIR_NAME, _BY_NAME_DIR_NAME}:
94
+ continue
95
+ if (child / SKILL_FILE_NAME).exists():
96
+ return True
97
+ return False
98
+
99
+
100
+ def _iter_installed_skill_dirs(skills_dir: Path) -> list[Path]:
101
+ skill_dirs: dict[str, Path] = {}
102
+
103
+ installed_dir = installed_skills_root(skills_dir)
104
+ if installed_dir.exists():
105
+ for child in installed_dir.iterdir():
106
+ if child.is_dir() and (child / SKILL_FILE_NAME).exists():
107
+ skill_dirs[child.name] = child
108
+
109
+ if skills_dir.exists():
110
+ for child in skills_dir.iterdir():
111
+ if not child.is_dir() or child.name in {_INSTALLED_DIR_NAME, _BY_NAME_DIR_NAME}:
112
+ continue
113
+ if (child / SKILL_FILE_NAME).exists():
114
+ skill_dirs.setdefault(child.name, child)
115
+
116
+ return list(skill_dirs.values())
117
+
118
+
119
+ def _coerce_skill_directory(path: str | Path) -> Path:
120
+ candidate = Path(path).expanduser().resolve()
121
+ if candidate.is_file():
122
+ if candidate.name != SKILL_FILE_NAME:
123
+ raise ValueError(f"Expected `{SKILL_FILE_NAME}` or a skill directory, got `{candidate}`.")
124
+ candidate = candidate.parent
125
+ if not candidate.is_dir():
126
+ raise ValueError(f"Skill source `{candidate}` is not a directory.")
127
+ if not (candidate / SKILL_FILE_NAME).exists():
128
+ raise ValueError(f"Skill directory `{candidate}` does not contain `{SKILL_FILE_NAME}`.")
129
+ return candidate
130
+
131
+
132
+ def _parse_frontmatter(content: str, skill_file: Path) -> dict:
133
+ normalized = content.replace("\r\n", "\n")
134
+ match = _FRONTMATTER_PATTERN.match(normalized)
135
+ if not match:
136
+ raise ValueError(f"Skill `{skill_file}` is missing YAML frontmatter.")
137
+ loaded = yaml.safe_load(match.group(1))
138
+ if not isinstance(loaded, dict):
139
+ raise ValueError(f"Skill `{skill_file}` has invalid YAML frontmatter.")
140
+ return loaded
141
+
142
+
143
+ def _copy_skill_tree(source_dir: Path, target_dir: Path, *, overwrite: bool) -> None:
144
+ source_dir = source_dir.resolve()
145
+ target_dir = target_dir.resolve()
146
+ if source_dir == target_dir:
147
+ return
148
+ if target_dir.exists():
149
+ if not overwrite:
150
+ raise FileExistsError(f"Skill destination `{target_dir}` already exists.")
151
+ shutil.rmtree(target_dir)
152
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
153
+ shutil.copytree(source_dir, target_dir)
@@ -0,0 +1 @@
1
+ """Tool loaders and local tool definitions for AgenCLI."""
agencli/tools/mcp.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ import logging
6
+ import shutil
7
+ from typing import Any
8
+
9
+ from agencli.mcp.client import MCPToolClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def load_mcp_tools_async(
15
+ mcp_config_path: str,
16
+ server_names: list[str] | None = None,
17
+ *,
18
+ diagnostics: list[str] | None = None,
19
+ ) -> list[Any]:
20
+ client = MCPToolClient(mcp_config_path)
21
+ server_map = client.load_server_map()
22
+ requested_servers = server_names or list(server_map)
23
+ if not requested_servers:
24
+ return []
25
+
26
+ resolved_tools: list[Any] = []
27
+ for server_name in requested_servers:
28
+ if server_name not in server_map:
29
+ continue
30
+ try:
31
+ resolved_tools.extend(await client.get_tools(server_names=[server_name]))
32
+ except Exception as exc:
33
+ message = _format_mcp_error(server_name, server_map[server_name], exc)
34
+ if diagnostics is not None:
35
+ if message not in diagnostics:
36
+ diagnostics.append(message)
37
+ else:
38
+ logger.warning(message)
39
+ return resolved_tools
40
+
41
+
42
+ def _run_tool_request(client: MCPToolClient, server_names: list[str] | None = None) -> list[Any]:
43
+ try:
44
+ asyncio.get_running_loop()
45
+ except RuntimeError:
46
+ return asyncio.run(client.get_tools(server_names=server_names))
47
+
48
+ with ThreadPoolExecutor(max_workers=1) as executor:
49
+ future = executor.submit(asyncio.run, client.get_tools(server_names=server_names))
50
+ return future.result()
51
+
52
+
53
+ def _format_mcp_error(server_name: str, server_config: dict[str, Any], exc: Exception) -> str:
54
+ command = server_config.get("command") or ""
55
+ if command.lower().startswith("npx") and shutil.which(command) is None and shutil.which(f"{command}.cmd") is None:
56
+ return (
57
+ f"Skipping MCP server `{server_name}` because `{command}` is not available. "
58
+ "Install Node.js/npm or update the MCP config."
59
+ )
60
+ if command.lower().startswith("npx") and isinstance(exc, OSError) and getattr(exc, "errno", None) == 9:
61
+ return (
62
+ f"Skipping MCP server `{server_name}` because Windows could not open `{command}` cleanly. "
63
+ "Install or repair Node.js/npm, or update the MCP config."
64
+ )
65
+ if isinstance(exc, FileNotFoundError):
66
+ if command.lower().startswith("npx"):
67
+ return (
68
+ f"Skipping MCP server `{server_name}` because `{command}` is not available. "
69
+ "Install Node.js/npm or update the MCP config."
70
+ )
71
+ if command:
72
+ return f"Skipping MCP server `{server_name}` because command `{command}` was not found."
73
+ return f"Skipping MCP server `{server_name}` because it could not start: {exc}"
74
+
75
+
76
+ def load_mcp_tools(
77
+ mcp_config_path: str,
78
+ server_names: list[str] | None = None,
79
+ *,
80
+ diagnostics: list[str] | None = None,
81
+ ) -> list[Any]:
82
+ try:
83
+ asyncio.get_running_loop()
84
+ except RuntimeError:
85
+ return asyncio.run(load_mcp_tools_async(mcp_config_path, server_names=server_names, diagnostics=diagnostics))
86
+
87
+ client = MCPToolClient(mcp_config_path)
88
+ server_map = client.load_server_map()
89
+ requested_servers = server_names or list(server_map)
90
+ if not requested_servers:
91
+ return []
92
+
93
+ resolved_tools: list[Any] = []
94
+ for server_name in requested_servers:
95
+ if server_name not in server_map:
96
+ continue
97
+ try:
98
+ resolved_tools.extend(_run_tool_request(client, server_names=[server_name]))
99
+ except Exception as exc:
100
+ message = _format_mcp_error(server_name, server_map[server_name], exc)
101
+ if diagnostics is not None:
102
+ if message not in diagnostics:
103
+ diagnostics.append(message)
104
+ else:
105
+ logger.warning(message)
106
+ return resolved_tools
@@ -0,0 +1 @@
1
+ """Textual UI for AgenCLI."""