diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..a394084 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,32 @@ +{ + "name": "gauntlet-loop", + "description": "The gauntlet loop skill, installable as a Claude Code plugin.", + "owner": { + "name": "Jay E", + "url": "https://robonuggets.com" + }, + "metadata": { + "version": "1.1.0" + }, + "plugins": [ + { + "name": "gauntlet-loop", + "source": "./", + "version": "1.1.0", + "description": "Turns any goal into one short, paste-ready prompt that makes your agent set a real quality bar, run builder and critic pairs, compare blind, and loop until it wins.", + "author": { + "name": "Jay E", + "url": "https://robonuggets.com" + }, + "license": "CC-BY-4.0", + "homepage": "https://github.com/robonuggets/gauntlet-loop", + "keywords": [ + "prompt-engineering", + "evaluator", + "critic", + "quality-bar", + "agent-loop" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8bca9a8 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "gauntlet-loop", + "version": "1.1.0", + "description": "Turns any goal into one short, paste-ready prompt that makes your agent set a real quality bar, run builder and critic pairs, compare blind, and loop until it wins.", + "author": { + "name": "Jay E", + "url": "https://robonuggets.com" + }, + "homepage": "https://github.com/robonuggets/gauntlet-loop", + "repository": "https://github.com/robonuggets/gauntlet-loop", + "license": "CC-BY-4.0", + "keywords": [ + "prompt-engineering", + "evaluator", + "critic", + "quality-bar", + "agent-loop" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09eb3d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Rust build artifacts for the migration tool +/skills/migrate/scripts/target/ + +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aaed407 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 1.1.0 + +The bar can now be the project's own spec, for work that has nothing to copy. + +- New bars table entry, and a section on judging against a spec instead of an outside product. +- A spec variant of the prompt template, plus a worked example. +- A resolver script, so a project that keeps its specs in one place can point at that directory once in `.claude/gauntlet-loop.conf`. The config file belongs to the project, so plugin updates never touch it. +- The skill asks for the bar when it does not have one. It never searches the project for it. + +The migration tool's source moved to `skills/migrate/scripts/`, beside the skill that uses it. The compiled binaries stay in `bin/`, which is what puts them on the Bash tool's `PATH`. + +## 1.0.0 + +Initial plugin release. diff --git a/README.md b/README.md index 35aec33..e08bfdc 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,41 @@ Most agent output stops at "good enough" because nothing is holding it to a stan ## Quick start +**As a plugin.** Add this repo as a marketplace once, then install it wherever +you want it: + ``` -git clone https://github.com/robonuggets/gauntlet-loop +/plugin marketplace add robonuggets/gauntlet-loop +/plugin install gauntlet-loop@gauntlet-loop ``` -Copy the skill folder into your project: +Pick the scope when you install: `user` puts it in every project, `project` +commits it so your team gets it too, `local` keeps it to your machine. Updates +come with `/plugin update gauntlet-loop`. Invoke it as +`/gauntlet-loop:gauntlet-loop`, since plugin skills are namespaced. + +### Already copied the skill in by hand? + +Install the plugin, then run: ``` -cp -r gauntlet-loop/.claude/skills/gauntlet-loop your-project/.claude/skills/ +/gauntlet-loop:migrate ``` +It finds every hand-copied copy on your machine, replaces each with a plugin +install **at the same scope**, and keeps whichever ones you had switched off +switched off. It shows you the full list before touching anything, and it leaves +alone any copy you have edited. + +Worth doing rather than ignoring: a loose copy and a plugin copy do not override +each other. Claude Code namespaces the plugin one, so you end up with both +`/gauntlet-loop` and `/gauntlet-loop:gauntlet-loop` live, and the loose one never +updates. + Then in your agent: ``` -/gauntlet-loop build me a pricing page for my SaaS +/gauntlet-loop:gauntlet-loop build me a pricing page for my SaaS ``` It offers you 2 or 3 quality bars to aim at, you pick one, and it hands back a single prompt you paste into a fresh session. @@ -33,12 +54,27 @@ It offers you 2 or 3 quality bars to aim at, you pick one, and it hands back a s ## What's included ``` -.claude/skills/gauntlet-loop/ -└── SKILL.md # the whole skill, one file +skills/ +├── gauntlet-loop/ +│ ├── SKILL.md # the whole skill, still one file +│ └── scripts/ +│ └── resolve-bars.sh # finds your configured bars directory +└── migrate/ + ├── SKILL.md # /gauntlet-loop:migrate + └── scripts/ # the migration tool's source +.claude-plugin/ +├── plugin.json # plugin manifest +└── marketplace.json # this repo is its own marketplace +bin/ # migration tool, one build per platform, added to PATH +CHANGELOG.md README.md -LICENSE # CC BY 4.0 +LICENSE # CC BY 4.0 ``` +The skill moved from `.claude/skills/gauntlet-loop/` to `skills/gauntlet-loop/`, +which is where Claude Code resolves a plugin's skills, and the invocation gained +the plugin namespace. + ## How it works 1. **You give a goal.** Anything. A site, an essay, a CLI tool, a research brief. @@ -60,6 +96,37 @@ The skill will not accept a vague bar. It checks three things before it writes a - **Fetchable.** The critic can screenshot it, read it, run it, or open it. If the agent cannot get the reference, it hallucinates the comparison and approves everything. - **Comparable.** Both can sit side by side and a judge can pick one. +## When there is nothing to copy + +An outside reference only exists for work someone has already shipped. Internal +tools, billing rules, anything built for one business — there is no product to +hold it against, and that is the normal case rather than the exception. With no +reference, the critic invents a standard and approves work against it. + +Your own spec is the bar in that case. It has to be judgeable to work: a critic +must be able to read a line and answer pass or fail without deciding anything. +Vision docs and prose fail that test, so the skill offers to turn them into a +pass/fail checklist first rather than looping against prose. + +Where both exist, use both. The spec says *what*, an outside product says *how +good*, and they catch different failures — work can meet every requirement and +still feel cheap. + +The skill never goes looking for your spec. Projects organise these too +differently for guessing to be safe, and a wrong bar is worse than no bar +because the loop still exits confidently. It uses what you give it, or asks. + +If you keep specs in one place, point at it once and skip the question. Create +`.claude/gauntlet-loop.conf` in your project: + +``` +bars_dir = docs/specs +``` + +Relative paths resolve against the project root. The file belongs to your +project rather than to the plugin, so updating the plugin never touches it. +Without it, the skill just asks. + ## Examples ``` diff --git a/bin/gauntlet-loop-migrate-linux-arm64 b/bin/gauntlet-loop-migrate-linux-arm64 new file mode 100755 index 0000000..dd956f1 Binary files /dev/null and b/bin/gauntlet-loop-migrate-linux-arm64 differ diff --git a/bin/gauntlet-loop-migrate-linux-x64 b/bin/gauntlet-loop-migrate-linux-x64 new file mode 100755 index 0000000..5c94487 Binary files /dev/null and b/bin/gauntlet-loop-migrate-linux-x64 differ diff --git a/bin/gauntlet-loop-migrate-macos-arm64 b/bin/gauntlet-loop-migrate-macos-arm64 new file mode 100755 index 0000000..767db78 Binary files /dev/null and b/bin/gauntlet-loop-migrate-macos-arm64 differ diff --git a/bin/gauntlet-loop-migrate-macos-x64 b/bin/gauntlet-loop-migrate-macos-x64 new file mode 100755 index 0000000..ef064ad Binary files /dev/null and b/bin/gauntlet-loop-migrate-macos-x64 differ diff --git a/bin/gauntlet-loop-migrate-windows-x64.exe b/bin/gauntlet-loop-migrate-windows-x64.exe new file mode 100755 index 0000000..392d303 Binary files /dev/null and b/bin/gauntlet-loop-migrate-windows-x64.exe differ diff --git a/.claude/skills/gauntlet-loop/SKILL.md b/skills/gauntlet-loop/SKILL.md similarity index 55% rename from .claude/skills/gauntlet-loop/SKILL.md rename to skills/gauntlet-loop/SKILL.md index 5c33caa..567e109 100644 --- a/.claude/skills/gauntlet-loop/SKILL.md +++ b/skills/gauntlet-loop/SKILL.md @@ -1,6 +1,9 @@ --- name: gauntlet-loop -description: Turns any goal into one short, paste-ready "gauntlet loop" prompt - a prompt that makes an agent set a concrete quality bar, split the work into small judgeable pieces, run a builder and a separate harsh critic on each, compare blind against the bar, and loop until it wins. Works for builds, writing, code, research, or design. Triggers on "/gauntlet-loop", "gauntlet loop", "gauntlet this", "make a gauntlet prompt", "loop until it beats X". +description: Turns any goal into one short, paste-ready "gauntlet loop" prompt - a prompt that makes an agent set a concrete quality bar, split the work into small judgeable pieces, run a builder and a separate harsh critic on each, compare blind against the bar, and loop until it wins. Works for builds, writing, code, research, or design. Triggers on "/gauntlet-loop:gauntlet-loop", "gauntlet loop", "gauntlet this", "make a gauntlet prompt", "loop until it beats X". +argument-hint: [goal] +allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/resolve-bars.sh *) +license: CC-BY-4.0 --- # Gauntlet Loop @@ -12,9 +15,15 @@ You are not doing the work. You are writing the prompt that makes another agent ## Flow 1. **Read the goal.** One line restatement in your head, not on screen. -2. **Set the bar.** If the user supplied a reference, use it. If not, offer **2 or 3 candidate bars**, one line each, and stop. Wait for their pick. Do not write the prompt yet. -3. **Write the prompt.** One block, paste-ready, no preamble, no headings inside it, no narration after it. -4. **Offer to run it.** One flat line under the prompt: "I can run this here." Not a question. +2. **Find the bar material.** Take the first of these that hits, then stop looking: + - The bar is already in your context, or the user named it. Use it. + - A bars directory is configured. Read what is in it. See [Configured bars](#configured-bars). + - Neither. **Ask one question** and wait: is there a spec, requirements doc, or acceptance criteria to measure against, or should the bar be an outside product? + + Do not go hunting through the project for a spec. Every project names and arranges these differently, some keep them in archives or scratch directories, and a wrong file silently becomes the wrong bar for the entire run. +3. **Set the bar.** If you have one, use it. If not, offer **2 or 3 candidate bars**, one line each, and stop. Wait for their pick. Do not write the prompt yet. +4. **Write the prompt.** One block, paste-ready, no preamble, no headings inside it, no narration after it. +5. **Offer to run it.** One flat line under the prompt: "I can run this here." Not a question. If they say run it, you become the lead agent and follow the prompt you just wrote. @@ -38,11 +47,55 @@ Bars by goal type: | Code, tooling | A named repo's implementation, plus its benchmark or test suite as the measurable half | | Research, analysis | A named analyst report or a paper's methods section, judged on rigour and coverage | | Deck, doc, deliverable | A real artifact from a firm known for it, same page count | +| **New work with no outside equal** | **The project's own spec, requirements, or acceptance criteria, read as pass/fail** | When you propose bars, prefer the hardest one the agent can genuinely reach. A bar that is too easy makes the loop exit on round one. If the goal has a measurable half (load time, token cost, benchmark score, word count, pass rate), name it alongside the reference. Taste plus a number beats taste alone. +## When the bar is the project's own spec + +Most real work has nothing to copy. Internal tools, billing rules, anything built for one business - there is no shipped product to hold it against. This is the normal case, not the exception, and it is where a gauntlet loop quietly fails: with no outside reference, the critic invents a standard and approves work against it. + +A spec fixes that, but only if it is judgeable. + +**Gate it before you use it.** A spec is a valid bar only if a critic can read a line and answer pass or fail without deciding anything. Vision docs, goals, and prose descriptions fail the **Comparable** test - they read as agreement rather than judgement, and a soft critic passes everything. + +If the material is prose, say so and offer to convert it into a pass/fail checklist first. One line per check, each one answerable without interpretation. That checklist becomes the bar. Do not write the gauntlet prompt against prose. + +**Prefer both bars when both exist.** A spec says *what*. An outside product says *how good*. Real dev work usually wants both, and they judge different failures - a build can satisfy every requirement and still feel cheap, or look excellent and do the wrong thing. Name both in the prompt and let the critic check each in turn. + +**Silence is not permission.** A spec never covers everything. Say plainly in the prompt that where the spec is silent, the builder flags the gap rather than inventing an answer - otherwise the invented parts are indistinguishable from the specified ones by the time you read the result. + + +## Configured bars + +Users who keep their specs in one place can set that directory once and skip the question every run. Do not work this out yourself - run the resolver and act on what it returns: + +``` +${CLAUDE_SKILL_DIR}/scripts/resolve-bars.sh +``` + +It checks, in order: an explicit path you pass as an argument, the plugin's `bars_dir` option, then `bars_dir =` in the project's `.claude/gauntlet-loop.conf`. Relative paths resolve against the project root. + +Act on the exit code, and nothing else: + +| Exit | Meaning | What you do | +|---|---|---| +| `0` | Resolved. Path and candidate files follow. | Read the candidates and treat them as the bar material | +| `3` | Nothing configured | Ask the user for the bar | +| `4` | Configured, but the path is wrong | Tell the user the path is wrong, and stop | + +Never search for a spec because the resolver came back empty. Exit `3` means ask, and exit `4` means the user has a broken setting to fix. + +When several candidates come back, name the ones that could apply and let the user pick. Do not merge them, and do not assume the newest or largest is the right one. + +The config file belongs to the project, not to this plugin, so it survives plugin updates: + +``` +bars_dir = docs/specs +``` + ## Prompt template Adapt the wording every time. Fill the brackets, keep it short, keep the last line. @@ -63,9 +116,17 @@ Keep a live progress page updating as the work evolves so I can watch it. Fan out subagents and ultracode. ``` +When the bar is a spec, swap the second paragraph for: + +``` +The bar is [SPEC PATH]. Every check in it has to come back pass. Read it first and judge against what it actually says, not against your reading of what the project probably wants. Where it is silent, flag the gap and ask - do not decide it yourself. +``` + +When both bars apply, name them together: the spec for what has to be true, the outside reference for how good it has to look or feel. + Rules for what you fill in: -- Bake the bar in as a concrete, fetchable thing. URL, product name, repo, title. +- Bake the bar in as a concrete, fetchable thing. URL, product name, repo, title, or a path to a checked-in spec. - Add a budget or cost ceiling line **only if the user named one**. No default cap. - Add tool names only if the goal needs them (image or video generation, a browser, a deploy target). - Everything else stays out. No architecture, no file layout, no decomposition, no round count, no stack choice unless the user demanded it. The agent decides those, and it decides better than a spec written before the work started. @@ -124,9 +185,31 @@ Keep a live progress page updating as the work evolves so I can watch it. Fan out subagents and ultracode. ``` +**Internal goal, no outside equal.** User: "the leave request and approval flow for our HR system." + +No outside product to copy. The user confirms an acceptance checklist exists at `docs/hr/acceptance.md`. + +``` +Build the leave request and approval flow for our HR system. + +The bar is docs/hr/acceptance.md. Every check in it has to come back pass. Read it first and judge against what it actually says, not against your reading of what the project probably wants. Where it is silent, flag the gap and ask - do not decide it yourself. + +Break this into the smallest pieces that can be improved and judged on their own - request submission, balance rules, the approval chain, notifications, the audit trail. For each piece, fan out a builder and a separate critic with fresh context. The critic runs the actual flow, walks the checks that cover that piece, marks each pass or fail, and names the single biggest remaining gap. Then it goes back to the builder. + +The critic should be a harsh critic. Praise is not useful. A partial pass is a fail. + +/loop on each piece until every check covering it passes. Do not stop before that. + +Keep a live progress page updating as the work evolves so I can watch it. + +Fan out subagents and ultracode. +``` + ## What breaks a gauntlet loop - **A vague bar.** The critic invents a comparison and approves everything. Most common failure by far. +- **A bar that is prose, not checks.** A spec full of goals and intentions reads as agreement rather than judgement. Convert it to pass/fail first. +- **Guessing which file is the spec.** A wrong bar is worse than no bar, because the loop still exits confidently. Ask, or read the configured path. Never search. - **The builder judging its own work.** The critic must be a separate agent with fresh context. It should not know how hard the builder tried. - **A soft critic.** Say "harsh" in the prompt and give it a binary job: which one is better, A or B. Scores out of 10 drift upward every round. - **Named exit after N rounds.** The exit is winning the comparison, or the user stopping the run. Never a round count. diff --git a/skills/gauntlet-loop/scripts/resolve-bars.sh b/skills/gauntlet-loop/scripts/resolve-bars.sh new file mode 100755 index 0000000..1f1ad9d --- /dev/null +++ b/skills/gauntlet-loop/scripts/resolve-bars.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env sh +# Resolve the directory holding this project's bars (specs, requirements, +# acceptance criteria) and list what is in it. +# +# Precedence, first hit wins: +# 1. $1 explicit path passed by the caller +# 2. $CLAUDE_PLUGIN_OPTION_BARS_DIR the plugin's bars_dir option +# 3. bars_dir= in the project config (see CONFIG below) +# +# Exit codes: +# 0 resolved; path on stdout, contents listed after it +# 3 nothing configured +# 4 configured but the path does not exist +# +# Never guesses. Never searches the project. + +set -u + +CONFIG_REL=".claude/gauntlet-loop.conf" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" +CONFIG="$PROJECT_DIR/$CONFIG_REL" + +bars="" +origin="" + +if [ "$#" -gt 0 ] && [ -n "${1:-}" ]; then + bars="$1" + origin="argument" +elif [ -n "${CLAUDE_PLUGIN_OPTION_BARS_DIR:-}" ]; then + bars="$CLAUDE_PLUGIN_OPTION_BARS_DIR" + origin="plugin option bars_dir" +elif [ -f "$CONFIG" ]; then + bars=$(sed -n 's/^[[:space:]]*bars_dir[[:space:]]*=[[:space:]]*//p' "$CONFIG" \ + | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" \ + | head -n 1) + origin="$CONFIG_REL" +fi + +if [ -z "$bars" ]; then + echo "UNSET" + echo "No bars directory configured. Ask the user for the bar." + echo "To set one, create $CONFIG_REL in the project with:" + echo " bars_dir = path/to/your/specs" + exit 3 +fi + +case "$bars" in + /*|?:*|\\\\*) ;; # absolute (POSIX, Windows drive, UNC) + *) bars="$PROJECT_DIR/$bars" ;; # relative to the project +esac + +if [ ! -d "$bars" ]; then + echo "MISSING" + echo "Configured via $origin, but no directory exists at:" + echo " $bars" + echo "Tell the user the path is wrong. Do not go looking for another one." + exit 4 +fi + +echo "$bars" +echo "(from $origin)" +echo +echo "Candidate bars:" +find "$bars" -type f \( -name '*.md' -o -name '*.markdown' -o -name '*.txt' \) \ + | sort \ + | sed 's/^/ /' diff --git a/skills/migrate/SKILL.md b/skills/migrate/SKILL.md new file mode 100644 index 0000000..891c9aa --- /dev/null +++ b/skills/migrate/SKILL.md @@ -0,0 +1,50 @@ +--- +name: migrate +description: Replaces hand-copied installs of the gauntlet-loop skill with the plugin, keeping each one's enabled or disabled state. Use when the user has copied this skill into projects by hand and wants it managed by the plugin instead, or asks to clean up duplicate gauntlet-loop skills. +disable-model-invocation: true +license: CC-BY-4.0 +--- + +# Migrate hand-copied installs + +The migration tool ships with this plugin and is already on your PATH. Pick the +build for this machine: + +| Platform | Command | +|---|---| +| macOS, Apple silicon | `gauntlet-loop-migrate-macos-arm64` | +| macOS, Intel | `gauntlet-loop-migrate-macos-x64` | +| Linux, x86_64 | `gauntlet-loop-migrate-linux-x64` | +| Linux, arm64 | `gauntlet-loop-migrate-linux-arm64` | +| Windows | `gauntlet-loop-migrate-windows-x64.exe` | + +Run `uname -sm` to determine which. + +## 1. Report + +Run it with no arguments. It changes nothing. + +Relay the output and explain what it found: + +- `[ok]` entries will be replaced by a plugin install at the same scope, keeping + their current enabled or disabled state +- `[skip]` entries contain files this project does not ship, so they are left + alone for the user to review +- if it found nothing, `--write` installs the plugin anyway + +Ask whether to proceed. Wait for an answer. + +## 2. Apply + +``` + --write +``` + +Add `--scope project` if nothing was found and the user wants the plugin in the +current project rather than across their account. + +Report what changed. The skill is invoked as `/gauntlet-loop:gauntlet-loop` +afterwards, and takes effect in a new session or after `/reload-plugins`. + +`--json` emits the findings as structured data if you need to reason about them +rather than relay them. diff --git a/skills/migrate/scripts/Cargo.lock b/skills/migrate/scripts/Cargo.lock new file mode 100644 index 0000000..1910596 --- /dev/null +++ b/skills/migrate/scripts/Cargo.lock @@ -0,0 +1,128 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "gauntlet-loop-migrate" +version = "1.0.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/skills/migrate/scripts/Cargo.toml b/skills/migrate/scripts/Cargo.toml new file mode 100644 index 0000000..ae6ae79 --- /dev/null +++ b/skills/migrate/scripts/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gauntlet-loop-migrate" +version = "1.0.0" +edition = "2021" +description = "Replaces hand-copied installs of the gauntlet-loop skill with the plugin, preserving enabled/disabled state." +license = "CC-BY-4.0" +repository = "https://github.com/robonuggets/gauntlet-loop" + +[[bin]] +name = "gauntlet-loop-migrate" +path = "src/main.rs" + +[dependencies] +serde_json = { version = "1", features = ["preserve_order"] } # keeps user key order + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/skills/migrate/scripts/src/main.rs b/skills/migrate/scripts/src/main.rs new file mode 100644 index 0000000..bdb162d --- /dev/null +++ b/skills/migrate/scripts/src/main.rs @@ -0,0 +1,532 @@ +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const SKILL: &str = "gauntlet-loop"; +const PLUGIN_ID: &str = "gauntlet-loop@gauntlet-loop"; +const MARKETPLACE: &str = "robonuggets/gauntlet-loop"; + +const OWNED_FILES: &[&str] = &["SKILL.md"]; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Scope { + User, + Project, +} + +impl Scope { + fn as_str(self) -> &'static str { + match self { + Scope::User => "user", + Scope::Project => "project", + } + } + + fn parse(s: &str) -> Option { + match s { + "user" => Some(Scope::User), + "project" => Some(Scope::Project), + _ => None, + } + } +} + +fn fresh_install(scope: Scope) -> Result<(), String> { + let cwd = match scope { + Scope::Project => Some(std::env::current_dir().map_err(|e| e.to_string())?), + Scope::User => None, + }; + claude( + &[ + OsStr::new("plugin"), + OsStr::new("install"), + OsStr::new(PLUGIN_ID), + OsStr::new("--scope"), + OsStr::new(scope.as_str()), + ], + cwd.as_deref(), + )?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Verdict { + Managed, + Unrecognised, +} + +#[derive(Debug)] +struct Site { + dir: PathBuf, + /// None for the user-level skills directory. + project: Option, + scope: Scope, + verdict: Verdict, + unexpected: Vec, + disabled: bool, +} + +fn home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +/// Project roots, read from Claude Code's own index. Exact absolute paths. +fn project_roots(home: &Path) -> Vec { + let path = home.join(".claude.json"); + let Ok(text) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + eprintln!("warning: could not parse {}", path.display()); + return Vec::new(); + }; + value + .get("projects") + .and_then(|p| p.as_object()) + .map(|o| o.keys().map(PathBuf::from).collect()) + .unwrap_or_default() +} + +/// `skillOverrides` keys the bare skill name, so this never matches the +/// namespaced plugin skill. +fn is_disabled(root: &Path) -> bool { + let path = root.join(".claude").join("settings.local.json"); + let Ok(text) = fs::read_to_string(&path) else { + return false; + }; + serde_json::from_str::(&text) + .ok() + .and_then(|v| { + v.get("skillOverrides") + .and_then(|s| s.get(SKILL)) + .and_then(|s| s.as_str()) + .map(|s| s.eq_ignore_ascii_case("off")) + }) + .unwrap_or(false) +} + +fn classify(dir: &Path) -> (Verdict, Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return (Verdict::Unrecognised, vec!["".into()]); + }; + let mut found: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n != ".DS_Store") + .collect(); + found.sort(); + + let unexpected: Vec = found + .iter() + .filter(|n| !OWNED_FILES.contains(&n.as_str())) + .cloned() + .collect(); + + let has_all_owned = OWNED_FILES.iter().all(|o| found.iter().any(|f| f == o)); + + if unexpected.is_empty() && has_all_owned { + (Verdict::Managed, Vec::new()) + } else { + (Verdict::Unrecognised, unexpected) + } +} + +fn discover(home: &Path) -> Vec { + let mut sites = Vec::new(); + + let user_dir = home.join(".claude").join("skills").join(SKILL); + if user_dir.is_dir() { + let (verdict, unexpected) = classify(&user_dir); + sites.push(Site { + dir: user_dir, + project: None, + scope: Scope::User, + verdict, + unexpected, + disabled: is_disabled(home), + }); + } + + for root in project_roots(home) { + let dir = root.join(".claude").join("skills").join(SKILL); + if dir.is_dir() { + let (verdict, unexpected) = classify(&dir); + sites.push(Site { + disabled: is_disabled(&root), + dir, + project: Some(root), + scope: Scope::Project, + verdict, + unexpected, + }); + } + } + sites +} + +fn guard(site: &Site, home: &Path) -> Result<(), String> { + if !site.dir.ends_with(Path::new("skills").join(SKILL)) { + return Err(format!( + "refusing: unexpected path shape {}", + site.dir.display() + )); + } + let permitted = match &site.project { + Some(root) => site.dir.starts_with(root), + None => site.dir.starts_with(home.join(".claude")), + }; + if !permitted { + return Err(format!( + "refusing: {} is outside its root", + site.dir.display() + )); + } + Ok(()) +} + +fn claude(args: &[&OsStr], cwd: Option<&Path>) -> Result { + let mut cmd = Command::new("claude"); + cmd.args(args).stdin(Stdio::null()); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + let out = cmd + .output() + .map_err(|e| format!("could not run `claude`: {e}. Is Claude Code on your PATH?"))?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn plugin_installed(cwd: Option<&Path>) -> bool { + let Ok(json) = claude( + &[ + OsStr::new("plugin"), + OsStr::new("list"), + OsStr::new("--json"), + ], + cwd, + ) else { + return false; + }; + serde_json::from_str::(&json) + .ok() + .and_then(|v| { + v.as_array().map(|a| { + a.iter() + .any(|e| e.get("id").and_then(|i| i.as_str()) == Some(PLUGIN_ID)) + }) + }) + .unwrap_or(false) +} + +fn clear_override(root: &Path) -> Result { + let path = root.join(".claude").join("settings.local.json"); + let Ok(text) = fs::read_to_string(&path) else { + return Ok(false); + }; + let mut value: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + + let removed = value + .get_mut("skillOverrides") + .and_then(|s| s.as_object_mut()) + .map(|o| o.remove(SKILL).is_some()) + .unwrap_or(false); + + if !removed { + return Ok(false); + } + if value + .get("skillOverrides") + .and_then(|s| s.as_object()) + .is_some_and(|o| o.is_empty()) + { + value.as_object_mut().map(|o| o.remove("skillOverrides")); + } + let mut out = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?; + out.push('\n'); + fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(true) +} + +/// Fatal on failure: without it every install fails with a misleading +/// "not found in marketplace". +fn ensure_marketplace() -> Result<(), String> { + match claude( + &[ + OsStr::new("plugin"), + OsStr::new("marketplace"), + OsStr::new("add"), + OsStr::new(MARKETPLACE), + ], + None, + ) { + Ok(_) => Ok(()), + Err(e) if e.contains("already") => Ok(()), + Err(e) => Err(format!( + "could not register marketplace `{MARKETPLACE}`: {e}" + )), + } +} + +fn migrate(site: &Site, home: &Path) -> Result, String> { + guard(site, home)?; + let mut log = Vec::new(); + let cwd = site.project.as_deref(); + + // Install before removing, so a failure leaves the existing skill working. + claude( + &[ + OsStr::new("plugin"), + OsStr::new("install"), + OsStr::new(PLUGIN_ID), + OsStr::new("--scope"), + OsStr::new(site.scope.as_str()), + ], + cwd, + )?; + if !plugin_installed(cwd) { + return Err("plugin did not appear after install; nothing was removed".into()); + } + log.push(format!( + "installed {PLUGIN_ID} (scope: {})", + site.scope.as_str() + )); + + if site.disabled { + claude( + &[ + OsStr::new("plugin"), + OsStr::new("disable"), + OsStr::new(PLUGIN_ID), + OsStr::new("--scope"), + OsStr::new(site.scope.as_str()), + ], + cwd, + )?; + log.push("re-disabled to match previous state".into()); + } + + fs::remove_dir_all(&site.dir).map_err(|e| format!("{}: {e}", site.dir.display()))?; + log.push(format!("removed {}", site.dir.display())); + + let root = site.project.clone().unwrap_or_else(|| home.to_path_buf()); + if clear_override(&root)? { + log.push("cleared stale skillOverrides entry".into()); + } + Ok(log) +} + +fn print_report(sites: &[Site], write: bool) { + if sites.is_empty() { + println!("No hand-copied installs of `{SKILL}` found."); + if !write { + println!("Re-run with --write to install the plugin anyway."); + } + return; + } + let managed = sites + .iter() + .filter(|s| s.verdict == Verdict::Managed) + .count(); + let skipped = sites.len() - managed; + + println!( + "Found {} hand-copied install(s) of `{SKILL}`:\n", + sites.len() + ); + for s in sites { + let where_ = match &s.project { + Some(p) => p.display().to_string(), + None => "(user level)".into(), + }; + let state = if s.disabled { "disabled" } else { "enabled" }; + match s.verdict { + Verdict::Managed => { + println!(" [ok] {where_}\n {} ({state})", s.dir.display()); + } + Verdict::Unrecognised => { + println!( + " [skip] {where_}\n {} ({state})\n contains files we did not ship: {}", + s.dir.display(), + s.unexpected.join(", ") + ); + } + } + } + println!(); + if skipped > 0 { + println!( + "{skipped} skipped. Those directories were modified locally, so they are left alone." + ); + println!("Review them yourself, then delete them once you are satisfied.\n"); + } + if !write { + println!("This was a dry run. Nothing has changed."); + println!( + "Re-run with --write to replace the {managed} recognised install(s) with the plugin." + ); + } +} + +fn print_json(sites: &[Site]) { + let mut out = Vec::new(); + for s in sites { + let mut m = BTreeMap::new(); + m.insert("directory", serde_json::json!(s.dir.display().to_string())); + m.insert( + "project", + match &s.project { + Some(p) => serde_json::json!(p.display().to_string()), + None => serde_json::Value::Null, + }, + ); + m.insert("scope", serde_json::json!(s.scope.as_str())); + m.insert( + "verdict", + serde_json::json!(match s.verdict { + Verdict::Managed => "managed", + Verdict::Unrecognised => "unrecognised", + }), + ); + m.insert("disabled", serde_json::json!(s.disabled)); + m.insert("unexpectedFiles", serde_json::json!(s.unexpected)); + out.push(m); + } + println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); +} + +const USAGE: &str = "\ +gauntlet-loop-migrate — replace hand-copied installs of the gauntlet-loop skill +with the plugin, preserving each one's enabled/disabled state. + +USAGE: + gauntlet-loop-migrate [--write] [--json] [--scope ] + +OPTIONS: + --write Perform the migration. Without this, nothing changes. + --json Emit findings as JSON and exit. Implies a dry run. + --scope Where to install when there is nothing to migrate. + Defaults to user. Ignored for sites we found, which are + installed at the scope their hand-copy occupied. + --help Show this message. + +It reads Claude Code's own project index to find installs. It does not walk +your filesystem, and it never removes a directory containing anything other +than the file this project ships. +"; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--help" || a == "-h") { + print!("{USAGE}"); + return; + } + let write = args.iter().any(|a| a == "--write"); + let as_json = args.iter().any(|a| a == "--json"); + + let mut fallback_scope = Scope::User; + let mut skip_next = false; + for (i, a) in args.iter().enumerate() { + if skip_next { + skip_next = false; + continue; + } + match a.as_str() { + "--write" | "--json" | "--help" | "-h" => {} + "--scope" => { + let Some(v) = args.get(i + 1) else { + eprintln!("--scope needs a value (user or project)\n"); + eprint!("{USAGE}"); + std::process::exit(2); + }; + let Some(s) = Scope::parse(v) else { + eprintln!("unknown scope: {v} (expected user or project)\n"); + eprint!("{USAGE}"); + std::process::exit(2); + }; + fallback_scope = s; + skip_next = true; + } + other => { + eprintln!("unknown argument: {other}\n"); + eprint!("{USAGE}"); + std::process::exit(2); + } + } + } + + let Some(home) = home() else { + eprintln!("error: could not determine your home directory (HOME / USERPROFILE unset)."); + std::process::exit(1); + }; + + let sites = discover(&home); + + if as_json { + print_json(&sites); + return; + } + if !write { + print_report(&sites, false); + return; + } + + print_report(&sites, true); + + if let Err(e) = ensure_marketplace() { + eprintln!("\nerror: {e}"); + eprintln!("nothing was changed."); + std::process::exit(1); + } + + if sites.iter().all(|s| s.verdict != Verdict::Managed) { + match fresh_install(fallback_scope) { + Ok(()) => { + println!( + "Installed {PLUGIN_ID} (scope: {}).", + fallback_scope.as_str() + ); + println!("Invoke the skill as /gauntlet-loop:gauntlet-loop."); + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + return; + } + + let mut failures = 0; + for site in sites.iter().filter(|s| s.verdict == Verdict::Managed) { + let label = match &site.project { + Some(p) => p.display().to_string(), + None => "(user level)".into(), + }; + println!("\n{label}"); + match migrate(site, &home) { + Ok(log) => { + for line in log { + println!(" - {line}"); + } + } + Err(e) => { + failures += 1; + eprintln!(" ! {e}"); + eprintln!(" ! left unchanged"); + } + } + } + println!(); + if failures > 0 { + eprintln!("{failures} site(s) failed and were left unchanged."); + std::process::exit(1); + } + println!("Done. Invoke the skill as /gauntlet-loop:gauntlet-loop from now on."); +}