caudate-cli 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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
core/skills.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Skills — markdown-defined capability packs for Cognos.
|
|
2
|
+
|
|
3
|
+
Compatible with the open `vercel-labs/skills` ecosystem: a "skill" is
|
|
4
|
+
a directory containing a `SKILL.md` file with YAML frontmatter
|
|
5
|
+
(`name`, `description`) and markdown instructions.
|
|
6
|
+
|
|
7
|
+
Cognos scans three locations:
|
|
8
|
+
- `~/.cognos/skills/` — Cognos-specific
|
|
9
|
+
- `~/.claude/skills/` — Claude Code's standard location (compat)
|
|
10
|
+
- `<repo>/skills/` — bundled with this checkout
|
|
11
|
+
|
|
12
|
+
At chat time, the names + descriptions are injected into the system
|
|
13
|
+
prompt as a *table of contents*. The LLM calls `LoadSkill(name)` to
|
|
14
|
+
pull the full body of a skill it deems relevant. This keeps the
|
|
15
|
+
default prompt size bounded even with dozens of skills installed.
|
|
16
|
+
|
|
17
|
+
Skills are pure markdown — no runtime, no code execution. They're
|
|
18
|
+
prompt-engineering instructions the LLM follows.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Default scan locations. Order matters: later entries override earlier
|
|
31
|
+
# ones if names collide (later wins → repo skills override globals).
|
|
32
|
+
DEFAULT_SKILL_DIRS: tuple[Path, ...] = (
|
|
33
|
+
Path.home() / ".claude" / "skills",
|
|
34
|
+
Path.home() / ".cognos" / "skills",
|
|
35
|
+
Path("/home/raveuk/cognos/skills"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Skill:
|
|
41
|
+
"""One markdown-defined capability pack."""
|
|
42
|
+
name: str
|
|
43
|
+
description: str
|
|
44
|
+
body: str # the markdown instructions (no frontmatter)
|
|
45
|
+
path: Path # source SKILL.md
|
|
46
|
+
source_dir: Path # which scan dir it came from
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def header(self) -> str:
|
|
50
|
+
"""One-line summary used in the system-prompt table of contents."""
|
|
51
|
+
desc = self.description or "(no description)"
|
|
52
|
+
return f"- **{self.name}** — {desc[:160]}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------
|
|
56
|
+
# Parsing
|
|
57
|
+
# ---------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
61
|
+
"""Split YAML-frontmatter markdown into (metadata, body).
|
|
62
|
+
|
|
63
|
+
We do a minimal hand-roll instead of pulling in PyYAML — skills
|
|
64
|
+
only use scalar string fields (name, description), not nested
|
|
65
|
+
structures. This avoids adding a runtime dependency.
|
|
66
|
+
"""
|
|
67
|
+
if not text.startswith("---"):
|
|
68
|
+
return {}, text
|
|
69
|
+
end_marker = text.find("\n---", 3)
|
|
70
|
+
if end_marker < 0:
|
|
71
|
+
return {}, text
|
|
72
|
+
fm_block = text[3:end_marker].strip()
|
|
73
|
+
body_start = end_marker + 4
|
|
74
|
+
if body_start < len(text) and text[body_start] == "\n":
|
|
75
|
+
body_start += 1
|
|
76
|
+
body = text[body_start:].strip()
|
|
77
|
+
|
|
78
|
+
meta: dict[str, str] = {}
|
|
79
|
+
for line in fm_block.splitlines():
|
|
80
|
+
line = line.rstrip()
|
|
81
|
+
if not line or line.startswith("#"):
|
|
82
|
+
continue
|
|
83
|
+
# Naive key:value, supports quoted scalars + multi-word values
|
|
84
|
+
if ":" in line:
|
|
85
|
+
k, _, v = line.partition(":")
|
|
86
|
+
v = v.strip()
|
|
87
|
+
# Strip surrounding quotes
|
|
88
|
+
if (len(v) >= 2 and v[0] == v[-1]
|
|
89
|
+
and v[0] in ('"', "'")):
|
|
90
|
+
v = v[1:-1]
|
|
91
|
+
meta[k.strip()] = v
|
|
92
|
+
return meta, body
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_skill(skill_md: Path, source_dir: Path) -> Skill | None:
|
|
96
|
+
try:
|
|
97
|
+
text = skill_md.read_text()
|
|
98
|
+
except OSError as e:
|
|
99
|
+
logger.warning(f"Failed to read {skill_md}: {e}")
|
|
100
|
+
return None
|
|
101
|
+
meta, body = _parse_frontmatter(text)
|
|
102
|
+
name = (meta.get("name") or skill_md.parent.name).strip()
|
|
103
|
+
description = (meta.get("description") or "").strip()
|
|
104
|
+
if not name:
|
|
105
|
+
return None
|
|
106
|
+
return Skill(
|
|
107
|
+
name=name,
|
|
108
|
+
description=description,
|
|
109
|
+
body=body,
|
|
110
|
+
path=skill_md,
|
|
111
|
+
source_dir=source_dir,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------
|
|
116
|
+
# Registry
|
|
117
|
+
# ---------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class SkillRegistry:
|
|
122
|
+
"""In-memory index of all loaded skills, keyed by name."""
|
|
123
|
+
|
|
124
|
+
skills: dict[str, Skill] = field(default_factory=dict)
|
|
125
|
+
scanned_dirs: list[Path] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
def reload(self, dirs: tuple[Path, ...] = DEFAULT_SKILL_DIRS) -> None:
|
|
128
|
+
"""Rescan all configured directories. Idempotent."""
|
|
129
|
+
self.skills.clear()
|
|
130
|
+
self.scanned_dirs = []
|
|
131
|
+
for root in dirs:
|
|
132
|
+
self.scanned_dirs.append(root)
|
|
133
|
+
if not root.exists():
|
|
134
|
+
continue
|
|
135
|
+
for skill_md in root.rglob("SKILL.md"):
|
|
136
|
+
skill = _load_skill(skill_md, root)
|
|
137
|
+
if skill is None:
|
|
138
|
+
continue
|
|
139
|
+
# Last-wins for duplicate names
|
|
140
|
+
self.skills[skill.name] = skill
|
|
141
|
+
|
|
142
|
+
def list_names(self) -> list[str]:
|
|
143
|
+
return sorted(self.skills.keys())
|
|
144
|
+
|
|
145
|
+
def get(self, name: str) -> Skill | None:
|
|
146
|
+
return self.skills.get(name)
|
|
147
|
+
|
|
148
|
+
def render_toc(self) -> str:
|
|
149
|
+
"""Format the skills as a system-prompt table of contents.
|
|
150
|
+
|
|
151
|
+
Returns empty string if no skills are loaded — avoids polluting
|
|
152
|
+
the prompt with "no skills installed" boilerplate.
|
|
153
|
+
"""
|
|
154
|
+
if not self.skills:
|
|
155
|
+
return ""
|
|
156
|
+
lines = [
|
|
157
|
+
"## Available skills",
|
|
158
|
+
"",
|
|
159
|
+
"Skills are markdown capability packs. Use `LoadSkill(name=...)` "
|
|
160
|
+
"to read the full instructions for any of these when relevant:",
|
|
161
|
+
"",
|
|
162
|
+
]
|
|
163
|
+
for skill in sorted(self.skills.values(), key=lambda s: s.name.lower()):
|
|
164
|
+
lines.append(skill.header)
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
def find_relevant(
|
|
168
|
+
self,
|
|
169
|
+
user_text: str,
|
|
170
|
+
top_k: int = 2,
|
|
171
|
+
min_score: float = 0.18,
|
|
172
|
+
) -> list[tuple["Skill", float]]:
|
|
173
|
+
"""Score skills against a free-form user prompt; return the top-K.
|
|
174
|
+
|
|
175
|
+
Scoring strategy: split everything (name, description, prompt)
|
|
176
|
+
into alnum-only tokens (so 'deploy-to-vercel' → {'deploy',
|
|
177
|
+
'to', 'vercel'} after stop-word filtering), then score on
|
|
178
|
+
TWO axes:
|
|
179
|
+
|
|
180
|
+
1. user_coverage — what fraction of the user's distinctive
|
|
181
|
+
tokens does this skill cover? Catches "user mentioned
|
|
182
|
+
vercel and deploy → vercel skill is relevant".
|
|
183
|
+
2. name_match — what fraction of the skill's NAME tokens
|
|
184
|
+
showed up in the user prompt? Strong signal because the
|
|
185
|
+
name is a curated label.
|
|
186
|
+
|
|
187
|
+
Final score = user_coverage * 0.5 + name_match * 0.5.
|
|
188
|
+
Default threshold 0.18 catches strong matches without firing
|
|
189
|
+
on every passing keyword.
|
|
190
|
+
"""
|
|
191
|
+
if not user_text or not self.skills:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
def _tokens(s: str) -> set[str]:
|
|
195
|
+
# Split on any non-alnum character — gives us the components
|
|
196
|
+
# of hyphen-words like 'deploy-to-vercel' separately.
|
|
197
|
+
import re
|
|
198
|
+
tokens = re.findall(r"[a-z0-9]+", s.lower())
|
|
199
|
+
return {t for t in tokens if len(t) > 2 and t not in _STOP}
|
|
200
|
+
|
|
201
|
+
user_tokens = _tokens(user_text)
|
|
202
|
+
if not user_tokens:
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
scored: list[tuple[Skill, float]] = []
|
|
206
|
+
for skill in self.skills.values():
|
|
207
|
+
name_tokens = _tokens(skill.name)
|
|
208
|
+
desc_tokens = _tokens(skill.description)
|
|
209
|
+
all_skill_tokens = name_tokens | desc_tokens
|
|
210
|
+
if not all_skill_tokens:
|
|
211
|
+
continue
|
|
212
|
+
overlap = user_tokens & all_skill_tokens
|
|
213
|
+
if not overlap:
|
|
214
|
+
continue
|
|
215
|
+
user_coverage = len(overlap) / len(user_tokens)
|
|
216
|
+
name_match = (len(user_tokens & name_tokens)
|
|
217
|
+
/ max(1, len(name_tokens)))
|
|
218
|
+
score = user_coverage * 0.5 + name_match * 0.5
|
|
219
|
+
if score >= min_score:
|
|
220
|
+
scored.append((skill, score))
|
|
221
|
+
scored.sort(key=lambda p: p[1], reverse=True)
|
|
222
|
+
return scored[:top_k]
|
|
223
|
+
|
|
224
|
+
def render_active_skills(self, user_text: str, top_k: int = 2) -> str:
|
|
225
|
+
"""Auto-activate the top-K skills relevant to a user prompt and
|
|
226
|
+
return their FULL bodies for system-prompt injection.
|
|
227
|
+
|
|
228
|
+
Returns empty string if nothing matches. The chat path injects
|
|
229
|
+
this output below the TOC so the LLM both knows about all
|
|
230
|
+
skills (TOC) AND has the full instructions for the most-likely-
|
|
231
|
+
relevant ones already in-context (no LoadSkill round-trip).
|
|
232
|
+
"""
|
|
233
|
+
matches = self.find_relevant(user_text, top_k=top_k)
|
|
234
|
+
if not matches:
|
|
235
|
+
return ""
|
|
236
|
+
parts = [
|
|
237
|
+
"## Active skills (auto-selected for this turn)",
|
|
238
|
+
"",
|
|
239
|
+
"These skills look relevant to the user's prompt. **Follow "
|
|
240
|
+
"their instructions when they apply** — they are more "
|
|
241
|
+
"specific and current than your training defaults. Other "
|
|
242
|
+
"skills are listed in the TOC above; LoadSkill if needed.",
|
|
243
|
+
]
|
|
244
|
+
for skill, score in matches:
|
|
245
|
+
parts.append(f"\n### {skill.name} _(relevance ≈ {score:.2f})_")
|
|
246
|
+
if skill.description:
|
|
247
|
+
parts.append(f"_{skill.description}_\n")
|
|
248
|
+
parts.append(skill.body)
|
|
249
|
+
return "\n".join(parts)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Common stop words, dropped from token sets so they don't drive bogus
|
|
253
|
+
# matches. Not exhaustive — just the worst offenders.
|
|
254
|
+
_STOP: frozenset[str] = frozenset({
|
|
255
|
+
"the", "and", "for", "with", "from", "that", "this", "have",
|
|
256
|
+
"was", "were", "will", "are", "you", "your", "want", "would",
|
|
257
|
+
"could", "should", "what", "when", "where", "how", "why", "who",
|
|
258
|
+
"can", "all", "any", "but", "not", "they", "them", "their", "his",
|
|
259
|
+
"her", "she", "him", "its", "our", "out", "use", "using", "into",
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------
|
|
264
|
+
# Module-level singleton (lazy)
|
|
265
|
+
# ---------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_registry: SkillRegistry | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_skills() -> SkillRegistry:
|
|
272
|
+
global _registry
|
|
273
|
+
if _registry is None:
|
|
274
|
+
_registry = SkillRegistry()
|
|
275
|
+
_registry.reload()
|
|
276
|
+
logger.info(
|
|
277
|
+
f"SkillRegistry loaded {len(_registry.skills)} skill(s) "
|
|
278
|
+
f"from {len(_registry.scanned_dirs)} dirs"
|
|
279
|
+
)
|
|
280
|
+
return _registry
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def reload_skills() -> SkillRegistry:
|
|
284
|
+
"""Force re-scan of all skill directories. Use after `npx skills add ...`."""
|
|
285
|
+
global _registry
|
|
286
|
+
if _registry is None:
|
|
287
|
+
_registry = SkillRegistry()
|
|
288
|
+
_registry.reload()
|
|
289
|
+
return _registry
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------
|
|
293
|
+
# Lockfile — pin skill content hashes so silent edits are caught
|
|
294
|
+
# ---------------------------------------------------------------------
|
|
295
|
+
#
|
|
296
|
+
# LocalForge calls this its "skills lockfile". It records a SHA-256 of
|
|
297
|
+
# every SKILL.md so a `cognos skills verify` run can flag any skill
|
|
298
|
+
# that drifted between deploys (someone hand-edited the markdown,
|
|
299
|
+
# upstream pushed an update, etc.). Tier 3 in the LocalForge plan but
|
|
300
|
+
# valuable: it makes "what skills did this build of Cognos ship with?"
|
|
301
|
+
# answerable. The lock is written to data/skills.lock.json.
|
|
302
|
+
|
|
303
|
+
import hashlib
|
|
304
|
+
import json
|
|
305
|
+
from datetime import datetime, timezone
|
|
306
|
+
|
|
307
|
+
LOCKFILE_PATH = Path(__file__).resolve().parents[1] / "data" / "skills.lock.json"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _hash_skill(skill: Skill) -> str:
|
|
311
|
+
"""Stable SHA-256 of the SKILL.md raw bytes."""
|
|
312
|
+
try:
|
|
313
|
+
return hashlib.sha256(skill.path.read_bytes()).hexdigest()
|
|
314
|
+
except OSError:
|
|
315
|
+
return ""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def lock_skills(reg: SkillRegistry | None = None,
|
|
319
|
+
lock_path: Path | None = None,
|
|
320
|
+
dirs: tuple[Path, ...] | None = None) -> dict:
|
|
321
|
+
"""Compute hashes for every loaded skill and write the lockfile.
|
|
322
|
+
|
|
323
|
+
Returns the dict that was written.
|
|
324
|
+
"""
|
|
325
|
+
if reg is None:
|
|
326
|
+
if dirs is not None:
|
|
327
|
+
reg = SkillRegistry()
|
|
328
|
+
reg.reload(dirs=dirs)
|
|
329
|
+
else:
|
|
330
|
+
reg = get_skills()
|
|
331
|
+
if lock_path is None:
|
|
332
|
+
lock_path = LOCKFILE_PATH
|
|
333
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
|
|
335
|
+
entries: list[dict] = []
|
|
336
|
+
for name in sorted(reg.skills):
|
|
337
|
+
skill = reg.skills[name]
|
|
338
|
+
entries.append({
|
|
339
|
+
"name": skill.name,
|
|
340
|
+
"description": skill.description,
|
|
341
|
+
"path": str(skill.path),
|
|
342
|
+
"source_dir": str(skill.source_dir),
|
|
343
|
+
"sha256": _hash_skill(skill),
|
|
344
|
+
"size_bytes": skill.path.stat().st_size if skill.path.exists() else 0,
|
|
345
|
+
})
|
|
346
|
+
payload = {
|
|
347
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
348
|
+
"count": len(entries),
|
|
349
|
+
"skills": entries,
|
|
350
|
+
}
|
|
351
|
+
lock_path.write_text(json.dumps(payload, indent=2))
|
|
352
|
+
return payload
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def verify_skills(lock_path: Path | None = None,
|
|
356
|
+
dirs: tuple[Path, ...] | None = None) -> dict:
|
|
357
|
+
"""Compare the live filesystem to a lockfile.
|
|
358
|
+
|
|
359
|
+
Returns a report dict::
|
|
360
|
+
|
|
361
|
+
{
|
|
362
|
+
"ok": bool,
|
|
363
|
+
"missing": [name, …] # in lock but no longer on disk
|
|
364
|
+
"added": [name, …] # on disk but not in lock
|
|
365
|
+
"changed": [name, …] # hash mismatch
|
|
366
|
+
"unchanged": int,
|
|
367
|
+
}
|
|
368
|
+
"""
|
|
369
|
+
if lock_path is None:
|
|
370
|
+
lock_path = LOCKFILE_PATH
|
|
371
|
+
if not lock_path.exists():
|
|
372
|
+
return {"ok": False, "error": f"lockfile not found: {lock_path}"}
|
|
373
|
+
|
|
374
|
+
lock = json.loads(lock_path.read_text())
|
|
375
|
+
locked = {e["name"]: e for e in lock.get("skills", [])}
|
|
376
|
+
|
|
377
|
+
reg = SkillRegistry()
|
|
378
|
+
reg.reload(dirs=dirs) if dirs is not None else reg.reload()
|
|
379
|
+
live = {name: _hash_skill(skill) for name, skill in reg.skills.items()}
|
|
380
|
+
|
|
381
|
+
missing = [n for n in locked if n not in live]
|
|
382
|
+
added = [n for n in live if n not in locked]
|
|
383
|
+
changed = [
|
|
384
|
+
n for n in locked
|
|
385
|
+
if n in live and live[n] != locked[n].get("sha256")
|
|
386
|
+
]
|
|
387
|
+
unchanged = sum(
|
|
388
|
+
1 for n in locked
|
|
389
|
+
if n in live and live[n] == locked[n].get("sha256")
|
|
390
|
+
)
|
|
391
|
+
return {
|
|
392
|
+
"ok": not (missing or added or changed),
|
|
393
|
+
"missing": missing,
|
|
394
|
+
"added": added,
|
|
395
|
+
"changed": changed,
|
|
396
|
+
"unchanged": unchanged,
|
|
397
|
+
"lock_path": str(lock_path),
|
|
398
|
+
}
|