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 ADDED
@@ -0,0 +1,3 @@
1
+ """agpack - Fetch and deploy AI agent resources from git repos."""
2
+
3
+ __version__ = "0.1.0"
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
+ )