Add sssf skill, installable via the skills CLI
Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
This commit is contained in:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
301
sssf/scripts/install.py
Executable file
301
sssf/scripts/install.py
Executable file
|
|
@ -0,0 +1,301 @@
|
|||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""/install — stamp the SSSF factory from the skill into the cwd. Idempotent.
|
||||
|
||||
Usage:
|
||||
uv run <skill>/scripts/install.py [--force] [--harness pi|omp]
|
||||
|
||||
Stamps: adws/ (modules + starter ADWs), adws/adw_data/prompt_engineering/
|
||||
(4 starter agents), adws/adw_sssf_config/sssf.config.yaml, .env.sample,
|
||||
.gitignore entries.
|
||||
|
||||
Prompts for the coding-agent harness (pi or omp) unless --harness is given,
|
||||
writes it into the stamped config's defaults, and registers the repo in the
|
||||
visualizer's repos.json so the multi-repo UI can browse it.
|
||||
|
||||
Existing files are skipped unless --force.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parent.parent / "templates"
|
||||
# The skill's own directory — where the visualizer app lives. install.py stamps
|
||||
# it into the justfile's `skill_dir` so `just obs` finds the app no matter
|
||||
# where the skill was installed (user scope, a repo, or via the skills CLI).
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
# The visualizer's repo registry lives next to the skill (machine-specific:
|
||||
# it maps repo slugs to local db paths). install.py keeps it in sync.
|
||||
REPOS_FILE = SKILL_DIR / "repos.json"
|
||||
|
||||
GITIGNORE_ENTRIES = [
|
||||
"adws/adw_data/sessions/",
|
||||
"adws/adw_data/sssf.db*",
|
||||
".env",
|
||||
# The ADWs are Python, so importing adw_modules writes bytecode next to it.
|
||||
# Chains that end in a commit phase call `git add -A`, so without this a
|
||||
# stamped repo commits its own .pyc files — 15 of them showed up in the
|
||||
# first repo that was ever installed into from scratch.
|
||||
"__pycache__/",
|
||||
"*.pyc",
|
||||
]
|
||||
|
||||
HARNESSES = ("pi", "omp")
|
||||
|
||||
|
||||
def stamp(src: Path, dest: Path, force: bool, stamped: list, skipped: list) -> None:
|
||||
if src.is_dir():
|
||||
for child in sorted(src.iterdir()):
|
||||
if child.name == "__pycache__":
|
||||
continue
|
||||
stamp(child, dest / child.name, force, stamped, skipped)
|
||||
return
|
||||
if dest.exists() and not force:
|
||||
skipped.append(str(dest))
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
stamped.append(str(dest))
|
||||
|
||||
|
||||
def ensure_gitignore(root: Path, stamped: list) -> None:
|
||||
gitignore = root / ".gitignore"
|
||||
existing = gitignore.read_text().splitlines() if gitignore.exists() else []
|
||||
missing = [e for e in GITIGNORE_ENTRIES if e not in existing]
|
||||
if missing:
|
||||
with gitignore.open("a") as f:
|
||||
f.write("\n# sssf runtime\n" + "\n".join(missing) + "\n")
|
||||
stamped.append(f"{gitignore} (+{len(missing)} entries)")
|
||||
|
||||
|
||||
def stamp_justfile(root: Path, force: bool, stamped: list, skipped: list) -> None:
|
||||
"""Stamp the justfile, substituting the skill's real path into `skill_dir`.
|
||||
|
||||
The `obs` recipe boots the visualizer app, which ships with the skill, so
|
||||
the stamped justfile has to know where the skill lives. The template keeps
|
||||
a `@SSSF_SKILL_DIR@` placeholder; this fills it with the actual install
|
||||
location (user scope, a repo, or wherever the skills CLI put it).
|
||||
"""
|
||||
dest = root / "justfile"
|
||||
if dest.exists() and not force:
|
||||
skipped.append(str(dest))
|
||||
return
|
||||
text = (TEMPLATES / "justfile").read_text()
|
||||
text = text.replace("@SSSF_SKILL_DIR@", str(SKILL_DIR))
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(text)
|
||||
stamped.append(str(dest))
|
||||
|
||||
|
||||
def choose_harness(arg: str | None) -> str:
|
||||
"""Pick the coding-agent harness: --harness wins, else prompt."""
|
||||
if arg:
|
||||
if arg not in HARNESSES:
|
||||
raise SystemExit(f"--harness must be one of {', '.join(HARNESSES)}, got {arg!r}")
|
||||
return arg
|
||||
while True:
|
||||
choice = input(f"Coding-agent harness [{HARNESSES[0]}/{HARNESSES[1]}] (default {HARNESSES[0]}): ").strip().lower()
|
||||
if not choice:
|
||||
return HARNESSES[0]
|
||||
if choice in HARNESSES:
|
||||
return choice
|
||||
print(f" {choice!r} is not a harness; pick {', '.join(HARNESSES)}.")
|
||||
|
||||
|
||||
def set_harness_in_config(config_path: Path, harness: str) -> None:
|
||||
"""Rewrite the stamped config's defaults.coding_agent to the chosen harness."""
|
||||
text = config_path.read_text()
|
||||
# Target the `defaults:` block's coding_agent, not the comment above it
|
||||
# (which also contains the words "coding_agent"). The template ships
|
||||
# ` coding_agent: pi` as the first line under `defaults:`.
|
||||
defaults_marker = "defaults:"
|
||||
d_idx = text.find(defaults_marker)
|
||||
if d_idx == -1:
|
||||
return
|
||||
marker = "coding_agent:"
|
||||
idx = text.find(marker, d_idx)
|
||||
if idx == -1:
|
||||
return
|
||||
line_end = text.find("\n", idx)
|
||||
text = text[:idx] + f"coding_agent: {harness}" + text[line_end:]
|
||||
config_path.write_text(text)
|
||||
|
||||
|
||||
def harness_catalog(harness: str) -> list[str]:
|
||||
"""List the model selectors the harness can actually run, or [] if unknown.
|
||||
|
||||
The template config ships with a roster of models (google/gemini-3.6-flash,
|
||||
fireworks/..., openai/...) that only resolve if the harness has them
|
||||
registered. Most machines have a handful of local/cloud models, so the
|
||||
stamped config usually fails validation until every agent is pointed at a
|
||||
model that exists. Reading the harness's own catalog lets install offer to
|
||||
fix that automatically.
|
||||
"""
|
||||
try:
|
||||
if harness == "pi":
|
||||
out = subprocess.run(
|
||||
["pi", "--list-models"], capture_output=True, text=True,
|
||||
timeout=30, check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
# provider model context max-out thinking images
|
||||
return [f"{row[0]}/{row[1]}" for row in
|
||||
(line.split() for line in out.stdout.splitlines()[1:])
|
||||
if len(row) >= 2]
|
||||
if harness == "omp":
|
||||
out = subprocess.run(
|
||||
["omp", "models", "--json"], capture_output=True, text=True,
|
||||
timeout=30, check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
data = json.loads(out.stdout)
|
||||
return [m.get("selector") or f"{m['provider']}/{m['id']}"
|
||||
for m in data.get("models", []) if isinstance(m, dict)]
|
||||
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def fix_models_in_config(config_path: Path, harness: str) -> None:
|
||||
"""Point every agent at a model the harness can actually run.
|
||||
|
||||
The template roster names several providers; if none of them resolve in the
|
||||
harness's catalog, offer to set `defaults.model` to the first registered
|
||||
model and drop the per-agent `model:` overrides — the fastest way to a
|
||||
validating roster on a single model. No-op when the catalog is unknown or
|
||||
the template models already resolve.
|
||||
"""
|
||||
catalog = harness_catalog(harness)
|
||||
if not catalog:
|
||||
return
|
||||
text = config_path.read_text()
|
||||
# Collect the model patterns the template ships (defaults + per-agent).
|
||||
import re
|
||||
patterns = re.findall(r"^\s*model:\s*(\S+)", text, re.M)
|
||||
if not patterns:
|
||||
return
|
||||
# Does any template model resolve? A pattern resolves if it's in the catalog
|
||||
# or is a bare id that matches exactly one catalog entry.
|
||||
def resolves(pattern: str) -> bool:
|
||||
if pattern in catalog:
|
||||
return True
|
||||
matches = [c for c in catalog if pattern == c.split("/")[-1]]
|
||||
return len(matches) == 1
|
||||
if any(resolves(p) for p in patterns):
|
||||
return
|
||||
# None resolve — offer to point the whole roster at the first model.
|
||||
target = catalog[0]
|
||||
print(f" none of the template models resolve in {harness}'s catalog "
|
||||
f"({len(catalog)} model(s) available)")
|
||||
choice = input(f" point the whole roster at {target}? [Y/n]: ").strip().lower()
|
||||
if choice in ("n", "no"):
|
||||
return
|
||||
# Set defaults.model and drop every per-agent `model:` line.
|
||||
defaults_marker = "defaults:"
|
||||
d_idx = text.find(defaults_marker)
|
||||
if d_idx != -1:
|
||||
m_idx = text.find("model:", d_idx)
|
||||
if m_idx != -1:
|
||||
line_end = text.find("\n", m_idx)
|
||||
text = text[:m_idx] + f"model: {target}" + text[line_end:]
|
||||
# Remove per-agent model overrides (indented `model:` lines in agent
|
||||
# blocks, i.e. after the defaults block ends). The defaults.model line we
|
||||
# just wrote stays.
|
||||
lines = text.splitlines(keepends=True)
|
||||
kept = []
|
||||
in_defaults = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("defaults:"):
|
||||
in_defaults = True
|
||||
elif stripped and not stripped.startswith("#") and not line.startswith((" ", "\t")):
|
||||
in_defaults = False
|
||||
if not in_defaults and stripped.startswith("model:") and line.startswith((" ", "\t")):
|
||||
continue
|
||||
kept.append(line)
|
||||
config_path.write_text("".join(kept))
|
||||
print(f" defaults.model = {target}; per-agent model overrides removed")
|
||||
|
||||
|
||||
def register_repo(root: Path) -> None:
|
||||
"""Add/update this repo in the visualizer's repos.json (idempotent)."""
|
||||
slug = root.resolve().name
|
||||
db = str((root / "adws" / "adw_data" / "sssf.db").resolve())
|
||||
entry = {"slug": slug, "name": slug, "db": db}
|
||||
|
||||
repos = []
|
||||
if REPOS_FILE.exists():
|
||||
try:
|
||||
repos = json.loads(REPOS_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
repos = []
|
||||
if not isinstance(repos, list):
|
||||
repos = []
|
||||
|
||||
# Replace an existing entry for this slug, else append.
|
||||
repos = [r for r in repos if not (isinstance(r, dict) and r.get("slug") == slug)]
|
||||
repos.append(entry)
|
||||
REPOS_FILE.write_text(json.dumps(repos, indent=2) + "\n")
|
||||
print(f" registered {slug} in {REPOS_FILE}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--force", action="store_true", help="overwrite existing files")
|
||||
parser.add_argument("--harness", choices=HARNESSES, help="coding-agent harness (pi or omp)")
|
||||
args = parser.parse_args()
|
||||
|
||||
harness = choose_harness(args.harness)
|
||||
|
||||
root = Path.cwd()
|
||||
stamped, skipped = [], []
|
||||
|
||||
stamp(TEMPLATES / "adws", root / "adws", args.force, stamped, skipped)
|
||||
stamp(TEMPLATES / "prompt_engineering",
|
||||
root / "adws" / "adw_data" / "prompt_engineering", args.force, stamped, skipped)
|
||||
stamp(TEMPLATES / "harness_engineering",
|
||||
root / "adws" / "adw_data" / "harness_engineering", args.force, stamped, skipped)
|
||||
stamp(TEMPLATES / "sssf.config.yaml",
|
||||
root / "adws" / "adw_sssf_config" / "sssf.config.yaml",
|
||||
args.force, stamped, skipped)
|
||||
stamp(TEMPLATES / "env.sample", root / ".env.sample", args.force, stamped, skipped)
|
||||
# The recipes are part of the operating experience, and several cookbooks
|
||||
# plus the run banner tell you to use them, so a stamped repo has to have
|
||||
# them. Skipped like any other file if the repo already has a justfile.
|
||||
stamp_justfile(root, args.force, stamped, skipped)
|
||||
ensure_gitignore(root, stamped)
|
||||
|
||||
config_path = root / "adws" / "adw_sssf_config" / "sssf.config.yaml"
|
||||
if config_path.exists():
|
||||
set_harness_in_config(config_path, harness)
|
||||
print(f" defaults.coding_agent = {harness} in {config_path}")
|
||||
fix_models_in_config(config_path, harness)
|
||||
register_repo(root)
|
||||
|
||||
print(f"sssf installed into {root}")
|
||||
print(f" stamped: {len(stamped)} file(s)")
|
||||
for s in stamped:
|
||||
print(f" + {s}")
|
||||
if skipped:
|
||||
print(f" skipped (already exist, use --force to overwrite): {len(skipped)}")
|
||||
print("\nnext steps:")
|
||||
print(" 1. just validate # roster check: names, prompts, models all resolve")
|
||||
print(" 2. cp .env.sample .env # if no .env yet; else append the keys you need")
|
||||
print(" 3. just demo # two cheap read-only runs, end to end")
|
||||
print(" 4. just sessions # what just happened")
|
||||
print(" 5. just obs # the trace UI, needs bun")
|
||||
print("\n no just? the raw form of step 3 is:")
|
||||
print(" uv run adws/adw_prompt.py \"say hello\" --agent scout")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue