@event4u/agent-config 5.4.1 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/knowledge/cross-repo.md +71 -0
- package/.agent-src/commands/knowledge.md +2 -0
- package/.agent-src/commands/skill/preview.md +67 -0
- package/.agent-src/commands/skill.md +48 -0
- package/.agent-src/commands/skills/discover.md +76 -0
- package/.agent-src/commands/skills.md +56 -0
- package/.agent-src/commands/video/from-song.md +317 -0
- package/.agent-src/commands/video.md +19 -9
- package/.agent-src/rules/linked-projects-onboarding-gate.md +1 -1
- package/.agent-src/skills/song-to-script/SKILL.md +193 -0
- package/.claude-plugin/marketplace.json +9 -2
- package/CHANGELOG.md +37 -0
- package/CONTRIBUTING.md +6 -0
- package/README.md +3 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +171 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +17 -10
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +13 -6
- package/dist/mcp/registry-manifest.json +2 -2
- package/docs/architecture.md +2 -2
- package/docs/contracts/command-clusters.md +4 -1
- package/docs/contracts/cross-repo-retrieval.md +64 -0
- package/docs/contracts/skill-discovery.md +80 -0
- package/docs/contracts/skill-dry-run.md +47 -0
- package/docs/decisions/ADR-032-linked-projects-scope.md +7 -3
- package/docs/getting-started.md +1 -1
- package/docs/guides/cross-repo-linked-projects.md +7 -0
- package/docs/guides/cross-repo-retrieval.md +61 -0
- package/docs/guides/skill-discovery.md +71 -0
- package/docs/guides/skill-preview.md +71 -0
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_dispatch.bash +10 -0
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/ai-video/lib/probe-audio.sh +181 -0
- package/scripts/cross_repo_retrieve.py +172 -0
- package/scripts/inventory_meta_layers.py +288 -0
- package/scripts/linked_projects_list.py +91 -0
- package/scripts/memory_lookup.py +53 -2
- package/scripts/skill_discovery.py +254 -0
- package/scripts/skill_linter.py +8 -4
- package/scripts/skill_preview.py +179 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Skill discovery recommender — local-only, explained, no network.
|
|
2
|
+
|
|
3
|
+
Phase 3 of `road-to-leaner-core-and-discovery`. Turns existing local signals
|
|
4
|
+
(skill catalog frontmatter, role shortlists, optional local-analytics JSONL)
|
|
5
|
+
into a short, *explained* skill shortlist. Every recommendation carries a
|
|
6
|
+
non-empty `why` (contract: docs/contracts/skill-discovery.md). Adds no
|
|
7
|
+
always-loaded layer; reads local files only.
|
|
8
|
+
|
|
9
|
+
Four classes:
|
|
10
|
+
most-useful-for-role — role skills.yml priority order
|
|
11
|
+
related-to-current-task— skills sharing the role's core domains
|
|
12
|
+
recently-adopted — analytics events (last 14d) with a skill id
|
|
13
|
+
popular-in-role — analytics skill-events filtered by role, by frequency
|
|
14
|
+
|
|
15
|
+
Analytics is optional; missing / empty / opted-out degrades gracefully to
|
|
16
|
+
the role shortlist with an honest `why`. Honours the same opt-out as
|
|
17
|
+
local-analytics.md (AGENT_CONFIG_NO_LOCAL_ANALYTICS env + analytics.local config).
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 scripts/skill_discovery.py [--role ROLE] [--format text|json] [--limit N]
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from collections import Counter, defaultdict
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
36
|
+
SKILLS_DIR = REPO_ROOT / ".agent-src" / "skills"
|
|
37
|
+
ROLES_DIR = REPO_ROOT / "agents" / "roles"
|
|
38
|
+
COMMANDS_DIR = REPO_ROOT / ".agent-src" / "commands"
|
|
39
|
+
RECENT_DAYS = 14
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
42
|
+
try:
|
|
43
|
+
from _lib.user_global_paths import event4u_root # type: ignore
|
|
44
|
+
except Exception: # pragma: no cover - fallback when run outside repo
|
|
45
|
+
def event4u_root(env=None): # type: ignore
|
|
46
|
+
return Path.home() / ".event4u" / "agent-config"
|
|
47
|
+
|
|
48
|
+
CLASSES = ("most-useful-for-role", "related-to-current-task", "recently-adopted", "popular-in-role")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Skill:
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
domain: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Rec:
|
|
60
|
+
skill: str
|
|
61
|
+
cls: str
|
|
62
|
+
why: str
|
|
63
|
+
first_command: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _frontmatter(text: str) -> dict:
|
|
67
|
+
if not text.startswith("---"):
|
|
68
|
+
return {}
|
|
69
|
+
end = text.find("\n---", 3)
|
|
70
|
+
if end == -1:
|
|
71
|
+
return {}
|
|
72
|
+
try:
|
|
73
|
+
return yaml.safe_load(text[3:end]) or {}
|
|
74
|
+
except yaml.YAMLError:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_catalog() -> dict[str, Skill]:
|
|
79
|
+
out: dict[str, Skill] = {}
|
|
80
|
+
if not SKILLS_DIR.exists():
|
|
81
|
+
return out
|
|
82
|
+
for d in sorted(SKILLS_DIR.iterdir()):
|
|
83
|
+
sk = d / "SKILL.md"
|
|
84
|
+
if not sk.is_file():
|
|
85
|
+
continue
|
|
86
|
+
fm = _frontmatter(sk.read_text(encoding="utf-8", errors="replace"))
|
|
87
|
+
name = str(fm.get("name") or d.name).strip().strip('"')
|
|
88
|
+
out[name] = Skill(name, str(fm.get("description", "")).strip(), str(fm.get("domain", "")).strip())
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_role_shortlist(role: str) -> list[dict]:
|
|
93
|
+
f = ROLES_DIR / role / "skills.yml"
|
|
94
|
+
if not f.is_file():
|
|
95
|
+
return []
|
|
96
|
+
data = yaml.safe_load(f.read_text(encoding="utf-8", errors="replace")) or {}
|
|
97
|
+
return [s for s in (data.get("skills") or []) if isinstance(s, dict) and s.get("id")]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def available_roles() -> list[str]:
|
|
101
|
+
if not ROLES_DIR.exists():
|
|
102
|
+
return []
|
|
103
|
+
return sorted(d.name for d in ROLES_DIR.iterdir() if (d / "skills.yml").is_file())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def analytics_enabled(settings: dict) -> bool:
|
|
107
|
+
if os.environ.get("AGENT_CONFIG_NO_LOCAL_ANALYTICS", "").strip():
|
|
108
|
+
return False
|
|
109
|
+
val = ((settings.get("analytics") or {}).get("local"))
|
|
110
|
+
return str(val).strip().lower() not in ("off", "false", "0", "no")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_settings() -> dict:
|
|
114
|
+
try:
|
|
115
|
+
from _lib.agent_settings import load_agent_settings # type: ignore
|
|
116
|
+
return load_agent_settings(cwd=Path.cwd()) or {}
|
|
117
|
+
except Exception:
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_analytics_events() -> list[dict]:
|
|
122
|
+
path = event4u_root() / "workspace" / "analytics" / "events.jsonl"
|
|
123
|
+
if not path.is_file():
|
|
124
|
+
return []
|
|
125
|
+
events: list[dict] = []
|
|
126
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if not line:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
events.append(json.loads(line))
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
continue
|
|
134
|
+
return events
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _days_ago(ts: str, now: datetime) -> int | None:
|
|
138
|
+
try:
|
|
139
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
140
|
+
if dt.tzinfo is None:
|
|
141
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
142
|
+
return (now - dt).days
|
|
143
|
+
except (ValueError, AttributeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def first_command(name: str) -> str:
|
|
148
|
+
for cand in (COMMANDS_DIR / f"{name}.md", *COMMANDS_DIR.glob(f"*/{name}.md")):
|
|
149
|
+
if cand.is_file():
|
|
150
|
+
return f"/{name}"
|
|
151
|
+
return f"Skill › {name}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def recommend(role: str, catalog: dict[str, Skill], shortlist: list[dict],
|
|
155
|
+
events: list[dict], use_analytics: bool, now: datetime, limit: int) -> list[Rec]:
|
|
156
|
+
recs: list[Rec] = []
|
|
157
|
+
claimed: set[str] = set()
|
|
158
|
+
|
|
159
|
+
def add(name: str, cls: str, why: str) -> None:
|
|
160
|
+
if name in claimed or name not in catalog or not why:
|
|
161
|
+
return
|
|
162
|
+
claimed.add(name)
|
|
163
|
+
recs.append(Rec(name, cls, why, first_command(name)))
|
|
164
|
+
|
|
165
|
+
# 1. most-useful-for-role — role shortlist priority order.
|
|
166
|
+
short_ids = [s["id"] for s in shortlist]
|
|
167
|
+
for s in shortlist[:limit]:
|
|
168
|
+
why = (s.get("why") or "").strip() or f"on the {role} role's priority shortlist"
|
|
169
|
+
add(s["id"], "most-useful-for-role", why)
|
|
170
|
+
|
|
171
|
+
# 2. related-to-current-task — same domain as the role's core skills, not yet shortlisted.
|
|
172
|
+
role_domains = {catalog[i].domain for i in short_ids if i in catalog and catalog[i].domain}
|
|
173
|
+
related = [sk for n, sk in sorted(catalog.items())
|
|
174
|
+
if sk.domain in role_domains and n not in short_ids and sk.domain]
|
|
175
|
+
for sk in related[:limit]:
|
|
176
|
+
add(sk.name, "related-to-current-task", f"same domain ({sk.domain}) as your {role} core skills")
|
|
177
|
+
|
|
178
|
+
# 3 + 4. analytics-backed, or graceful role-shortlist fallback.
|
|
179
|
+
skill_events = [e for e in events if isinstance(e.get("data"), dict) and e["data"].get("skill")]
|
|
180
|
+
if use_analytics and skill_events:
|
|
181
|
+
recent = sorted(
|
|
182
|
+
((e["data"]["skill"], _days_ago(e.get("ts", ""), now)) for e in skill_events),
|
|
183
|
+
key=lambda kv: (kv[1] is None, kv[1] if kv[1] is not None else 1e9),
|
|
184
|
+
)
|
|
185
|
+
for name, days in recent:
|
|
186
|
+
if days is not None and days <= RECENT_DAYS:
|
|
187
|
+
add(name, "recently-adopted", f"used {days}d ago in this workspace")
|
|
188
|
+
role_counts = Counter(
|
|
189
|
+
e["data"]["skill"] for e in skill_events if e["data"].get("role") == role
|
|
190
|
+
)
|
|
191
|
+
for name, n in role_counts.most_common(limit):
|
|
192
|
+
add(name, "popular-in-role", f"launched {n}× by the {role} role locally")
|
|
193
|
+
else:
|
|
194
|
+
reason = "from your role shortlist — no local usage signal yet"
|
|
195
|
+
for s in shortlist[limit: limit * 2]:
|
|
196
|
+
add(s["id"], "recently-adopted", reason)
|
|
197
|
+
for s in shortlist:
|
|
198
|
+
add(s["id"], "popular-in-role", reason)
|
|
199
|
+
return recs
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_text(role: str, recs: list[Rec], analytics_on: bool) -> str:
|
|
203
|
+
lines = [f"# Suggested skills for the `{role}` role", ""]
|
|
204
|
+
note = "local analytics: on" if analytics_on else "local analytics: off (role shortlist only)"
|
|
205
|
+
lines.append(f"_{note}_\n")
|
|
206
|
+
lines += ["| skill | class | why | first command |", "|---|---|---|---|"]
|
|
207
|
+
for r in recs:
|
|
208
|
+
lines.append(f"| `{r.skill}` | {r.cls} | {r.why} | `{r.first_command}` |")
|
|
209
|
+
lines.append("")
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> int:
|
|
214
|
+
ap = argparse.ArgumentParser(description="Local skill-discovery recommender (read-only, explained).")
|
|
215
|
+
ap.add_argument("--role", default=None, help="Role id (defaults to active role experience, else prompts).")
|
|
216
|
+
ap.add_argument("--format", choices=("text", "json"), default="text")
|
|
217
|
+
ap.add_argument("--limit", type=int, default=5)
|
|
218
|
+
ap.add_argument("--now", default=None, help="ISO timestamp override for tests.")
|
|
219
|
+
args = ap.parse_args(argv)
|
|
220
|
+
|
|
221
|
+
settings = load_settings()
|
|
222
|
+
role = args.role or ((settings.get("roles") or {}).get("active_role") or "").strip()
|
|
223
|
+
roles = available_roles()
|
|
224
|
+
if not role:
|
|
225
|
+
print(f"No role given and no active role set. Available roles: {', '.join(roles) or '(none)'}", file=sys.stderr)
|
|
226
|
+
print("Re-run with --role <role>.", file=sys.stderr)
|
|
227
|
+
return 2
|
|
228
|
+
if role not in roles:
|
|
229
|
+
print(f"Unknown role {role!r}. Available: {', '.join(roles) or '(none)'}", file=sys.stderr)
|
|
230
|
+
return 2
|
|
231
|
+
|
|
232
|
+
catalog = load_catalog()
|
|
233
|
+
shortlist = load_role_shortlist(role)
|
|
234
|
+
use_analytics = analytics_enabled(settings)
|
|
235
|
+
events = load_analytics_events() if use_analytics else []
|
|
236
|
+
now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc)
|
|
237
|
+
if now.tzinfo is None:
|
|
238
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
239
|
+
|
|
240
|
+
recs = recommend(role, catalog, shortlist, events, use_analytics, now, args.limit)
|
|
241
|
+
|
|
242
|
+
if args.format == "json":
|
|
243
|
+
print(json.dumps({
|
|
244
|
+
"role": role,
|
|
245
|
+
"analytics": use_analytics,
|
|
246
|
+
"recommendations": [r.__dict__ for r in recs],
|
|
247
|
+
}, indent=2))
|
|
248
|
+
else:
|
|
249
|
+
print(render_text(role, recs, use_analytics))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|
package/scripts/skill_linter.py
CHANGED
|
@@ -533,12 +533,16 @@ def detect_artifact_type(path: Path, text: str) -> ArtifactType:
|
|
|
533
533
|
path_str = str(path).lower()
|
|
534
534
|
has_skill_heading = "## When to use" in text and "## Procedure" in text
|
|
535
535
|
|
|
536
|
-
#
|
|
536
|
+
# A file inside a /commands/ tree is a command — the commands tree wins,
|
|
537
|
+
# even for a cluster head literally named `skill.md` or a sub-command under
|
|
538
|
+
# a `skills/` cluster dir (e.g. /commands/skills/discover.md). The only
|
|
539
|
+
# /commands/ file that is NOT a command is a nested skill body, which is
|
|
540
|
+
# always `SKILL.md` (case-sensitive — command files are lowercase).
|
|
541
|
+
if "/commands/" in path_str and path.name != "SKILL.md":
|
|
542
|
+
return "command"
|
|
543
|
+
# Skills: a SKILL.md body, or anything under a /skills/ tree.
|
|
537
544
|
if path.name.lower() == "skill.md" or "/skills/" in path_str:
|
|
538
545
|
return "skill"
|
|
539
|
-
# Commands are flat .md files in /commands/ directories (not SKILL.md)
|
|
540
|
-
if "/commands/" in path_str and path.name.lower() != "skill.md":
|
|
541
|
-
return "command"
|
|
542
546
|
if "/rules/" in path_str:
|
|
543
547
|
return "rule"
|
|
544
548
|
if "/guidelines/" in path_str:
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Skill preview — non-destructive "what will this skill do?" summary.
|
|
2
|
+
|
|
3
|
+
Phase 5 of `road-to-leaner-core-and-discovery`. Reads a skill's declared intent
|
|
4
|
+
(frontmatter + `## Steps` body) and renders a plain-language summary BEFORE the
|
|
5
|
+
skill runs. Read-only, no network, no execution.
|
|
6
|
+
|
|
7
|
+
NOT a sandbox: it surfaces declared intent, it does not run the skill or prove
|
|
8
|
+
side-effect-freeness (contract: docs/contracts/skill-dry-run.md). For
|
|
9
|
+
`execution: manual` skills (the default) it states "instructional only".
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 scripts/skill_preview.py <name> [--technical] [--format text|json]
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
SKILLS_DIR = REPO_ROOT / ".agent-src" / "skills"
|
|
26
|
+
|
|
27
|
+
_CMD_RE = re.compile(r"`(python3?|bash|node|php|npm|task|pytest)\s+[^`]+`")
|
|
28
|
+
_PATH_RE = re.compile(r"`([\w./-]+\.(?:py|sh|md|json|yml|yaml|ts|js|php))`")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PreviewError(Exception):
|
|
32
|
+
"""Raised for a missing or malformed SKILL.md — rendered, never crashed on."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
36
|
+
if not text.startswith("---"):
|
|
37
|
+
raise PreviewError("SKILL.md has no YAML frontmatter (missing leading `---`).")
|
|
38
|
+
end = text.find("\n---", 3)
|
|
39
|
+
if end == -1:
|
|
40
|
+
raise PreviewError("SKILL.md frontmatter is not closed (missing terminating `---`).")
|
|
41
|
+
try:
|
|
42
|
+
fm = yaml.safe_load(text[3:end]) or {}
|
|
43
|
+
except yaml.YAMLError as exc:
|
|
44
|
+
raise PreviewError(f"SKILL.md frontmatter is not valid YAML: {exc}")
|
|
45
|
+
if not isinstance(fm, dict):
|
|
46
|
+
raise PreviewError("SKILL.md frontmatter did not parse to a mapping.")
|
|
47
|
+
return fm, text[end + 4:]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _steps(body: str) -> list[str]:
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
in_steps = False
|
|
53
|
+
for line in body.splitlines():
|
|
54
|
+
if re.match(r"^##\s+Steps\b", line, re.IGNORECASE):
|
|
55
|
+
in_steps = True
|
|
56
|
+
continue
|
|
57
|
+
if in_steps and re.match(r"^##\s+\S", line): # next top-level section
|
|
58
|
+
break
|
|
59
|
+
if in_steps:
|
|
60
|
+
m = re.match(r"^###\s+(.*)", line)
|
|
61
|
+
if m:
|
|
62
|
+
out.append(m.group(1).strip())
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _targets(body: str) -> tuple[list[str], list[str]]:
|
|
67
|
+
cmds = sorted({m.group(0).strip("`") for m in _CMD_RE.finditer(body)})
|
|
68
|
+
paths = sorted({m.group(1) for m in _PATH_RE.finditer(body)})
|
|
69
|
+
return cmds, paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_preview(name: str) -> dict:
|
|
73
|
+
skill_dir = SKILLS_DIR / name
|
|
74
|
+
sk = skill_dir / "SKILL.md"
|
|
75
|
+
if not sk.is_file():
|
|
76
|
+
try:
|
|
77
|
+
shown = sk.relative_to(REPO_ROOT)
|
|
78
|
+
except ValueError:
|
|
79
|
+
shown = sk
|
|
80
|
+
raise PreviewError(f"no skill named {name!r} (looked for {shown}).")
|
|
81
|
+
fm, body = _split_frontmatter(sk.read_text(encoding="utf-8", errors="replace"))
|
|
82
|
+
execution = fm.get("execution") or {}
|
|
83
|
+
if not isinstance(execution, dict):
|
|
84
|
+
execution = {}
|
|
85
|
+
cmds, paths = _targets(body)
|
|
86
|
+
return {
|
|
87
|
+
"name": fm.get("name") or name,
|
|
88
|
+
"description": (fm.get("description") or "").strip(),
|
|
89
|
+
"domain": fm.get("domain") or "",
|
|
90
|
+
"execution_type": (execution.get("type") or "manual"),
|
|
91
|
+
"handler": (execution.get("handler") or "none"),
|
|
92
|
+
"allowed_tools": execution.get("allowed_tools") or [],
|
|
93
|
+
"command": execution.get("command") or [],
|
|
94
|
+
"steps": _steps(body),
|
|
95
|
+
"commands_named": cmds,
|
|
96
|
+
"paths_named": paths,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_plain(p: dict) -> str:
|
|
101
|
+
lines = [f"# Preview — `{p['name']}`", ""]
|
|
102
|
+
if p["description"]:
|
|
103
|
+
lines += [p["description"], ""]
|
|
104
|
+
etype = p["execution_type"]
|
|
105
|
+
if etype == "manual":
|
|
106
|
+
lines.append("**Execution: instructional only.** This skill does not run anything "
|
|
107
|
+
"automatically — it guides the agent step by step.")
|
|
108
|
+
elif etype == "assisted":
|
|
109
|
+
lines.append(f"**Execution: assisted** (handler `{p['handler']}`). It will *propose* actions "
|
|
110
|
+
"for you to approve — it never executes silently.")
|
|
111
|
+
else:
|
|
112
|
+
lines.append(f"**Execution: {etype}** (handler `{p['handler']}`). It can run actions; review the "
|
|
113
|
+
"declared tools and commands below before allowing it.")
|
|
114
|
+
lines.append("")
|
|
115
|
+
if p["steps"]:
|
|
116
|
+
lines.append("This skill will walk these steps:")
|
|
117
|
+
lines += [f"- {s}" for s in p["steps"]]
|
|
118
|
+
lines.append("")
|
|
119
|
+
if p["allowed_tools"]:
|
|
120
|
+
lines.append(f"Declared tools: {', '.join(p['allowed_tools'])}")
|
|
121
|
+
if p["command"]:
|
|
122
|
+
lines.append(f"Declared command: `{' '.join(str(c) for c in p['command'])}`")
|
|
123
|
+
if p["commands_named"]:
|
|
124
|
+
lines.append("Commands it may run:")
|
|
125
|
+
lines += [f"- `{c}`" for c in p["commands_named"]]
|
|
126
|
+
if p["paths_named"]:
|
|
127
|
+
lines.append("Files / scripts it references:")
|
|
128
|
+
lines += [f"- `{f}`" for f in p["paths_named"]]
|
|
129
|
+
if not (p["allowed_tools"] or p["command"] or p["commands_named"] or p["paths_named"]):
|
|
130
|
+
lines.append("_No tools, commands, or file targets declared — pure guidance._")
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append("> Preview shows declared intent only — it does not run the skill or guarantee "
|
|
133
|
+
"side-effect-freeness. Contract: docs/contracts/skill-dry-run.md")
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_technical(p: dict) -> str:
|
|
138
|
+
lines = [f"# Preview (technical) — {p['name']}", "", "## Frontmatter (execution)", "```yaml"]
|
|
139
|
+
lines.append(f"execution_type: {p['execution_type']}")
|
|
140
|
+
lines.append(f"handler: {p['handler']}")
|
|
141
|
+
lines.append(f"allowed_tools: {p['allowed_tools']}")
|
|
142
|
+
if p["command"]:
|
|
143
|
+
lines.append(f"command: {p['command']}")
|
|
144
|
+
lines += ["```", "", "## Declared steps"]
|
|
145
|
+
lines += [f"{i+1}. {s}" for i, s in enumerate(p["steps"])] or ["(none)"]
|
|
146
|
+
if p["commands_named"]:
|
|
147
|
+
lines += ["", "## Commands named in body"] + [f"- `{c}`" for c in p["commands_named"]]
|
|
148
|
+
if p["paths_named"]:
|
|
149
|
+
lines += ["", "## Paths named in body"] + [f"- `{f}`" for f in p["paths_named"]]
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
ap = argparse.ArgumentParser(description="Preview a skill's declared intent (read-only, no execution).")
|
|
155
|
+
ap.add_argument("name", help="Skill name (directory under .agent-src/skills/).")
|
|
156
|
+
ap.add_argument("--technical", action="store_true", help="Show raw frontmatter + step list.")
|
|
157
|
+
ap.add_argument("--format", choices=("text", "json"), default="text")
|
|
158
|
+
args = ap.parse_args(argv)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
preview = load_preview(args.name)
|
|
162
|
+
except PreviewError as exc:
|
|
163
|
+
if args.format == "json":
|
|
164
|
+
print(json.dumps({"error": str(exc), "name": args.name}, indent=2))
|
|
165
|
+
else:
|
|
166
|
+
print(f"❌ Cannot preview {args.name!r}: {exc}", file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
|
|
169
|
+
if args.format == "json":
|
|
170
|
+
print(json.dumps(preview, indent=2))
|
|
171
|
+
elif args.technical:
|
|
172
|
+
print(render_technical(preview))
|
|
173
|
+
else:
|
|
174
|
+
print(render_plain(preview))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
raise SystemExit(main())
|