apmw 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.
apmw/__init__.py ADDED
@@ -0,0 +1,443 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import platform as _platform
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ TARGET_DIRS = {
13
+ "opencode": ".opencode",
14
+ "copilot": ".github",
15
+ "claude": ".claude",
16
+ "cursor": ".cursor",
17
+ "codex": ".codex",
18
+ }
19
+
20
+ # Which frontmatter fields to drop per target platform. GravionLabs/apm-hub
21
+ # keeps an equivalent copy at config/agent-strip-fields.json (shared with
22
+ # src/generators/agent-target-generator.ts there) — update both if the set
23
+ # of fields per platform changes.
24
+ #
25
+ # Note on "claude": Claude Code accepts only name/description/tools/model;
26
+ # source `tools` arrays use Copilot names and `model` values are rarely
27
+ # valid Anthropic ids, so both are stripped — Claude then inherits the
28
+ # session model.
29
+ _STRIP_FIELDS_PATH = Path(__file__).resolve().parent / "data" / "agent-strip-fields.json"
30
+
31
+
32
+ def _load_target_strip_fields() -> dict[str, list[str]]:
33
+ if _STRIP_FIELDS_PATH.is_file():
34
+ return json.loads(_STRIP_FIELDS_PATH.read_text())
35
+ return {
36
+ "opencode": ["tools", "user-invocable", "agents"],
37
+ "copilot": ["mode", "steps", "color", "permission", "task"],
38
+ "claude": ["mode", "steps", "color", "tools", "permission", "user-invocable", "agents", "model"],
39
+ "cursor": ["mode", "steps", "color", "permission"],
40
+ "codex": ["mode", "steps", "color", "permission"],
41
+ }
42
+
43
+
44
+ TARGET_STRIP_FIELDS = _load_target_strip_fields()
45
+
46
+ GLOBAL_PATHS: dict[str, dict[str, list[str]]] = {
47
+ "opencode": {
48
+ "Linux": ["~/.config/opencode/agents"],
49
+ "Darwin": ["~/Library/Application Support/opencode/agents"],
50
+ "Windows": ["%APPDATA%\\opencode\\agents"],
51
+ },
52
+ "copilot": {
53
+ "Linux": ["~/.config/github-copilot/agents"],
54
+ "Darwin": ["~/Library/Application Support/github-copilot/agents"],
55
+ "Windows": ["%APPDATA%\\GitHub Copilot\\agents"],
56
+ },
57
+ "claude": {
58
+ "Linux": ["~/.claude/agents", "~/.config/claude/agents"],
59
+ "Darwin": ["~/Library/Application Support/claude/agents"],
60
+ "Windows": ["%APPDATA%\\Claude\\agents"],
61
+ },
62
+ "cursor": {
63
+ "Linux": ["~/.cursor/agents", "~/.config/cursor/agents"],
64
+ "Darwin": ["~/Library/Application Support/cursor/agents"],
65
+ "Windows": ["%APPDATA%\\Cursor\\agents"],
66
+ },
67
+ "codex": {
68
+ "Linux": ["~/.codex/agents"],
69
+ "Darwin": ["~/.codex/agents"],
70
+ "Windows": ["%APPDATA%\\Codex\\agents"],
71
+ },
72
+ }
73
+
74
+ TOOLS_ARRAY_RE = re.compile(r'^tools:\s*\[')
75
+ TOOLS_BLOCK_RE = re.compile(r'^tools:\s*$')
76
+ PERMISSION_RE = re.compile(r'^permission:')
77
+ METADATA_RE = re.compile(r'^metadata\s*:')
78
+
79
+
80
+ def resolve_path(raw: str) -> Path:
81
+ expanded = os.path.expandvars(os.path.expanduser(raw))
82
+ return Path(expanded)
83
+
84
+
85
+ def get_global_agents_dirs(target: str) -> list[Path]:
86
+ system = _platform.system()
87
+ dirs = GLOBAL_PATHS.get(target, {}).get(system, [])
88
+ return [resolve_path(d) for d in dirs]
89
+
90
+
91
+ def _add_sanitize_args(parser: argparse.ArgumentParser) -> None:
92
+ parser.add_argument(
93
+ "-p", "--platform",
94
+ default=None,
95
+ choices=list(TARGET_STRIP_FIELDS.keys()),
96
+ help="Target platform (default: all known platforms)",
97
+ )
98
+ parser.add_argument(
99
+ "-s", "--source",
100
+ action="store_true",
101
+ help="Also add permission block to source agent files under packages/",
102
+ )
103
+ parser.add_argument(
104
+ "-g", "--global",
105
+ action="store_true",
106
+ dest="global_",
107
+ help="Sanitize global installation paths instead of CWD",
108
+ )
109
+
110
+
111
+ def build_parser() -> argparse.ArgumentParser:
112
+ parser = argparse.ArgumentParser(
113
+ prog="apmw",
114
+ description="Wrapper for the apm CLI that sanitizes agent frontmatter per target platform.",
115
+ )
116
+ subparsers = parser.add_subparsers(dest="command", required=True)
117
+
118
+ install_parser = subparsers.add_parser(
119
+ "install", help="Run apm install then sanitize agent frontmatter per target."
120
+ )
121
+ _add_sanitize_args(install_parser)
122
+
123
+ sanitize_parser = subparsers.add_parser(
124
+ "sanitize", help="Sanitize an already-installed agent directory without running apm."
125
+ )
126
+ _add_sanitize_args(sanitize_parser)
127
+
128
+ update_parser = subparsers.add_parser(
129
+ "update", help="Run apm update then sanitize agent frontmatter per target."
130
+ )
131
+ _add_sanitize_args(update_parser)
132
+ update_parser.add_argument(
133
+ "-y", "--yes",
134
+ action="store_true",
135
+ help="Skip the consent prompt (passed as --yes to apm update)",
136
+ )
137
+ update_parser.add_argument(
138
+ "--dry-run",
139
+ action="store_true",
140
+ help="Preview changes without writing (passed to apm update, skips sanitization)",
141
+ )
142
+
143
+ return parser
144
+
145
+
146
+ def find_frontmatter(lines: list[str]) -> tuple[int, int] | None:
147
+ if len(lines) < 3 or lines[0].strip() != "---":
148
+ return None
149
+ for i, line in enumerate(lines[1:], start=1):
150
+ if line.strip() == "---":
151
+ return (0, i)
152
+ return None
153
+
154
+
155
+ def has_array_tools(fm_lines: list[str]) -> bool:
156
+ for line in fm_lines:
157
+ if TOOLS_ARRAY_RE.match(line) or TOOLS_BLOCK_RE.match(line):
158
+ return True
159
+ return False
160
+
161
+
162
+ def strip_fields(fm_lines: list[str], fields: list[str]) -> list[str] | None:
163
+ patterns = [re.compile(r'^' + re.escape(f) + r'\s*:') for f in fields]
164
+
165
+ new_lines: list[str] = []
166
+ skip_indent = False
167
+ skip_indent_level = 0
168
+
169
+ for line in fm_lines:
170
+ if skip_indent:
171
+ if line and not line[0].isspace():
172
+ skip_indent = False
173
+ elif line and line[0].isspace():
174
+ indent = len(line) - len(line.lstrip())
175
+ if indent <= skip_indent_level:
176
+ skip_indent = False
177
+ else:
178
+ continue
179
+ else:
180
+ skip_indent = False
181
+
182
+ matched = False
183
+ for pat in patterns:
184
+ if pat.match(line):
185
+ matched = True
186
+ skip_indent = True
187
+ skip_indent_level = len(line) - len(line.lstrip()) if line else 0
188
+ break
189
+
190
+ if not matched:
191
+ new_lines.append(line)
192
+
193
+ if len(new_lines) == len(fm_lines):
194
+ return None
195
+ return new_lines
196
+
197
+
198
+ def add_permission_block(fm_lines: list[str]) -> list[str] | None:
199
+ for line in fm_lines:
200
+ if PERMISSION_RE.match(line):
201
+ return None
202
+
203
+ tool_names: list[str] = []
204
+ in_block = False
205
+ for line in fm_lines:
206
+ if in_block:
207
+ m = re.match(r'^\s+-\s+(.+)$', line)
208
+ if m:
209
+ tool_names.append(m.group(1).strip())
210
+ else:
211
+ break
212
+ elif TOOLS_ARRAY_RE.match(line):
213
+ m = TOOLS_ARRAY_RE.match(line)
214
+ rest = line[m.end():]
215
+ if rest.endswith("]"):
216
+ rest = rest[:-1]
217
+ tool_names = [t.strip().strip("'\"") for t in rest.split(",") if t.strip()]
218
+ break
219
+ elif TOOLS_BLOCK_RE.match(line):
220
+ in_block = True
221
+
222
+ if not tool_names:
223
+ return None
224
+
225
+ insert_at = None
226
+ for i, line in enumerate(fm_lines):
227
+ if line.strip().startswith("# Copilot/APM fields") or line.strip().startswith("# Copilot"):
228
+ insert_at = i
229
+ break
230
+
231
+ perm_block = ["permission:"]
232
+ for name in tool_names:
233
+ perm_block.append(f" {name}: allow")
234
+
235
+ if insert_at is not None:
236
+ return fm_lines[:insert_at] + perm_block + fm_lines[insert_at:]
237
+ else:
238
+ return fm_lines + perm_block
239
+
240
+
241
+ def extract_platform_metadata(fm_lines: list[str], platform: str) -> list[str]:
242
+ platform_re = re.compile(r'^(\s+)' + re.escape(platform) + r'\s*:')
243
+
244
+ in_metadata = False
245
+ platform_indent: int | None = None
246
+ content_base_indent: int | None = None
247
+ result: list[str] = []
248
+
249
+ for line in fm_lines:
250
+ if not in_metadata:
251
+ if METADATA_RE.match(line):
252
+ in_metadata = True
253
+ continue
254
+
255
+ if platform_indent is None:
256
+ if not line or not line[0].isspace():
257
+ break
258
+ m = platform_re.match(line)
259
+ if m:
260
+ platform_indent = len(m.group(1))
261
+ continue
262
+
263
+ if not line or not line[0].isspace():
264
+ break
265
+ current_indent = len(line) - len(line.lstrip())
266
+ if current_indent <= platform_indent:
267
+ break
268
+ if content_base_indent is None:
269
+ content_base_indent = current_indent
270
+ result.append(line[content_base_indent:])
271
+
272
+ return result
273
+
274
+
275
+ def inject_metadata_fields(fm_lines: list[str], meta_lines: list[str]) -> list[str]:
276
+ if not meta_lines:
277
+ return fm_lines
278
+
279
+ meta_fields = []
280
+ for line in meta_lines:
281
+ if line and not line[0].isspace():
282
+ m = re.match(r'^(\S+)\s*:', line)
283
+ if m:
284
+ meta_fields.append(m.group(1))
285
+
286
+ stripped = strip_fields(fm_lines, meta_fields)
287
+ base = stripped if stripped is not None else list(fm_lines)
288
+ return base + meta_lines
289
+
290
+
291
+ def rewrite_file(path: Path, new_fm_lines: list[str], original_lines: list[str], fm_end: int) -> None:
292
+ body = original_lines[fm_end + 1:]
293
+ result = ["---"] + new_fm_lines + ["---"] + body
294
+ text = "\n".join(result)
295
+ if text and not text.endswith("\n"):
296
+ text += "\n"
297
+ path.write_text(text)
298
+
299
+
300
+ def sanitize_installed(agents_dir: Path, fields: list[str], platform: str | None = None) -> int:
301
+ if not agents_dir.is_dir():
302
+ return 0
303
+
304
+ changed = 0
305
+ for f in sorted(agents_dir.glob("*.md")):
306
+ lines = f.read_text().splitlines()
307
+ fm = find_frontmatter(lines)
308
+ if fm is None:
309
+ continue
310
+ _, fm_end = fm
311
+ fm_lines = lines[1:fm_end]
312
+
313
+ platform_meta = extract_platform_metadata(fm_lines, platform) if platform else []
314
+
315
+ stripped = strip_fields(fm_lines, fields)
316
+ new_fm = stripped if stripped is not None else list(fm_lines)
317
+
318
+ if platform_meta:
319
+ new_fm = inject_metadata_fields(new_fm, platform_meta)
320
+
321
+ if new_fm == fm_lines:
322
+ continue
323
+
324
+ rewrite_file(f, new_fm, lines, fm_end)
325
+ changed += 1
326
+ name = f.stem
327
+ removed = [k for k in fields if any(re.match(r'^' + re.escape(k) + r'\s*:', l) for l in fm_lines)]
328
+ injected = [m.group(1) for l in platform_meta if (m := re.match(r'^(\S+)\s*:', l))]
329
+ parts = []
330
+ if removed:
331
+ parts.append(f"removed {', '.join(removed)}")
332
+ if injected:
333
+ parts.append(f"injected {', '.join(injected)}")
334
+ print(f" [strip] {name} — {'; '.join(parts)}")
335
+
336
+ return changed
337
+
338
+
339
+ def sanitize_source(packages_dir: Path) -> int:
340
+ if not packages_dir.is_dir():
341
+ return 0
342
+
343
+ changed = 0
344
+ for f in sorted(packages_dir.glob("*/.apm/agents/*.agent.md")):
345
+ lines = f.read_text().splitlines()
346
+ fm = find_frontmatter(lines)
347
+ if fm is None:
348
+ continue
349
+ _, fm_end = fm
350
+ fm_lines = lines[1:fm_end]
351
+
352
+ if not has_array_tools(fm_lines):
353
+ continue
354
+
355
+ new_fm = add_permission_block(fm_lines)
356
+ if new_fm is None:
357
+ continue
358
+
359
+ rewrite_file(f, new_fm, lines, fm_end)
360
+ changed += 1
361
+ name = f.stem
362
+ tools = [l.split(":")[0].strip() for l in new_fm if l.startswith(" ") and ":" in l and l.strip() != "permission:"]
363
+ print(f" [convert] {name} — added permission ({', '.join(tools)})")
364
+
365
+ return changed
366
+
367
+
368
+ def run_apm(subcommand: str, args: list[str]) -> int:
369
+ apm_exe = shutil.which("apm")
370
+ if apm_exe is None:
371
+ print("error: 'apm' command not found in PATH", file=sys.stderr)
372
+ return 1
373
+
374
+ cmd = [apm_exe, subcommand] + args
375
+ print(f"$ {' '.join(cmd)}")
376
+ result = subprocess.run(cmd)
377
+ return result.returncode
378
+
379
+
380
+ def do_sanitize(platform_name: str | None, global_: bool, source: bool) -> None:
381
+ if platform_name is None:
382
+ for name in TARGET_STRIP_FIELDS:
383
+ print(f"[{name}]")
384
+ do_sanitize(name, global_, source)
385
+ return
386
+
387
+ fields = TARGET_STRIP_FIELDS.get(platform_name, [])
388
+ packages_dir = Path.cwd() / "packages"
389
+
390
+ changed_installed = 0
391
+
392
+ if global_:
393
+ dirs = get_global_agents_dirs(platform_name)
394
+ for d in dirs:
395
+ cnt = sanitize_installed(d, fields, platform_name)
396
+ if cnt:
397
+ print(f" ({d})")
398
+ changed_installed += cnt
399
+ else:
400
+ target_subdir = TARGET_DIRS.get(platform_name, f".{platform_name}")
401
+ target_dir = Path.cwd() / target_subdir
402
+ changed_installed = sanitize_installed(target_dir / "agents", fields, platform_name)
403
+
404
+ changed_source = 0
405
+ if source:
406
+ changed_source = sanitize_source(packages_dir)
407
+
408
+ parts = [f"{changed_installed} {'global ' if global_ else ''}installed files sanitized"]
409
+ if source:
410
+ parts.append(f"{changed_source} source files updated")
411
+ print(f"\nDone: {', '.join(parts)}")
412
+
413
+
414
+ def main() -> None:
415
+ parser = build_parser()
416
+ ns, apm_args = parser.parse_known_args(sys.argv[1:])
417
+
418
+ if ns.command == "sanitize":
419
+ do_sanitize(ns.platform, ns.global_, ns.source)
420
+ return
421
+
422
+ apm_extra: list[str] = []
423
+ if ns.global_:
424
+ apm_extra.append("-g")
425
+
426
+ if ns.command == "install":
427
+ rc = run_apm("install", apm_extra + apm_args)
428
+ if rc != 0:
429
+ sys.exit(rc)
430
+ do_sanitize(ns.platform, ns.global_, ns.source)
431
+ return
432
+
433
+ if ns.command == "update":
434
+ if ns.yes:
435
+ apm_extra.insert(0, "--yes")
436
+ if ns.dry_run:
437
+ apm_extra.insert(0, "--dry-run")
438
+ rc = run_apm("update", apm_extra + apm_args)
439
+ if rc != 0 or ns.dry_run:
440
+ if rc != 0:
441
+ sys.exit(rc)
442
+ return
443
+ do_sanitize(ns.platform, ns.global_, ns.source)
@@ -0,0 +1,7 @@
1
+ {
2
+ "opencode": ["tools", "user-invocable", "agents", "metadata"],
3
+ "copilot": ["mode", "steps", "color", "permission", "task", "metadata"],
4
+ "claude": ["mode", "steps", "color", "tools", "permission", "user-invocable", "agents", "model", "metadata"],
5
+ "cursor": ["mode", "steps", "color", "permission", "metadata"],
6
+ "codex": ["mode", "steps", "color", "permission", "metadata"]
7
+ }
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.5
2
+ Name: apmw
3
+ Version: 0.1.0
4
+ Summary: Cross-platform wrapper for the apm CLI — sanitizes agent frontmatter per target
5
+ Requires-Python: >=3.10
@@ -0,0 +1,6 @@
1
+ apmw/__init__.py,sha256=BkZpII5WVeyPMJ68A8Of2VcXtPCal6K_pEgu-4TJN50,13877
2
+ apmw/data/agent-strip-fields.json,sha256=OzumPaVWN-spMm0WZSqA6fZew_S_Elg6vx1fHT289X4,386
3
+ apmw-0.1.0.dist-info/METADATA,sha256=FnSCxc5qNKyZXw-Sq1GwkXkzcKN5A4lpFWUeo48cQqU,163
4
+ apmw-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ apmw-0.1.0.dist-info/entry_points.txt,sha256=NpuUklSa4JdJTN7uW6mc9CRix2osVPH2nkxICaVQg7w,35
6
+ apmw-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ apmw = apmw:main