@smilintux/skmemory 0.5.0 → 0.9.2

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.
Files changed (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,580 @@
1
+ """SK* skill, MCP server, and OpenClaw plugin auto-registration.
2
+
3
+ Detects the user's development environments (OpenClaw, Claude Code, Cursor,
4
+ VS Code, OpenCode CLI, mcporter) and registers SKILL.md symlinks, MCP
5
+ server entries, and OpenClaw plugin manifests so everything works out-of-the-box.
6
+
7
+ This module is the shared engine — individual packages call it, and
8
+ skcapstone orchestrates registration for the whole suite.
9
+
10
+ Usage (from any SK* package):
11
+ from skmemory.register import register_package, detect_environments
12
+
13
+ register_package(
14
+ name="skmemory",
15
+ skill_md_path=Path(__file__).parent / "SKILL.md",
16
+ mcp_command="skmemory-mcp",
17
+ mcp_args=[],
18
+ openclaw_plugin_path=Path(__file__).parent.parent / "openclaw-plugin" / "src" / "index.ts",
19
+ )
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ from pathlib import Path
29
+
30
+ # ── Environment detection ────────────────────────────────────────────────────
31
+
32
+
33
+ def detect_environments() -> list[str]:
34
+ """Return list of detected development environments.
35
+
36
+ Checks for:
37
+ - openclaw: OpenClaw CLI installed
38
+ - claude-code: ~/.claude/ directory
39
+ - cursor: ~/.cursor/ directory
40
+ - vscode: ~/.vscode/ or ~/.config/Code/ directory
41
+ - opencode: ~/.opencode/ or opencode binary
42
+ - mcporter: mcporter.json in known locations
43
+
44
+ Returns:
45
+ List of environment identifiers (e.g. ["claude-code", "mcporter"]).
46
+ """
47
+ home = Path.home()
48
+ envs: list[str] = []
49
+
50
+ # OpenClaw
51
+ openclaw_lib = home / ".npm-global" / "lib" / "node_modules" / "openclaw"
52
+ if openclaw_lib.is_dir() or shutil.which("openclaw"):
53
+ envs.append("openclaw")
54
+
55
+ # Claude Code
56
+ if (home / ".claude").is_dir():
57
+ envs.append("claude-code")
58
+
59
+ # Cursor
60
+ if (home / ".cursor").is_dir():
61
+ envs.append("cursor")
62
+
63
+ # VS Code
64
+ if (home / ".vscode").is_dir() or (home / ".config" / "Code").is_dir():
65
+ envs.append("vscode")
66
+
67
+ # OpenCode CLI
68
+ if (home / ".opencode").is_dir() or shutil.which("opencode"):
69
+ envs.append("opencode")
70
+
71
+ # mcporter
72
+ mcporter_paths = [
73
+ home / "clawd" / "config" / "mcporter.json",
74
+ home / ".config" / "mcporter" / "mcporter.json",
75
+ ]
76
+ for p in mcporter_paths:
77
+ if p.is_file():
78
+ envs.append("mcporter")
79
+ break
80
+
81
+ return envs
82
+
83
+
84
+ # ── Skill registration ───────────────────────────────────────────────────────
85
+
86
+
87
+ def register_skill(
88
+ name: str,
89
+ skill_md_path: Path,
90
+ workspace: Path | None = None,
91
+ ) -> dict:
92
+ """Register a skill by symlinking its SKILL.md into the workspace skills dir.
93
+
94
+ Args:
95
+ name: Package/skill name (e.g. "skmemory").
96
+ skill_md_path: Absolute path to the source SKILL.md file.
97
+ workspace: Workspace root (defaults to ~/clawd/).
98
+
99
+ Returns:
100
+ Dict with 'skill' key describing what was done.
101
+ """
102
+ if workspace is None:
103
+ workspace = Path.home() / "clawd"
104
+
105
+ skill_dir = workspace / "skills" / name
106
+ target = skill_dir / "SKILL.md"
107
+
108
+ result: dict = {"name": name, "action": "skip", "path": str(target)}
109
+
110
+ if not skill_md_path.exists():
111
+ result["action"] = "error"
112
+ result["error"] = f"Source SKILL.md not found: {skill_md_path}"
113
+ return result
114
+
115
+ skill_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ # If target already exists and is correct, skip
118
+ if target.is_symlink():
119
+ try:
120
+ if target.resolve() == skill_md_path.resolve():
121
+ result["action"] = "exists"
122
+ return result
123
+ except OSError:
124
+ pass
125
+ # Remove broken or wrong symlink
126
+ target.unlink()
127
+
128
+ if target.exists():
129
+ result["action"] = "exists"
130
+ return result
131
+
132
+ # Create symlink — prefer relative path
133
+ try:
134
+ rel = os.path.relpath(skill_md_path, skill_dir)
135
+ target.symlink_to(rel)
136
+ except (ValueError, OSError):
137
+ target.symlink_to(skill_md_path)
138
+
139
+ result["action"] = "created"
140
+ return result
141
+
142
+
143
+ # ── MCP registration ─────────────────────────────────────────────────────────
144
+
145
+
146
+ def _read_json(path: Path) -> dict:
147
+ """Read a JSON file, returning empty dict on error."""
148
+ try:
149
+ return json.loads(path.read_text())
150
+ except (FileNotFoundError, json.JSONDecodeError):
151
+ return {}
152
+
153
+
154
+ def _write_json(path: Path, data: dict) -> None:
155
+ """Write dict as pretty-printed JSON."""
156
+ path.parent.mkdir(parents=True, exist_ok=True)
157
+ path.write_text(json.dumps(data, indent=2) + "\n")
158
+
159
+
160
+ def _upsert_mcp_entry(
161
+ path: Path,
162
+ name: str,
163
+ command: str,
164
+ args: list,
165
+ env: dict | None = None,
166
+ ) -> str:
167
+ """Add or update an MCP server entry in a JSON config file.
168
+
169
+ Expects the file to have a top-level "mcpServers" key.
170
+
171
+ Args:
172
+ path: Path to the JSON config file.
173
+ name: Server name.
174
+ command: Command to run.
175
+ args: Command arguments.
176
+ env: Optional environment variables.
177
+
178
+ Returns:
179
+ "created", "updated", or "exists".
180
+ """
181
+ data = _read_json(path)
182
+ servers = data.setdefault("mcpServers", {})
183
+
184
+ entry: dict = {"command": command, "args": args}
185
+ if env:
186
+ entry["env"] = env
187
+
188
+ if name in servers:
189
+ if servers[name] == entry:
190
+ return "exists"
191
+ servers[name] = entry
192
+ _write_json(path, data)
193
+ return "updated"
194
+
195
+ servers[name] = entry
196
+ _write_json(path, data)
197
+ return "created"
198
+
199
+
200
+ def register_mcp(
201
+ name: str,
202
+ command: str,
203
+ args: list,
204
+ env: dict | None = None,
205
+ environments: list[str] | None = None,
206
+ ) -> dict:
207
+ """Register an MCP server in detected (or specified) environments.
208
+
209
+ Writes to:
210
+ - claude-code: ~/.claude/mcp.json
211
+ - cursor: ~/.cursor/mcp.json
212
+ - vscode: (skipped — requires workspace .vscode/)
213
+ - opencode: ~/.opencode/mcp.json
214
+ - mcporter: ~/clawd/config/mcporter.json or ~/.config/mcporter/mcporter.json
215
+
216
+ Args:
217
+ name: Server name (e.g. "skmemory").
218
+ command: Command to run (e.g. "skmemory-mcp").
219
+ args: Command arguments.
220
+ env: Optional environment variables.
221
+ environments: Target environments. If None, auto-detect.
222
+
223
+ Returns:
224
+ Dict mapping environment -> action taken.
225
+ """
226
+ if environments is None:
227
+ environments = detect_environments()
228
+
229
+ home = Path.home()
230
+ results: dict = {}
231
+
232
+ env_to_path: dict[str, Path] = {
233
+ "claude-code": home / ".claude" / "mcp.json",
234
+ "cursor": home / ".cursor" / "mcp.json",
235
+ "opencode": home / ".opencode" / "mcp.json",
236
+ }
237
+
238
+ # OpenClaw: uses mcporter for MCP — no native mcpServers key in
239
+ # openclaw.json. MCP registration for OpenClaw happens via mcporter.
240
+
241
+ # mcporter: find first existing file
242
+ mcporter_candidates = [
243
+ home / "clawd" / "config" / "mcporter.json",
244
+ home / ".config" / "mcporter" / "mcporter.json",
245
+ ]
246
+ for p in mcporter_candidates:
247
+ if p.is_file():
248
+ env_to_path["mcporter"] = p
249
+ break
250
+
251
+ for env_name in environments:
252
+ path = env_to_path.get(env_name)
253
+ if path is None:
254
+ continue
255
+ try:
256
+ action = _upsert_mcp_entry(path, name, command, args, env)
257
+ results[env_name] = action
258
+ except Exception as exc:
259
+ results[env_name] = f"error: {exc}"
260
+
261
+ return results
262
+
263
+
264
+ # ── OpenClaw plugin registration ─────────────────────────────────────────────
265
+
266
+
267
+ def _get_openclaw_json_path() -> Path:
268
+ """Return path to ~/.openclaw/openclaw.json."""
269
+ return Path.home() / ".openclaw" / "openclaw.json"
270
+
271
+
272
+ def _is_openclaw_plugin_registered(plugin_path: Path) -> bool:
273
+ """Check if plugin_path is already in openclaw.json plugins.load.paths."""
274
+ oc_json = _get_openclaw_json_path()
275
+ if not oc_json.is_file():
276
+ return False
277
+ data = _read_json(oc_json)
278
+ paths = data.get("plugins", {}).get("load", {}).get("paths", [])
279
+ resolved = str(plugin_path.resolve())
280
+ return any(str(Path(p).resolve()) == resolved for p in paths)
281
+
282
+
283
+ def _upsert_openclaw_plugin_path(plugin_id: str, plugin_path: Path) -> None:
284
+ """Add plugin to plugins.load.paths and plugins.installs in openclaw.json."""
285
+ oc_json = _get_openclaw_json_path()
286
+ data = _read_json(oc_json)
287
+
288
+ plugins = data.setdefault("plugins", {})
289
+ load = plugins.setdefault("load", {})
290
+ paths = load.setdefault("paths", [])
291
+
292
+ resolved = str(plugin_path.resolve())
293
+ if resolved not in paths:
294
+ paths.append(resolved)
295
+
296
+ installs = plugins.setdefault("installs", {})
297
+ # Point to the plugin directory (parent of src/index.ts)
298
+ plugin_dir = str(plugin_path.resolve().parent.parent)
299
+ installs[plugin_id] = {"path": plugin_dir, "linked": True}
300
+
301
+ _write_json(oc_json, data)
302
+
303
+
304
+ def _ensure_openclaw_plugin_enabled(plugin_id: str) -> None:
305
+ """Set plugins.entries.<plugin_id>.enabled = true (idempotent)."""
306
+ oc_json = _get_openclaw_json_path()
307
+ data = _read_json(oc_json)
308
+
309
+ entries = data.setdefault("plugins", {}).setdefault("entries", {})
310
+ entry = entries.setdefault(plugin_id, {})
311
+
312
+ if entry.get("enabled") is True:
313
+ return
314
+
315
+ entry["enabled"] = True
316
+ _write_json(oc_json, data)
317
+
318
+
319
+ def _detect_plugin_id(plugin_path: Path, fallback: str) -> str:
320
+ """Read plugin id from openclaw.plugin.json manifest."""
321
+ manifest = plugin_path.parent / "openclaw.plugin.json"
322
+ if manifest.is_file():
323
+ try:
324
+ data = json.loads(manifest.read_text())
325
+ return data.get("id", fallback)
326
+ except (json.JSONDecodeError, KeyError):
327
+ pass
328
+ return fallback
329
+
330
+
331
+ def register_openclaw_plugin(
332
+ plugin_id: str,
333
+ plugin_path: Path,
334
+ dry_run: bool = False,
335
+ ) -> str:
336
+ """Register an OpenClaw plugin. Returns 'exists', 'created', or 'error:...'.
337
+
338
+ 1. Check _is_openclaw_plugin_registered() -> skip if already registered
339
+ 2. Try `openclaw plugins install --link <path>` via subprocess
340
+ 3. Fallback: write openclaw.json directly if CLI fails
341
+ 4. _ensure_openclaw_plugin_enabled()
342
+
343
+ Args:
344
+ plugin_id: Plugin identifier (e.g. "skmemory").
345
+ plugin_path: Path to the plugin entry point (e.g. src/index.ts).
346
+ dry_run: If True, only report what would be done.
347
+
348
+ Returns:
349
+ Action taken: "exists", "created", "dry-run", or "error:...".
350
+ """
351
+ if dry_run:
352
+ return "dry-run"
353
+
354
+ if not plugin_path.exists():
355
+ return f"error: plugin not found: {plugin_path}"
356
+
357
+ # Detect actual plugin ID from manifest
358
+ actual_id = _detect_plugin_id(plugin_path, plugin_id)
359
+
360
+ if _is_openclaw_plugin_registered(plugin_path):
361
+ _ensure_openclaw_plugin_enabled(actual_id)
362
+ return "exists"
363
+
364
+ # Try CLI first
365
+ plugin_dir = str(plugin_path.resolve().parent.parent)
366
+ try:
367
+ subprocess.run(
368
+ ["openclaw", "plugins", "install", "--link", plugin_dir],
369
+ capture_output=True,
370
+ text=True,
371
+ timeout=15,
372
+ )
373
+ if _is_openclaw_plugin_registered(plugin_path):
374
+ _ensure_openclaw_plugin_enabled(actual_id)
375
+ return "created"
376
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
377
+ pass
378
+
379
+ # Fallback: write directly
380
+ try:
381
+ _upsert_openclaw_plugin_path(actual_id, plugin_path)
382
+ _ensure_openclaw_plugin_enabled(actual_id)
383
+ return "created"
384
+ except Exception as exc:
385
+ return f"error: {exc}"
386
+
387
+
388
+ # ── Claude Code hooks registration ───────────────────────────────────────────
389
+
390
+
391
+ def register_hooks(
392
+ environments: list[str] | None = None,
393
+ dry_run: bool = False,
394
+ ) -> dict:
395
+ """Register skmemory auto-save hooks in Claude Code settings.
396
+
397
+ Adds hooks for:
398
+ - PreCompact: save context to skmemory before compaction
399
+ - SessionEnd: journal session end
400
+ - SessionStart (compact): reinject memory context after compaction
401
+
402
+ Args:
403
+ environments: Target environments (auto-detect if None).
404
+ dry_run: If True, only report what would be done.
405
+
406
+ Returns:
407
+ Dict with action taken: "created", "updated", "exists", "skip", or "error:...".
408
+ """
409
+ if environments is None:
410
+ environments = detect_environments()
411
+
412
+ if "claude-code" not in environments:
413
+ return {"action": "skip", "reason": "claude-code not detected"}
414
+
415
+ if dry_run:
416
+ return {"action": "dry-run"}
417
+
418
+ home = Path.home()
419
+ settings_path = home / ".claude" / "settings.json"
420
+
421
+ # Resolve hook script paths from the installed package
422
+ hooks_dir = Path(__file__).parent / "hooks"
423
+ pre_compact = str(hooks_dir / "pre-compact-save.sh")
424
+ session_end = str(hooks_dir / "session-end-save.sh")
425
+ post_compact = str(hooks_dir / "post-compact-reinject.sh")
426
+
427
+ # Verify hook scripts exist
428
+ for script in [pre_compact, session_end, post_compact]:
429
+ if not Path(script).exists():
430
+ return {"action": f"error: hook script not found: {script}"}
431
+
432
+ desired_hooks = {
433
+ "PreCompact": [
434
+ {
435
+ "matcher": "",
436
+ "hooks": [{"type": "command", "command": pre_compact}],
437
+ }
438
+ ],
439
+ "SessionEnd": [
440
+ {
441
+ "matcher": "",
442
+ "hooks": [{"type": "command", "command": session_end}],
443
+ }
444
+ ],
445
+ "SessionStart": [
446
+ {
447
+ "matcher": "compact",
448
+ "hooks": [{"type": "command", "command": post_compact}],
449
+ }
450
+ ],
451
+ }
452
+
453
+ try:
454
+ data = _read_json(settings_path)
455
+ existing_hooks = data.get("hooks", {})
456
+
457
+ # Check if already configured
458
+ needs_update = False
459
+ for event, hook_list in desired_hooks.items():
460
+ if event not in existing_hooks:
461
+ needs_update = True
462
+ break
463
+ # Check if our hook command is already present
464
+ existing_cmds = []
465
+ for entry in existing_hooks[event]:
466
+ for h in entry.get("hooks", []):
467
+ existing_cmds.append(h.get("command", ""))
468
+ desired_cmd = hook_list[0]["hooks"][0]["command"]
469
+ if desired_cmd not in existing_cmds:
470
+ needs_update = True
471
+ break
472
+
473
+ if not needs_update:
474
+ return {"action": "exists"}
475
+
476
+ # Merge: add our hooks without removing existing ones
477
+ for event, hook_list in desired_hooks.items():
478
+ if event not in existing_hooks:
479
+ existing_hooks[event] = hook_list
480
+ else:
481
+ # Check if our command is already there
482
+ desired_cmd = hook_list[0]["hooks"][0]["command"]
483
+ already_present = False
484
+ for entry in existing_hooks[event]:
485
+ for h in entry.get("hooks", []):
486
+ if h.get("command") == desired_cmd:
487
+ already_present = True
488
+ break
489
+ if not already_present:
490
+ existing_hooks[event].extend(hook_list)
491
+
492
+ data["hooks"] = existing_hooks
493
+ _write_json(settings_path, data)
494
+
495
+ action = "updated" if settings_path.exists() else "created"
496
+ return {"action": action}
497
+
498
+ except Exception as exc:
499
+ return {"action": f"error: {exc}"}
500
+
501
+
502
+ # ── High-level package registration ──────────────────────────────────────────
503
+
504
+
505
+ def register_package(
506
+ name: str,
507
+ skill_md_path: Path,
508
+ mcp_command: str | None = None,
509
+ mcp_args: list | None = None,
510
+ mcp_env: dict | None = None,
511
+ openclaw_plugin_path: Path | None = None,
512
+ install_hooks: bool = False,
513
+ workspace: Path | None = None,
514
+ environments: list[str] | None = None,
515
+ dry_run: bool = False,
516
+ ) -> dict:
517
+ """Register a skill, MCP server, hooks, and OpenClaw plugin in all detected environments.
518
+
519
+ Args:
520
+ name: Package/skill name.
521
+ skill_md_path: Path to the SKILL.md file.
522
+ mcp_command: MCP server command (None to skip MCP registration).
523
+ mcp_args: MCP server arguments.
524
+ mcp_env: MCP server environment variables.
525
+ openclaw_plugin_path: Path to OpenClaw plugin entry (e.g. src/index.ts).
526
+ install_hooks: If True, register Claude Code hooks for auto-save.
527
+ workspace: Workspace root for skill symlinks.
528
+ environments: Target environments (auto-detect if None).
529
+ dry_run: If True, only report what would be done.
530
+
531
+ Returns:
532
+ Dict with 'skill', 'mcp', 'hooks', and 'openclaw_plugin' results.
533
+ """
534
+ if environments is None:
535
+ environments = detect_environments()
536
+
537
+ result: dict = {"name": name, "environments": environments}
538
+
539
+ if dry_run:
540
+ result["skill"] = {
541
+ "action": "dry-run",
542
+ "path": str((workspace or Path.home() / "clawd") / "skills" / name / "SKILL.md"),
543
+ }
544
+ if mcp_command:
545
+ result["mcp"] = {env: "dry-run" for env in environments}
546
+ if install_hooks:
547
+ result["hooks"] = {"action": "dry-run"}
548
+ if openclaw_plugin_path and "openclaw" in environments:
549
+ result["openclaw_plugin"] = "dry-run"
550
+ return result
551
+
552
+ # Register skill
553
+ result["skill"] = register_skill(name, skill_md_path, workspace)
554
+
555
+ # Register MCP server
556
+ if mcp_command is not None:
557
+ result["mcp"] = register_mcp(
558
+ name,
559
+ mcp_command,
560
+ mcp_args or [],
561
+ env=mcp_env,
562
+ environments=environments,
563
+ )
564
+
565
+ # Register Claude Code hooks
566
+ if install_hooks:
567
+ result["hooks"] = register_hooks(
568
+ environments=environments,
569
+ dry_run=dry_run,
570
+ )
571
+
572
+ # Register OpenClaw plugin
573
+ if openclaw_plugin_path is not None and "openclaw" in environments:
574
+ result["openclaw_plugin"] = register_openclaw_plugin(
575
+ name,
576
+ openclaw_plugin_path,
577
+ dry_run=dry_run,
578
+ )
579
+
580
+ return result