super-skill-cli 0.9.2__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.
@@ -0,0 +1,221 @@
1
+ """Git-backed registry (WS backend).
2
+
3
+ Each skill is a directory under ``registry/skills/<skill_id>/`` holding immutable
4
+ ``versions/<version>/SKILL.md`` snapshots plus a ``meta.json`` aggregate (Skill
5
+ pointer + SkillVersion DAG + audit trail). Git provides integrity, audit history
6
+ and rollback; M1 replaces this with the SQLite Registry + signed Publisher
7
+ without changing the entity shapes (docs/03 §3 M1, docs/02 §7.7).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from . import config
18
+ from .schemas import (
19
+ AuditEvent,
20
+ CandidateType,
21
+ OperationType,
22
+ Provenance,
23
+ Scope,
24
+ Skill,
25
+ SkillStatus,
26
+ SkillVersion,
27
+ )
28
+ from .skillmd import content_hash, parse
29
+
30
+ _GIT_ID = ["-c", "user.name=super-skill", "-c", "user.email=super-skill@localhost"]
31
+
32
+
33
+ class SkillRecord(BaseModel):
34
+ """Per-skill aggregate persisted as meta.json."""
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ skill: Skill
39
+ versions: dict[str, SkillVersion] = Field(default_factory=dict)
40
+ audit: list[AuditEvent] = Field(default_factory=list)
41
+
42
+ @property
43
+ def active(self) -> SkillVersion | None:
44
+ v = self.skill.active_version
45
+ return self.versions.get(v) if v else None
46
+
47
+ def next_version(self) -> str:
48
+ nums = [int(k.lstrip("v")) for k in self.versions if k.lstrip("v").isdigit()]
49
+ return f"v{1 + max(nums, default=0)}"
50
+
51
+
52
+ class RegistryError(RuntimeError):
53
+ pass
54
+
55
+
56
+ class Registry:
57
+ """WS registry rooted at a state dir (injectable for tests)."""
58
+
59
+ def __init__(self, root: Path | None = None) -> None:
60
+ self.root = root or config.state_root()
61
+ self.skills_root = self.root / "registry" / "skills"
62
+
63
+ # ---- lifecycle ---------------------------------------------------------
64
+ def init(self) -> None:
65
+ self.skills_root.mkdir(parents=True, exist_ok=True)
66
+ # candidates/ holds pre-promotion draft scratch — kept out of tracked
67
+ # history so only approve (the Publisher path) writes registry state.
68
+ gitignore = "locks/\ncandidates/\n"
69
+ gi = self.root / ".gitignore"
70
+ if not (self.root / ".git").exists():
71
+ self._git("init", "-q")
72
+ gi.write_text(gitignore, encoding="utf-8")
73
+ self._commit("chore: initialize super-skill registry")
74
+ elif gi.exists() and "candidates/" not in gi.read_text(encoding="utf-8"):
75
+ gi.write_text(gitignore, encoding="utf-8")
76
+ self._commit("chore: ignore candidates/ scratch dir")
77
+
78
+ def _git(self, *args: str) -> str:
79
+ proc = subprocess.run(
80
+ ["git", "-C", str(self.root), *args],
81
+ capture_output=True,
82
+ text=True,
83
+ )
84
+ if proc.returncode != 0:
85
+ raise RegistryError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
86
+ return proc.stdout.strip()
87
+
88
+ def _commit(self, message: str) -> str:
89
+ self._git("add", "-A")
90
+ # nothing staged is not an error (idempotent re-runs)
91
+ status = self._git("status", "--porcelain")
92
+ if not status:
93
+ return self.head()
94
+ subprocess.run(
95
+ ["git", "-C", str(self.root), *_GIT_ID, "commit", "-q", "-m", message],
96
+ capture_output=True,
97
+ text=True,
98
+ check=True,
99
+ )
100
+ return self.head()
101
+
102
+ def head(self) -> str:
103
+ try:
104
+ return self._git("rev-parse", "--short", "HEAD")
105
+ except RegistryError:
106
+ return "(no commits)"
107
+
108
+ # ---- reads -------------------------------------------------------------
109
+ def _meta_path(self, skill_id: str) -> Path:
110
+ return self.skills_root / skill_id / "meta.json"
111
+
112
+ def get(self, skill_id: str) -> SkillRecord | None:
113
+ p = self._meta_path(skill_id)
114
+ if not p.exists():
115
+ return None
116
+ return SkillRecord.model_validate_json(p.read_text(encoding="utf-8"))
117
+
118
+ def list_skills(self) -> list[SkillRecord]:
119
+ if not self.skills_root.exists():
120
+ return []
121
+ out = [self.get(d.name) for d in sorted(self.skills_root.iterdir()) if d.is_dir()]
122
+ return [r for r in out if r is not None]
123
+
124
+ def version_text(self, skill_id: str, version: str) -> str:
125
+ p = self.skills_root / skill_id / "versions" / version / "SKILL.md"
126
+ if not p.exists():
127
+ raise RegistryError(f"{skill_id}@{version} has no SKILL.md")
128
+ return p.read_text(encoding="utf-8")
129
+
130
+ # ---- writes ------------------------------------------------------------
131
+ def add_version(
132
+ self,
133
+ skill_id: str,
134
+ raw: str,
135
+ candidate_type: CandidateType,
136
+ provenance: list[Provenance],
137
+ *,
138
+ status: SkillStatus = SkillStatus.ACTIVE,
139
+ scope: Scope = Scope.GLOBAL,
140
+ make_active: bool = True,
141
+ actor: str = "user",
142
+ reason: str | None = None,
143
+ commit: bool = True,
144
+ ) -> SkillVersion:
145
+ """Register a new immutable version; optionally make it the active pointer."""
146
+ parsed = parse(raw)
147
+ rec = self.get(skill_id) or SkillRecord(skill=Skill(skill_id=skill_id, scope=scope))
148
+ version = rec.next_version()
149
+ parents = [rec.skill.active_version] if rec.skill.active_version else []
150
+ sv = SkillVersion(
151
+ skill_id=skill_id,
152
+ version=version,
153
+ parent_versions=[p for p in parents if p],
154
+ candidate_type=candidate_type,
155
+ status=status,
156
+ artifact_hash=content_hash(raw),
157
+ frontmatter=parsed.frontmatter,
158
+ provenance=provenance,
159
+ )
160
+ vdir = self.skills_root / skill_id / "versions" / version
161
+ vdir.mkdir(parents=True, exist_ok=True)
162
+ (vdir / "SKILL.md").write_text(parsed.raw, encoding="utf-8")
163
+
164
+ rec.versions[version] = sv
165
+ prev = rec.skill.active_version
166
+ if make_active:
167
+ rec.skill.active_version = version
168
+ rec.audit.append(
169
+ AuditEvent(
170
+ skill_id=skill_id,
171
+ op=OperationType.PROMOTE,
172
+ from_version=prev,
173
+ to_version=version if make_active else prev,
174
+ actor=actor,
175
+ reason=reason,
176
+ artifact_hash=sv.artifact_hash,
177
+ )
178
+ )
179
+ self._write(rec)
180
+ if commit:
181
+ self._commit(f"promote: {skill_id}@{version} ({candidate_type})")
182
+ return sv
183
+
184
+ def set_active(
185
+ self, skill_id: str, version: str, *, op: OperationType, actor: str = "user",
186
+ reason: str | None = None, commit: bool = True,
187
+ ) -> SkillRecord:
188
+ """Switch the active-version pointer (rollback = point at an older version)."""
189
+ rec = self.get(skill_id)
190
+ if rec is None:
191
+ raise RegistryError(f"unknown skill {skill_id!r}")
192
+ if version not in rec.versions:
193
+ raise RegistryError(f"{skill_id} has no version {version!r}")
194
+ prev = rec.skill.active_version
195
+ rec.skill.active_version = version
196
+ rec.audit.append(
197
+ AuditEvent(skill_id=skill_id, op=op, from_version=prev, to_version=version,
198
+ actor=actor, reason=reason)
199
+ )
200
+ self._write(rec)
201
+ if commit:
202
+ self._commit(f"{op.lower()}: {skill_id} -> {version}")
203
+ return rec
204
+
205
+ def _write(self, rec: SkillRecord) -> None:
206
+ p = self._meta_path(rec.skill.skill_id)
207
+ p.parent.mkdir(parents=True, exist_ok=True)
208
+ p.write_text(rec.model_dump_json(indent=2), encoding="utf-8")
209
+
210
+ # ---- distribution ------------------------------------------------------
211
+ def materialize(self, skill_id: str, host_dir: Path) -> Path:
212
+ """Write the active version's SKILL.md into the host skills dir so the
213
+ Agent picks it up. Only writes that one file — never deletes siblings."""
214
+ rec = self.get(skill_id)
215
+ if rec is None or rec.skill.active_version is None:
216
+ raise RegistryError(f"{skill_id!r} has no active version to materialize")
217
+ raw = self.version_text(skill_id, rec.skill.active_version)
218
+ dest = host_dir / skill_id / "SKILL.md"
219
+ dest.parent.mkdir(parents=True, exist_ok=True)
220
+ dest.write_text(raw, encoding="utf-8")
221
+ return dest
super_skill/schemas.py ADDED
@@ -0,0 +1,207 @@
1
+ """Core data model — single source of truth (M0 deliverable).
2
+
3
+ Scoped to the v1 package manager. Fields the self-learning loop (M2-M5) needs
4
+ (ExperienceCard, CapabilityLedger, EvalRun, ...) are intentionally absent; they
5
+ land when/if the GATE opens the research track. Entity shapes here mirror
6
+ docs/02-architecture.md §5 so the M1 Registry/Publisher can adopt them unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import UTC, datetime
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+
18
+ # agentskills.io spec: name 1-64 chars, lowercase + hyphen, must match dir name.
19
+ NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
20
+
21
+
22
+ def utcnow() -> datetime:
23
+ """Timezone-aware now(). Centralized so tests can monkeypatch one symbol."""
24
+ return datetime.now(UTC)
25
+
26
+
27
+ class Scope(StrEnum):
28
+ GLOBAL = "global"
29
+ PROJECT = "project"
30
+
31
+
32
+ class CandidateType(StrEnum):
33
+ """FR-GEN-3 six types. v1 seed/capture uses CAPTURED/FIX/DISTILLED."""
34
+
35
+ FIX = "FIX"
36
+ DERIVED = "DERIVED"
37
+ CAPTURED = "CAPTURED"
38
+ DISTILLED = "DISTILLED"
39
+ CONSOLIDATED = "CONSOLIDATED"
40
+ RETIRED = "RETIRED"
41
+
42
+
43
+ class SkillStatus(StrEnum):
44
+ """Lifecycle states (docs/02 §6). v1/WS uses OBSERVED..ACTIVE minus the
45
+ security-gate states (QUARANTINED/EVALUATED) that need M4 machinery."""
46
+
47
+ OBSERVED = "Observed"
48
+ CANDIDATE = "Candidate"
49
+ QUARANTINED = "Quarantined"
50
+ EVALUATED = "Evaluated"
51
+ STAGED = "Staged"
52
+ CANARY = "Canary"
53
+ ACTIVE = "Active"
54
+ REJECTED = "Rejected"
55
+ RETIRED = "Retired"
56
+
57
+
58
+ class OperationType(StrEnum):
59
+ """Writes that mutate the active skill set. In WS these are git commits;
60
+ M1 wraps them in a signed OperationRecord verified by the Publisher."""
61
+
62
+ PROMOTE = "PROMOTE"
63
+ ROLLBACK = "ROLLBACK"
64
+ REVOKE_CANARY = "REVOKE_CANARY"
65
+ QUARANTINE = "QUARANTINE"
66
+ RETIRE = "RETIRE"
67
+
68
+
69
+ class ProvenanceKind(StrEnum):
70
+ SEED_EXISTING_SKILL = "seed_existing_skill"
71
+ CAPTURED_SESSION = "captured_session"
72
+ DISTILLED_EXTERNAL = "distilled_external"
73
+
74
+
75
+ class EventType(StrEnum):
76
+ """The six host hook events captured in WS (docs/01 FR-CAP-1)."""
77
+
78
+ SESSION_START = "SessionStart"
79
+ USER_PROMPT_SUBMIT = "UserPromptSubmit"
80
+ PRE_TOOL_USE = "PreToolUse"
81
+ POST_TOOL_USE = "PostToolUse"
82
+ STOP = "Stop"
83
+ SESSION_END = "SessionEnd"
84
+
85
+
86
+ class RedactionMark(BaseModel):
87
+ """What was redacted and where — never the value (docs/01 FR-CAP-2)."""
88
+
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ kind: str
92
+ location: str = Field(description="dotted path of the field the secret was found in")
93
+ count: int = 1
94
+
95
+
96
+ class CaptureEvent(BaseModel):
97
+ """One redacted host event appended to the JSONL WAL. WS keeps only observable
98
+ actions + short outcomes — never hidden chain-of-thought (FR-CAP-7)."""
99
+
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ event_id: str
103
+ session_id: str
104
+ event_type: EventType
105
+ project_id: str | None = None
106
+ timestamp: datetime = Field(default_factory=utcnow)
107
+ payload: dict[str, Any] = Field(default_factory=dict)
108
+ redactions: list[RedactionMark] = Field(default_factory=list)
109
+ consent_scope: str = "default"
110
+
111
+
112
+ class Provenance(BaseModel):
113
+ """Where a version's content came from and under what license."""
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ kind: ProvenanceKind
118
+ origin: str = Field(description="path, session id, or source URL")
119
+ imported_at: datetime = Field(default_factory=utcnow)
120
+ license: str | None = None
121
+ notes: str | None = None
122
+
123
+
124
+ class SkillFrontmatter(BaseModel):
125
+ """agentskills.io core frontmatter. Host-specific extensions are NOT held
126
+ here — they are injected per-host at distribution time (docs/02 §4.2)."""
127
+
128
+ model_config = ConfigDict(extra="allow")
129
+
130
+ name: str = Field(min_length=1, max_length=64)
131
+ description: str = Field(min_length=1, max_length=1024)
132
+ license: str | None = None
133
+ compatibility: str | None = None
134
+ metadata: dict[str, Any] | None = None
135
+
136
+ @field_validator("name")
137
+ @classmethod
138
+ def _name_shape(cls, v: str) -> str:
139
+ if not NAME_RE.match(v):
140
+ raise ValueError(
141
+ f"name must be lowercase alphanumerics + single hyphens (got {v!r})"
142
+ )
143
+ return v
144
+
145
+
146
+ class SkillVersion(BaseModel):
147
+ """One immutable node in a skill's version DAG."""
148
+
149
+ model_config = ConfigDict(extra="forbid")
150
+
151
+ skill_id: str
152
+ version: str
153
+ parent_versions: list[str] = Field(default_factory=list)
154
+ candidate_type: CandidateType
155
+ status: SkillStatus
156
+ artifact_hash: str = Field(description="sha256 of the normalized SKILL.md payload")
157
+ frontmatter: SkillFrontmatter
158
+ provenance: list[Provenance] = Field(default_factory=list)
159
+ created_at: datetime = Field(default_factory=utcnow)
160
+
161
+
162
+ class Skill(BaseModel):
163
+ """Skill-level state: the active-version pointer that rollback switches,
164
+ distinct from the SkillVersion DAG nodes (docs/02 §5.1, review H4)."""
165
+
166
+ model_config = ConfigDict(extra="forbid")
167
+
168
+ skill_id: str
169
+ scope: Scope = Scope.GLOBAL
170
+ active_version: str | None = None
171
+ user_disabled: bool = False
172
+
173
+
174
+ class OperationRecord(BaseModel):
175
+ """Record of an active-set mutation. WS fills actor/reason and relies on git
176
+ for integrity; nonce/registry_state_hash/signature are added at M1 when the
177
+ real Publisher exists (fields present-but-optional so the shape is stable)."""
178
+
179
+ model_config = ConfigDict(extra="forbid")
180
+
181
+ op: OperationType
182
+ skill_id: str
183
+ version: str | None = None
184
+ rollback_version: str | None = None
185
+ actor: str = "user"
186
+ reason: str | None = None
187
+ created_at: datetime = Field(default_factory=utcnow)
188
+ # --- M1 (signed Publisher) ---
189
+ nonce: str | None = None
190
+ registry_state_hash: str | None = None
191
+ signature: str | None = None
192
+
193
+
194
+ class AuditEvent(BaseModel):
195
+ """Immutable audit trail entry. In WS these are reconstructable from git
196
+ history; kept structured so `explain` renders a uniform provenance chain."""
197
+
198
+ model_config = ConfigDict(extra="forbid")
199
+
200
+ skill_id: str
201
+ op: OperationType
202
+ from_version: str | None = None
203
+ to_version: str | None = None
204
+ actor: str = "user"
205
+ reason: str | None = None
206
+ artifact_hash: str | None = None
207
+ created_at: datetime = Field(default_factory=utcnow)
super_skill/seed.py ADDED
@@ -0,0 +1,75 @@
1
+ """Seed mode (docs/03 M0, D-3): ingest existing host skills into the registry so
2
+ explain/rollback/list have day-1 value with zero authoring.
3
+
4
+ Read-only with respect to the host skills directory — it reads each SKILL.md and
5
+ writes only into the registry. Idempotent: unchanged skills are skipped, changed
6
+ ones get a new version.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from .registry import Registry
15
+ from .schemas import CandidateType, Provenance, ProvenanceKind, SkillStatus
16
+ from .skillmd import SkillMdError, content_hash, parse
17
+
18
+
19
+ @dataclass
20
+ class SeedReport:
21
+ imported: list[str] = field(default_factory=list)
22
+ updated: list[str] = field(default_factory=list)
23
+ unchanged: list[str] = field(default_factory=list)
24
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (name, reason)
25
+
26
+ @property
27
+ def total_seen(self) -> int:
28
+ return len(self.imported) + len(self.updated) + len(self.unchanged) + len(self.skipped)
29
+
30
+
31
+ def seed_from_host(reg: Registry, host_dir: Path) -> SeedReport:
32
+ report = SeedReport()
33
+ reg.init()
34
+ if not host_dir.exists():
35
+ return report
36
+
37
+ for d in sorted(p for p in host_dir.iterdir() if p.is_dir()):
38
+ md = d / "SKILL.md"
39
+ if not md.exists():
40
+ report.skipped.append((d.name, "no SKILL.md"))
41
+ continue
42
+ raw = md.read_text(encoding="utf-8")
43
+ try:
44
+ parsed = parse(raw)
45
+ except SkillMdError as e:
46
+ report.skipped.append((d.name, str(e)))
47
+ continue
48
+
49
+ skill_id = parsed.frontmatter.name
50
+ existing = reg.get(skill_id)
51
+ if (
52
+ existing is not None
53
+ and existing.active is not None
54
+ and existing.active.artifact_hash == content_hash(raw)
55
+ ):
56
+ report.unchanged.append(skill_id)
57
+ continue
58
+
59
+ prov = [Provenance(kind=ProvenanceKind.SEED_EXISTING_SKILL, origin=str(md))]
60
+ reg.add_version(
61
+ skill_id,
62
+ raw,
63
+ CandidateType.DISTILLED,
64
+ prov,
65
+ status=SkillStatus.ACTIVE,
66
+ actor="seed",
67
+ reason=f"seed import from {md}",
68
+ commit=False,
69
+ )
70
+ (report.updated if existing else report.imported).append(skill_id)
71
+
72
+ reg._commit(
73
+ f"seed: import {len(report.imported)} new / {len(report.updated)} updated from host"
74
+ )
75
+ return report
super_skill/skillmd.py ADDED
@@ -0,0 +1,61 @@
1
+ """Parse and hash SKILL.md files (YAML frontmatter + markdown body)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+
8
+ import yaml
9
+
10
+ from .schemas import SkillFrontmatter
11
+
12
+ _FENCE = "---"
13
+
14
+
15
+ class SkillMdError(ValueError):
16
+ """Malformed SKILL.md (missing/broken frontmatter)."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ParsedSkillMd:
21
+ frontmatter: SkillFrontmatter
22
+ body: str
23
+ raw: str
24
+
25
+
26
+ def normalize(text: str) -> str:
27
+ """Strip BOM and CRLF so hashes ignore meaningless byte differences
28
+ (docs/02 §4.2 normalized hash)."""
29
+ return text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
30
+
31
+
32
+ def content_hash(text: str) -> str:
33
+ return "sha256:" + hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
34
+
35
+
36
+ def parse(text: str) -> ParsedSkillMd:
37
+ """Split a SKILL.md into validated frontmatter + body.
38
+
39
+ Frontmatter is the first ``---``-fenced YAML block. Raises SkillMdError if
40
+ absent or not a mapping — the seed importer skips such directories rather
41
+ than registering an invalid skill.
42
+ """
43
+ norm = normalize(text)
44
+ if not norm.startswith(_FENCE):
45
+ raise SkillMdError("missing YAML frontmatter fence")
46
+ end = norm.find(f"\n{_FENCE}", len(_FENCE))
47
+ if end == -1:
48
+ raise SkillMdError("unterminated frontmatter fence")
49
+ fm_text = norm[len(_FENCE) : end]
50
+ body = norm[end + len(_FENCE) + 1 :].lstrip("\n")
51
+ try:
52
+ data = yaml.safe_load(fm_text)
53
+ except yaml.YAMLError as e:
54
+ raise SkillMdError(f"invalid frontmatter YAML: {e}") from e
55
+ if not isinstance(data, dict):
56
+ raise SkillMdError("frontmatter is not a mapping")
57
+ try:
58
+ fm = SkillFrontmatter.model_validate(data)
59
+ except ValueError as e:
60
+ raise SkillMdError(f"frontmatter failed validation: {e}") from e
61
+ return ParsedSkillMd(frontmatter=fm, body=body, raw=norm)