@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.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- 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
|