drskill 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.
- drskill/__init__.py +0 -0
- drskill/checks/__init__.py +69 -0
- drskill/checks/budget.py +40 -0
- drskill/checks/duplicates.py +103 -0
- drskill/checks/filesystem.py +36 -0
- drskill/checks/lockfile.py +129 -0
- drskill/checks/shadowing.py +64 -0
- drskill/checks/spec.py +108 -0
- drskill/cli.py +182 -0
- drskill/data/__init__.py +0 -0
- drskill/data/harnesses.toml +880 -0
- drskill/discovery.py +96 -0
- drskill/harnesses.py +58 -0
- drskill/ledger.py +79 -0
- drskill/models.py +67 -0
- drskill/pipeline.py +45 -0
- drskill/report.py +124 -0
- drskill/resolution.py +158 -0
- drskill/tokens.py +28 -0
- drskill-0.1.0.dist-info/METADATA +130 -0
- drskill-0.1.0.dist-info/RECORD +24 -0
- drskill-0.1.0.dist-info/WHEEL +4 -0
- drskill-0.1.0.dist-info/entry_points.txt +2 -0
- drskill-0.1.0.dist-info/licenses/LICENSE +21 -0
drskill/discovery.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from drskill.harnesses import HarnessDef
|
|
7
|
+
from drskill.models import BrokenSymlink, RawInstance
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _walk_dirs(base: Path):
|
|
11
|
+
"""os.walk following symlinks, guarded against loops."""
|
|
12
|
+
seen: set[str] = set()
|
|
13
|
+
for dirpath, dirnames, filenames in os.walk(base, followlinks=True):
|
|
14
|
+
real = os.path.realpath(dirpath)
|
|
15
|
+
if real in seen:
|
|
16
|
+
dirnames[:] = []
|
|
17
|
+
continue
|
|
18
|
+
seen.add(real)
|
|
19
|
+
yield Path(dirpath), dirnames, filenames
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_skill_files(base: Path, recursive: bool) -> list[Path]:
|
|
23
|
+
if not recursive:
|
|
24
|
+
return sorted(base.glob("*/SKILL.md"))
|
|
25
|
+
out = []
|
|
26
|
+
for dirpath, _dirnames, filenames in _walk_dirs(base):
|
|
27
|
+
if "SKILL.md" in filenames:
|
|
28
|
+
out.append(dirpath / "SKILL.md")
|
|
29
|
+
return sorted(out)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_broken_symlinks(base: Path, recursive: bool = True) -> list[Path]:
|
|
33
|
+
out = []
|
|
34
|
+
if not recursive:
|
|
35
|
+
# Check only entries directly in base and in base/* directories (depth matching */SKILL.md glob)
|
|
36
|
+
for name in os.listdir(base):
|
|
37
|
+
p = base / name
|
|
38
|
+
if p.is_symlink() and not p.exists():
|
|
39
|
+
out.append(p)
|
|
40
|
+
# Also check one level deep (base/*/...)
|
|
41
|
+
for subdir in base.iterdir():
|
|
42
|
+
if subdir.is_dir():
|
|
43
|
+
for name in os.listdir(subdir):
|
|
44
|
+
p = subdir / name
|
|
45
|
+
if p.is_symlink() and not p.exists():
|
|
46
|
+
out.append(p)
|
|
47
|
+
else:
|
|
48
|
+
for dirpath, dirnames, filenames in _walk_dirs(base):
|
|
49
|
+
for name in list(dirnames) + list(filenames):
|
|
50
|
+
p = dirpath / name
|
|
51
|
+
if p.is_symlink() and not p.exists():
|
|
52
|
+
out.append(p)
|
|
53
|
+
return sorted(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _via_symlink(f: Path, base: Path) -> bool:
|
|
57
|
+
cur = f
|
|
58
|
+
while True:
|
|
59
|
+
if cur.is_symlink():
|
|
60
|
+
return True
|
|
61
|
+
if cur == base or cur.parent == cur:
|
|
62
|
+
return False
|
|
63
|
+
cur = cur.parent
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover(
|
|
67
|
+
h: HarnessDef, project_root: Path, home: Path, global_only: bool = False
|
|
68
|
+
) -> tuple[list[RawInstance], list[BrokenSymlink]]:
|
|
69
|
+
instances: list[RawInstance] = []
|
|
70
|
+
broken: list[BrokenSymlink] = []
|
|
71
|
+
for order, (base, scope, spec_str) in enumerate(
|
|
72
|
+
h.search_paths(project_root, home, global_only)
|
|
73
|
+
):
|
|
74
|
+
if not base.is_dir():
|
|
75
|
+
continue
|
|
76
|
+
files = _find_skill_files(base, h.recursive)
|
|
77
|
+
if spec_str in h.root_md_paths:
|
|
78
|
+
files += sorted(
|
|
79
|
+
p for p in base.glob("*.md")
|
|
80
|
+
if p.name != "SKILL.md" and p.is_file()
|
|
81
|
+
)
|
|
82
|
+
for f in files:
|
|
83
|
+
# Skip dangling symlinks (they are already reported as broken)
|
|
84
|
+
if not f.exists():
|
|
85
|
+
continue
|
|
86
|
+
instances.append(
|
|
87
|
+
RawInstance(
|
|
88
|
+
harness=h.id,
|
|
89
|
+
scope=scope,
|
|
90
|
+
skill_file=f,
|
|
91
|
+
via_symlink=_via_symlink(f, base),
|
|
92
|
+
order=order,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
broken += [BrokenSymlink(harness=h.id, path=p) for p in _find_broken_symlinks(base, h.recursive)]
|
|
96
|
+
return instances, broken
|
drskill/harnesses.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from functools import cache
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HarnessDef(BaseModel):
|
|
13
|
+
id: str
|
|
14
|
+
display_name: str
|
|
15
|
+
verified: bool = False
|
|
16
|
+
detect: list[str] = Field(default_factory=list)
|
|
17
|
+
project_paths: list[str] = Field(default_factory=list)
|
|
18
|
+
global_paths: list[str] = Field(default_factory=list)
|
|
19
|
+
search_order: Literal["project-first", "global-first"] = "project-first"
|
|
20
|
+
recursive: bool = True
|
|
21
|
+
root_md_paths: list[str] = Field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
def search_paths(
|
|
24
|
+
self, project_root: Path, home: Path, global_only: bool = False
|
|
25
|
+
) -> list[tuple[Path, str, str]]:
|
|
26
|
+
"""(directory, scope, spec_str) triples in precedence order."""
|
|
27
|
+
proj = [(project_root / s, "project", s) for s in self.project_paths]
|
|
28
|
+
glob = [(home / s.removeprefix("~/"), "user", s) for s in self.global_paths]
|
|
29
|
+
if global_only:
|
|
30
|
+
return glob
|
|
31
|
+
if self.search_order == "global-first":
|
|
32
|
+
return glob + proj
|
|
33
|
+
return proj + glob
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cache
|
|
37
|
+
def load_harnesses() -> tuple[HarnessDef, ...]:
|
|
38
|
+
text = resources.files("drskill.data").joinpath("harnesses.toml").read_text()
|
|
39
|
+
data = tomllib.loads(text)
|
|
40
|
+
return tuple(HarnessDef(**h) for h in data["harness"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_harnesses(
|
|
44
|
+
project_root: Path, home: Path, global_only: bool = False
|
|
45
|
+
) -> list[HarnessDef]:
|
|
46
|
+
found = []
|
|
47
|
+
for h in load_harnesses():
|
|
48
|
+
for marker in h.detect:
|
|
49
|
+
if marker.startswith("~/"):
|
|
50
|
+
p = home / marker.removeprefix("~/")
|
|
51
|
+
elif global_only:
|
|
52
|
+
continue
|
|
53
|
+
else:
|
|
54
|
+
p = project_root / marker
|
|
55
|
+
if p.exists():
|
|
56
|
+
found.append(h)
|
|
57
|
+
break
|
|
58
|
+
return found
|
drskill/ledger.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import tomli_w
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
9
|
+
|
|
10
|
+
from drskill.models import Finding
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LedgerError(Exception):
|
|
14
|
+
"""Raised when a drskill.toml ledger file is malformed or schema-invalid.
|
|
15
|
+
|
|
16
|
+
Callers (the CLI layer) catch this and print a one-line error instead of
|
|
17
|
+
letting a raw tomllib/pydantic traceback reach the user."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Budget(BaseModel):
|
|
21
|
+
catalog_tokens_max: int = 6000
|
|
22
|
+
body_tokens_warn: int = 20000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Thresholds(BaseModel):
|
|
26
|
+
near_duplicate: float = 0.85
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Ack(BaseModel):
|
|
30
|
+
check: str
|
|
31
|
+
skills: list[str]
|
|
32
|
+
fingerprint: str
|
|
33
|
+
note: str | None = None
|
|
34
|
+
date: dt.date | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Config(BaseModel):
|
|
38
|
+
budget: Budget = Budget()
|
|
39
|
+
thresholds: Thresholds = Thresholds()
|
|
40
|
+
ack: list[Ack] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ledger_path(project_root: Path, home: Path, global_mode: bool) -> Path:
|
|
44
|
+
return home / ".drskill.toml" if global_mode else project_root / "drskill.toml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validation_one_liner(e: ValidationError) -> str:
|
|
48
|
+
first = e.errors()[0]
|
|
49
|
+
loc = ".".join(str(p) for p in first["loc"])
|
|
50
|
+
return f"{loc}: {first['msg']}" if loc else first["msg"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config(path: Path) -> Config:
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
return Config()
|
|
56
|
+
try:
|
|
57
|
+
data = tomllib.loads(path.read_text())
|
|
58
|
+
except tomllib.TOMLDecodeError as e:
|
|
59
|
+
raise LedgerError(f"{path}: invalid TOML: {e}") from e
|
|
60
|
+
try:
|
|
61
|
+
return Config(**data)
|
|
62
|
+
except ValidationError as e:
|
|
63
|
+
raise LedgerError(f"{path}: {_validation_one_liner(e)}") from e
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def append_ack(path: Path, ack: Ack) -> None:
|
|
67
|
+
data = tomllib.loads(path.read_text()) if path.is_file() else {}
|
|
68
|
+
entry = {k: v for k, v in ack.model_dump().items() if v is not None}
|
|
69
|
+
data.setdefault("ack", []).append(entry)
|
|
70
|
+
path.write_text(tomli_w.dumps(data))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def filter_findings(
|
|
74
|
+
findings: list[Finding], config: Config
|
|
75
|
+
) -> tuple[list[Finding], list[Finding]]:
|
|
76
|
+
acked_fps = {a.fingerprint for a in config.ack}
|
|
77
|
+
active = [f for f in findings if f.fingerprint not in acked_fps]
|
|
78
|
+
acked = [f for f in findings if f.fingerprint in acked_fps]
|
|
79
|
+
return active, acked
|
drskill/models.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RawInstance(BaseModel):
|
|
10
|
+
"""A skill file as one harness sees it, before resolution."""
|
|
11
|
+
|
|
12
|
+
harness: str
|
|
13
|
+
scope: Literal["project", "user"]
|
|
14
|
+
skill_file: Path
|
|
15
|
+
via_symlink: bool
|
|
16
|
+
order: int # index of the containing search path in the harness's list
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Deployment(BaseModel):
|
|
20
|
+
harness: str
|
|
21
|
+
path: Path
|
|
22
|
+
scope: Literal["project", "user"]
|
|
23
|
+
via_symlink: bool
|
|
24
|
+
order: int
|
|
25
|
+
shadowed_by: str | None = None # contributor id of the winner, when shadowed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Provenance(BaseModel):
|
|
29
|
+
kind: Literal["skills-lock", "gh-skill", "linked", "unmanaged"] = "unmanaged"
|
|
30
|
+
source: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenCost(BaseModel):
|
|
34
|
+
catalog_tokens: int # name + description, approximate
|
|
35
|
+
body_tokens: int # full body, approximate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Contributor(BaseModel):
|
|
39
|
+
id: str # str(realpath of the skill file)
|
|
40
|
+
kind: Literal["skill"] = "skill"
|
|
41
|
+
name: str
|
|
42
|
+
source: Provenance = Provenance()
|
|
43
|
+
scope: Literal["project", "user"]
|
|
44
|
+
deployments: list[Deployment] = Field(default_factory=list)
|
|
45
|
+
routing_text: str = ""
|
|
46
|
+
body: str = ""
|
|
47
|
+
token_cost: TokenCost
|
|
48
|
+
content_hash: str
|
|
49
|
+
frontmatter_valid: bool = True
|
|
50
|
+
frontmatter: dict = Field(default_factory=dict)
|
|
51
|
+
frontmatter_text: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Finding(BaseModel):
|
|
55
|
+
check_id: str
|
|
56
|
+
severity: Literal["error", "warning"]
|
|
57
|
+
contributors: list[str] # contributor ids
|
|
58
|
+
contributor_names: list[str]
|
|
59
|
+
harnesses: list[str]
|
|
60
|
+
message: str
|
|
61
|
+
fix_commands: list[str] = Field(default_factory=list)
|
|
62
|
+
fingerprint: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BrokenSymlink(BaseModel):
|
|
66
|
+
harness: str
|
|
67
|
+
path: Path
|
drskill/pipeline.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from drskill.checks import run_all
|
|
6
|
+
from drskill.checks.lockfile import load_lockfile
|
|
7
|
+
from drskill.discovery import discover
|
|
8
|
+
from drskill.harnesses import detect_harnesses, load_harnesses
|
|
9
|
+
from drskill.ledger import Config, ledger_path, load_config
|
|
10
|
+
from drskill.models import Finding, Provenance
|
|
11
|
+
from drskill.resolution import World, build_world
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_scan(
|
|
15
|
+
project_root: Path,
|
|
16
|
+
home: Path,
|
|
17
|
+
global_only: bool = False,
|
|
18
|
+
config: Config | None = None,
|
|
19
|
+
harness: str | None = None,
|
|
20
|
+
) -> tuple[World, list[Finding]]:
|
|
21
|
+
if config is None:
|
|
22
|
+
config = load_config(ledger_path(project_root, home, global_only))
|
|
23
|
+
if harness is None:
|
|
24
|
+
harnesses = detect_harnesses(project_root, home, global_only)
|
|
25
|
+
else:
|
|
26
|
+
harnesses = [h for h in load_harnesses() if h.id == harness]
|
|
27
|
+
instances, broken = [], []
|
|
28
|
+
for h in harnesses:
|
|
29
|
+
i, b = discover(h, project_root, home, global_only)
|
|
30
|
+
instances += i
|
|
31
|
+
broken += b
|
|
32
|
+
world = build_world(instances, {h.id: h for h in harnesses}, broken)
|
|
33
|
+
world.lockfile = load_lockfile(project_root)
|
|
34
|
+
if world.lockfile:
|
|
35
|
+
for c in world.contributors.values():
|
|
36
|
+
if c.source.kind in ("unmanaged", "linked") and c.name in world.lockfile:
|
|
37
|
+
entry = world.lockfile[c.name]
|
|
38
|
+
world.contributors[c.id] = c.model_copy(
|
|
39
|
+
update={
|
|
40
|
+
"source": Provenance(
|
|
41
|
+
kind="skills-lock", source=entry.get("source")
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return world, run_all(world, config)
|
drskill/report.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from drskill.models import Finding
|
|
11
|
+
from drskill.resolution import World
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def to_json(findings: list[Finding]) -> str:
|
|
15
|
+
rows = [
|
|
16
|
+
dict(sorted(f.model_dump(mode="json").items())) for f in findings
|
|
17
|
+
]
|
|
18
|
+
return json.dumps(rows, indent=2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_harness_tables(
|
|
22
|
+
world: World,
|
|
23
|
+
console: Console,
|
|
24
|
+
*,
|
|
25
|
+
tokens: bool = False,
|
|
26
|
+
harness: str | None = None,
|
|
27
|
+
show_all: bool = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
hidden: list[str] = []
|
|
30
|
+
for hid, hdef in sorted(world.harnesses.items()):
|
|
31
|
+
if harness and hid != harness:
|
|
32
|
+
continue
|
|
33
|
+
if not show_all and harness is None and not world.effective(hid):
|
|
34
|
+
hidden.append(hid)
|
|
35
|
+
continue
|
|
36
|
+
title = escape(hdef.display_name) + ("" if hdef.verified else " (best effort)")
|
|
37
|
+
table = Table(title=title)
|
|
38
|
+
table.add_column("skill")
|
|
39
|
+
table.add_column("scope")
|
|
40
|
+
table.add_column("source")
|
|
41
|
+
if tokens:
|
|
42
|
+
table.add_column("catalog", justify="right")
|
|
43
|
+
table.add_column("body", justify="right")
|
|
44
|
+
table.add_column("notes")
|
|
45
|
+
cat_total = body_total = 0
|
|
46
|
+
for c, d in world.harness_loads(hid):
|
|
47
|
+
notes = []
|
|
48
|
+
if d.shadowed_by:
|
|
49
|
+
notes.append("shadowed")
|
|
50
|
+
if d.via_symlink:
|
|
51
|
+
notes.append("symlink")
|
|
52
|
+
row = [escape(c.name), escape(d.scope), escape(c.source.kind)]
|
|
53
|
+
if tokens:
|
|
54
|
+
row += [str(c.token_cost.catalog_tokens), str(c.token_cost.body_tokens)]
|
|
55
|
+
if d.shadowed_by is None:
|
|
56
|
+
cat_total += c.token_cost.catalog_tokens
|
|
57
|
+
body_total += c.token_cost.body_tokens
|
|
58
|
+
row.append(escape(", ".join(notes)))
|
|
59
|
+
table.add_row(*row)
|
|
60
|
+
if tokens:
|
|
61
|
+
table.add_row("total (effective)", "", "", str(cat_total), str(body_total), "",
|
|
62
|
+
style="bold")
|
|
63
|
+
console.print(table)
|
|
64
|
+
if hidden:
|
|
65
|
+
plural = "es" if len(hidden) != 1 else ""
|
|
66
|
+
console.print(
|
|
67
|
+
f"[dim]{len(hidden)} more harness{plural} detected with no skills "
|
|
68
|
+
f"({escape(', '.join(sorted(hidden)))}); show with --all[/dim]"
|
|
69
|
+
)
|
|
70
|
+
if tokens:
|
|
71
|
+
console.print("[dim]token counts are approximate[/dim]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _print_finding(world: World, f: Finding, console: Console) -> None:
|
|
75
|
+
tags = ""
|
|
76
|
+
if any(
|
|
77
|
+
hid in world.harnesses and not world.harnesses[hid].verified
|
|
78
|
+
for hid in f.harnesses
|
|
79
|
+
):
|
|
80
|
+
tags = " [dim](best effort)[/dim]"
|
|
81
|
+
console.print(f" [[bold]{escape(f.check_id)}[/bold]] {escape(f.message)}{tags}")
|
|
82
|
+
if f.harnesses:
|
|
83
|
+
console.print(f" harnesses: {escape(', '.join(f.harnesses))}")
|
|
84
|
+
for cmd in f.fix_commands:
|
|
85
|
+
console.print(f" fix: {escape(cmd)}")
|
|
86
|
+
if f.contributor_names:
|
|
87
|
+
names = " ".join(shlex.quote(n) for n in f.contributor_names)
|
|
88
|
+
console.print(f" or: drskill ack {escape(f.check_id)} {escape(names)}")
|
|
89
|
+
else:
|
|
90
|
+
console.print(f" or: drskill ack {escape(f.check_id)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render(
|
|
94
|
+
world: World, active: list[Finding], acked: list[Finding], console: Console
|
|
95
|
+
) -> None:
|
|
96
|
+
populated = [hid for hid in world.harnesses if world.effective(hid)]
|
|
97
|
+
empty = len(world.harnesses) - len(populated)
|
|
98
|
+
n_skills = len(world.contributors)
|
|
99
|
+
plural = "es" if len(populated) != 1 else ""
|
|
100
|
+
header = f"[bold]drskill scan[/bold] — {len(populated)} harness{plural}"
|
|
101
|
+
if empty:
|
|
102
|
+
header += f" ({empty} more empty)"
|
|
103
|
+
header += f", {n_skills} skills"
|
|
104
|
+
console.print(header)
|
|
105
|
+
errors = [f for f in active if f.severity == "error"]
|
|
106
|
+
warnings = [f for f in active if f.severity == "warning"]
|
|
107
|
+
if not active:
|
|
108
|
+
console.print("\n[green]No findings.[/green]", end="")
|
|
109
|
+
if errors:
|
|
110
|
+
console.print("\n[red bold]ERRORS[/red bold]")
|
|
111
|
+
for f in errors:
|
|
112
|
+
_print_finding(world, f, console)
|
|
113
|
+
if warnings:
|
|
114
|
+
console.print("\n[yellow bold]WARNINGS[/yellow bold]")
|
|
115
|
+
for f in warnings:
|
|
116
|
+
_print_finding(world, f, console)
|
|
117
|
+
summary = (
|
|
118
|
+
f"\n{len(errors)} error{'s' if len(errors) != 1 else ''}, "
|
|
119
|
+
f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}"
|
|
120
|
+
)
|
|
121
|
+
if acked:
|
|
122
|
+
summary += f" ({len(acked)} acknowledged)"
|
|
123
|
+
summary += " · token counts are approximate"
|
|
124
|
+
console.print(summary)
|
drskill/resolution.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from drskill import tokens
|
|
10
|
+
from drskill.harnesses import HarnessDef
|
|
11
|
+
from drskill.models import BrokenSymlink, Contributor, Deployment, Provenance, RawInstance, TokenCost
|
|
12
|
+
|
|
13
|
+
# Frontmatter keys `gh skill` writes for provenance (repo, ref, tree SHA).
|
|
14
|
+
# Verify against a real `gh skill` install during Task 10 and adjust if the
|
|
15
|
+
# observed key names differ.
|
|
16
|
+
GH_PROVENANCE_KEYS: frozenset[str] = frozenset({"source", "ref", "tree_sha"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def split_frontmatter(text: str) -> tuple[dict | None, str, str]:
|
|
20
|
+
if not text.startswith("---\n"):
|
|
21
|
+
return {}, "", text
|
|
22
|
+
end = text.find("\n---", 4)
|
|
23
|
+
if end == -1:
|
|
24
|
+
return {}, "", text
|
|
25
|
+
raw = text[4:end]
|
|
26
|
+
body = text[end + 4 :].lstrip("\n")
|
|
27
|
+
try:
|
|
28
|
+
parsed = yaml.safe_load(raw)
|
|
29
|
+
except yaml.YAMLError:
|
|
30
|
+
return None, raw, body
|
|
31
|
+
if not isinstance(parsed, dict):
|
|
32
|
+
return None, raw, body
|
|
33
|
+
return parsed, raw, body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_content(text: str) -> str:
|
|
37
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
38
|
+
fm, _raw, body = split_frontmatter(text)
|
|
39
|
+
if not fm: # None (invalid) or {} (absent): hash the raw text
|
|
40
|
+
return text
|
|
41
|
+
kept = {k: v for k, v in fm.items() if k not in GH_PROVENANCE_KEYS}
|
|
42
|
+
canonical_fm = yaml.safe_dump(kept, sort_keys=True)
|
|
43
|
+
return canonical_fm + "\n---\n" + body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _in_agents_store(path: Path) -> bool:
|
|
47
|
+
"""Layout heuristic: the realpath lives under a `.agents/skills` canonical
|
|
48
|
+
store, which is how installers like `npx skills` materialize skills. It is
|
|
49
|
+
evidence of installer management, not a claim about which installer."""
|
|
50
|
+
return any(
|
|
51
|
+
p.name == "skills" and p.parent.name == ".agents" for p in [path, *path.parents]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def content_hash(text: str) -> str:
|
|
56
|
+
digest = hashlib.sha256(normalize_content(text).encode()).hexdigest()
|
|
57
|
+
return "sha256:" + digest
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class World(BaseModel):
|
|
61
|
+
contributors: dict[str, Contributor] = Field(default_factory=dict)
|
|
62
|
+
harnesses: dict[str, HarnessDef] = Field(default_factory=dict)
|
|
63
|
+
broken_symlinks: list[BrokenSymlink] = Field(default_factory=list)
|
|
64
|
+
unreadable: list[tuple[str, str]] = Field(default_factory=list) # (harness, path)
|
|
65
|
+
lockfile: dict[str, dict] | None = None
|
|
66
|
+
|
|
67
|
+
def harness_loads(self, harness_id: str) -> list[tuple[Contributor, Deployment]]:
|
|
68
|
+
out = [
|
|
69
|
+
(c, d)
|
|
70
|
+
for c in self.contributors.values()
|
|
71
|
+
for d in c.deployments
|
|
72
|
+
if d.harness == harness_id
|
|
73
|
+
]
|
|
74
|
+
return sorted(out, key=lambda cd: (cd[1].order, str(cd[1].path)))
|
|
75
|
+
|
|
76
|
+
def effective(self, harness_id: str) -> list[Contributor]:
|
|
77
|
+
seen: list[Contributor] = []
|
|
78
|
+
for c, d in self.harness_loads(harness_id):
|
|
79
|
+
if d.shadowed_by is None and c not in seen:
|
|
80
|
+
seen.append(c)
|
|
81
|
+
return seen
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _skill_name(fm: dict | None, skill_file: Path) -> str:
|
|
85
|
+
if fm and isinstance(fm.get("name"), str) and fm["name"].strip():
|
|
86
|
+
return fm["name"].strip()
|
|
87
|
+
if skill_file.name == "SKILL.md":
|
|
88
|
+
return skill_file.parent.name
|
|
89
|
+
return skill_file.stem
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_world(
|
|
93
|
+
instances: list[RawInstance],
|
|
94
|
+
harnesses: dict[str, HarnessDef],
|
|
95
|
+
broken: list[BrokenSymlink],
|
|
96
|
+
) -> World:
|
|
97
|
+
world = World(harnesses=harnesses, broken_symlinks=broken)
|
|
98
|
+
for inst in instances:
|
|
99
|
+
real = inst.skill_file.resolve()
|
|
100
|
+
cid = str(real)
|
|
101
|
+
c = world.contributors.get(cid)
|
|
102
|
+
if c is None:
|
|
103
|
+
try:
|
|
104
|
+
text = real.read_text(encoding="utf-8", errors="replace")
|
|
105
|
+
except OSError:
|
|
106
|
+
world.unreadable.append((inst.harness, cid))
|
|
107
|
+
continue
|
|
108
|
+
fm, raw_fm, body = split_frontmatter(text)
|
|
109
|
+
name = _skill_name(fm, real)
|
|
110
|
+
description = ""
|
|
111
|
+
if fm and isinstance(fm.get("description"), str):
|
|
112
|
+
description = fm["description"]
|
|
113
|
+
provenance = Provenance()
|
|
114
|
+
if fm and GH_PROVENANCE_KEYS & fm.keys():
|
|
115
|
+
provenance = Provenance(kind="gh-skill", source=fm.get("source"))
|
|
116
|
+
elif _in_agents_store(real):
|
|
117
|
+
provenance = Provenance(kind="linked")
|
|
118
|
+
c = Contributor(
|
|
119
|
+
id=cid,
|
|
120
|
+
name=name,
|
|
121
|
+
scope=inst.scope,
|
|
122
|
+
source=provenance,
|
|
123
|
+
routing_text=description,
|
|
124
|
+
body=body,
|
|
125
|
+
token_cost=TokenCost(
|
|
126
|
+
catalog_tokens=tokens.count(f"{name}: {description}"),
|
|
127
|
+
body_tokens=tokens.count(body),
|
|
128
|
+
),
|
|
129
|
+
content_hash=content_hash(text),
|
|
130
|
+
frontmatter_valid=fm is not None,
|
|
131
|
+
frontmatter=fm or {},
|
|
132
|
+
frontmatter_text=raw_fm,
|
|
133
|
+
)
|
|
134
|
+
world.contributors[cid] = c
|
|
135
|
+
if inst.scope == "project":
|
|
136
|
+
c.scope = "project"
|
|
137
|
+
c.deployments.append(
|
|
138
|
+
Deployment(
|
|
139
|
+
harness=inst.harness,
|
|
140
|
+
path=inst.skill_file,
|
|
141
|
+
scope=inst.scope,
|
|
142
|
+
via_symlink=inst.via_symlink,
|
|
143
|
+
order=inst.order,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
_mark_shadows(world)
|
|
147
|
+
return world
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _mark_shadows(world: World) -> None:
|
|
151
|
+
for hid in world.harnesses:
|
|
152
|
+
first_by_name: dict[str, Contributor] = {}
|
|
153
|
+
for c, d in world.harness_loads(hid):
|
|
154
|
+
prior = first_by_name.get(c.name)
|
|
155
|
+
if prior is None:
|
|
156
|
+
first_by_name[c.name] = c
|
|
157
|
+
elif prior.id != c.id and prior.content_hash != c.content_hash:
|
|
158
|
+
d.shadowed_by = prior.id
|
drskill/tokens.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Approximate token counting. tiktoken loads lazily; if it cannot load
|
|
2
|
+
(or cannot fetch its encoding file offline) we fall back to len // 4."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
_encoder = None
|
|
7
|
+
_unavailable = False
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fallback_count(text: str) -> int:
|
|
11
|
+
return len(text) // 4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def count(text: str) -> int:
|
|
15
|
+
global _encoder, _unavailable
|
|
16
|
+
if not text:
|
|
17
|
+
return 0
|
|
18
|
+
if _unavailable:
|
|
19
|
+
return _fallback_count(text)
|
|
20
|
+
if _encoder is None:
|
|
21
|
+
try:
|
|
22
|
+
import tiktoken
|
|
23
|
+
|
|
24
|
+
_encoder = tiktoken.get_encoding("o200k_base")
|
|
25
|
+
except Exception:
|
|
26
|
+
_unavailable = True
|
|
27
|
+
return _fallback_count(text)
|
|
28
|
+
return len(_encoder.encode(text))
|