agpack 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.
- agpack/__init__.py +3 -0
- agpack/cli.py +357 -0
- agpack/config.py +232 -0
- agpack/deployer.py +242 -0
- agpack/fetcher.py +205 -0
- agpack/lockfile.py +177 -0
- agpack/mcp.py +278 -0
- agpack/targets.py +85 -0
- agpack-0.1.0.dist-info/METADATA +181 -0
- agpack-0.1.0.dist-info/RECORD +13 -0
- agpack-0.1.0.dist-info/WHEEL +4 -0
- agpack-0.1.0.dist-info/entry_points.txt +2 -0
- agpack-0.1.0.dist-info/licenses/LICENSE +674 -0
agpack/__init__.py
ADDED
agpack/cli.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Click entrypoints for the agpack CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from agpack import __version__
|
|
11
|
+
from agpack.config import ConfigError
|
|
12
|
+
from agpack.config import load_config
|
|
13
|
+
from agpack.deployer import cleanup_deployed_files
|
|
14
|
+
from agpack.deployer import deploy_agent
|
|
15
|
+
from agpack.deployer import deploy_command
|
|
16
|
+
from agpack.deployer import deploy_skill
|
|
17
|
+
from agpack.fetcher import FetchError
|
|
18
|
+
from agpack.fetcher import cleanup_fetch
|
|
19
|
+
from agpack.fetcher import fetch_dependency
|
|
20
|
+
from agpack.lockfile import InstalledEntry
|
|
21
|
+
from agpack.lockfile import Lockfile
|
|
22
|
+
from agpack.lockfile import McpLockEntry
|
|
23
|
+
from agpack.lockfile import find_removed_dependencies
|
|
24
|
+
from agpack.lockfile import find_removed_mcp_servers
|
|
25
|
+
from agpack.lockfile import read_lockfile
|
|
26
|
+
from agpack.lockfile import write_lockfile
|
|
27
|
+
from agpack.mcp import McpError
|
|
28
|
+
from agpack.mcp import cleanup_mcp_server
|
|
29
|
+
from agpack.mcp import deploy_mcp_servers
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.version_option(version=__version__)
|
|
34
|
+
def main() -> None:
|
|
35
|
+
"""agpack — fetch and deploy AI agent resources."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@main.command()
|
|
39
|
+
@click.option("--dry-run", is_flag=True, help="Print actions without writing files.")
|
|
40
|
+
@click.option(
|
|
41
|
+
"--config",
|
|
42
|
+
"config_path",
|
|
43
|
+
default="./agpack.yml",
|
|
44
|
+
type=click.Path(),
|
|
45
|
+
help="Path to config file.",
|
|
46
|
+
)
|
|
47
|
+
@click.option("--verbose", is_flag=True, help="Print each file being written.")
|
|
48
|
+
def sync(dry_run: bool, config_path: str, verbose: bool) -> None:
|
|
49
|
+
"""Fetch all dependencies and deploy to all target directories."""
|
|
50
|
+
cfg_path = Path(config_path).resolve()
|
|
51
|
+
project_root = cfg_path.parent
|
|
52
|
+
|
|
53
|
+
# 1. Load and validate config
|
|
54
|
+
try:
|
|
55
|
+
config = load_config(cfg_path)
|
|
56
|
+
except ConfigError as exc:
|
|
57
|
+
click.echo(f"Error: {exc}", err=True)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
# 2. Read existing lockfile
|
|
61
|
+
old_lockfile = read_lockfile(project_root)
|
|
62
|
+
|
|
63
|
+
# 3. Build set of current dependency identities
|
|
64
|
+
current_identities: set[str] = set()
|
|
65
|
+
for dep in [*config.skills, *config.commands, *config.agents]:
|
|
66
|
+
current_identities.add(dep.identity)
|
|
67
|
+
|
|
68
|
+
current_mcp_names = {m.name for m in config.mcp}
|
|
69
|
+
|
|
70
|
+
# 4. Clean up removed dependencies
|
|
71
|
+
removed_deps = find_removed_dependencies(old_lockfile, current_identities)
|
|
72
|
+
for entry in removed_deps:
|
|
73
|
+
if verbose or dry_run:
|
|
74
|
+
click.echo(f"Removing {entry.type} '{entry.url}/{entry.path or ''}'...")
|
|
75
|
+
cleanup_deployed_files(
|
|
76
|
+
entry.deployed_files, project_root, dry_run=dry_run, verbose=verbose
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 5. Clean up removed MCP servers
|
|
80
|
+
removed_mcp = find_removed_mcp_servers(old_lockfile, current_mcp_names)
|
|
81
|
+
for entry in removed_mcp:
|
|
82
|
+
if verbose or dry_run:
|
|
83
|
+
click.echo(f"Removing MCP server '{entry.name}'...")
|
|
84
|
+
cleanup_mcp_server(
|
|
85
|
+
entry.name,
|
|
86
|
+
entry.targets,
|
|
87
|
+
project_root,
|
|
88
|
+
config.targets,
|
|
89
|
+
dry_run=dry_run,
|
|
90
|
+
verbose=verbose,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# 6. Fetch and deploy dependencies
|
|
94
|
+
new_lockfile = Lockfile()
|
|
95
|
+
counts = {"skills": 0, "commands": 0, "agents": 0, "mcp": 0}
|
|
96
|
+
|
|
97
|
+
# Skills
|
|
98
|
+
for dep in config.skills:
|
|
99
|
+
click.echo(f"Fetching skill '{dep.name}' from {dep.url}...")
|
|
100
|
+
try:
|
|
101
|
+
result = fetch_dependency(dep)
|
|
102
|
+
except FetchError as exc:
|
|
103
|
+
click.echo(f"Error: {exc}", err=True)
|
|
104
|
+
sys.exit(2)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
deployed = deploy_skill(
|
|
108
|
+
result,
|
|
109
|
+
config.targets,
|
|
110
|
+
project_root,
|
|
111
|
+
dry_run=dry_run,
|
|
112
|
+
verbose=verbose,
|
|
113
|
+
)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
click.echo(f"Error deploying skill '{dep.name}': {exc}", err=True)
|
|
116
|
+
cleanup_fetch(result)
|
|
117
|
+
sys.exit(3)
|
|
118
|
+
|
|
119
|
+
new_lockfile.installed.append(
|
|
120
|
+
InstalledEntry(
|
|
121
|
+
url=dep.url,
|
|
122
|
+
path=dep.path,
|
|
123
|
+
resolved_ref=result.resolved_ref,
|
|
124
|
+
type="skill",
|
|
125
|
+
deployed_files=deployed,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
cleanup_fetch(result)
|
|
129
|
+
counts["skills"] += 1
|
|
130
|
+
|
|
131
|
+
# Commands
|
|
132
|
+
for dep in config.commands:
|
|
133
|
+
click.echo(f"Fetching command '{dep.name}' from {dep.url}...")
|
|
134
|
+
try:
|
|
135
|
+
result = fetch_dependency(dep)
|
|
136
|
+
except FetchError as exc:
|
|
137
|
+
click.echo(f"Error: {exc}", err=True)
|
|
138
|
+
sys.exit(2)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
deployed = deploy_command(
|
|
142
|
+
result,
|
|
143
|
+
config.targets,
|
|
144
|
+
project_root,
|
|
145
|
+
dry_run=dry_run,
|
|
146
|
+
verbose=verbose,
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
click.echo(f"Error deploying command '{dep.name}': {exc}", err=True)
|
|
150
|
+
cleanup_fetch(result)
|
|
151
|
+
sys.exit(3)
|
|
152
|
+
|
|
153
|
+
new_lockfile.installed.append(
|
|
154
|
+
InstalledEntry(
|
|
155
|
+
url=dep.url,
|
|
156
|
+
path=dep.path,
|
|
157
|
+
resolved_ref=result.resolved_ref,
|
|
158
|
+
type="command",
|
|
159
|
+
deployed_files=deployed,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
cleanup_fetch(result)
|
|
163
|
+
counts["commands"] += 1
|
|
164
|
+
|
|
165
|
+
# Agents
|
|
166
|
+
for dep in config.agents:
|
|
167
|
+
click.echo(f"Fetching agent '{dep.name}' from {dep.url}...")
|
|
168
|
+
try:
|
|
169
|
+
result = fetch_dependency(dep)
|
|
170
|
+
except FetchError as exc:
|
|
171
|
+
click.echo(f"Error: {exc}", err=True)
|
|
172
|
+
sys.exit(2)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
deployed = deploy_agent(
|
|
176
|
+
result,
|
|
177
|
+
config.targets,
|
|
178
|
+
project_root,
|
|
179
|
+
dry_run=dry_run,
|
|
180
|
+
verbose=verbose,
|
|
181
|
+
)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
click.echo(f"Error deploying agent '{dep.name}': {exc}", err=True)
|
|
184
|
+
cleanup_fetch(result)
|
|
185
|
+
sys.exit(3)
|
|
186
|
+
|
|
187
|
+
new_lockfile.installed.append(
|
|
188
|
+
InstalledEntry(
|
|
189
|
+
url=dep.url,
|
|
190
|
+
path=dep.path,
|
|
191
|
+
resolved_ref=result.resolved_ref,
|
|
192
|
+
type="agent",
|
|
193
|
+
deployed_files=deployed,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
cleanup_fetch(result)
|
|
197
|
+
counts["agents"] += 1
|
|
198
|
+
|
|
199
|
+
# MCP servers
|
|
200
|
+
if config.mcp:
|
|
201
|
+
click.echo("Deploying MCP servers...")
|
|
202
|
+
try:
|
|
203
|
+
mcp_result = deploy_mcp_servers(
|
|
204
|
+
config.mcp,
|
|
205
|
+
config.targets,
|
|
206
|
+
project_root,
|
|
207
|
+
dry_run=dry_run,
|
|
208
|
+
verbose=verbose,
|
|
209
|
+
)
|
|
210
|
+
except McpError as exc:
|
|
211
|
+
click.echo(f"Error: {exc}", err=True)
|
|
212
|
+
sys.exit(3)
|
|
213
|
+
|
|
214
|
+
for server_name, target_paths in mcp_result.items():
|
|
215
|
+
new_lockfile.mcp.append(
|
|
216
|
+
McpLockEntry(name=server_name, targets=target_paths)
|
|
217
|
+
)
|
|
218
|
+
counts["mcp"] += 1
|
|
219
|
+
|
|
220
|
+
# 7. Write lockfile
|
|
221
|
+
if not dry_run:
|
|
222
|
+
write_lockfile(project_root, new_lockfile)
|
|
223
|
+
|
|
224
|
+
# 8. Summary
|
|
225
|
+
target_count = len(config.targets)
|
|
226
|
+
click.echo(
|
|
227
|
+
f"\n{counts['skills']} skills, {counts['commands']} commands, "
|
|
228
|
+
f"{counts['agents']} agents, {counts['mcp']} MCP servers "
|
|
229
|
+
f"synced to {target_count} targets."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@main.command()
|
|
234
|
+
@click.option(
|
|
235
|
+
"--config",
|
|
236
|
+
"config_path",
|
|
237
|
+
default="./agpack.yml",
|
|
238
|
+
type=click.Path(),
|
|
239
|
+
help="Path to config file.",
|
|
240
|
+
)
|
|
241
|
+
def status(config_path: str) -> None:
|
|
242
|
+
"""Show the current state of installed resources vs the config."""
|
|
243
|
+
cfg_path = Path(config_path).resolve()
|
|
244
|
+
project_root = cfg_path.parent
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
config = load_config(cfg_path)
|
|
248
|
+
except ConfigError as exc:
|
|
249
|
+
click.echo(f"Error: {exc}", err=True)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
lockfile = read_lockfile(project_root)
|
|
253
|
+
|
|
254
|
+
# Build lookup from lockfile
|
|
255
|
+
installed_map: dict[str, InstalledEntry] = {}
|
|
256
|
+
if lockfile:
|
|
257
|
+
for entry in lockfile.installed:
|
|
258
|
+
installed_map[entry.identity] = entry
|
|
259
|
+
|
|
260
|
+
mcp_map: dict[str, McpLockEntry] = {}
|
|
261
|
+
if lockfile:
|
|
262
|
+
for entry in lockfile.mcp:
|
|
263
|
+
mcp_map[entry.name] = entry
|
|
264
|
+
|
|
265
|
+
# Skills
|
|
266
|
+
click.echo("Skills:")
|
|
267
|
+
if not config.skills:
|
|
268
|
+
click.echo(" (none configured)")
|
|
269
|
+
else:
|
|
270
|
+
for dep in config.skills:
|
|
271
|
+
entry = installed_map.get(dep.identity)
|
|
272
|
+
if entry:
|
|
273
|
+
short_ref = entry.resolved_ref[:7]
|
|
274
|
+
click.echo(f" ✓ {dep.name:<20} ({dep.url} @ {short_ref})")
|
|
275
|
+
else:
|
|
276
|
+
click.echo(f" ✗ {dep.name:<20} (not yet synced)")
|
|
277
|
+
|
|
278
|
+
# Commands
|
|
279
|
+
click.echo("\nCommands:")
|
|
280
|
+
if not config.commands:
|
|
281
|
+
click.echo(" (none configured)")
|
|
282
|
+
else:
|
|
283
|
+
for dep in config.commands:
|
|
284
|
+
entry = installed_map.get(dep.identity)
|
|
285
|
+
if entry:
|
|
286
|
+
short_ref = entry.resolved_ref[:7]
|
|
287
|
+
click.echo(f" ✓ {dep.name:<20} ({dep.url} @ {short_ref})")
|
|
288
|
+
else:
|
|
289
|
+
click.echo(f" ✗ {dep.name:<20} (not yet synced)")
|
|
290
|
+
|
|
291
|
+
# Agents
|
|
292
|
+
click.echo("\nAgents:")
|
|
293
|
+
if not config.agents:
|
|
294
|
+
click.echo(" (none configured)")
|
|
295
|
+
else:
|
|
296
|
+
for dep in config.agents:
|
|
297
|
+
entry = installed_map.get(dep.identity)
|
|
298
|
+
if entry:
|
|
299
|
+
short_ref = entry.resolved_ref[:7]
|
|
300
|
+
click.echo(f" ✓ {dep.name:<20} ({dep.url} @ {short_ref})")
|
|
301
|
+
else:
|
|
302
|
+
click.echo(f" ✗ {dep.name:<20} (not yet synced)")
|
|
303
|
+
|
|
304
|
+
# MCP
|
|
305
|
+
click.echo("\nMCP:")
|
|
306
|
+
if not config.mcp:
|
|
307
|
+
click.echo(" (none configured)")
|
|
308
|
+
else:
|
|
309
|
+
for server in config.mcp:
|
|
310
|
+
entry = mcp_map.get(server.name)
|
|
311
|
+
if entry and entry.targets:
|
|
312
|
+
targets_str = ", ".join(entry.targets)
|
|
313
|
+
click.echo(f" ✓ {server.name:<20} → {targets_str}")
|
|
314
|
+
else:
|
|
315
|
+
click.echo(f" ✗ {server.name:<20} (not yet synced)")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@main.command()
|
|
319
|
+
def init() -> None:
|
|
320
|
+
"""Scaffold a new agpack.yml in the current directory."""
|
|
321
|
+
path = Path.cwd() / "agpack.yml"
|
|
322
|
+
if path.exists():
|
|
323
|
+
click.echo("agpack.yml already exists — doing nothing.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
template = """\
|
|
327
|
+
name: my-project
|
|
328
|
+
version: 0.1.0
|
|
329
|
+
|
|
330
|
+
targets:
|
|
331
|
+
# - claude
|
|
332
|
+
# - opencode
|
|
333
|
+
# - codex
|
|
334
|
+
# - cursor
|
|
335
|
+
# - copilot
|
|
336
|
+
|
|
337
|
+
dependencies:
|
|
338
|
+
skills:
|
|
339
|
+
# - url: https://github.com/owner/repo
|
|
340
|
+
# path: skills/my-skill
|
|
341
|
+
# ref: v1.0.0
|
|
342
|
+
|
|
343
|
+
commands:
|
|
344
|
+
# - url: https://github.com/owner/repo
|
|
345
|
+
# path: commands/my-command.md
|
|
346
|
+
|
|
347
|
+
agents:
|
|
348
|
+
# - url: https://github.com/owner/repo
|
|
349
|
+
# path: agents/my-agent.md
|
|
350
|
+
|
|
351
|
+
mcp:
|
|
352
|
+
# - name: my-server
|
|
353
|
+
# command: npx
|
|
354
|
+
# args: ["-y", "@example/mcp-server"]
|
|
355
|
+
"""
|
|
356
|
+
path.write_text(template, encoding="utf-8")
|
|
357
|
+
click.echo(f"Created {path}")
|
agpack/config.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""agpack.yml parsing and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from agpack.targets import VALID_TARGETS
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Data classes
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DependencySource:
|
|
21
|
+
"""A parsed skill, command, or agent dependency."""
|
|
22
|
+
|
|
23
|
+
url: str
|
|
24
|
+
path: str | None = None
|
|
25
|
+
ref: str | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
"""Derive the resource name (last path segment, or url basename)."""
|
|
30
|
+
if self.path:
|
|
31
|
+
return self.path.rstrip("/").rsplit("/", 1)[-1]
|
|
32
|
+
# Strip trailing .git and take the last segment of the URL
|
|
33
|
+
cleaned = self.url.rstrip("/")
|
|
34
|
+
if cleaned.endswith(".git"):
|
|
35
|
+
cleaned = cleaned[:-4]
|
|
36
|
+
return cleaned.rsplit("/", 1)[-1]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def identity(self) -> str:
|
|
40
|
+
"""A unique key for this dependency (used for lockfile matching)."""
|
|
41
|
+
key = self.url
|
|
42
|
+
if self.path:
|
|
43
|
+
key = f"{key}::{self.path}"
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class McpServer:
|
|
49
|
+
"""An MCP server definition."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
type: str = "stdio" # stdio | sse | http
|
|
53
|
+
command: str | None = None
|
|
54
|
+
args: list[str] = field(default_factory=list)
|
|
55
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
url: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AgpackConfig:
|
|
61
|
+
"""Parsed and validated agpack.yml."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
version: str
|
|
65
|
+
targets: list[str]
|
|
66
|
+
skills: list[DependencySource] = field(default_factory=list)
|
|
67
|
+
commands: list[DependencySource] = field(default_factory=list)
|
|
68
|
+
agents: list[DependencySource] = field(default_factory=list)
|
|
69
|
+
mcp: list[McpServer] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Validation errors
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigError(Exception):
|
|
78
|
+
"""Raised when agpack.yml is invalid."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Parsing
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_dependency(raw: dict[str, Any], context: str) -> DependencySource:
|
|
87
|
+
"""Parse a single dependency entry (object form).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
raw: The raw YAML dict for this dependency.
|
|
91
|
+
context: Human-readable location for error messages (e.g. "skills[0]").
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(raw, dict):
|
|
94
|
+
raise ConfigError(
|
|
95
|
+
f"{context}: expected an object with 'url' key, got {type(raw).__name__}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
url = raw.get("url")
|
|
99
|
+
if not url:
|
|
100
|
+
raise ConfigError(f"{context}: missing required field 'url'")
|
|
101
|
+
if not isinstance(url, str):
|
|
102
|
+
raise ConfigError(f"{context}: 'url' must be a string")
|
|
103
|
+
|
|
104
|
+
path = raw.get("path")
|
|
105
|
+
if path is not None and not isinstance(path, str):
|
|
106
|
+
raise ConfigError(f"{context}: 'path' must be a string")
|
|
107
|
+
|
|
108
|
+
ref = raw.get("ref")
|
|
109
|
+
if ref is not None:
|
|
110
|
+
ref = str(ref)
|
|
111
|
+
|
|
112
|
+
return DependencySource(url=url, path=path, ref=ref)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_mcp(raw: dict[str, Any], context: str) -> McpServer:
|
|
116
|
+
"""Parse a single MCP server entry."""
|
|
117
|
+
if not isinstance(raw, dict):
|
|
118
|
+
raise ConfigError(f"{context}: expected an object, got {type(raw).__name__}")
|
|
119
|
+
|
|
120
|
+
name = raw.get("name")
|
|
121
|
+
if not name:
|
|
122
|
+
raise ConfigError(f"{context}: missing required field 'name'")
|
|
123
|
+
|
|
124
|
+
server_type = str(raw.get("type", "stdio"))
|
|
125
|
+
if server_type not in ("stdio", "sse", "http"):
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
f"{context}: 'type' must be 'stdio', 'sse', or 'http', got '{server_type}'"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if server_type == "stdio":
|
|
131
|
+
command = raw.get("command")
|
|
132
|
+
if not command:
|
|
133
|
+
raise ConfigError(
|
|
134
|
+
f"{context}: stdio MCP server '{name}'"
|
|
135
|
+
" is missing required field 'command'"
|
|
136
|
+
)
|
|
137
|
+
return McpServer(
|
|
138
|
+
name=name,
|
|
139
|
+
type=server_type,
|
|
140
|
+
command=str(command),
|
|
141
|
+
args=[str(a) for a in raw.get("args", [])],
|
|
142
|
+
env={str(k): str(v) for k, v in raw.get("env", {}).items()},
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
url = raw.get("url")
|
|
146
|
+
if not url:
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
f"{context}: {server_type} MCP server '{name}'"
|
|
149
|
+
" is missing required field 'url'"
|
|
150
|
+
)
|
|
151
|
+
return McpServer(
|
|
152
|
+
name=name,
|
|
153
|
+
type=server_type,
|
|
154
|
+
url=str(url),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_config(path: Path) -> AgpackConfig:
|
|
159
|
+
"""Load and validate agpack.yml.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path: Path to the agpack.yml file.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A validated AgpackConfig.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ConfigError: If the config is invalid.
|
|
169
|
+
"""
|
|
170
|
+
if not path.exists():
|
|
171
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
175
|
+
except yaml.YAMLError as exc:
|
|
176
|
+
raise ConfigError(f"Failed to parse YAML: {exc}") from exc
|
|
177
|
+
|
|
178
|
+
if not isinstance(data, dict):
|
|
179
|
+
raise ConfigError("Config file must be a YAML mapping")
|
|
180
|
+
|
|
181
|
+
# Required top-level fields
|
|
182
|
+
name = data.get("name")
|
|
183
|
+
if not name:
|
|
184
|
+
raise ConfigError("Missing required field 'name'")
|
|
185
|
+
|
|
186
|
+
version = data.get("version")
|
|
187
|
+
if not version:
|
|
188
|
+
raise ConfigError("Missing required field 'version'")
|
|
189
|
+
version = str(version)
|
|
190
|
+
|
|
191
|
+
# Targets
|
|
192
|
+
targets = data.get("targets")
|
|
193
|
+
if not targets or not isinstance(targets, list):
|
|
194
|
+
raise ConfigError("Missing or invalid 'targets' (must be a list)")
|
|
195
|
+
|
|
196
|
+
for t in targets:
|
|
197
|
+
if t not in VALID_TARGETS:
|
|
198
|
+
raise ConfigError(
|
|
199
|
+
f"Unrecognised target '{t}'. Valid targets: {sorted(VALID_TARGETS)}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Dependencies
|
|
203
|
+
deps = data.get("dependencies", {})
|
|
204
|
+
if not isinstance(deps, dict):
|
|
205
|
+
raise ConfigError("'dependencies' must be a mapping")
|
|
206
|
+
|
|
207
|
+
skills = [
|
|
208
|
+
_parse_dependency(s, f"dependencies.skills[{i}]")
|
|
209
|
+
for i, s in enumerate(deps.get("skills") or [])
|
|
210
|
+
]
|
|
211
|
+
commands = [
|
|
212
|
+
_parse_dependency(c, f"dependencies.commands[{i}]")
|
|
213
|
+
for i, c in enumerate(deps.get("commands") or [])
|
|
214
|
+
]
|
|
215
|
+
agents = [
|
|
216
|
+
_parse_dependency(a, f"dependencies.agents[{i}]")
|
|
217
|
+
for i, a in enumerate(deps.get("agents") or [])
|
|
218
|
+
]
|
|
219
|
+
mcp = [
|
|
220
|
+
_parse_mcp(m, f"dependencies.mcp[{i}]")
|
|
221
|
+
for i, m in enumerate(deps.get("mcp") or [])
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
return AgpackConfig(
|
|
225
|
+
name=str(name),
|
|
226
|
+
version=version,
|
|
227
|
+
targets=targets,
|
|
228
|
+
skills=skills,
|
|
229
|
+
commands=commands,
|
|
230
|
+
agents=agents,
|
|
231
|
+
mcp=mcp,
|
|
232
|
+
)
|