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.
- agencli/__init__.py +5 -0
- agencli/__main__.py +9 -0
- agencli/agents/__init__.py +1 -0
- agencli/agents/editor.py +110 -0
- agencli/agents/factory.py +335 -0
- agencli/agents/management_tools.py +277 -0
- agencli/agents/prebuilt/__init__.py +1 -0
- agencli/agents/prebuilt/catalog.py +66 -0
- agencli/agents/registry.py +50 -0
- agencli/agents/runtime.py +266 -0
- agencli/agents/supervisor.py +67 -0
- agencli/cli.py +561 -0
- agencli/core/__init__.py +1 -0
- agencli/core/config.py +179 -0
- agencli/core/keystore.py +14 -0
- agencli/core/logger.py +17 -0
- agencli/core/paths.py +37 -0
- agencli/core/session.py +513 -0
- agencli/mcp/__init__.py +1 -0
- agencli/mcp/client.py +33 -0
- agencli/mcp/config.py +99 -0
- agencli/providers/__init__.py +1 -0
- agencli/providers/model.py +180 -0
- agencli/skills/__init__.py +37 -0
- agencli/skills/cli_backend.py +446 -0
- agencli/skills/loader.py +77 -0
- agencli/skills/manager.py +153 -0
- agencli/tools/__init__.py +1 -0
- agencli/tools/mcp.py +106 -0
- agencli/tui/__init__.py +1 -0
- agencli/tui/app.py +4274 -0
- agencli/tui/commands.py +86 -0
- agencli/tui/screens.py +939 -0
- agencli/tui/trace.py +334 -0
- agencli/tui/voice.py +77 -0
- agencode-0.1.0.dist-info/METADATA +44 -0
- agencode-0.1.0.dist-info/RECORD +39 -0
- agencode-0.1.0.dist-info/WHEEL +4 -0
- agencode-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agencli.core.config import AgenCLIConfig, get_openai_compatible_api_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ModelProfile:
|
|
14
|
+
id: str
|
|
15
|
+
label: str
|
|
16
|
+
provider: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODELS = [
|
|
20
|
+
ModelProfile(id="openai:gpt-4.1", label="GPT-4.1", provider="openai"),
|
|
21
|
+
ModelProfile(id="anthropic:claude-sonnet-4-5", label="Claude Sonnet 4.5", provider="anthropic"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_models() -> list[ModelProfile]:
|
|
26
|
+
return DEFAULT_MODELS.copy()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _candidate_api_key_envs(config: AgenCLIConfig) -> list[str]:
|
|
30
|
+
provider = config.openai_compatible
|
|
31
|
+
candidates = [provider.api_key_env]
|
|
32
|
+
provider_name = provider.provider_name.lower()
|
|
33
|
+
base_url = provider.base_url.lower()
|
|
34
|
+
model_name = provider.model.lower()
|
|
35
|
+
|
|
36
|
+
if any("deepseek" in value for value in (provider_name, base_url, model_name)):
|
|
37
|
+
candidates.append("DEEPSEEK_API_KEY")
|
|
38
|
+
if "openai" not in candidates:
|
|
39
|
+
candidates.append("OPENAI_API_KEY")
|
|
40
|
+
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
ordered: list[str] = []
|
|
43
|
+
for item in candidates:
|
|
44
|
+
if item and item not in seen:
|
|
45
|
+
seen.add(item)
|
|
46
|
+
ordered.append(item)
|
|
47
|
+
return ordered
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def describe_api_key_source(config: AgenCLIConfig) -> str:
|
|
51
|
+
env_names = _candidate_api_key_envs(config)
|
|
52
|
+
for name in env_names:
|
|
53
|
+
if os.getenv(name):
|
|
54
|
+
return f"environment:{name}"
|
|
55
|
+
if get_openai_compatible_api_key(config):
|
|
56
|
+
return "keyring"
|
|
57
|
+
return f"missing ({', '.join(env_names)})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_model_input(value: Any) -> Any:
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
normalized = {key: normalize_model_input(item) for key, item in value.items()}
|
|
63
|
+
if "content" in normalized:
|
|
64
|
+
normalized["content"] = _normalize_message_content(normalized["content"])
|
|
65
|
+
return normalized
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [normalize_model_input(item) for item in value]
|
|
68
|
+
if isinstance(value, tuple):
|
|
69
|
+
if len(value) == 2 and isinstance(value[0], str):
|
|
70
|
+
return (value[0], _normalize_message_content(value[1]))
|
|
71
|
+
return tuple(normalize_model_input(item) for item in value)
|
|
72
|
+
|
|
73
|
+
content = getattr(value, "content", None)
|
|
74
|
+
if content is None:
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
normalized_content = _normalize_message_content(content)
|
|
78
|
+
if normalized_content == content:
|
|
79
|
+
return value
|
|
80
|
+
if hasattr(value, "model_copy"):
|
|
81
|
+
return value.model_copy(update={"content": normalized_content})
|
|
82
|
+
if hasattr(value, "copy"):
|
|
83
|
+
try:
|
|
84
|
+
return value.copy(update={"content": normalized_content})
|
|
85
|
+
except TypeError:
|
|
86
|
+
pass
|
|
87
|
+
cloned = copy.copy(value)
|
|
88
|
+
setattr(cloned, "content", normalized_content)
|
|
89
|
+
return cloned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _normalize_message_content(content: Any) -> str:
|
|
93
|
+
if isinstance(content, str):
|
|
94
|
+
return content.strip()
|
|
95
|
+
if isinstance(content, list):
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
for item in content:
|
|
98
|
+
text = _extract_content_text(item)
|
|
99
|
+
if text:
|
|
100
|
+
parts.append(text)
|
|
101
|
+
return "\n".join(parts).strip()
|
|
102
|
+
if isinstance(content, dict):
|
|
103
|
+
text = _extract_content_text(content)
|
|
104
|
+
if text:
|
|
105
|
+
return text
|
|
106
|
+
try:
|
|
107
|
+
return json.dumps(content, ensure_ascii=True, sort_keys=True)
|
|
108
|
+
except TypeError:
|
|
109
|
+
return str(content).strip()
|
|
110
|
+
return str(content).strip()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_content_text(value: Any) -> str:
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
return value.strip()
|
|
116
|
+
if isinstance(value, list):
|
|
117
|
+
parts = [_extract_content_text(item) for item in value]
|
|
118
|
+
return "\n".join(part for part in parts if part).strip()
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
if isinstance(value.get("text"), str):
|
|
121
|
+
return value["text"].strip()
|
|
122
|
+
if "content" in value:
|
|
123
|
+
return _extract_content_text(value["content"])
|
|
124
|
+
if isinstance(value.get("input"), str):
|
|
125
|
+
return value["input"].strip()
|
|
126
|
+
if isinstance(value.get("output"), str):
|
|
127
|
+
return value["output"].strip()
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def init_model(config: AgenCLIConfig, model_name: str | None = None):
|
|
132
|
+
provider = config.openai_compatible
|
|
133
|
+
tried_envs = _candidate_api_key_envs(config)
|
|
134
|
+
api_key = next((os.getenv(name) for name in tried_envs if os.getenv(name)), None) or get_openai_compatible_api_key(config)
|
|
135
|
+
resolved_model = model_name or provider.model or config.default_model
|
|
136
|
+
if provider.base_url and provider.model:
|
|
137
|
+
try:
|
|
138
|
+
from langchain_openai import ChatOpenAI
|
|
139
|
+
except ImportError as exc:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
"langchain-openai is not installed yet. Run `uv sync` after dependencies are added."
|
|
142
|
+
) from exc
|
|
143
|
+
|
|
144
|
+
class AgenChatOpenAI(ChatOpenAI):
|
|
145
|
+
def invoke(self, input: Any, config: Any | None = None, **kwargs: Any) -> Any:
|
|
146
|
+
return super().invoke(normalize_model_input(input), config=config, **kwargs)
|
|
147
|
+
|
|
148
|
+
async def ainvoke(self, input: Any, config: Any | None = None, **kwargs: Any) -> Any:
|
|
149
|
+
return await super().ainvoke(normalize_model_input(input), config=config, **kwargs)
|
|
150
|
+
|
|
151
|
+
def stream(self, input: Any, config: Any | None = None, **kwargs: Any):
|
|
152
|
+
return super().stream(normalize_model_input(input), config=config, **kwargs)
|
|
153
|
+
|
|
154
|
+
async def astream(self, input: Any, config: Any | None = None, **kwargs: Any):
|
|
155
|
+
async for chunk in super().astream(normalize_model_input(input), config=config, **kwargs):
|
|
156
|
+
yield chunk
|
|
157
|
+
|
|
158
|
+
if not api_key:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"Missing API key for provider `{provider.provider_name}`. "
|
|
161
|
+
f"Set one of {', '.join(tried_envs)} or store it with `agencode config-openai --api-key ...`."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if ":" in resolved_model:
|
|
165
|
+
resolved_model = resolved_model.split(":", 1)[1]
|
|
166
|
+
|
|
167
|
+
return AgenChatOpenAI(
|
|
168
|
+
model=resolved_model,
|
|
169
|
+
base_url=provider.base_url,
|
|
170
|
+
api_key=api_key,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
from langchain.chat_models import init_chat_model
|
|
175
|
+
except ImportError as exc:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
"langchain is not installed yet. Run `uv sync` after dependencies are added."
|
|
178
|
+
) from exc
|
|
179
|
+
|
|
180
|
+
return init_chat_model(resolved_model)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from agencli.skills.loader import resolve_skill_sources
|
|
2
|
+
from agencli.skills.cli_backend import (
|
|
3
|
+
CliCommandResult,
|
|
4
|
+
InstalledSkillRecord,
|
|
5
|
+
SKILLS_BROWSE_CATEGORIES,
|
|
6
|
+
SkillResultQuality,
|
|
7
|
+
SkillSearchResult,
|
|
8
|
+
SkillsStatusResult,
|
|
9
|
+
check_skills,
|
|
10
|
+
find_skills,
|
|
11
|
+
install_skill_cli,
|
|
12
|
+
list_installed_skills_cli,
|
|
13
|
+
normalize_repo_skill_target,
|
|
14
|
+
render_command,
|
|
15
|
+
update_skills,
|
|
16
|
+
)
|
|
17
|
+
from agencli.skills.manager import InstalledSkill, install_skill, list_installed_skills
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CliCommandResult",
|
|
21
|
+
"InstalledSkill",
|
|
22
|
+
"InstalledSkillRecord",
|
|
23
|
+
"SKILLS_BROWSE_CATEGORIES",
|
|
24
|
+
"SkillResultQuality",
|
|
25
|
+
"SkillSearchResult",
|
|
26
|
+
"SkillsStatusResult",
|
|
27
|
+
"check_skills",
|
|
28
|
+
"find_skills",
|
|
29
|
+
"install_skill",
|
|
30
|
+
"install_skill_cli",
|
|
31
|
+
"list_installed_skills",
|
|
32
|
+
"list_installed_skills_cli",
|
|
33
|
+
"normalize_repo_skill_target",
|
|
34
|
+
"render_command",
|
|
35
|
+
"resolve_skill_sources",
|
|
36
|
+
"update_skills",
|
|
37
|
+
]
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SKILLS_BROWSE_CATEGORIES: tuple[tuple[str, str, str], ...] = (
|
|
10
|
+
("web-development", "Web Development", "react"),
|
|
11
|
+
("testing", "Testing", "playwright"),
|
|
12
|
+
("devops", "DevOps", "docker"),
|
|
13
|
+
("documentation", "Documentation", "docs"),
|
|
14
|
+
("code-quality", "Code Quality", "lint"),
|
|
15
|
+
("design", "Design", "ui"),
|
|
16
|
+
("productivity", "Productivity", "automation"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
20
|
+
_REPO_PATTERN = re.compile(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b")
|
|
21
|
+
_CONTROL_PATTERN = re.compile(r"[\x00\r\n]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class CliCommandResult:
|
|
26
|
+
argv: tuple[str, ...]
|
|
27
|
+
returncode: int
|
|
28
|
+
stdout: str
|
|
29
|
+
stderr: str
|
|
30
|
+
ok: bool
|
|
31
|
+
error_summary: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class SkillResultQuality:
|
|
36
|
+
score: int = 0
|
|
37
|
+
labels: list[str] = field(default_factory=list)
|
|
38
|
+
install_count: int | None = None
|
|
39
|
+
trusted_source: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class SkillSearchResult:
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
source: str
|
|
47
|
+
install_source: str | None
|
|
48
|
+
skill_name: str | None = None
|
|
49
|
+
skills_page_url: str | None = None
|
|
50
|
+
github_repo_url: str | None = None
|
|
51
|
+
install_count_text: str | None = None
|
|
52
|
+
why_useful: str | None = None
|
|
53
|
+
install_command: tuple[str, ...] = ()
|
|
54
|
+
quality: SkillResultQuality = field(default_factory=SkillResultQuality)
|
|
55
|
+
raw_block: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class InstalledSkillRecord:
|
|
60
|
+
name: str
|
|
61
|
+
details: str
|
|
62
|
+
scope: str | None = None
|
|
63
|
+
source: str | None = None
|
|
64
|
+
agents: list[str] = field(default_factory=list)
|
|
65
|
+
raw_line: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class SkillsStatusResult:
|
|
70
|
+
summary: str
|
|
71
|
+
items: list[str] = field(default_factory=list)
|
|
72
|
+
raw_output: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_skills_cli(args: list[str], *, workspace_dir: str) -> CliCommandResult:
|
|
76
|
+
argv = ("npx", "skills", *args)
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
list(argv),
|
|
80
|
+
cwd=workspace_dir,
|
|
81
|
+
check=False,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
return CliCommandResult(
|
|
87
|
+
argv=argv,
|
|
88
|
+
returncode=127,
|
|
89
|
+
stdout="",
|
|
90
|
+
stderr=str(exc),
|
|
91
|
+
ok=False,
|
|
92
|
+
error_summary=f"Unable to run `{render_command(argv)}`: {exc}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
stdout = result.stdout or ""
|
|
96
|
+
stderr = result.stderr or ""
|
|
97
|
+
ok = result.returncode == 0
|
|
98
|
+
error_summary = None
|
|
99
|
+
if not ok:
|
|
100
|
+
error_summary = (stderr or stdout or f"`{render_command(argv)}` failed.").strip()
|
|
101
|
+
return CliCommandResult(
|
|
102
|
+
argv=argv,
|
|
103
|
+
returncode=result.returncode,
|
|
104
|
+
stdout=stdout,
|
|
105
|
+
stderr=stderr,
|
|
106
|
+
ok=ok,
|
|
107
|
+
error_summary=error_summary,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def find_skills(query: str, *, workspace_dir: str) -> tuple[CliCommandResult, list[SkillSearchResult]]:
|
|
112
|
+
cleaned = query.strip()
|
|
113
|
+
if not cleaned:
|
|
114
|
+
raise ValueError("Search query cannot be empty.")
|
|
115
|
+
result = run_skills_cli(["find", cleaned], workspace_dir=workspace_dir)
|
|
116
|
+
return result, parse_find_output(result.stdout, query=cleaned)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_installed_skills_cli(
|
|
120
|
+
*,
|
|
121
|
+
workspace_dir: str,
|
|
122
|
+
global_only: bool = False,
|
|
123
|
+
agents: list[str] | None = None,
|
|
124
|
+
) -> tuple[CliCommandResult, list[InstalledSkillRecord]]:
|
|
125
|
+
argv = ["list"]
|
|
126
|
+
for agent in agents or ():
|
|
127
|
+
argv.extend(["-a", agent])
|
|
128
|
+
if global_only:
|
|
129
|
+
global_result = run_skills_cli([*argv, "-g"], workspace_dir=workspace_dir)
|
|
130
|
+
return global_result, parse_installed_output(global_result.stdout, default_scope="global")
|
|
131
|
+
|
|
132
|
+
project_result = run_skills_cli(argv, workspace_dir=workspace_dir)
|
|
133
|
+
global_result = run_skills_cli([*argv, "-g"], workspace_dir=workspace_dir)
|
|
134
|
+
merged = _merge_cli_results(project_result, global_result)
|
|
135
|
+
project_records = parse_installed_output(project_result.stdout, default_scope="project")
|
|
136
|
+
global_records = parse_installed_output(global_result.stdout, default_scope="global")
|
|
137
|
+
return merged, _merge_installed_records(project_records, global_records)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install_skill_cli(
|
|
141
|
+
source: str,
|
|
142
|
+
*,
|
|
143
|
+
workspace_dir: str,
|
|
144
|
+
skill_name: str | None = None,
|
|
145
|
+
global_install: bool = True,
|
|
146
|
+
yes: bool = True,
|
|
147
|
+
copy_files: bool = False,
|
|
148
|
+
agents: list[str] | None = None,
|
|
149
|
+
) -> CliCommandResult:
|
|
150
|
+
cleaned_source = validate_skill_source(source)
|
|
151
|
+
argv = ["add", cleaned_source]
|
|
152
|
+
if skill_name:
|
|
153
|
+
argv.extend(["--skill", validate_skill_name(skill_name)])
|
|
154
|
+
if global_install:
|
|
155
|
+
argv.append("-g")
|
|
156
|
+
if yes:
|
|
157
|
+
argv.append("-y")
|
|
158
|
+
if copy_files:
|
|
159
|
+
argv.append("--copy")
|
|
160
|
+
for agent in agents or ():
|
|
161
|
+
argv.extend(["-a", agent])
|
|
162
|
+
return run_skills_cli(argv, workspace_dir=workspace_dir)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_skills(*, workspace_dir: str) -> tuple[CliCommandResult, SkillsStatusResult]:
|
|
166
|
+
result = run_skills_cli(["check"], workspace_dir=workspace_dir)
|
|
167
|
+
return result, parse_status_output(result.stdout or result.stderr, empty_message="No skill updates reported.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def update_skills(*, workspace_dir: str) -> tuple[CliCommandResult, SkillsStatusResult]:
|
|
171
|
+
result = run_skills_cli(["update"], workspace_dir=workspace_dir)
|
|
172
|
+
return result, parse_status_output(result.stdout or result.stderr, empty_message="No skill updates were applied.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_command(argv: tuple[str, ...] | list[str]) -> str:
|
|
176
|
+
return " ".join(_quote_arg(part) for part in argv)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def normalize_repo_skill_target(raw: str) -> tuple[str, str | None]:
|
|
180
|
+
cleaned = raw.strip()
|
|
181
|
+
if not cleaned:
|
|
182
|
+
raise ValueError("Skill source cannot be empty.")
|
|
183
|
+
if cleaned.count("@") == 1:
|
|
184
|
+
repo, skill_name = cleaned.split("@", 1)
|
|
185
|
+
if "/" in repo and skill_name:
|
|
186
|
+
return validate_skill_source(repo), validate_skill_name(skill_name)
|
|
187
|
+
return validate_skill_source(cleaned), None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate_skill_source(source: str) -> str:
|
|
191
|
+
cleaned = source.strip()
|
|
192
|
+
if not cleaned:
|
|
193
|
+
raise ValueError("Skill source cannot be empty.")
|
|
194
|
+
if _CONTROL_PATTERN.search(cleaned):
|
|
195
|
+
raise ValueError("Skill source contains unsupported control characters.")
|
|
196
|
+
return cleaned
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_skill_name(skill_name: str) -> str:
|
|
200
|
+
cleaned = skill_name.strip()
|
|
201
|
+
if not cleaned:
|
|
202
|
+
raise ValueError("Skill name cannot be empty.")
|
|
203
|
+
if _CONTROL_PATTERN.search(cleaned):
|
|
204
|
+
raise ValueError("Skill name contains unsupported control characters.")
|
|
205
|
+
return cleaned
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_find_output(stdout: str, *, query: str) -> list[SkillSearchResult]:
|
|
209
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
210
|
+
if not cleaned:
|
|
211
|
+
return []
|
|
212
|
+
blocks = _split_blocks(cleaned)
|
|
213
|
+
results: list[SkillSearchResult] = []
|
|
214
|
+
for block in blocks:
|
|
215
|
+
parsed = _parse_find_block(block, query=query)
|
|
216
|
+
if parsed is not None:
|
|
217
|
+
results.append(parsed)
|
|
218
|
+
if results:
|
|
219
|
+
return sorted(results, key=lambda item: (-item.quality.score, item.name.lower(), item.source.lower()))
|
|
220
|
+
fallback = cleaned.splitlines()[0].strip()
|
|
221
|
+
return [
|
|
222
|
+
SkillSearchResult(
|
|
223
|
+
name=fallback or query,
|
|
224
|
+
description=cleaned,
|
|
225
|
+
source="skills",
|
|
226
|
+
install_source=None,
|
|
227
|
+
why_useful=f"Search results for `{query}`.",
|
|
228
|
+
raw_block=cleaned,
|
|
229
|
+
)
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_installed_output(stdout: str, *, default_scope: str | None = None) -> list[InstalledSkillRecord]:
|
|
234
|
+
cleaned = _strip_ansi(stdout).strip()
|
|
235
|
+
if not cleaned:
|
|
236
|
+
return []
|
|
237
|
+
records: list[InstalledSkillRecord] = []
|
|
238
|
+
for raw_line in cleaned.splitlines():
|
|
239
|
+
line = raw_line.strip()
|
|
240
|
+
if not line or _looks_like_heading(line) or _looks_like_installed_guidance(line):
|
|
241
|
+
continue
|
|
242
|
+
normalized = re.sub(r"^[*\-•\s]+", "", line)
|
|
243
|
+
name = re.split(r"\s{2,}| • | • | - | — ", normalized, maxsplit=1)[0].strip()
|
|
244
|
+
if not name:
|
|
245
|
+
continue
|
|
246
|
+
lowered = normalized.lower()
|
|
247
|
+
scope = "global" if "global" in lowered else ("project" if "project" in lowered else default_scope)
|
|
248
|
+
source_match = _REPO_PATTERN.search(normalized)
|
|
249
|
+
agents: list[str] = []
|
|
250
|
+
agents_match = re.search(r"agents:\s*(.+)$", normalized, re.IGNORECASE)
|
|
251
|
+
if agents_match:
|
|
252
|
+
agents = re.findall(r"\b[a-z0-9-]+(?:-cli)?\b", agents_match.group(1).lower())
|
|
253
|
+
records.append(
|
|
254
|
+
InstalledSkillRecord(
|
|
255
|
+
name=name,
|
|
256
|
+
details=normalized,
|
|
257
|
+
scope=scope,
|
|
258
|
+
source=source_match.group(1) if source_match else None,
|
|
259
|
+
agents=agents,
|
|
260
|
+
raw_line=line,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return records
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_status_output(output: str, *, empty_message: str) -> SkillsStatusResult:
|
|
267
|
+
cleaned = _strip_ansi(output).strip()
|
|
268
|
+
if not cleaned:
|
|
269
|
+
return SkillsStatusResult(summary=empty_message, raw_output="")
|
|
270
|
+
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
|
|
271
|
+
summary = lines[0]
|
|
272
|
+
return SkillsStatusResult(summary=summary, items=lines[1:], raw_output=cleaned)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_find_block(block: str, *, query: str) -> SkillSearchResult | None:
|
|
276
|
+
lines = [line.strip() for line in block.splitlines() if line.strip()]
|
|
277
|
+
if not lines:
|
|
278
|
+
return None
|
|
279
|
+
title = re.sub(r"^[*\-•\d.\s]+", "", lines[0]).strip()
|
|
280
|
+
install_source: str | None = None
|
|
281
|
+
skill_name: str | None = None
|
|
282
|
+
install_count_text: str | None = None
|
|
283
|
+
headline_match = re.match(
|
|
284
|
+
r"^(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)@(?P<skill>[A-Za-z0-9_.-]+)\s+(?P<count>.+?\s+installs?)$",
|
|
285
|
+
title,
|
|
286
|
+
)
|
|
287
|
+
if headline_match:
|
|
288
|
+
install_source = headline_match.group("repo")
|
|
289
|
+
skill_name = headline_match.group("skill")
|
|
290
|
+
install_count_text = headline_match.group("count").strip()
|
|
291
|
+
skills_page_url = next((line.lstrip("└ ").strip() for line in lines[1:] if "skills.sh/" in line), None)
|
|
292
|
+
if (install_source is None or skill_name is None) and skills_page_url:
|
|
293
|
+
url_match = re.search(r"skills\.sh/(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/(?P<skill>[A-Za-z0-9_.-]+)", skills_page_url)
|
|
294
|
+
if url_match:
|
|
295
|
+
install_source = url_match.group("repo")
|
|
296
|
+
skill_name = url_match.group("skill")
|
|
297
|
+
if install_source is None:
|
|
298
|
+
source_match = _REPO_PATTERN.search(block)
|
|
299
|
+
install_source = source_match.group(1) if source_match else None
|
|
300
|
+
source = install_source or "skills"
|
|
301
|
+
github_repo_url = f"https://github.com/{install_source}" if install_source else None
|
|
302
|
+
description = "Description not available from Skills CLI search output."
|
|
303
|
+
if skill_name:
|
|
304
|
+
title = skill_name
|
|
305
|
+
elif install_source:
|
|
306
|
+
title = install_source
|
|
307
|
+
else:
|
|
308
|
+
title = title or query
|
|
309
|
+
install_command = tuple(_install_command_for(install_source, skill_name=skill_name)) if install_source else ()
|
|
310
|
+
quality = _derive_quality(source=source, block=block, install_count_text=install_count_text)
|
|
311
|
+
why_useful = f"Install `{title}` from `{source}`." if install_source else f"Search results for `{query}`."
|
|
312
|
+
return SkillSearchResult(
|
|
313
|
+
name=title,
|
|
314
|
+
description=description,
|
|
315
|
+
source=source,
|
|
316
|
+
install_source=install_source,
|
|
317
|
+
skill_name=skill_name,
|
|
318
|
+
skills_page_url=skills_page_url,
|
|
319
|
+
github_repo_url=github_repo_url,
|
|
320
|
+
install_count_text=install_count_text,
|
|
321
|
+
why_useful=why_useful,
|
|
322
|
+
install_command=install_command,
|
|
323
|
+
quality=quality,
|
|
324
|
+
raw_block=block.strip(),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _derive_quality(*, source: str, block: str, install_count_text: str | None = None) -> SkillResultQuality:
|
|
329
|
+
labels: list[str] = []
|
|
330
|
+
score = 0
|
|
331
|
+
lowered = block.lower()
|
|
332
|
+
trusted = False
|
|
333
|
+
if source.startswith(("vercel-labs/", "anthropics/")) or "official" in lowered or "verified" in lowered:
|
|
334
|
+
labels.append("trusted")
|
|
335
|
+
score += 5
|
|
336
|
+
trusted = True
|
|
337
|
+
if "github.com" in lowered or "/" in source:
|
|
338
|
+
labels.append("source-known")
|
|
339
|
+
score += 2
|
|
340
|
+
install_count = None
|
|
341
|
+
count_source = install_count_text or block
|
|
342
|
+
match = re.search(r"(\d+(?:\.\d+)?)\s*([km]?)\s+installs?", count_source, re.IGNORECASE)
|
|
343
|
+
if match:
|
|
344
|
+
value = float(match.group(1).replace(",", ""))
|
|
345
|
+
suffix = match.group(2).lower()
|
|
346
|
+
multiplier = 1000 if suffix == "k" else (1000000 if suffix == "m" else 1)
|
|
347
|
+
install_count = int(value * multiplier)
|
|
348
|
+
score += min(install_count // 1000, 5)
|
|
349
|
+
labels.append((install_count_text or f"{install_count}+ installs").strip())
|
|
350
|
+
if "popular" in lowered or "leaderboard" in lowered:
|
|
351
|
+
labels.append("popular")
|
|
352
|
+
score += 2
|
|
353
|
+
return SkillResultQuality(score=score, labels=labels, install_count=install_count, trusted_source=trusted)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _install_command_for(source: str, *, skill_name: str | None = None) -> list[str]:
|
|
357
|
+
argv = ["npx", "skills", "add", source]
|
|
358
|
+
if skill_name:
|
|
359
|
+
argv.extend(["--skill", skill_name])
|
|
360
|
+
argv.extend(["-g", "-y"])
|
|
361
|
+
return argv
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _split_blocks(text: str) -> list[str]:
|
|
365
|
+
blocks = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
|
|
366
|
+
if len(blocks) == 1 and len(text.splitlines()) > 4:
|
|
367
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
368
|
+
return blocks
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _looks_like_heading(line: str) -> bool:
|
|
372
|
+
lowered = line.lower()
|
|
373
|
+
return lowered.startswith(("installed skills", "skills list", "checking", "updates", "found ", "using "))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _looks_like_installed_guidance(line: str) -> bool:
|
|
377
|
+
lowered = line.lower()
|
|
378
|
+
return lowered.startswith(
|
|
379
|
+
(
|
|
380
|
+
"try listing global skills",
|
|
381
|
+
"try listing project skills",
|
|
382
|
+
"no global skills found",
|
|
383
|
+
"no project skills found",
|
|
384
|
+
"run `npx skills list",
|
|
385
|
+
"use `npx skills list",
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _merge_cli_results(primary: CliCommandResult, secondary: CliCommandResult) -> CliCommandResult:
|
|
391
|
+
stdout_parts = [part.strip() for part in (primary.stdout, secondary.stdout) if part.strip()]
|
|
392
|
+
stderr_parts = [part.strip() for part in (primary.stderr, secondary.stderr) if part.strip()]
|
|
393
|
+
ok = primary.ok or secondary.ok
|
|
394
|
+
error_summary = None
|
|
395
|
+
if not ok:
|
|
396
|
+
error_summary = secondary.error_summary or primary.error_summary
|
|
397
|
+
return CliCommandResult(
|
|
398
|
+
argv=primary.argv,
|
|
399
|
+
returncode=0 if ok else (secondary.returncode or primary.returncode),
|
|
400
|
+
stdout="\n\n".join(stdout_parts),
|
|
401
|
+
stderr="\n\n".join(stderr_parts),
|
|
402
|
+
ok=ok,
|
|
403
|
+
error_summary=error_summary,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _merge_installed_records(*record_sets: list[InstalledSkillRecord]) -> list[InstalledSkillRecord]:
|
|
408
|
+
merged: dict[tuple[str, str | None, str | None], InstalledSkillRecord] = {}
|
|
409
|
+
for records in record_sets:
|
|
410
|
+
for record in records:
|
|
411
|
+
key = (record.name.lower(), record.scope, record.source)
|
|
412
|
+
existing = merged.get(key)
|
|
413
|
+
if existing is None:
|
|
414
|
+
merged[key] = InstalledSkillRecord(
|
|
415
|
+
name=record.name,
|
|
416
|
+
details=record.details,
|
|
417
|
+
scope=record.scope,
|
|
418
|
+
source=record.source,
|
|
419
|
+
agents=list(record.agents),
|
|
420
|
+
raw_line=record.raw_line,
|
|
421
|
+
)
|
|
422
|
+
continue
|
|
423
|
+
existing.agents = sorted(set([*existing.agents, *record.agents]))
|
|
424
|
+
if len(record.details) > len(existing.details):
|
|
425
|
+
existing.details = record.details
|
|
426
|
+
if existing.scope is None:
|
|
427
|
+
existing.scope = record.scope
|
|
428
|
+
if existing.source is None:
|
|
429
|
+
existing.source = record.source
|
|
430
|
+
return sorted(
|
|
431
|
+
merged.values(),
|
|
432
|
+
key=lambda item: (
|
|
433
|
+
0 if item.scope == "project" else 1 if item.scope == "global" else 2,
|
|
434
|
+
item.name.lower(),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _strip_ansi(text: str) -> str:
|
|
440
|
+
return _ANSI_PATTERN.sub("", text)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _quote_arg(part: str) -> str:
|
|
444
|
+
if re.fullmatch(r"[A-Za-z0-9_./:@=-]+", part):
|
|
445
|
+
return part
|
|
446
|
+
return '"' + part.replace('"', '\\"') + '"'
|