diff --git a/README.md b/README.md index d305e57..c1fbae9 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Copy the skills into a specific project (`/.agents/skills` and `/.cl make project TARGET=/path/to/repo ``` +Both commands also enable the caveman and ponytail plugins in the target's `.claude/settings.json` so their modes auto-activate on session start; existing settings are preserved. + Restart your agent afterwards so it loads the new skills. ## Maintenance 🔧 diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js new file mode 100644 index 0000000..0ccf901 --- /dev/null +++ b/scripts/enable-plugins.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Enable the caveman and ponytail plugins in a Claude Code settings.json so +// their SessionStart hooks auto-activate both modes. Merges non-destructively: +// existing marketplaces and enabled plugins are preserved, unparseable files +// are left untouched. +"use strict"; + +const fs = require("fs"); + +const settingsPath = process.argv[2]; +if (!settingsPath) { + console.error("usage: enable-plugins.js "); + process.exit(2); +} + +const MARKETPLACES = { + caveman: { source: { source: "github", repo: "JuliusBrussee/caveman" } }, + ponytail: { source: { source: "github", repo: "DietrichGebert/ponytail" } }, +}; +const PLUGINS = { "caveman@caveman": true, "ponytail@ponytail": true }; + +let data = {}; +if (fs.existsSync(settingsPath)) { + const raw = fs.readFileSync(settingsPath, "utf8").trim(); + if (raw) { + try { + data = JSON.parse(raw); + } catch (err) { + console.error(`skills: ${settingsPath} is not valid JSON; leaving it untouched (${err.message}).`); + process.exit(0); + } + } +} + +data.extraKnownMarketplaces = data.extraKnownMarketplaces || {}; +for (const [name, entry] of Object.entries(MARKETPLACES)) { + if (!data.extraKnownMarketplaces[name]) data.extraKnownMarketplaces[name] = entry; +} +data.enabledPlugins = Object.assign(data.enabledPlugins || {}, PLUGINS); + +fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n"); +console.log(`skills: enabled caveman + ponytail plugins in ${settingsPath}`); diff --git a/scripts/install.sh b/scripts/install.sh index 535db01..ff99ef7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -44,4 +44,12 @@ for dst in "${TARGET}/.agents/skills" "${TARGET}/.claude/skills"; do cp -a "${src}/." "${dst}/" done +settings="${TARGET}/.claude/settings.json" +if command -v node &>/dev/null; then + log "skills: auto-enabling caveman + ponytail plugins in ${settings}" + node "${REPO_ROOT}/scripts/enable-plugins.js" "${settings}" +else + warn "skills: node not found; skipped auto-enabling caveman/ponytail plugins." +fi + log "skills: done. Restart your agent to load the skills." diff --git a/skills/shortcuts/SKILL.md b/skills/shortcuts/SKILL.md new file mode 100644 index 0000000..e66e585 --- /dev/null +++ b/skills/shortcuts/SKILL.md @@ -0,0 +1,19 @@ +--- +name: shortcuts +description: > + Portable operator conversation shortcuts, reusable across every project. + Trigger when the user's message is exactly one of these tokens (optionally + followed by arguments): sobu, soco, socobu, socon, sona, sote. Expand the + shortcut to the listed meaning and act on the expanded instruction. +--- + +When an operator message is one of the shortcuts below, expand it to the meaning and act on it. + +| Shortcut | Meaning | +| --- | --- | +| `sobu` | Stage next bundle: stage the next logical bundle of verified changes, explain what it does, and list what still remains uncommitted. | +| `soco` | Commit: commit the currently staged changes (pre-commit hooks run). | +| `socobu` | Commit no-verify, then stage next bundle: run `socon` followed by `sobu`. | +| `socon` | Commit no-verify: commit the currently staged changes with `--no-verify`. | +| `sona` | New alias: add the shortcut the operator states to the appropriate table, keeping it sorted. | +| `sote` | Run `make test`. | diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py new file mode 100644 index 0000000..8864aa6 --- /dev/null +++ b/tests/test_shortcuts.py @@ -0,0 +1,59 @@ +"""Validate the shortcuts skill table: rows present, sorted, ``soc``-prefixed, +no two adjacent consonants.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL = REPO_ROOT / "skills" / "shortcuts" / "SKILL.md" + +ROW_RE = re.compile(r"^\|\s*`(?P[^`]+)`\s*\|") +VOWELS = frozenset("aeiou") + + +def _has_consonant_cluster(name: str) -> bool: + prev_consonant = False + for char in name: + if not char.isalpha(): + prev_consonant = False + continue + is_consonant = char.lower() not in VOWELS + if is_consonant and prev_consonant: + return True + prev_consonant = is_consonant + return False + + +class TestShortcuts(unittest.TestCase): + def setUp(self): + self.assertTrue(SKILL.is_file(), f"missing {SKILL}") + self.shortcuts = [ + match.group("shortcut") + for line in SKILL.read_text(encoding="utf-8").splitlines() + if (match := ROW_RE.match(line)) + ] + + def test_shortcuts_present(self): + self.assertTrue(self.shortcuts, "no shortcut rows found in shortcuts skill") + + def test_shortcuts_sorted_ascending(self): + self.assertEqual( + self.shortcuts, + sorted(self.shortcuts), + f"shortcuts table not sorted; expected {sorted(self.shortcuts)}", + ) + + def test_shortcuts_start_with_so(self): + bad = [s for s in self.shortcuts if not s.startswith("so")] + self.assertFalse(bad, f"shortcuts not starting with 'so': {bad}") + + def test_no_consecutive_consonants(self): + bad = [s for s in self.shortcuts if _has_consonant_cluster(s)] + self.assertFalse(bad, f"shortcuts with two adjacent consonants: {bad}") + + +if __name__ == "__main__": + unittest.main()