mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
commit
39c97ccacc
9 changed files with 819 additions and 3 deletions
|
|
@ -76,6 +76,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
|
||||
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
|
||||
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
|
||||
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
|
||||
|
||||
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
|
||||
>
|
||||
|
|
@ -162,6 +163,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
|
||||
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
|
||||
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
|
||||
- [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md)
|
||||
- [릴리스/배포 가이드](docs/releasing.md)
|
||||
|
||||
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.
|
||||
|
|
|
|||
34
docs/features/k-skill-cleaner.md
Normal file
34
docs/features/k-skill-cleaner.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# K-스킬 클리너 가이드
|
||||
|
||||
`k-skill-cleaner`는 K-스킬 묶음에서 사용자가 쓰지 않는 스킬을 찾기 위한 정리 보조 스킬이다. 몇 가지 인터뷰 답변과 로컬 코딩 에이전트 로그의 트리거 횟수 신호를 합쳐 삭제 후보와 검토 후보를 나눈다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 먼저 인터뷰로 보존할 스킬, 절대 쓰지 않는 스킬, 주로 쓰는 에이전트, 분석 기간을 확인한다.
|
||||
2. 설치된 단독 스킬에서는 `python3 scripts/k_skill_cleaner.py`를 `k-skill-cleaner` 스킬 디렉터리 안에서 실행한다. 전체 저장소 checkout에서는 `python3 k-skill-cleaner/scripts/k_skill_cleaner.py` 또는 호환 wrapper `python3 scripts/k_skill_cleaner.py`를 사용할 수 있다.
|
||||
3. helper는 root-level `SKILL.md` 디렉터리를 찾고, 사용자가 제공한 usage JSON 또는 로컬 로그를 스캔한다.
|
||||
4. 결과 JSON의 `candidates`를 읽어 `remove`와 `review`를 분리한다.
|
||||
5. 삭제는 추천 이후 사용자가 명시적으로 승인한 경우에만 진행한다.
|
||||
|
||||
## 트리거 횟수 확인 방법
|
||||
|
||||
| 에이전트 | 확인 위치 | 주의점 |
|
||||
| --- | --- | --- |
|
||||
| Claude Code | `~/.claude/projects/**/*.jsonl`, `~/.claude/transcripts/**/*.jsonl` | 스킬 이벤트, `$skill` 언급, `SKILL.md` 로드 흔적을 best-effort로 센다. |
|
||||
| Codex | `~/.codex/sessions/**/*.jsonl`, `~/.codex/log/**/*.log`, `.omx/logs/**/*.log` | 라우팅된 스킬명, `$skill` 호출, 스킬 파일 읽기 흔적을 센다. |
|
||||
| OpenCode | `~/.local/share/opencode/**/*.jsonl`, `~/.config/opencode/**/*.jsonl` | 설치별 schema가 다를 수 있어 export된 transcript가 더 정확할 수 있다. |
|
||||
| OpenClaw/ClawHub | `~/.openclaw/**/*.jsonl`, `~/.clawhub/**/*.jsonl` | 공개적으로 고정된 trigger-count schema를 가정하지 않는다. 가능하면 사용자가 export한 통계를 받는다. |
|
||||
| Hermes Agent | `~/.hermes/**/*.jsonl`, `~/.config/hermes/**/*.jsonl` | 공개적으로 고정된 trigger-count schema를 가정하지 않는다. 가능하면 사용자가 export한 통계를 받는다. |
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
python3 scripts/k_skill_cleaner.py \
|
||||
--skills-root . \
|
||||
--scan-default-logs \
|
||||
--days 90 \
|
||||
--never-use blue-ribbon-nearby,lotto-results \
|
||||
--keep k-skill-setup,k-skill-cleaner
|
||||
```
|
||||
|
||||
`--days 90`은 최근 90일 window만 카운트한다. timestamp가 없는 로그 줄은 파일 mtime으로 포함/제외를 결정한다. 단, `--usage-json`으로 넣은 값은 이미 집계된 count로 간주하므로 `--days`/`--since`로 다시 필터링하지 않는다. 같은 기간의 통계를 export하거나 직접 전처리한 JSON을 넣어야 한다. 출력은 `usage_json`과 `scanned_logs` provenance를 포함하고, 파일 삭제를 하지 않는 JSON 리포트다. `zero_triggers`나 `low_usage`만 있는 항목은 바로 삭제하지 말고 검토 후보로 남긴다. `interview_never_use`가 포함된 항목은 사용자의 의도가 확인된 삭제 후보로 보고한다.
|
||||
|
|
@ -88,7 +88,8 @@ npx --yes skills add <owner/repo> \
|
|||
--skill korean-spell-check \
|
||||
--skill library-book-search \
|
||||
--skill k-schoollunch-menu \
|
||||
--skill korean-character-count
|
||||
--skill korean-character-count \
|
||||
--skill k-skill-cleaner
|
||||
```
|
||||
|
||||
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
|
||||
|
|
|
|||
87
k-skill-cleaner/SKILL.md
Normal file
87
k-skill-cleaner/SKILL.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
name: k-skill-cleaner
|
||||
description: Interview the user and inspect coding-agent skill trigger counts to recommend unused K-skills for removal.
|
||||
---
|
||||
|
||||
# k-skill-cleaner
|
||||
|
||||
Use this skill when the user wants to slim down a K-skill bundle, find skills they never use, or make an evidence-backed deletion shortlist instead of deleting directories by guesswork.
|
||||
|
||||
## Safety contract
|
||||
|
||||
- **Do not delete skills automatically.** Produce a ranked recommendation first, then make deletions only after the user explicitly approves the shortlist.
|
||||
- Treat trigger counts as **best-effort signals**, not absolute truth. Different agents store transcripts differently and may rotate or omit logs.
|
||||
- Protect any skill the user marks as "keep", even if its trigger count is zero.
|
||||
- Prefer removing whole root-level skill directories only after checking README/docs/install references in the same change.
|
||||
|
||||
## Interview first
|
||||
|
||||
Ask a compact interview before scanning or recommending deletion:
|
||||
|
||||
1. 어떤 에이전트를 주로 쓰나요? (Claude Code, Codex, OpenCode, OpenClaw/ClawHub, Hermes Agent, 기타)
|
||||
2. 절대 지우면 안 되는 스킬은 무엇인가요?
|
||||
3. 본인이 절대로 쓰지 않는다고 확신하는 스킬은 무엇인가요?
|
||||
4. 최근 30/90/180일 중 어떤 기간의 사용 흔적을 우선 볼까요? helper 실행 시 `--days` 또는 `--since`로 반영합니다.
|
||||
5. 추천만 원하나요, 아니면 승인 후 실제 삭제까지 원하나요?
|
||||
|
||||
## Trigger count sources by agent
|
||||
|
||||
| Agent | Where to check | Reliability | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude Code | `~/.claude/projects/**/*.jsonl`, `~/.claude/transcripts/**/*.jsonl` | best-effort | Look for skill-trigger events, `$skill-name` mentions, and `SKILL.md` loads. |
|
||||
| Codex | `~/.codex/sessions/**/*.jsonl`, `~/.codex/log/**/*.log`, `.omx/logs/**/*.log` | best-effort | Look for routed skill names, explicit `$skill` invocations, and skill file reads. |
|
||||
| OpenCode | `~/.local/share/opencode/**/*.jsonl`, `~/.config/opencode/**/*.jsonl` | best-effort | If local schema differs, ask the user for an exported transcript or usage JSON. |
|
||||
| OpenClaw/ClawHub | `~/.openclaw/**/*.jsonl`, `~/.clawhub/**/*.jsonl` if present | manual-confirm | No stable public local trigger-count schema is assumed; prefer exported stats when available. |
|
||||
| Hermes Agent | `~/.hermes/**/*.jsonl`, `~/.config/hermes/**/*.jsonl` if present | manual-confirm | No stable public local trigger-count schema is assumed; prefer exported stats when available. |
|
||||
|
||||
## Local helper
|
||||
|
||||
From an installed standalone skill, run the deterministic helper from the `k-skill-cleaner` skill directory. In a full repository checkout, the compatibility wrapper at `scripts/k_skill_cleaner.py` accepts the same options.
|
||||
|
||||
```bash
|
||||
python3 scripts/k_skill_cleaner.py \
|
||||
--skills-root . \
|
||||
--scan-default-logs \
|
||||
--days 90 \
|
||||
--never-use blue-ribbon-nearby,lotto-results \
|
||||
--keep k-skill-setup,k-skill-cleaner
|
||||
```
|
||||
|
||||
For agent exports or hand-curated counts, pass a JSON object mapping skill name to trigger count:
|
||||
|
||||
```bash
|
||||
python3 scripts/k_skill_cleaner.py --skills-root . --usage-json usage-counts.json --days 90
|
||||
```
|
||||
|
||||
`--days` and `--since` filter scanned log records only. `--usage-json` values are already-aggregated counts, so prepare/export that JSON for the same time window before passing it to the helper.
|
||||
|
||||
The helper prints JSON with:
|
||||
|
||||
- `skill_count`: number of root-level skills discovered.
|
||||
- `candidates`: ranked `remove` or `review` candidates with `trigger_count` and `reasons`.
|
||||
- `agent_usage_sources`: the agent-specific paths and caveats above.
|
||||
- `time_window`: the effective `--since`/`--days` cutoff and mtime fallback caveat.
|
||||
- `usage_json`: whether imported counts were merged and the pre-windowing caveat.
|
||||
- `scanned_logs`: how many readable log files were scanned and which paths contributed best-effort evidence.
|
||||
- `safety`: reminder that no files were deleted.
|
||||
|
||||
## Recommendation policy
|
||||
|
||||
- `remove`: user explicitly marked the skill as never used. Mention any zero/low trigger evidence as supporting context.
|
||||
- `review`: trigger count is zero or below the selected low-usage threshold, but the user did not explicitly ask to remove it.
|
||||
- `keep`: user-protected skills and actively triggered skills.
|
||||
|
||||
When reporting, group recommendations like this:
|
||||
|
||||
1. **삭제 후보** — interview says never used, with trigger evidence.
|
||||
2. **검토 후보** — zero/low trigger count only.
|
||||
3. **보존 후보** — protected or recently used.
|
||||
4. **통계 한계** — which agents had no readable logs and require manual export.
|
||||
|
||||
## If deletion is approved
|
||||
|
||||
1. Remove the skill directory.
|
||||
2. Remove README table/list entries and `docs/features/<skill>.md` links.
|
||||
3. Remove `docs/install.md --skill <skill>` entries.
|
||||
4. Remove package/workspace/test references only if the skill owns those files.
|
||||
5. Run `npm run lint`, `npm run typecheck`, and `npm run test` (or `npm run ci` for packaging/release changes).
|
||||
410
k-skill-cleaner/scripts/k_skill_cleaner.py
Executable file
410
k-skill-cleaner/scripts/k_skill_cleaner.py
Executable file
|
|
@ -0,0 +1,410 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Utilities for the k-skill-cleaner skill.
|
||||
|
||||
The helper intentionally stays dependency-free: it scans root-level skill
|
||||
folders, best-effort local agent logs, and optional interview choices to produce
|
||||
a conservative cleanup shortlist. It never deletes files by itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXCLUDED_ROOT_DIRS = {
|
||||
".changeset",
|
||||
".claude",
|
||||
".codex",
|
||||
".cursor",
|
||||
".git",
|
||||
".github",
|
||||
".omx",
|
||||
".ouroboros",
|
||||
".vscode",
|
||||
"docs",
|
||||
"examples",
|
||||
"node_modules",
|
||||
"packages",
|
||||
"python-packages",
|
||||
"scripts",
|
||||
}
|
||||
|
||||
AGENT_USAGE_SOURCES = [
|
||||
{
|
||||
"agent": "Claude Code",
|
||||
"paths": ["~/.claude/projects/**/*.jsonl", "~/.claude/transcripts/**/*.jsonl"],
|
||||
"method": "Scan JSONL transcript lines for skill-trigger events, $skill mentions, and SKILL.md load markers.",
|
||||
"confidence": "best-effort",
|
||||
},
|
||||
{
|
||||
"agent": "Codex",
|
||||
"paths": ["~/.codex/sessions/**/*.jsonl", "~/.codex/log/**/*.log", ".omx/logs/**/*.log"],
|
||||
"method": "Scan Codex session/log lines for routed skill names, $skill invocations, and SKILL.md reads.",
|
||||
"confidence": "best-effort",
|
||||
},
|
||||
{
|
||||
"agent": "OpenCode",
|
||||
"paths": ["~/.local/share/opencode/**/*.jsonl", "~/.config/opencode/**/*.jsonl"],
|
||||
"method": "Scan OpenCode data/config logs when available; ask for an exported transcript otherwise.",
|
||||
"confidence": "best-effort",
|
||||
},
|
||||
{
|
||||
"agent": "OpenClaw/ClawHub",
|
||||
"paths": ["~/.openclaw/**/*.jsonl", "~/.clawhub/**/*.jsonl"],
|
||||
"method": "No stable public trigger-count schema is assumed; use local logs if present or imported JSON counts.",
|
||||
"confidence": "manual-confirm",
|
||||
"fallback": "Ask the user to export trigger stats or provide a usage JSON file.",
|
||||
},
|
||||
{
|
||||
"agent": "Hermes Agent",
|
||||
"paths": ["~/.hermes/**/*.jsonl", "~/.config/hermes/**/*.jsonl"],
|
||||
"method": "No stable public trigger-count schema is assumed; use local logs if present or imported JSON counts.",
|
||||
"confidence": "manual-confirm",
|
||||
"fallback": "Ask the user to export trigger stats or provide a usage JSON file.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def resolve_skills_root(root: Path | str) -> Path:
|
||||
"""Resolve the directory that contains installable skill directories.
|
||||
|
||||
Standalone installs tell users to run this helper from inside the
|
||||
``k-skill-cleaner`` directory with ``--skills-root .``. In that layout, the
|
||||
current directory is itself a skill, while sibling skill directories live in
|
||||
the parent directory. Treat that self-skill root as shorthand for its parent
|
||||
so the advertised standalone command scans the installed skill bundle.
|
||||
"""
|
||||
|
||||
root_path = Path(root).expanduser().resolve()
|
||||
if (root_path / "SKILL.md").is_file():
|
||||
parent = root_path.parent
|
||||
if any(
|
||||
child.is_dir()
|
||||
and child.name not in EXCLUDED_ROOT_DIRS
|
||||
and (child / "SKILL.md").is_file()
|
||||
for child in parent.iterdir()
|
||||
):
|
||||
return parent
|
||||
return root_path
|
||||
|
||||
|
||||
def find_skill_dirs(root: Path | str) -> list[str]:
|
||||
"""Return root-level directories that look like installable skills."""
|
||||
|
||||
root_path = resolve_skills_root(root)
|
||||
skills: list[str] = []
|
||||
for child in root_path.iterdir():
|
||||
if not child.is_dir() or child.name in EXCLUDED_ROOT_DIRS:
|
||||
continue
|
||||
if (child / "SKILL.md").is_file():
|
||||
skills.append(child.name)
|
||||
return sorted(skills)
|
||||
|
||||
|
||||
def _walk_strings(value: Any, key_hint: str | None = None) -> Iterable[tuple[str | None, str]]:
|
||||
if isinstance(value, str):
|
||||
yield key_hint, value
|
||||
elif isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
yield from _walk_strings(child, str(key))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from _walk_strings(child, key_hint)
|
||||
|
||||
|
||||
def _line_mentions_skill(line: str, skill: str) -> bool:
|
||||
escaped = re.escape(skill)
|
||||
patterns = [
|
||||
rf"(?<![\w-])\${escaped}(?![\w-])",
|
||||
rf"(?i)\bskill(?:[_ -]?name|[_ -]?id)?\s*[:=]\s*['\"]?{escaped}(?![\w-])",
|
||||
rf"(?<![\w-]){escaped}/SKILL\.md\b",
|
||||
rf"(?i)\bloaded skill\s*[:=]?\s*['\"]?{escaped}(?![\w-])",
|
||||
rf"(?i)\busing\s+\${escaped}(?![\w-])",
|
||||
]
|
||||
return any(re.search(pattern, line) for pattern in patterns)
|
||||
|
||||
|
||||
def _json_mentions_skill(record: Any, skill: str) -> bool:
|
||||
key_names = {"skill", "skillname", "skill_name", "skillid", "skill_id", "name"}
|
||||
for key, value in _walk_strings(record):
|
||||
normalized_key = (key or "").replace("-", "").replace("_", "").lower()
|
||||
if normalized_key in key_names and value == skill:
|
||||
return True
|
||||
if _line_mentions_skill(value, skill):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_datetime(value: str | datetime | None) -> datetime | None:
|
||||
if value is None or isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.endswith("Z"):
|
||||
raw = f"{raw[:-1]}+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(f"{raw}T00:00:00")
|
||||
except ValueError as exc:
|
||||
raise ValueError("since must be an ISO date or datetime") from exc
|
||||
if parsed is None:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _line_datetime_from_json(record: Any) -> datetime | None:
|
||||
timestamp_keys = {"timestamp", "time", "created_at", "createdat", "date", "datetime", "ts"}
|
||||
if not isinstance(record, Mapping):
|
||||
return None
|
||||
for key, value in record.items():
|
||||
normalized_key = str(key).replace("-", "").replace("_", "").lower()
|
||||
if normalized_key in timestamp_keys and isinstance(value, str):
|
||||
try:
|
||||
return _parse_datetime(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _line_datetime_from_text(line: str) -> datetime | None:
|
||||
match = re.search(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b", line)
|
||||
if not match:
|
||||
return None
|
||||
raw = match.group(0)
|
||||
if "T" not in raw and " " not in raw:
|
||||
raw = f"{raw}T00:00:00"
|
||||
if re.search(r"[+-]\d{4}$", raw):
|
||||
raw = f"{raw[:-2]}:{raw[-2:]}"
|
||||
try:
|
||||
return _parse_datetime(raw.replace(" ", "T", 1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _mtime_datetime(path: Path) -> datetime:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||
|
||||
|
||||
def _line_is_in_window(path: Path, line: str, parsed: Any | None, since: datetime | None) -> bool:
|
||||
if since is None:
|
||||
return True
|
||||
line_dt = _line_datetime_from_json(parsed) if parsed is not None else None
|
||||
if line_dt is None:
|
||||
line_dt = _line_datetime_from_text(line)
|
||||
if line_dt is None:
|
||||
line_dt = _mtime_datetime(path)
|
||||
return line_dt >= since
|
||||
|
||||
|
||||
def collect_skill_usage(
|
||||
log_paths: Iterable[Path | str],
|
||||
skill_names: Iterable[str],
|
||||
since: str | datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Best-effort count of skill trigger mentions across local agent logs.
|
||||
|
||||
When ``since`` is provided, timestamped records older than the cutoff are
|
||||
skipped. Lines without parseable timestamps fall back to the log file mtime,
|
||||
which keeps the selected interview window enforceable even for mixed log
|
||||
formats.
|
||||
"""
|
||||
|
||||
since_dt = _parse_datetime(since)
|
||||
skills = sorted(set(skill_names))
|
||||
counts = {skill: 0 for skill in skills}
|
||||
for raw_path in log_paths:
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
parsed: Any | None = None
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if not _line_is_in_window(path, line, parsed, since_dt):
|
||||
continue
|
||||
for skill in skills:
|
||||
if (parsed is not None and _json_mentions_skill(parsed, skill)) or _line_mentions_skill(line, skill):
|
||||
counts[skill] += 1
|
||||
except OSError:
|
||||
continue
|
||||
return counts
|
||||
|
||||
|
||||
def load_usage_json(path: Path | str | None) -> dict[str, int]:
|
||||
if path is None:
|
||||
return {}
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(data, Mapping):
|
||||
raise ValueError("usage JSON must be an object mapping skill names to counts")
|
||||
counts: dict[str, int] = {}
|
||||
for key, value in data.items():
|
||||
try:
|
||||
counts[str(key)] = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"usage count for {key!r} must be an integer") from exc
|
||||
return counts
|
||||
|
||||
|
||||
def rank_cleanup_candidates(
|
||||
skill_names: Iterable[str],
|
||||
usage_counts: Mapping[str, int] | None = None,
|
||||
never_use: Iterable[str] | None = None,
|
||||
keep: Iterable[str] | None = None,
|
||||
low_usage_threshold: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank deletion/review candidates without touching the filesystem."""
|
||||
|
||||
counts = usage_counts or {}
|
||||
never = set(never_use or [])
|
||||
protected = set(keep or [])
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
for skill in sorted(set(skill_names)):
|
||||
if skill in protected:
|
||||
continue
|
||||
count = int(counts.get(skill, 0))
|
||||
reasons: list[str] = []
|
||||
score = 0
|
||||
action = "keep"
|
||||
|
||||
if skill in never:
|
||||
reasons.append("interview_never_use")
|
||||
score += 100
|
||||
action = "remove"
|
||||
if count == 0:
|
||||
reasons.append("zero_triggers")
|
||||
score += 50
|
||||
elif count <= low_usage_threshold:
|
||||
reasons.append("low_usage")
|
||||
score += 20
|
||||
if not reasons:
|
||||
continue
|
||||
if action != "remove":
|
||||
action = "review"
|
||||
|
||||
candidates.append(
|
||||
{
|
||||
"skill": skill,
|
||||
"action": action,
|
||||
"trigger_count": count,
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(candidates, key=lambda item: (-item["score"], item["skill"]))
|
||||
|
||||
|
||||
def expand_default_log_paths() -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for source in AGENT_USAGE_SOURCES:
|
||||
for pattern in source.get("paths", []):
|
||||
paths.extend(Path().glob(os.path.expanduser(pattern)) if not pattern.startswith("~") else Path.home().glob(pattern[2:]))
|
||||
return sorted({path for path in paths if path.is_file()})
|
||||
|
||||
|
||||
def parse_csv(value: str | None) -> set[str]:
|
||||
if not value:
|
||||
return set()
|
||||
return {item.strip() for item in value.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _resolve_since(days: int | None, since: str | None, now: datetime | None = None) -> datetime | None:
|
||||
explicit_since = _parse_datetime(since)
|
||||
if explicit_since is not None:
|
||||
return explicit_since
|
||||
if days is None:
|
||||
return None
|
||||
if days < 0:
|
||||
raise ValueError("days must be zero or greater")
|
||||
base = now or datetime.now(timezone.utc)
|
||||
if base.tzinfo is None:
|
||||
base = base.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
base = base.astimezone(timezone.utc)
|
||||
return base - timedelta(days=days)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Suggest K-skill cleanup candidates from interviews and usage logs.")
|
||||
parser.add_argument(
|
||||
"--skills-root",
|
||||
default=".",
|
||||
help="Directory containing root-level skills; a skill directory with SKILL.md auto-scans its parent",
|
||||
)
|
||||
parser.add_argument("--usage-json", help="Optional JSON object mapping skill names to trigger counts")
|
||||
parser.add_argument("--log", action="append", default=[], help="Agent log file to scan; repeatable")
|
||||
parser.add_argument("--scan-default-logs", action="store_true", help="Best-effort scan known local agent log locations")
|
||||
parser.add_argument("--never-use", default="", help="Comma-separated skills the user says they never use")
|
||||
parser.add_argument("--keep", default="", help="Comma-separated skills to protect from suggestions")
|
||||
parser.add_argument("--low-usage-threshold", type=int, default=1, help="Counts at or below this threshold are review candidates")
|
||||
parser.add_argument("--days", type=int, help="Only count log records from the last N days; untimestamped lines use file mtime fallback")
|
||||
parser.add_argument("--since", help="Only count log records on or after this ISO date/datetime; overrides --days")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
skill_names = find_skill_dirs(args.skills_root)
|
||||
usage_counts = {skill: 0 for skill in skill_names}
|
||||
usage_counts.update(load_usage_json(args.usage_json))
|
||||
|
||||
log_paths = [Path(path) for path in args.log]
|
||||
if args.scan_default_logs:
|
||||
log_paths.extend(expand_default_log_paths())
|
||||
since = _resolve_since(args.days, args.since)
|
||||
scanned_log_paths = sorted({str(path.expanduser()) for path in log_paths if path.expanduser().is_file()})
|
||||
log_counts = collect_skill_usage(log_paths, skill_names, since=since)
|
||||
for skill, count in log_counts.items():
|
||||
usage_counts[skill] = usage_counts.get(skill, 0) + count
|
||||
|
||||
report = {
|
||||
"skill_count": len(skill_names),
|
||||
"candidates": rank_cleanup_candidates(
|
||||
skill_names=skill_names,
|
||||
usage_counts=usage_counts,
|
||||
never_use=parse_csv(args.never_use),
|
||||
keep=parse_csv(args.keep),
|
||||
low_usage_threshold=args.low_usage_threshold,
|
||||
),
|
||||
"agent_usage_sources": AGENT_USAGE_SOURCES,
|
||||
"time_window": {
|
||||
"since": since.isoformat() if since is not None else None,
|
||||
"days": args.days if args.since is None else None,
|
||||
"scope": "Applies to scanned logs only; usage JSON counts are merged as already aggregated/pre-windowed input.",
|
||||
"fallback": "Untimestamped log lines are included or skipped by log file mtime.",
|
||||
},
|
||||
"usage_json": {
|
||||
"applied": args.usage_json is not None,
|
||||
"path": args.usage_json,
|
||||
"caveat": "Usage JSON counts are treated as already aggregated/pre-windowed and are not filtered by --days or --since.",
|
||||
},
|
||||
"scanned_logs": {
|
||||
"count": len(scanned_log_paths),
|
||||
"paths": scanned_log_paths,
|
||||
"caveat": "Unreadable log files are skipped; trigger detection is best-effort.",
|
||||
},
|
||||
"safety": "No files were deleted. Review candidates and remove skills in a separate explicit edit.",
|
||||
}
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
43
scripts/k_skill_cleaner.py
Executable file
43
scripts/k_skill_cleaner.py
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compatibility wrapper for the k-skill-cleaner skill-local helper.
|
||||
|
||||
The standalone skill install includes ``k-skill-cleaner/scripts/k_skill_cleaner.py``.
|
||||
This repository-root wrapper preserves existing checkout workflows and tests while
|
||||
keeping the executable payload inside the skill directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
_HELPER_PATH = Path(__file__).resolve().parents[1] / "k-skill-cleaner" / "scripts" / "k_skill_cleaner.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("_k_skill_cleaner_impl", _HELPER_PATH)
|
||||
if _SPEC is None or _SPEC.loader is None: # pragma: no cover - importlib defensive guard
|
||||
raise ImportError(f"Unable to load k-skill-cleaner helper from {_HELPER_PATH}")
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
|
||||
AGENT_USAGE_SOURCES = _MODULE.AGENT_USAGE_SOURCES
|
||||
collect_skill_usage = _MODULE.collect_skill_usage
|
||||
find_skill_dirs = _MODULE.find_skill_dirs
|
||||
rank_cleanup_candidates = _MODULE.rank_cleanup_candidates
|
||||
load_usage_json = _MODULE.load_usage_json
|
||||
expand_default_log_paths = _MODULE.expand_default_log_paths
|
||||
parse_csv = _MODULE.parse_csv
|
||||
build_parser = _MODULE.build_parser
|
||||
main = _MODULE.main
|
||||
|
||||
__all__ = [
|
||||
"AGENT_USAGE_SOURCES",
|
||||
"collect_skill_usage",
|
||||
"find_skill_dirs",
|
||||
"rank_cleanup_candidates",
|
||||
"load_usage_json",
|
||||
"expand_default_log_paths",
|
||||
"parse_csv",
|
||||
"build_parser",
|
||||
"main",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -3281,6 +3281,7 @@ const README_SKILL_NAME_COLUMN_MAPPING = [
|
|||
["네이버 뉴스 검색", "naver-news-search"],
|
||||
["한국어 글자 수 세기", "korean-character-count"],
|
||||
["한국어 유행어 글쓰기", "korean-slang-writing"],
|
||||
["K-스킬 클리너", "k-skill-cleaner"],
|
||||
];
|
||||
|
||||
test("README skill table header advertises the new 스킬 이름 column (issue #165)", () => {
|
||||
|
|
@ -3296,6 +3297,11 @@ test("README skill table header advertises the new 스킬 이름 column (issue #
|
|||
test("README skill table includes inline-code skill names for every documented row (issue #165)", () => {
|
||||
const readme = read("README.md");
|
||||
|
||||
assert.ok(
|
||||
README_SKILL_NAME_COLUMN_MAPPING.some(([, skillName]) => skillName === "k-skill-cleaner"),
|
||||
"expected k-skill-cleaner to be covered by the central README skill-name mapping fixture",
|
||||
);
|
||||
|
||||
for (const [label, skillName] of README_SKILL_NAME_COLUMN_MAPPING) {
|
||||
const escapedLabel = escapeRegex(label);
|
||||
const escapedName = escapeRegex(skillName);
|
||||
|
|
@ -3342,3 +3348,34 @@ test("README skill table skill-name column entries match real on-disk skill dire
|
|||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the k-skill-cleaner skill and agent usage sources", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "k-skill-cleaner.md");
|
||||
const skillPath = path.join(repoRoot, "k-skill-cleaner", "SKILL.md");
|
||||
const skillLocalHelperPath = path.join(repoRoot, "k-skill-cleaner", "scripts", "k_skill_cleaner.py");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected k-skill-cleaner/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(skillLocalHelperPath), "expected k-skill-cleaner/scripts/k_skill_cleaner.py to be included in standalone skill installs");
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/k-skill-cleaner.md to exist");
|
||||
|
||||
const skill = read(path.join("k-skill-cleaner", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "k-skill-cleaner.md"));
|
||||
|
||||
assert.match(skill, /^name: k-skill-cleaner$/m);
|
||||
assert.match(skill, /Claude Code/);
|
||||
assert.match(skill, /Codex/);
|
||||
assert.match(skill, /OpenCode/);
|
||||
assert.match(skill, /OpenClaw\/ClawHub/);
|
||||
assert.match(skill, /Hermes Agent/);
|
||||
assert.match(skill, /python3 scripts\/k_skill_cleaner\.py/);
|
||||
assert.match(skill, /--days 90/);
|
||||
assert.match(featureDoc, /k-skill-cleaner\/scripts\/k_skill_cleaner\.py/);
|
||||
assert.match(featureDoc, /--days 90/);
|
||||
assert.match(featureDoc, /인터뷰/);
|
||||
assert.match(featureDoc, /트리거 횟수/);
|
||||
assert.match(readme, /\| K-스킬 클리너 \| `k-skill-cleaner` \|/);
|
||||
assert.match(readme, /\[K-스킬 클리너 가이드\]\(docs\/features\/k-skill-cleaner\.md\)/);
|
||||
assert.match(install, /--skill k-skill-cleaner/);
|
||||
});
|
||||
|
|
|
|||
202
scripts/test_k_skill_cleaner.py
Normal file
202
scripts/test_k_skill_cleaner.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from k_skill_cleaner import (
|
||||
AGENT_USAGE_SOURCES,
|
||||
collect_skill_usage,
|
||||
find_skill_dirs,
|
||||
rank_cleanup_candidates,
|
||||
)
|
||||
|
||||
|
||||
class KSkillCleanerTest(unittest.TestCase):
|
||||
def test_finds_root_skill_dirs_only_by_skill_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "keep-me").mkdir()
|
||||
(root / "keep-me" / "SKILL.md").write_text("---\nname: keep-me\n", encoding="utf-8")
|
||||
(root / "docs").mkdir()
|
||||
(root / "docs" / "SKILL.md").write_text("not a root skill", encoding="utf-8")
|
||||
(root / "no-skill").mkdir()
|
||||
|
||||
self.assertEqual(find_skill_dirs(root), ["keep-me"])
|
||||
|
||||
def test_collects_counts_from_jsonl_and_plain_agent_logs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "codex.jsonl").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps({"event": "skill_triggered", "skill": "kbo-results"}),
|
||||
json.dumps({"message": "Using $kbo-results for sports lookup"}),
|
||||
"Claude loaded skill: korean-law-search",
|
||||
json.dumps({"tool": {"name": "korean-law-search"}}),
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
counts = collect_skill_usage([root / "codex.jsonl"], ["kbo-results", "korean-law-search", "unused"])
|
||||
|
||||
self.assertEqual(counts["kbo-results"], 2)
|
||||
self.assertEqual(counts["korean-law-search"], 2)
|
||||
self.assertEqual(counts["unused"], 0)
|
||||
|
||||
def test_collects_usage_with_since_window_and_mtime_fallback(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
recent_log = root / "recent.jsonl"
|
||||
recent_log.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps({"timestamp": "2026-04-20T12:00:00+09:00", "skill": "kbo-results"}),
|
||||
json.dumps({"timestamp": "2026-01-10T12:00:00+09:00", "skill": "korean-law-search"}),
|
||||
"loaded skill: fallback-skill",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
old_log = root / "old.log"
|
||||
old_log.write_text("loaded skill: old-fallback", encoding="utf-8")
|
||||
|
||||
# Lines without parseable timestamps use file mtime as the fallback signal.
|
||||
recent_mtime = 1_776_643_200 # 2026-04-24T00:00:00Z
|
||||
old_mtime = 1_766_275_200 # 2025-12-20T00:00:00Z
|
||||
recent_log.touch()
|
||||
old_log.touch()
|
||||
import os
|
||||
|
||||
os.utime(recent_log, (recent_mtime, recent_mtime))
|
||||
os.utime(old_log, (old_mtime, old_mtime))
|
||||
|
||||
counts = collect_skill_usage(
|
||||
[recent_log, old_log],
|
||||
["kbo-results", "korean-law-search", "fallback-skill", "old-fallback"],
|
||||
since="2026-04-01T00:00:00+09:00",
|
||||
)
|
||||
|
||||
self.assertEqual(counts["kbo-results"], 1)
|
||||
self.assertEqual(counts["korean-law-search"], 0)
|
||||
self.assertEqual(counts["fallback-skill"], 1)
|
||||
self.assertEqual(counts["old-fallback"], 0)
|
||||
|
||||
def test_collect_skill_usage_streams_log_files_without_reading_whole_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
log_path = Path(tmp) / "codex.jsonl"
|
||||
log_path.write_text(json.dumps({"skill": "kbo-results"}) + "\n", encoding="utf-8")
|
||||
|
||||
with patch.object(Path, "read_text", side_effect=AssertionError("collect_skill_usage must stream logs")):
|
||||
counts = collect_skill_usage([log_path], ["kbo-results", "unused"])
|
||||
|
||||
self.assertEqual(counts["kbo-results"], 1)
|
||||
self.assertEqual(counts["unused"], 0)
|
||||
|
||||
def test_cli_reports_usage_json_provenance_and_window_caveat(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
skill_dir = root / "kbo-results"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: kbo-results\n", encoding="utf-8")
|
||||
usage_json = root / "usage.json"
|
||||
usage_json.write_text(json.dumps({"kbo-results": 3}), encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import sys; "
|
||||
"from k_skill_cleaner import main; "
|
||||
"sys.exit(main(sys.argv[1:]))"
|
||||
),
|
||||
"--skills-root",
|
||||
str(root),
|
||||
"--usage-json",
|
||||
str(usage_json),
|
||||
"--days",
|
||||
"90",
|
||||
],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
report = json.loads(result.stdout)
|
||||
|
||||
self.assertTrue(report["usage_json"]["applied"])
|
||||
self.assertEqual(report["usage_json"]["path"], str(usage_json))
|
||||
self.assertIn("pre-windowed", report["usage_json"]["caveat"])
|
||||
self.assertEqual(report["scanned_logs"]["count"], 0)
|
||||
self.assertIn("usage JSON", report["time_window"]["scope"])
|
||||
|
||||
def test_ranks_deletion_candidates_with_interview_and_usage_reasons(self):
|
||||
candidates = rank_cleanup_candidates(
|
||||
skill_names=["unused", "rare", "protected", "active"],
|
||||
usage_counts={"unused": 0, "rare": 1, "protected": 0, "active": 12},
|
||||
never_use={"unused"},
|
||||
keep={"protected"},
|
||||
low_usage_threshold=1,
|
||||
)
|
||||
|
||||
self.assertEqual([candidate["skill"] for candidate in candidates], ["unused", "rare"])
|
||||
self.assertEqual(candidates[0]["action"], "remove")
|
||||
self.assertIn("interview_never_use", candidates[0]["reasons"])
|
||||
self.assertEqual(candidates[1]["action"], "review")
|
||||
self.assertIn("low_usage", candidates[1]["reasons"])
|
||||
|
||||
def test_documents_agent_specific_usage_sources(self):
|
||||
agents = {source["agent"] for source in AGENT_USAGE_SOURCES}
|
||||
expected_agents = {"Claude Code", "Codex", "OpenCode", "OpenClaw/ClawHub", "Hermes Agent"}
|
||||
|
||||
self.assertTrue(expected_agents.issubset(agents))
|
||||
for source in AGENT_USAGE_SOURCES:
|
||||
self.assertTrue(source["paths"] or source["fallback"])
|
||||
self.assertIn("confidence", source)
|
||||
|
||||
def test_skill_local_helper_autodetects_parent_skills_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
skills_root = Path(tmp)
|
||||
cleaner_dir = skills_root / "k-skill-cleaner"
|
||||
cleaner_scripts = cleaner_dir / "scripts"
|
||||
cleaner_scripts.mkdir(parents=True)
|
||||
(cleaner_dir / "SKILL.md").write_text("---\nname: k-skill-cleaner\n", encoding="utf-8")
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "k-skill-cleaner" / "scripts" / "k_skill_cleaner.py",
|
||||
cleaner_scripts / "k_skill_cleaner.py",
|
||||
)
|
||||
|
||||
for skill in ["kbo-results", "k-skill-setup"]:
|
||||
skill_dir = skills_root / skill
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(f"---\nname: {skill}\n", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/k_skill_cleaner.py",
|
||||
"--skills-root",
|
||||
".",
|
||||
"--never-use",
|
||||
"kbo-results",
|
||||
"--keep",
|
||||
"k-skill-setup",
|
||||
],
|
||||
cwd=cleaner_dir,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
report = json.loads(result.stdout)
|
||||
|
||||
self.assertEqual(report["skill_count"], 3)
|
||||
self.assertEqual(report["candidates"][0]["skill"], "kbo-results")
|
||||
self.assertEqual(report["candidates"][0]["action"], "remove")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue