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,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('"', '\\"') + '"'