-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsync_helpers.py
More file actions
282 lines (222 loc) · 9.68 KB
/
Copy pathsync_helpers.py
File metadata and controls
282 lines (222 loc) · 9.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""Shared sync logic for applying cached configs to target tools.
Used by `apc sync`, `apc skill sync`, `apc memory sync`, and `apc mcp sync`.
"""
from typing import Dict, List, Optional, Tuple
from appliers import get_applier
from cache import load_local_bundle, load_mcp_servers
from extractors import detect_installed_tools
from secrets_manager import retrieve_secret
from skills import get_skills_dir
from ui import error, numbered_selection, success, warning
def _resolve_all_mcp_secrets(mcp_servers: List[Dict]) -> Dict[str, str]:
"""Collect all secret_placeholders from MCP servers and resolve from keychain."""
secrets: Dict[str, str] = {}
for srv in mcp_servers:
for key in srv.get("secret_placeholders", []):
if key not in secrets:
value = retrieve_secret("local", key)
if value:
secrets[key] = value
return secrets
def _discover_installed_skills() -> List[dict]:
"""Find installed skills from ~/.apc/skills/ (directories with SKILL.md)."""
skills_dir = get_skills_dir()
if not skills_dir.exists():
return []
return [
{"name": d.name}
for d in sorted(skills_dir.iterdir())
if d.is_dir() and (d / "SKILL.md").exists()
]
def count_installed_skills() -> int:
"""Count installed skills in ~/.apc/skills/. Used for summary display."""
return len(_discover_installed_skills())
def resolve_target_tools(tools_flag: Optional[str], apply_all: bool) -> List[str]:
"""Resolve target tools from --tools flag, --all flag, or interactive selection."""
if tools_flag is not None:
tool_list = [t.strip() for t in tools_flag.split(",") if t.strip()]
if not tool_list:
warning("--tools requires at least one tool name (e.g. --tools cursor,gemini)")
return []
return tool_list
if apply_all:
tool_list = detect_installed_tools()
if not tool_list:
warning("No AI tools detected on this machine.")
return tool_list
# Interactive selection
detected = detect_installed_tools()
if not detected:
warning("No AI tools detected on this machine.")
return []
indices = numbered_selection(detected, "Select tools to apply to")
return [detected[i] for i in indices]
def sync_skills(tool_list: List[str]) -> Tuple[int, int]:
"""Apply all skills to tools. Returns (copy_count, link_count).
Two skill sources:
1. Collected skills (from cache, have body inline) -> apply_skills() copy mode
2. Installed skills (from ~/.apc/skills/, have SKILL.md files) -> link_skills() symlink mode
"""
bundle = load_local_bundle()
collected_skills = bundle["skills"]
skills_dir = get_skills_dir()
installed_skills = _discover_installed_skills()
total_copy = 0
total_link = 0
# Build combined name list for pruning
all_skill_names = list(
{s.get("name", "unnamed") for s in collected_skills}
| {s.get("name", "unnamed") for s in installed_skills}
)
for tool_name in tool_list:
try:
applier = get_applier(tool_name)
manifest = applier.get_manifest()
# Per-tool counts (reset each iteration)
tool_copy = 0
tool_link = 0
# Copy collected skills
if collected_skills:
tool_copy = applier.apply_skills(collected_skills, manifest)
total_copy += tool_copy
# Link installed skills
if installed_skills:
tool_link = applier.link_skills(installed_skills, skills_dir, manifest)
total_link += tool_link
# Prune orphaned skills (keep MCP names empty — not our concern)
applier.prune(all_skill_names, [], manifest)
manifest.save()
success(f"{tool_name}: {tool_copy} copied, {tool_link} linked")
except Exception as e:
error(f"Failed to sync skills to {tool_name}: {e}")
return total_copy, total_link
def sync_mcp(tool_list: List[str], override: bool = False) -> int:
"""Apply MCP servers from cache to tools. Returns count."""
mcp_servers = load_mcp_servers()
if not mcp_servers:
warning("No MCP servers in cache. Run 'apc collect' first.")
return 0
# Warn once if any server has secrets that will be written to disk (#32)
servers_with_secrets = [s for s in mcp_servers if s.get("secret_placeholders")]
if servers_with_secrets:
warning(
f"{len(servers_with_secrets)} MCP server(s) have secrets that will be resolved "
"and written to tool config files (chmod 600). "
"Ensure those files are excluded from version control."
)
current_mcp_names = [s.get("name", "unnamed") for s in mcp_servers]
total = 0
for tool_name in tool_list:
try:
applier = get_applier(tool_name)
manifest = applier.get_manifest()
secrets = _resolve_all_mcp_secrets(mcp_servers)
m = applier.apply_mcp_servers(mcp_servers, secrets, manifest, override=override)
# Prune orphaned MCP servers (keep skill names empty — not our concern)
applier.prune([], current_mcp_names, manifest)
manifest.save()
total += m
success(f"{tool_name}: {m} MCP servers")
except Exception as e:
error(f"Failed to sync MCP to {tool_name}: {e}")
return total
def sync_memory(tool_list: List[str]) -> int:
"""Apply memory via LLM transformation to tools. Returns count.
Returns -1 if every tool's LLM call failed (no ✓ should be shown).
"""
bundle = load_local_bundle()
memory_entries = bundle["memory"]
if not memory_entries:
warning("No memory entries in cache. Run 'apc collect' or 'apc memory add' first.")
return 0
total = 0
any_llm_failure = False
for tool_name in tool_list:
try:
applier = get_applier(tool_name)
manifest = applier.get_manifest()
mem = applier.apply_memory_via_llm(memory_entries, manifest)
if mem < 0:
# LLM auth/call failed — do not show ✓, do not mark as synced (#31)
any_llm_failure = True
manifest.save_failure(f"LLM call failed for {tool_name}")
error(f"{tool_name}: memory sync failed (LLM unavailable — run 'apc configure')")
else:
manifest.save()
total += mem
success(f"{tool_name}: {mem} memory files")
except Exception as e:
error(f"Failed to sync memory to {tool_name}: {e}")
if any_llm_failure and total == 0:
return -1
return total
def sync_all(tool_list: List[str], no_memory: bool = False, override_mcp: bool = False) -> bool:
"""Apply everything (skills + MCP + memory). Used by `apc sync`.
Returns True if at least one tool was synced successfully, False otherwise.
"""
bundle = load_local_bundle()
collected_skills = bundle["skills"]
mcp_servers = bundle["mcp_servers"]
memory_entries = bundle["memory"] if not no_memory else []
skills_dir = get_skills_dir()
installed_skills = _discover_installed_skills()
# Build combined name lists for pruning
all_skill_names = list(
{s.get("name", "unnamed") for s in collected_skills}
| {s.get("name", "unnamed") for s in installed_skills}
)
current_mcp_names = [s.get("name", "unnamed") for s in mcp_servers]
total_skills = 0
total_mcp = 0
total_memory = 0
failed_tools = []
for tool_name in tool_list:
manifest = None
try:
applier = get_applier(tool_name)
manifest = applier.get_manifest()
# Copy collected skills
s = applier.apply_skills(collected_skills, manifest)
# Link installed skills
lk = applier.link_skills(installed_skills, skills_dir, manifest)
# MCP servers
secrets = _resolve_all_mcp_secrets(mcp_servers)
m = applier.apply_mcp_servers(mcp_servers, secrets, manifest, override=override_mcp)
# Memory (-1 means LLM call failed, not a hard error for overall sync)
mem = 0
mem_failed = False
if memory_entries:
mem = applier.apply_memory_via_llm(memory_entries, manifest)
if mem < 0:
mem_failed = True
mem = 0
# Prune orphans
applier.prune(all_skill_names, current_mcp_names, manifest)
if mem_failed:
# Save manifest but mark memory as failed (#31, #34)
manifest.save_failure(f"LLM memory sync failed for {tool_name}")
mem_label = "memory sync failed"
else:
manifest.save()
mem_label = f"{mem} memory files"
total_skills += s + lk
total_mcp += m
total_memory += mem
success(f"{tool_name}: {s + lk} skills, {m} MCP servers, {mem_label}")
except Exception as e:
error(f"Failed to apply to {tool_name}: {e}")
failed_tools.append(tool_name)
# Persist failure so `apc status` reflects the error (#34)
if manifest is not None:
try:
manifest.save_failure(str(e))
except Exception:
pass
any_success = len(failed_tools) < len(tool_list)
if any_success:
success(
f"\nSynced: {total_skills} skills, {total_mcp} MCP servers, {total_memory} memory files"
)
elif failed_tools:
warning(f"\nSync failed for all tools: {', '.join(failed_tools)}")
return any_success