@sakanano-moe/agenthelm 6.6.0 → 6.8.0
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.
- package/README.md +68 -0
- package/README.zh-CN.md +28 -0
- package/package.json +2 -2
- package/runtime/index.json +15 -15
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
- package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/agents-init/content.json +15 -0
- package/runtime/plugins/agents-init/plugin.json +2 -2
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +14 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +2 -2
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Claude Code projection of a project's capabilities: a dedicated package, sync /
|
|
2
|
+
reconcile, preserved local credential overrides, and drift reports.
|
|
3
|
+
|
|
4
|
+
Claude Code has no Agent Plugins loader, so a package is projected onto what it
|
|
5
|
+
reads: ``~/.claude/skills/<skill>`` (links), ``~/.claude.json#mcpServers`` (user
|
|
6
|
+
scope) and the install record ``~/.claude/agent-pack/plugins/<package>`` (a link
|
|
7
|
+
inventory uses to report the version).
|
|
8
|
+
|
|
9
|
+
``sync`` / ``reconcile`` build a package filtered by the project's Claude policy
|
|
10
|
+
into ``~/.claude/agent-pack/packages/<package>/`` instead of borrowing another
|
|
11
|
+
client's package directory. ``~/.claude/agent-pack/state/<package>.json`` lists
|
|
12
|
+
the Skills and MCP servers this package put into Claude; only those are updated
|
|
13
|
+
or removed, entries the user added are never touched. Local credential values
|
|
14
|
+
(``headers`` / ``env``) on an MCP entry survive updates unless reset explicitly.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import stat
|
|
23
|
+
import tempfile
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Iterable
|
|
27
|
+
|
|
28
|
+
import agent_pack_config as core
|
|
29
|
+
|
|
30
|
+
OVERRIDE_CONTAINERS = ("headers", "env")
|
|
31
|
+
STATE_SCHEMA = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- locations
|
|
35
|
+
|
|
36
|
+
def package_root(home: Path, name: str) -> Path:
|
|
37
|
+
return home / ".claude" / "agent-pack" / "packages" / name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def record_path(home: Path, name: str) -> Path:
|
|
41
|
+
return core._claude_plugin_records(home) / name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def state_path(home: Path, name: str) -> Path:
|
|
45
|
+
return home / ".claude" / "agent-pack" / "state" / f"{name}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def data_root(home: Path, name: str) -> Path:
|
|
49
|
+
return home / ".claude" / "agent-pack" / "data" / name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------- local overrides
|
|
53
|
+
|
|
54
|
+
def _placeholder(value: Any) -> bool:
|
|
55
|
+
return value in (None, "") or (isinstance(value, str) and "${" in value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merge_overrides(rendered: dict[str, Any], current: Any, *, reset: bool = False) -> tuple[dict[str, Any], list[str]]:
|
|
59
|
+
"""Keep the user's local credential values on top of the project's entry.
|
|
60
|
+
|
|
61
|
+
A ``headers`` / ``env`` key keeps its local value when the project entry
|
|
62
|
+
lacks it, leaves it as a placeholder, or the key is credential-like. Other
|
|
63
|
+
differences follow the project. Returns the merged entry and the kept key
|
|
64
|
+
names (never their values).
|
|
65
|
+
"""
|
|
66
|
+
merged = json.loads(json.dumps(rendered))
|
|
67
|
+
if reset or not isinstance(current, dict):
|
|
68
|
+
return merged, []
|
|
69
|
+
kept: list[str] = []
|
|
70
|
+
for container in OVERRIDE_CONTAINERS:
|
|
71
|
+
local = current.get(container)
|
|
72
|
+
if not isinstance(local, dict):
|
|
73
|
+
continue
|
|
74
|
+
target = dict(merged.get(container) or {}) if isinstance(merged.get(container) or {}, dict) else {}
|
|
75
|
+
for key, value in local.items():
|
|
76
|
+
ours = target.get(key)
|
|
77
|
+
credential = bool(core.SECRET_NAME_RE.search(str(key)))
|
|
78
|
+
if ours != value and (key not in target or _placeholder(ours) or credential):
|
|
79
|
+
target[key] = value
|
|
80
|
+
kept.append(f"{container}.{key}")
|
|
81
|
+
if target:
|
|
82
|
+
merged[container] = target
|
|
83
|
+
return merged, sorted(kept)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def classify_mcp(rendered: dict[str, Any], current: Any, *, reset: bool = False) -> tuple[str, dict[str, Any], list[str]]:
|
|
87
|
+
"""(state, entry to write, kept override keys); state is add / keep / keep-override / differ."""
|
|
88
|
+
if current is None:
|
|
89
|
+
return "add", json.loads(json.dumps(rendered)), []
|
|
90
|
+
merged, kept = merge_overrides(rendered, current, reset=reset)
|
|
91
|
+
if merged == current:
|
|
92
|
+
return ("keep-override" if kept else "keep"), merged, kept
|
|
93
|
+
return "differ", merged, kept
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------- reading the client
|
|
97
|
+
|
|
98
|
+
def _read_state(home: Path, name: str) -> dict[str, Any]:
|
|
99
|
+
path = state_path(home, name)
|
|
100
|
+
if not path.is_file() or path.is_symlink():
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
data = core._read_json(path)
|
|
104
|
+
except core.AgentPackError:
|
|
105
|
+
return {}
|
|
106
|
+
return data if data.get("schema_version") == STATE_SCHEMA else {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_config(home: Path) -> dict[str, Any]:
|
|
110
|
+
path = core._claude_user_config(home)
|
|
111
|
+
if not path.exists():
|
|
112
|
+
return {}
|
|
113
|
+
if path.is_symlink():
|
|
114
|
+
raise core.AgentPackError(f"refusing to edit symlinked Claude config: {path}")
|
|
115
|
+
config = core._read_json(path)
|
|
116
|
+
if not isinstance(config.get("mcpServers", {}), dict):
|
|
117
|
+
raise core.AgentPackError(f"mcpServers must be an object: {path}")
|
|
118
|
+
return config
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strings(value: Any) -> Iterable[str]:
|
|
122
|
+
if isinstance(value, str):
|
|
123
|
+
yield value
|
|
124
|
+
elif isinstance(value, list):
|
|
125
|
+
for child in value:
|
|
126
|
+
yield from _strings(child)
|
|
127
|
+
elif isinstance(value, dict):
|
|
128
|
+
for child in value.values():
|
|
129
|
+
yield from _strings(child)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _references(entry: Any, base: Path) -> bool:
|
|
133
|
+
prefix = str(base) + os.sep
|
|
134
|
+
return any(text == str(base) or prefix in text or text.startswith(prefix) for text in _strings(entry))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _link_target(path: Path) -> Path | None:
|
|
138
|
+
if not path.is_symlink():
|
|
139
|
+
return None
|
|
140
|
+
target = Path(os.readlink(path))
|
|
141
|
+
return target if target.is_absolute() else (path.parent / target)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
path.resolve().relative_to(root.resolve())
|
|
147
|
+
return True
|
|
148
|
+
except (ValueError, OSError):
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------- plan
|
|
153
|
+
|
|
154
|
+
def _render_package_mcps(package: Path, home: Path, name: str) -> dict[str, dict[str, Any]]:
|
|
155
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
156
|
+
mcp_path = package / "mcp.json"
|
|
157
|
+
if not mcp_path.is_file():
|
|
158
|
+
return servers
|
|
159
|
+
for server, raw in sorted(core._read_json(mcp_path).get("mcpServers", {}).items()):
|
|
160
|
+
parsed = core._portable_mcp(server, raw)
|
|
161
|
+
if not parsed.portable:
|
|
162
|
+
raise core.AgentPackError(f"MCP {server} is not portable: " + "; ".join(parsed.issues))
|
|
163
|
+
servers[server] = core._claude_mcp_config(parsed.config, record_path(home, name), data_root(home, name))
|
|
164
|
+
return servers
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def plan_project(project: Path, home: Path, *, built: Path, reset_overrides: bool = False,
|
|
168
|
+
retire_skills: Iterable[str] = (), retire_plugins: Iterable[str] = (),
|
|
169
|
+
retire_only: bool = False) -> dict[str, Any]:
|
|
170
|
+
"""Every change a Claude sync would make, without writing; conflicts are listed, not raised."""
|
|
171
|
+
layout = core.load_project_layout(project)
|
|
172
|
+
authority = core.inventory_project(layout.root)
|
|
173
|
+
name = core._package_name(layout.root)
|
|
174
|
+
package, record = package_root(home, name), record_path(home, name)
|
|
175
|
+
state = _read_state(home, name)
|
|
176
|
+
config = _read_config(home)
|
|
177
|
+
existing = config.get("mcpServers", {})
|
|
178
|
+
old_target = _link_target(record)
|
|
179
|
+
legacy_root = old_target if old_target is not None and not _inside(old_target, package) else None
|
|
180
|
+
|
|
181
|
+
desired_skills = {child.name for child in (built / "skills").iterdir()
|
|
182
|
+
if child.is_dir() and (child / "SKILL.md").is_file()} if (built / "skills").is_dir() else set()
|
|
183
|
+
desired_mcps = _render_package_mcps(built, home, name)
|
|
184
|
+
excluded = sorted(item.name for item in authority.mcps
|
|
185
|
+
if item.portable and not core._mcp_allowed(layout, item.name, "claude"))
|
|
186
|
+
owned_mcps = set(state.get("mcps", {}))
|
|
187
|
+
owned_mcps |= {server for server, entry in existing.items() if _references(entry, record)}
|
|
188
|
+
owned_skills = set(state.get("skills", []))
|
|
189
|
+
|
|
190
|
+
operations: list[dict[str, Any]] = []
|
|
191
|
+
conflicts: list[str] = []
|
|
192
|
+
overrides: list[dict[str, Any]] = []
|
|
193
|
+
|
|
194
|
+
if not retire_only:
|
|
195
|
+
if package.exists() and not package.is_symlink():
|
|
196
|
+
same = core._tree_fingerprint(package) == core._tree_fingerprint(built)
|
|
197
|
+
operations.append({"action": "keep-package" if same else "replace-package", "name": name, "to": str(package)})
|
|
198
|
+
else:
|
|
199
|
+
operations.append({"action": "install-package", "name": name, "to": str(package)})
|
|
200
|
+
if old_target is not None and package.is_dir() and old_target.resolve() == package.resolve():
|
|
201
|
+
operations.append({"action": "keep-record", "name": name, "to": str(record)})
|
|
202
|
+
elif record.is_symlink() or record.exists():
|
|
203
|
+
detail = {"from": str(old_target)} if old_target is not None else {}
|
|
204
|
+
operations.append({"action": "relink-record", "name": name, "to": str(record), **detail})
|
|
205
|
+
else:
|
|
206
|
+
operations.append({"action": "link-record", "name": name, "to": str(record)})
|
|
207
|
+
|
|
208
|
+
skills_root = home / ".claude" / "skills"
|
|
209
|
+
for skill in sorted(desired_skills):
|
|
210
|
+
path, target = skills_root / skill, package / "skills" / skill
|
|
211
|
+
link = _link_target(path)
|
|
212
|
+
ours = skill in owned_skills or (link is not None and (
|
|
213
|
+
_inside(link, package) or (legacy_root is not None and _inside(link, legacy_root))))
|
|
214
|
+
if not path.exists() and not path.is_symlink():
|
|
215
|
+
action = "link-skill"
|
|
216
|
+
elif link is not None and link == target:
|
|
217
|
+
action = "keep-skill"
|
|
218
|
+
elif ours:
|
|
219
|
+
action = "relink-skill"
|
|
220
|
+
else:
|
|
221
|
+
action = "conflict-skill"
|
|
222
|
+
conflicts.append(f"skill {skill}: {path}")
|
|
223
|
+
operations.append({"action": action, "name": skill, "to": str(path)})
|
|
224
|
+
for skill in sorted(owned_skills - desired_skills):
|
|
225
|
+
path = skills_root / skill
|
|
226
|
+
if path.is_symlink() or path.exists():
|
|
227
|
+
operations.append({"action": "remove-skill", "name": skill, "from": str(path)})
|
|
228
|
+
if legacy_root is not None and skills_root.is_dir():
|
|
229
|
+
for path in sorted(skills_root.iterdir()):
|
|
230
|
+
link = _link_target(path)
|
|
231
|
+
if (path.name not in desired_skills and path.name not in owned_skills and link is not None
|
|
232
|
+
and _inside(link, legacy_root)):
|
|
233
|
+
operations.append({"action": "remove-skill", "name": path.name, "from": str(path)})
|
|
234
|
+
|
|
235
|
+
for server, rendered in desired_mcps.items():
|
|
236
|
+
state_name, entry, kept = classify_mcp(rendered, existing.get(server), reset=reset_overrides)
|
|
237
|
+
if state_name == "differ":
|
|
238
|
+
if server in owned_mcps:
|
|
239
|
+
state_name = "update"
|
|
240
|
+
else:
|
|
241
|
+
state_name = "conflict"
|
|
242
|
+
conflicts.append(f"mcp {server}: {core._claude_user_config(home)}")
|
|
243
|
+
if kept:
|
|
244
|
+
overrides.append({"name": server, "keys": kept})
|
|
245
|
+
operations.append({"action": f"{state_name}-mcp", "name": server, "keys": kept, "_entry": entry})
|
|
246
|
+
for server in sorted(owned_mcps - set(desired_mcps)):
|
|
247
|
+
if server in existing:
|
|
248
|
+
operations.append({"action": "remove-mcp", "name": server,
|
|
249
|
+
"reason": "excluded for claude" if server in excluded else "no longer in project"})
|
|
250
|
+
|
|
251
|
+
client_inventory = core.inventory_client("claude", home)
|
|
252
|
+
actual_skills = {item.name: Path(item.path) for item in client_inventory.skills}
|
|
253
|
+
actual_plugins = {str(item.get("name")): Path(str(item.get("path")))
|
|
254
|
+
for item in client_inventory.plugins if item.get("name") and item.get("path")}
|
|
255
|
+
for skill in sorted(set(retire_skills)):
|
|
256
|
+
if skill in desired_skills:
|
|
257
|
+
raise core.AgentPackError(f"cannot retire Skill provided by the project package: {skill}")
|
|
258
|
+
path = actual_skills.get(skill, home / ".claude" / "skills" / skill)
|
|
259
|
+
if not path.exists() and not path.is_symlink():
|
|
260
|
+
raise core.AgentPackError(f"retire Skill not installed: {skill}")
|
|
261
|
+
operations.append({"action": "archive-skill", "name": skill, "from": str(path)})
|
|
262
|
+
for plugin in sorted(set(retire_plugins)):
|
|
263
|
+
if plugin == name:
|
|
264
|
+
raise core.AgentPackError(f"cannot retire active project package: {plugin}")
|
|
265
|
+
path = actual_plugins.get(plugin)
|
|
266
|
+
if path is None:
|
|
267
|
+
raise core.AgentPackError(f"retire plugin not installed: {plugin}")
|
|
268
|
+
operations.append({"action": "archive-plugin", "name": plugin, "from": str(path)})
|
|
269
|
+
if retire_only and not (retire_skills or retire_plugins):
|
|
270
|
+
raise core.AgentPackError("retire-only reconcile needs at least one --retire-skill or --retire-plugin")
|
|
271
|
+
|
|
272
|
+
native = sorted(server for server in existing if server not in desired_mcps and server not in owned_mcps)
|
|
273
|
+
return {"name": name, "layout": layout, "operations": operations, "conflicts": conflicts,
|
|
274
|
+
"retire_only": retire_only,
|
|
275
|
+
"overrides": overrides, "excluded": [server for server in excluded if server in existing],
|
|
276
|
+
"native": native, "legacy_root": str(legacy_root) if legacy_root else None,
|
|
277
|
+
"desired_skills": sorted(desired_skills), "desired_mcps": sorted(desired_mcps)}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _public(operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
281
|
+
return [{key: value for key, value in item.items() if not key.startswith("_") and value != []}
|
|
282
|
+
| {"client": "claude"} for item in operations]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------- apply
|
|
286
|
+
|
|
287
|
+
def sync_project(project: Path, *, home: Path | None = None, apply: bool = False, replace: bool = False,
|
|
288
|
+
reset_overrides: bool = False, recovery_root: Path | None = None,
|
|
289
|
+
retire_skills: Iterable[str] = (), retire_plugins: Iterable[str] = (),
|
|
290
|
+
retire_only: bool = False) -> dict[str, Any]:
|
|
291
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
292
|
+
retire_skills, retire_plugins = list(retire_skills), list(retire_plugins)
|
|
293
|
+
with tempfile.TemporaryDirectory(prefix="agent-pack-claude-") as temporary:
|
|
294
|
+
layout = core.load_project_layout(project)
|
|
295
|
+
authority = core.inventory_project(layout.root)
|
|
296
|
+
name = core._package_name(layout.root)
|
|
297
|
+
built = core._build_package(authority, name, Path(temporary), "claude")
|
|
298
|
+
plan = plan_project(project, home, built=built, reset_overrides=reset_overrides,
|
|
299
|
+
retire_skills=retire_skills, retire_plugins=retire_plugins, retire_only=retire_only)
|
|
300
|
+
if plan["conflicts"] and not replace:
|
|
301
|
+
raise core.AgentPackError("Claude client already has different content not installed by this package "
|
|
302
|
+
"(use --replace; replaced items go to recovery): " + "; ".join(plan["conflicts"]))
|
|
303
|
+
result: dict[str, Any] = {
|
|
304
|
+
"ok": True, "applied": False, "client": "claude", "package": name,
|
|
305
|
+
"operations": _public(plan["operations"]), "overrides": plan["overrides"],
|
|
306
|
+
"excluded": plan["excluded"], "native": plan["native"],
|
|
307
|
+
"notes": ["restart Claude Code to load changed Skills and MCP servers"],
|
|
308
|
+
}
|
|
309
|
+
if plan["legacy_root"]:
|
|
310
|
+
result["migrated_from"] = plan["legacy_root"]
|
|
311
|
+
changes = [item for item in plan["operations"] if not item["action"].startswith("keep-")]
|
|
312
|
+
if not apply or not changes:
|
|
313
|
+
result["applied"] = apply and not changes
|
|
314
|
+
return result
|
|
315
|
+
result["recovery"] = str(_apply(plan, built, home, recovery_root))
|
|
316
|
+
result["applied"] = True
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _apply(plan: dict[str, Any], built: Path, home: Path, recovery_root: Path | None) -> Path:
|
|
321
|
+
name = plan["name"]
|
|
322
|
+
package, record = package_root(home, name), record_path(home, name)
|
|
323
|
+
recovery_base = (recovery_root or home / ".agent-pack" / "recovery").expanduser().resolve()
|
|
324
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
325
|
+
transaction = recovery_base / f"{stamp}-claude-{name}"
|
|
326
|
+
transaction.mkdir(parents=True, mode=0o700)
|
|
327
|
+
config_path = core._claude_user_config(home)
|
|
328
|
+
config = _read_config(home)
|
|
329
|
+
original_config = json.loads(json.dumps(config))
|
|
330
|
+
config_written = False
|
|
331
|
+
moved: list[tuple[Path, Path]] = [] # (archived, original)
|
|
332
|
+
created: list[Path] = []
|
|
333
|
+
actions = {(item["action"], item["name"]): item for item in plan["operations"]}
|
|
334
|
+
|
|
335
|
+
def archive(path: Path, bucket: str, item_name: str) -> None:
|
|
336
|
+
archived = transaction / bucket / item_name
|
|
337
|
+
archived.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
os.replace(path, archived)
|
|
339
|
+
moved.append((archived, path))
|
|
340
|
+
|
|
341
|
+
def link(path: Path, target: Path) -> None:
|
|
342
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
os.symlink(target, path, target_is_directory=True)
|
|
344
|
+
created.append(path)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
if ("install-package", name) in actions or ("replace-package", name) in actions:
|
|
348
|
+
package.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
staging = Path(tempfile.mkdtemp(prefix=".agent-pack-", dir=package.parent))
|
|
350
|
+
try:
|
|
351
|
+
staged = staging / name
|
|
352
|
+
shutil.copytree(built, staged, symlinks=False)
|
|
353
|
+
if package.exists():
|
|
354
|
+
archive(package, "replaced-package", name)
|
|
355
|
+
os.replace(staged, package)
|
|
356
|
+
created.append(package)
|
|
357
|
+
finally:
|
|
358
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
359
|
+
if ("relink-record", name) in actions or ("link-record", name) in actions:
|
|
360
|
+
if record.is_symlink() or record.exists():
|
|
361
|
+
previous = _link_target(record)
|
|
362
|
+
core._write_json_atomic(transaction / "record.json",
|
|
363
|
+
{"record": str(record), "target": str(previous) if previous else None}, mode=0o600)
|
|
364
|
+
archive(record, "replaced-records", name)
|
|
365
|
+
link(record, package)
|
|
366
|
+
for item in plan["operations"]:
|
|
367
|
+
action, item_name = item["action"], item["name"]
|
|
368
|
+
if action in ("relink-skill", "conflict-skill", "remove-skill", "archive-skill"):
|
|
369
|
+
path = Path(item.get("to") or item["from"])
|
|
370
|
+
if path.is_symlink() or path.exists():
|
|
371
|
+
archive(path, "replaced-skills" if action != "archive-skill" else "skills", item_name)
|
|
372
|
+
if action in ("link-skill", "relink-skill", "conflict-skill"):
|
|
373
|
+
link(Path(item["to"]), package / "skills" / item_name)
|
|
374
|
+
if action == "archive-plugin":
|
|
375
|
+
archive(Path(item["from"]), "plugins", item_name)
|
|
376
|
+
servers = config.setdefault("mcpServers", {})
|
|
377
|
+
replaced: dict[str, Any] = {}
|
|
378
|
+
mcp_state: dict[str, str] = {}
|
|
379
|
+
for item in plan["operations"]:
|
|
380
|
+
action, server = item["action"], item["name"]
|
|
381
|
+
if action in ("add-mcp", "update-mcp", "conflict-mcp", "keep-mcp", "keep-override-mcp"):
|
|
382
|
+
if action in ("update-mcp", "conflict-mcp") and server in servers:
|
|
383
|
+
replaced[server] = servers[server]
|
|
384
|
+
servers[server] = item["_entry"]
|
|
385
|
+
mcp_state[server] = hashlib.sha256(
|
|
386
|
+
json.dumps(item["_entry"], sort_keys=True).encode("utf-8")).hexdigest()
|
|
387
|
+
elif action == "remove-mcp":
|
|
388
|
+
replaced[server] = servers.pop(server)
|
|
389
|
+
if replaced:
|
|
390
|
+
core._write_json_atomic(transaction / "replaced-mcp.json", {"mcpServers": replaced}, mode=0o600)
|
|
391
|
+
if config != original_config:
|
|
392
|
+
mode = stat.S_IMODE(config_path.stat().st_mode) if config_path.exists() else 0o600
|
|
393
|
+
core._write_json_atomic(config_path, config, mode=mode)
|
|
394
|
+
config_written = True
|
|
395
|
+
if not plan["retire_only"]:
|
|
396
|
+
core._write_json_atomic(state_path(home, name), {
|
|
397
|
+
"schema_version": STATE_SCHEMA, "package": name, "skills": plan["desired_skills"],
|
|
398
|
+
"mcps": mcp_state, "updated_at": stamp}, mode=0o600)
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
if config_written:
|
|
401
|
+
core._write_json_atomic(config_path, original_config, mode=0o600)
|
|
402
|
+
for path in reversed(created):
|
|
403
|
+
if path.is_symlink():
|
|
404
|
+
path.unlink()
|
|
405
|
+
elif path.is_dir():
|
|
406
|
+
shutil.rmtree(path)
|
|
407
|
+
for archived, original in reversed(moved):
|
|
408
|
+
if (archived.exists() or archived.is_symlink()) and not (original.exists() or original.is_symlink()):
|
|
409
|
+
original.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
os.replace(archived, original)
|
|
411
|
+
raise core.AgentPackError(f"claude sync rolled back: {exc}") from exc
|
|
412
|
+
return transaction
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------- drift (doctor)
|
|
416
|
+
|
|
417
|
+
def drift(project: Path, home: Path | None = None) -> dict[str, Any]:
|
|
418
|
+
home = (home or Path.home()).expanduser().resolve()
|
|
419
|
+
with tempfile.TemporaryDirectory(prefix="agent-pack-claude-") as temporary:
|
|
420
|
+
layout = core.load_project_layout(project)
|
|
421
|
+
name = core._package_name(layout.root)
|
|
422
|
+
built = core._build_package(core.inventory_project(layout.root), name, Path(temporary), "claude")
|
|
423
|
+
plan = plan_project(project, home, built=built)
|
|
424
|
+
report: dict[str, Any] = {"client": "claude", "package": name, "missing": [], "different": [],
|
|
425
|
+
"overrides": plan["overrides"], "stale": [], "native": plan["native"],
|
|
426
|
+
"excluded": plan["excluded"], "dangling": [], "coupled": plan["legacy_root"]}
|
|
427
|
+
for item in plan["operations"]:
|
|
428
|
+
action, item_name = item["action"], item["name"]
|
|
429
|
+
if action in ("install-package", "link-record", "link-skill", "add-mcp"):
|
|
430
|
+
report["missing"].append(f"{action.split('-')[1]} {item_name}")
|
|
431
|
+
elif action in ("replace-package", "relink-skill", "conflict-skill", "update-mcp", "conflict-mcp"):
|
|
432
|
+
report["different"].append(f"{action.split('-')[-1]} {item_name}")
|
|
433
|
+
elif action in ("remove-skill", "remove-mcp"):
|
|
434
|
+
report["stale"].append(f"{action.split('-')[1]} {item_name}")
|
|
435
|
+
skills_root = home / ".claude" / "skills"
|
|
436
|
+
if skills_root.is_dir():
|
|
437
|
+
report["dangling"] += [f"skill {path.name}" for path in sorted(skills_root.iterdir())
|
|
438
|
+
if path.is_symlink() and not path.exists()]
|
|
439
|
+
record = record_path(home, name)
|
|
440
|
+
config = _read_config(home).get("mcpServers", {})
|
|
441
|
+
for server, entry in sorted(config.items()):
|
|
442
|
+
for text in _strings(entry):
|
|
443
|
+
if text.startswith(str(record) + os.sep) and not Path(text).exists():
|
|
444
|
+
report["dangling"].append(f"mcp {server}: {text}")
|
|
445
|
+
break
|
|
446
|
+
report["in_sync"] = not (report["missing"] or report["different"] or report["stale"]
|
|
447
|
+
or report["dangling"] or report["coupled"])
|
|
448
|
+
return report
|