From e542678ba65fa88b540e7808c89e9ae8aa04a5ac Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 00:38:24 +0530 Subject: [PATCH 1/6] Ship an agsearch skill so the agent searches its own history agsearch has been a thing you run beside a coding agent. As a skill it is something the agent runs itself: you refer to an earlier conversation, and it searches the transcripts on disk and answers from them instead of telling you it has no record of it. The skill is one markdown file. It ships in `skills/agsearch/SKILL.md` where people can read it before installing, and as a literal inside the script, because brew, uv and the curl installer each put exactly one file on your PATH and none of them carries a data directory. tools/sync-skill.py copies one to the other and a test fails when they drift. What the skill teaches is query construction, because that is the one place an agent reliably fails. Measured over 247 queries: content words alone rank the right session first 49% of the time, the same words left inside the question that carried them score 10% to 22%. Extending the stoplist to absorb question phrasing was tried and rejected: it recovered a few points at best, and the words still doing the damage ("decide", "session", "discuss") are real search terms in other queries, so stoplisting them fits the benchmark rather than the corpus. Two behaviours came out of watching agents run against a real corpus rather than from reasoning. They fall back to grepping ~/.claude/projects, where JSONL tool calls and base64 attachments produce matches nobody said, so the skill says to go through agsearch. And on a handoff they reach for git first, which is correct as far as it goes, so the skill says the transcript carries the reasoning and the discarded options while git carries what shipped. A capped `read` given search terms was keeping the last turns and dropping the matched ones, which answers a question nobody asked. Matched turns now outrank the ending, and on one session that took the kept matches from 8 to 27 inside the same budget. `--install-skill` writes it to ~/.claude/skills/agsearch/ and leaves an edited copy alone unless forced; the curl installer does the same, honouring AGSEARCH_SKILL=0. CI now fails if an install does not carry the skill. Closes #46. --- .github/workflows/ci.yml | 6 ++ CHANGELOG.md | 9 ++ README.md | 30 +++++- agsearch | 192 ++++++++++++++++++++++++++++++++----- install.sh | 25 +++++ skills/agsearch/SKILL.md | 92 ++++++++++++++++++ tests/test_agent_output.py | 36 +++++++ tests/test_skill.py | 107 +++++++++++++++++++++ tools/sync-skill.py | 34 +++++++ 9 files changed, 508 insertions(+), 23 deletions(-) create mode 100644 skills/agsearch/SKILL.md create mode 100644 tests/test_skill.py create mode 100644 tools/sync-skill.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ab5e0..7e75920 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: run: | "$RUNNER_TEMP/bin/agsearch" --version "$RUNNER_TEMP/bin/agsearch" --help >/dev/null + - name: The install carried the skill + # The skill is the agent-facing half of the product, so a curl install that + # silently skips it is a broken install, not a cosmetic miss. + run: | + test -f "$HOME/.claude/skills/agsearch/SKILL.md" + head -n 1 "$HOME/.claude/skills/agsearch/SKILL.md" | grep -q '^---$' - name: Search an empty corpus without crashing run: | # No ~/.claude or ~/.codex on a runner: exit 1 with a message is the diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a0a4b..0c827ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,15 @@ migration in the same line. ### Added +- **A Claude Code skill, installed with `agsearch --install-skill`** (and by the curl installer, + unless `AGSEARCH_SKILL=0`). With it Claude searches your transcripts itself when you refer to + earlier work, rather than answering that it has no record of the conversation, and it can + carry an old session's context forward into the one you are in now, which resuming cannot do. + The skill teaches query construction because that is where an agent fails: on a 247-query + benchmark, content words alone rank the right session first 49% of the time, and the same + words left inside the question that carried them score 10% to 22%. An installed skill you + have edited is never overwritten without `--force`. + - **`agsearch read `** prints a whole conversation without resuming it. This was already there as the TUI's Ctrl-O, reachable only as an internal subcommand; it is now a documented command, so a search hit can actually be opened. diff --git a/README.md b/README.md index 1f8087a..61ae676 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ uvx agsearch -n "stripe tax id" - **Ranked results.** BM25 ranking favors focused sessions and shows matching lines in context. - **Preview, read, or resume.** Inspect a match, open the transcript in a pager, or return to the original session. +- **Your agent can search too.** A one-command Claude Code skill, so Claude finds the earlier + conversation itself instead of answering that it has no record of it. - **Fully local.** No uploads, API keys, hosted index, or network calls. - **Fast warm searches.** A per-file cache reparses only transcripts that changed. @@ -99,6 +101,7 @@ agsearch --thinking "query" # include assistant thinking blocks agsearch --no-resume "query" # print the selected resume command agsearch --reindex # rebuild the transcript cache agsearch --version # print the installed version +agsearch --install-skill # install the Claude Code skill ``` ### Scripts and coding agents @@ -112,7 +115,32 @@ agsearch read 3f2a1c4e-... # the whole conversation, no resume, ``` Ranking is the same as the interactive list, so a term you half-remember or mistype finds -the same session either way. +the same session either way. Piped, the session ids shorten to a unique prefix, the columns +lose their padding, and `read` prints the start and end of a long session rather than all of +it. A terminal sees none of that. + +### Let Claude search for you + +The curl installer sets this up. Homebrew and uv users run it once: + +```sh +agsearch --install-skill +``` + +That writes a skill to `~/.claude/skills/agsearch/`. From the next session on, Claude searches +your transcripts itself when you refer to work from an earlier conversation: + +> **you:** what did we decide about the webhook retry backoff? +> +> **Claude:** *runs `agsearch -n "webhook retry backoff"`, reads the top hit, answers from it* + +It also covers handoff, which resuming cannot do. `claude --resume` moves you back into the old +session in its own directory; the skill carries that session's context forward into the one you +are in now, so you can pick the work up in a different repository or on a different branch. + +The skill is [a single markdown file](https://github.com/devcodes9/agsearch/blob/main/skills/agsearch/SKILL.md). +Read it before you install it, and edit your copy freely: upgrades leave an edited skill alone +and tell you it is stale. ### Interactive keys diff --git a/agsearch b/agsearch index 0caafed..a8edef0 100755 --- a/agsearch +++ b/agsearch @@ -17,11 +17,18 @@ Usage: agsearch --thinking # also index assistant thinking blocks agsearch --reindex # force a full rebuild of the cache agsearch --version # print the installed version and exit + agsearch --install-skill # let Claude Code search these sessions itself agsearch _preview # (internal) fzf preview `-n` prints one entry per session, led by its session id, and drops colour when it is not writing to a terminal — so a script or a coding agent can search, then read a hit -with `agsearch read `. +with `agsearch read `. Piped, ids shorten to a unique prefix, the columns +lose their padding and `read` caps its output; a terminal sees none of that. + +`--install-skill` writes a Claude Code skill to ~/.claude/skills/agsearch/. With it, +Claude searches your past sessions on its own when you refer to earlier work, instead +of answering that it has no record of the conversation. The curl installer does this +for you; brew and uv users run it once. In the TUI the right pane previews the matched session, auto-scrolled to your match (marked ▶) with a "match N of M" header. Enter resumes the session (and copies your @@ -1015,24 +1022,27 @@ AGENT_READ_CHARS = 12_000 # what a piped `read` spends before it starts el OPENING_TURNS = 2 # kept whatever the budget: the opening states the goal -def _elide(blocks, budget): - """Keep the opening turns and as many closing turns as fit, drop the middle. +def _elide(blocks, budget, matched=()): + """Fit a transcript into `budget` characters, keeping the turns worth keeping. A session is read to answer one of two questions: what were we doing, and where did we - stop. The first lives in the opening turns and the second in the closing ones, so a - transcript too long to print whole loses least from the middle. Returns (blocks, dropped). + stop. Those live at the two ends, so the middle is what a too-long transcript can afford + to lose. A query changes the question to a third one, what was said about this, and then + the turns that matched outrank the ending: asking for matches and getting the last twenty + messages instead is the wrong answer to the question that was asked. + + Returns (blocks, dropped). """ if sum(len(b) for b in blocks) <= budget: return blocks, 0 - head = blocks[:OPENING_TURNS] - spent = sum(len(b) for b in head) - tail = [] - for b in reversed(blocks[OPENING_TURNS:]): - if spent + len(b) > budget: - break - tail.insert(0, b) - spent += len(b) - return head + tail, len(blocks) - len(head) - len(tail) + n = len(blocks) + keep = set(range(min(OPENING_TURNS, n))) # the opening states the goal + spent = sum(len(blocks[i]) for i in keep) + for i in list(matched) + list(range(n - 1, -1, -1)): # matches, then the ending + if i not in keep and spent + len(blocks[i]) <= budget: + keep.add(i) + spent += len(blocks[i]) + return [blocks[i] for i in sorted(keep)], n - len(keep) def render_transcript(sid, thinking="0", query="", color=None, budget=None): @@ -1063,22 +1073,24 @@ def render_transcript(sid, thinking="0", query="", color=None, budget=None): + (f" · {n_sub} subagent" if n_sub else "") + fork_line(sid) + "\033[0m", f"\033[2m{resume_line(sid, r0[1])}\033[0m\n"] - blocks = [] + blocks, matched = [], [] for r, sub in tagged: hit = bool(keys) and all(k in r[7].lower() for k in keys) mark = "\033[1;33m▶\033[0m " if hit else " " + if hit: + matched.append(len(blocks)) blocks.append(mark + _turn_header(r[4], source, sub) + "\n" + f"\033[2m{r[3][11:16]}\033[0m " + _snippet(r[7], keys, 100_000) + "\n") - dropped = 0 + total, dropped = len(blocks), 0 if budget: - blocks, dropped = _elide(blocks, budget) + blocks, dropped = _elide(blocks, budget, matched) if dropped: - note = (f"[{dropped} messages elided from the middle. " - f"To see them, search inside this session: " - f'agsearch -n "" | grep {sid[:13]}, ' - f"or read it whole: agsearch read {sid[:13]} --full]") - blocks.insert(OPENING_TURNS, "\033[2m" + note + "\033[0m\n") + kept = ("the opening, every turn matching your query that fits, and the ending" + if matched else "the opening and the ending") + note = (f"[{dropped} of {total} messages elided; kept {kept}. " + f"Whole transcript: agsearch read {sid[:13]} --full]") + blocks.insert(min(OPENING_TURNS, len(blocks)), "\033[2m" + note + "\033[0m\n") _emit("\n".join(out + blocks) + "\n", color) return 0 @@ -1699,6 +1711,8 @@ def main(argv): render_transcript(argv[1] if len(argv) > 1 else "", argv[2] if len(argv) > 2 else "0", " ".join(argv[3:]), color=True) return 0 + if argv and argv[0] in ("--install-skill", "install-skill"): + return install_skill(force="--force" in argv[1:]) if argv and argv[0] == "read": if len(argv) < 2 or not argv[1].strip(): print("usage: agsearch read [query]", file=sys.stderr) @@ -1761,6 +1775,140 @@ def main(argv): return run_fzf(lines, query, thinking=thinking, no_resume=no_resume, fuzzy=fuzzy) +SKILL_DIR = os.path.join(HOME, ".claude", "skills", "agsearch") + +# The skill text ships inside this script because that is the only copy every install channel +# carries: brew and the curl installer put one file on your PATH, and a wheel has no data dir +# worth the packaging. `skills/agsearch/SKILL.md` in the repo is the readable copy, and +# tests/test_skill.py fails if the two drift. Regenerate with tools/sync-skill.py. +SKILL_MD = """\ +--- +name: agsearch +description: Search past Claude Code and Codex sessions saved on this machine. Use when the user refers to earlier work ("what did we decide about X", "we hit this error before"), asks to continue or hand off work started in another session, or when you are about to say you have no record of a conversation that happened before this one. +--- + +# agsearch + +Every Claude Code and Codex session on this machine is a transcript on disk. `agsearch` +searches what was said inside them and prints ranked hits. You do not remember those +sessions. The transcripts do. + +## Build the query from content words + +Pass the nouns, error strings and identifiers. Question words match hundreds of sessions +and outweigh the two or three words that identify one. + + user: what did we decide about the webhook retry backoff? + you: agsearch -n "webhook retry backoff" + +Two to four content words is the target. On a 247-query benchmark, content words alone put +the right session first 49% of the time; the same words left inside the question that carried +them scored 10% to 22%. + +Pass the user's own vocabulary. Inflections and typos are handled, so `migration` finds +`migrate` and a misspelling still ranks. + +## Read the results + +Each hit is one session: id, date, agent, matched/total terms, project, title, then the line +that matched underneath. + +The top hit is the right session roughly three times in four. Read the matched line before +trusting it, and say which session you are answering from. + +**Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how +many it withheld. + +**No hits.** Drop to the single most distinctive word, usually an error string, a library name +or an identifier. If that finds nothing, stop and say the conversation is not on this machine. +That is a real answer, and a more useful one than a guess. + +Reach for the transcripts through `agsearch` rather than reading them directly. They are JSONL +with tool calls, diffs and base64 attachments interleaved, so grepping them returns matches +from machine noise rather than from anything anyone said. + +## Open a session + + agsearch read + +The id is the first field of a result row. Any unambiguous prefix works. Output is capped for +you: you get the opening turns and the closing turns, which is what tells you the goal and the +outcome. + +To find something specific inside a long session, pass the terms as well: + + agsearch read "retry backoff" + +The turns that matched are marked and are kept ahead of the ending when the cap applies. +`--full` prints everything when you need the middle too. + +## Handoff + +When the user asks to continue work from an earlier session: + +1. Find the session with the search above. +2. `agsearch read `. +3. Report the goal, what was already done, and where it stopped. +4. Continue the work. + +Step 3 is done when you can state all three from the transcript. If any of them is a guess, +read again with the terms you are missing, or with `--full`. + +Where the work left commits or a pull request, read those too: the transcript carries the +reasoning and the discarded options, and git carries what actually shipped. Neither is the +whole story on its own. + +This is not `claude --resume`. Resume moves you back into the old session in its own +directory. Handoff brings that context forward into the session you are in now. + +## Scope a search + + agsearch -n "..." --here # sessions from this directory's project only + agsearch -n "..." --project myapp # sessions whose path matches myapp + +Use these when a query returns the right topic from the wrong repository. + +## What this cannot do + +Matching is lexical, not semantic. A session that discussed an idea in different words will +not surface, so a search returning nothing is weak evidence that a conversation never +happened. Everything runs locally against files already on disk, and nothing leaves the +machine. +""" + + +def install_skill(force=False): + """Write the skill where Claude Code looks for it. + + Idempotent, and it keeps a file you edited: a skill is a document people tune, and + silently reverting someone's edit on an upgrade is worse than telling them it is stale. + """ + path = os.path.join(SKILL_DIR, "SKILL.md") + if os.path.isfile(path): + try: + current = open(path, errors="replace").read() + except OSError: + current = None + if current == SKILL_MD: + print(f"already installed: {path}") + return 0 + if not force: + print(f"{path} exists and differs from this version's skill.\n" + f"Keeping your copy. Replace it with: agsearch --install-skill --force", + file=sys.stderr) + return 1 + try: + os.makedirs(SKILL_DIR, exist_ok=True) + with open(path, "w") as fh: + fh.write(SKILL_MD) + except OSError as e: + print(f"could not write {path}: {e}", file=sys.stderr) + return 1 + print(f"installed: {path}") + print("Start a new Claude Code session to pick it up.") + return 0 + + def _entry(): """Console-script entry point. diff --git a/install.sh b/install.sh index 9657c63..950116d 100755 --- a/install.sh +++ b/install.sh @@ -7,6 +7,7 @@ # AGSEARCH_VERSION=v0.1.0 sh install.sh # a specific release # AGSEARCH_VERSION=main sh install.sh # unreleased tip, for testing # PREFIX=/usr/local/bin sh install.sh # a different install location +# AGSEARCH_SKILL=0 sh install.sh # skip the Claude Code skill set -eu REPO="devcodes9/agsearch" @@ -77,4 +78,28 @@ if ! command -v fzf >/dev/null 2>&1; then echo " (agsearch -n \"query\" works without fzf)" fi +# The skill is what lets a coding agent search these transcripts itself, so the one-line +# install should deliver it too. Taken from the checkout when there is one, fetched otherwise: +# this script never executes the binary it just downloaded, and a pinned older release has no +# --install-skill to call. +if [ "${AGSEARCH_SKILL:-1}" != "0" ]; then + SKILL_REF="${AGSEARCH_VERSION:-main}" + SKILL_DIR="$HOME/.claude/skills/agsearch" + SKILL_URL="https://raw.githubusercontent.com/$REPO/$SKILL_REF/skills/agsearch/SKILL.md" + if [ -f "$SKILL_DIR/SKILL.md" ]; then + echo "note: $SKILL_DIR/SKILL.md exists — leaving it alone." + echo " to replace it: agsearch --install-skill --force" + elif { [ -f "./skills/agsearch/SKILL.md" ] && [ -z "${AGSEARCH_VERSION:-}" ] && + cp "./skills/agsearch/SKILL.md" "$TARGET.skill"; } || + { curl -fsSL "$SKILL_URL" -o "$TARGET.skill" 2>/dev/null && + head -n 1 "$TARGET.skill" | grep -q '^---$'; }; then + mkdir -p "$SKILL_DIR" + mv "$TARGET.skill" "$SKILL_DIR/SKILL.md" + echo "installed skill: $SKILL_DIR/SKILL.md (start a new Claude Code session to pick it up)" + else + rm -f "$TARGET.skill" + echo "note: could not fetch the Claude Code skill. Install it later: agsearch --install-skill" + fi +fi + echo "done. run: agsearch" diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md new file mode 100644 index 0000000..a8bab3e --- /dev/null +++ b/skills/agsearch/SKILL.md @@ -0,0 +1,92 @@ +--- +name: agsearch +description: Search past Claude Code and Codex sessions saved on this machine. Use when the user refers to earlier work ("what did we decide about X", "we hit this error before"), asks to continue or hand off work started in another session, or when you are about to say you have no record of a conversation that happened before this one. +--- + +# agsearch + +Every Claude Code and Codex session on this machine is a transcript on disk. `agsearch` +searches what was said inside them and prints ranked hits. You do not remember those +sessions. The transcripts do. + +## Build the query from content words + +Pass the nouns, error strings and identifiers. Question words match hundreds of sessions +and outweigh the two or three words that identify one. + + user: what did we decide about the webhook retry backoff? + you: agsearch -n "webhook retry backoff" + +Two to four content words is the target. On a 247-query benchmark, content words alone put +the right session first 49% of the time; the same words left inside the question that carried +them scored 10% to 22%. + +Pass the user's own vocabulary. Inflections and typos are handled, so `migration` finds +`migrate` and a misspelling still ranks. + +## Read the results + +Each hit is one session: id, date, agent, matched/total terms, project, title, then the line +that matched underneath. + +The top hit is the right session roughly three times in four. Read the matched line before +trusting it, and say which session you are answering from. + +**Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how +many it withheld. + +**No hits.** Drop to the single most distinctive word, usually an error string, a library name +or an identifier. If that finds nothing, stop and say the conversation is not on this machine. +That is a real answer, and a more useful one than a guess. + +Reach for the transcripts through `agsearch` rather than reading them directly. They are JSONL +with tool calls, diffs and base64 attachments interleaved, so grepping them returns matches +from machine noise rather than from anything anyone said. + +## Open a session + + agsearch read + +The id is the first field of a result row. Any unambiguous prefix works. Output is capped for +you: you get the opening turns and the closing turns, which is what tells you the goal and the +outcome. + +To find something specific inside a long session, pass the terms as well: + + agsearch read "retry backoff" + +The turns that matched are marked and are kept ahead of the ending when the cap applies. +`--full` prints everything when you need the middle too. + +## Handoff + +When the user asks to continue work from an earlier session: + +1. Find the session with the search above. +2. `agsearch read `. +3. Report the goal, what was already done, and where it stopped. +4. Continue the work. + +Step 3 is done when you can state all three from the transcript. If any of them is a guess, +read again with the terms you are missing, or with `--full`. + +Where the work left commits or a pull request, read those too: the transcript carries the +reasoning and the discarded options, and git carries what actually shipped. Neither is the +whole story on its own. + +This is not `claude --resume`. Resume moves you back into the old session in its own +directory. Handoff brings that context forward into the session you are in now. + +## Scope a search + + agsearch -n "..." --here # sessions from this directory's project only + agsearch -n "..." --project myapp # sessions whose path matches myapp + +Use these when a query returns the right topic from the wrong repository. + +## What this cannot do + +Matching is lexical, not semantic. A session that discussed an idea in different words will +not surface, so a search returning nothing is weak evidence that a conversation never +happened. Everything runs locally against files already on disk, and nothing leaves the +machine. diff --git a/tests/test_agent_output.py b/tests/test_agent_output.py index 0446181..f08f965 100644 --- a/tests/test_agent_output.py +++ b/tests/test_agent_output.py @@ -89,6 +89,42 @@ def test_the_output_names_the_command_that_opens_a_hit(self): self.assertIn("agsearch read", out) +class ElideTests(unittest.TestCase): + """A capped `read` has to drop the right turns, not merely few enough of them.""" + + def blocks(self, n, size=100): + return [f"{i:03d}" + "x" * (size - 3) for i in range(n)] + + def test_a_transcript_inside_the_budget_is_untouched(self): + b = self.blocks(5) + self.assertEqual(ag._elide(b, 10_000), (b, 0)) + + def test_the_opening_and_the_ending_survive(self): + """What were we doing, and where did we stop.""" + b = self.blocks(50) + kept, dropped = ag._elide(b, 1000) + self.assertIn(b[0], kept) + self.assertIn(b[-1], kept) + self.assertEqual(dropped, 50 - len(kept)) + + def test_matched_turns_outrank_the_ending(self): + """Asking what was said about X and getting the last twenty messages is a wrong answer.""" + b = self.blocks(50) + kept, _dropped = ag._elide(b, 1000, matched=[20, 21, 22]) + for i in (20, 21, 22): + self.assertIn(b[i], kept) + + def test_kept_turns_stay_in_order(self): + b = self.blocks(50) + kept, _dropped = ag._elide(b, 1000, matched=[30, 10]) + self.assertEqual(kept, [x for x in b if x in set(kept)]) + + def test_a_budget_smaller_than_one_turn_still_returns(self): + b = self.blocks(10) + kept, dropped = ag._elide(b, 1) + self.assertEqual(len(kept) + dropped, 10) + + class ResolveSidTests(unittest.TestCase): def test_a_whole_id_resolves_to_itself(self): self.assertEqual(resolve(UUID4, UUID4[0]), (UUID4[0], None)) diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..26c7499 --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,107 @@ +"""The skill is documentation an agent executes, so a stale line is a bug, not a typo. + +Two copies exist by necessity: the file people read in the repo, and the literal inside the +script, which is the only copy brew, uv and the curl installer carry. These tests pin them +together, and pin the skill to the CLI it describes. +""" + +import io +import os +import pathlib +import re +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr + +from load_agsearch import load_agsearch + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SKILL = ROOT / "skills" / "agsearch" / "SKILL.md" + + +def frontmatter(text): + m = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not m: + return {} + out = {} + for line in m.group(1).splitlines(): + if ": " in line: + k, v = line.split(": ", 1) + out[k.strip()] = v.strip() + return out + + +class SkillFileTests(unittest.TestCase): + def setUp(self): + self.ag = load_agsearch() + self.text = SKILL.read_text() + + def test_the_shipped_copy_matches_the_repo_copy(self): + """Anyone who installed by brew, uv or curl gets the literal, never the repo file.""" + self.assertEqual(self.ag.SKILL_MD, self.text, + "SKILL.md and SKILL_MD have drifted. Run: python3 tools/sync-skill.py") + + def test_frontmatter_names_the_skill(self): + self.assertEqual(frontmatter(self.text).get("name"), "agsearch") + + def test_the_description_says_when_to_fire(self): + """The description is the only part always in context. It has to carry the triggers.""" + desc = frontmatter(self.text).get("description", "") + self.assertIn("session", desc.lower()) + self.assertGreater(len(desc), 80, desc) + + def test_every_flag_the_skill_teaches_exists_in_the_cli(self): + """A skill that names a removed flag sends the agent to a dead command.""" + script = (ROOT / "agsearch").read_text() + for flag in sorted(set(re.findall(r"agsearch [^\n]*?(--[a-z][a-z-]+)", self.text))): + self.assertIn(f'"{flag}"', script, f"the skill teaches {flag}, the CLI has no such flag") + + def test_every_subcommand_the_skill_teaches_exists(self): + for sub in sorted(set(re.findall(r"agsearch (read|-n) ", self.text))): + self.assertIn(f'"{sub}"', (ROOT / "agsearch").read_text()) + + +class InstallSkillTests(unittest.TestCase): + def setUp(self): + self.ag = load_agsearch() + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ag.SKILL_DIR = os.path.join(self.tmp.name, ".claude", "skills", "agsearch") + self.path = os.path.join(self.ag.SKILL_DIR, "SKILL.md") + + def install(self, force=False): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = self.ag.install_skill(force=force) + return code, out.getvalue() + err.getvalue() + + def test_it_writes_the_skill_where_claude_code_looks(self): + code, _out = self.install() + self.assertEqual(code, 0) + self.assertEqual(open(self.path).read(), self.ag.SKILL_MD) + + def test_running_it_twice_changes_nothing(self): + self.install() + code, out = self.install() + self.assertEqual(code, 0) + self.assertIn("already installed", out) + + def test_an_edited_skill_survives(self): + """People tune skills. Reverting someone's edit on an upgrade is worse than stale.""" + self.install() + open(self.path, "w").write("mine\n") + code, out = self.install() + self.assertEqual(code, 1) + self.assertEqual(open(self.path).read(), "mine\n") + self.assertIn("--force", out) + + def test_force_replaces_an_edited_skill(self): + self.install() + open(self.path, "w").write("mine\n") + code, _out = self.install(force=True) + self.assertEqual(code, 0) + self.assertEqual(open(self.path).read(), self.ag.SKILL_MD) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/sync-skill.py b/tools/sync-skill.py new file mode 100644 index 0000000..0320b38 --- /dev/null +++ b/tools/sync-skill.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Copy skills/agsearch/SKILL.md into the SKILL_MD literal in the agsearch script. + +The repo file is the one people read and edit. The literal is the copy that reaches anyone who +installed through brew, uv or the curl script, none of which put a data file on disk. +tests/test_skill.py fails when the two disagree; this is what fixes it. + + python3 tools/sync-skill.py +""" +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "agsearch" +SKILL = ROOT / "skills" / "agsearch" / "SKILL.md" + +BLOCK = re.compile(r'^SKILL_MD = """\\\n.*?"""$', re.S | re.M) + + +def main(): + text = SKILL.read_text() + for bad in ('"""', "\\"): + if bad in text: + sys.exit(f"SKILL.md contains {bad!r}, which cannot go in the literal unescaped") + script = SCRIPT.read_text() + if not BLOCK.search(script): + sys.exit("could not find the SKILL_MD literal in the agsearch script") + SCRIPT.write_text(BLOCK.sub('SKILL_MD = """\\\n' + text + '"""', script, count=1)) + print(f"synced {SKILL.relative_to(ROOT)} -> SKILL_MD") + + +if __name__ == "__main__": + main() From 29b04a9e4be0d022461a9346664da1559edab929 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 00:39:36 +0530 Subject: [PATCH 2/6] One way to install the skill, not two --- agsearch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agsearch b/agsearch index a8edef0..156fe27 100755 --- a/agsearch +++ b/agsearch @@ -1711,7 +1711,7 @@ def main(argv): render_transcript(argv[1] if len(argv) > 1 else "", argv[2] if len(argv) > 2 else "0", " ".join(argv[3:]), color=True) return 0 - if argv and argv[0] in ("--install-skill", "install-skill"): + if argv and argv[0] == "--install-skill": return install_skill(force="--force" in argv[1:]) if argv and argv[0] == "read": if len(argv) < 2 or not argv[1].strip(): From 4e21daf6f52ae66962ec4b1e391c6c91537da63f Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 00:42:18 +0530 Subject: [PATCH 3/6] Drop a hit-rate claim the untuned benchmark does not support The skill said the top hit is right about three times in four. That is the hand-labelled list (0.733); the 247-query set nobody tuned against reads 0.490. The instruction that matters is to check the matched line either way, and it does not need a number to land. --- agsearch | 4 ++-- skills/agsearch/SKILL.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agsearch b/agsearch index 156fe27..4606e10 100755 --- a/agsearch +++ b/agsearch @@ -1813,8 +1813,8 @@ Pass the user's own vocabulary. Inflections and typos are handled, so `migration Each hit is one session: id, date, agent, matched/total terms, project, title, then the line that matched underneath. -The top hit is the right session roughly three times in four. Read the matched line before -trusting it, and say which session you are answering from. +The top hit is usually the right session, not always. Read the matched line before trusting +it, and name the session you are answering from so the user can check you. **Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how many it withheld. diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index a8bab3e..8c1e60f 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -29,8 +29,8 @@ Pass the user's own vocabulary. Inflections and typos are handled, so `migration Each hit is one session: id, date, agent, matched/total terms, project, title, then the line that matched underneath. -The top hit is the right session roughly three times in four. Read the matched line before -trusting it, and say which session you are answering from. +The top hit is usually the right session, not always. Read the matched line before trusting +it, and name the session you are answering from so the user can check you. **Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how many it withheld. From d40f945159f2944f1b5896a1feb4f01fc94124a1 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 10:23:47 +0530 Subject: [PATCH 4/6] Ship the skill as a plugin instead of embedding it in the script The skill existed twice: as skills/agsearch/SKILL.md and as a literal inside the script, with a sync tool and a drift test holding them together. That was built to work around brew, uv and curl each installing exactly one file, but a skill is a user-level config file, not a program asset, and the ecosystem already has a way to install one. Two manifests replace all of it. `/plugin marketplace add devcodes9/agsearch` and `/plugin install agsearch@agsearch` install the skill from this repo, and `plugin.json`'s version controls when users get updates, so the staleness problem the sync test was guarding against stops existing. Copying skills/agsearch/ into ~/.claude/skills/ still works for anyone who would rather not add a marketplace. Removed: the SKILL_MD literal, install_skill(), --install-skill, the install.sh block, tools/sync-skill.py, and the CI step that checked the install carried the skill. Net effect on the script is that it goes back to being a search tool that does not know what a skill is. Verified with `claude plugin validate .`, then installed locally end to end: one skill, ~106 tokens always-on. The manifests are now tested rather than the drift: the marketplace lists the plugin, its source directory exists, that directory really contains a skill, and the version is a version. --- .claude-plugin/marketplace.json | 15 ++++ .claude-plugin/plugin.json | 8 ++ .github/workflows/ci.yml | 6 -- CHANGELOG.md | 14 ++-- README.md | 25 +++--- agsearch | 144 +------------------------------- install.sh | 25 ------ tests/test_skill.py | 96 ++++++++------------- tools/sync-skill.py | 34 -------- 9 files changed, 82 insertions(+), 285 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json delete mode 100644 tools/sync-skill.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..529e49a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "agsearch", + "owner": { + "name": "Dev Dalia", + "url": "https://github.com/devcodes9" + }, + "plugins": [ + { + "name": "agsearch", + "source": "./", + "description": "Search your past Claude Code and Codex sessions from inside Claude" + } + ], + "description": "agsearch: search your past Claude Code and Codex sessions from inside Claude" +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..9e72abf --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "agsearch", + "description": "Search your past Claude Code and Codex sessions from inside Claude", + "version": "0.1.0", + "author": { "name": "Dev Dalia" }, + "homepage": "https://github.com/devcodes9/agsearch", + "license": "MIT" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e75920..f5ab5e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,12 +47,6 @@ jobs: run: | "$RUNNER_TEMP/bin/agsearch" --version "$RUNNER_TEMP/bin/agsearch" --help >/dev/null - - name: The install carried the skill - # The skill is the agent-facing half of the product, so a curl install that - # silently skips it is a broken install, not a cosmetic miss. - run: | - test -f "$HOME/.claude/skills/agsearch/SKILL.md" - head -n 1 "$HOME/.claude/skills/agsearch/SKILL.md" | grep -q '^---$' - name: Search an empty corpus without crashing run: | # No ~/.claude or ~/.codex on a runner: exit 1 with a message is the diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c827ae..377d8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,14 +37,14 @@ migration in the same line. ### Added -- **A Claude Code skill, installed with `agsearch --install-skill`** (and by the curl installer, - unless `AGSEARCH_SKILL=0`). With it Claude searches your transcripts itself when you refer to +- **A Claude Code skill**, installed as a plugin (`/plugin marketplace add devcodes9/agsearch`, + then `/plugin install agsearch@agsearch`) or by copying `skills/agsearch/` into + `~/.claude/skills/`. With it Claude searches your transcripts itself when you refer to earlier work, rather than answering that it has no record of the conversation, and it can - carry an old session's context forward into the one you are in now, which resuming cannot do. - The skill teaches query construction because that is where an agent fails: on a 247-query - benchmark, content words alone rank the right session first 49% of the time, and the same - words left inside the question that carried them score 10% to 22%. An installed skill you - have edited is never overwritten without `--force`. + carry an old session's context forward into the session you are in now, which resuming + cannot do. The skill teaches query construction because that is where an agent fails: on a + 247-query benchmark, content words alone rank the right session first 49% of the time, and + the same words left inside the question that carried them score 10% to 22%. - **`agsearch read `** prints a whole conversation without resuming it. This was already there as the TUI's Ctrl-O, reachable only as an internal diff --git a/README.md b/README.md index 61ae676..f98265a 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ uvx agsearch -n "stripe tax id" - **Ranked results.** BM25 ranking favors focused sessions and shows matching lines in context. - **Preview, read, or resume.** Inspect a match, open the transcript in a pager, or return to the original session. -- **Your agent can search too.** A one-command Claude Code skill, so Claude finds the earlier - conversation itself instead of answering that it has no record of it. +- **Your agent can search too.** A Claude Code skill, so Claude finds the earlier conversation + itself instead of answering that it has no record of it. - **Fully local.** No uploads, API keys, hosted index, or network calls. - **Fast warm searches.** A per-file cache reparses only transcripts that changed. @@ -101,7 +101,6 @@ agsearch --thinking "query" # include assistant thinking blocks agsearch --no-resume "query" # print the selected resume command agsearch --reindex # rebuild the transcript cache agsearch --version # print the installed version -agsearch --install-skill # install the Claude Code skill ``` ### Scripts and coding agents @@ -121,14 +120,15 @@ it. A terminal sees none of that. ### Let Claude search for you -The curl installer sets this up. Homebrew and uv users run it once: +agsearch ships a Claude Code skill. Install it from inside Claude Code: -```sh -agsearch --install-skill +``` +/plugin marketplace add devcodes9/agsearch +/plugin install agsearch@agsearch ``` -That writes a skill to `~/.claude/skills/agsearch/`. From the next session on, Claude searches -your transcripts itself when you refer to work from an earlier conversation: +From the next session on, Claude searches your transcripts itself when you refer to work from +an earlier conversation: > **you:** what did we decide about the webhook retry backoff? > @@ -139,8 +139,13 @@ session in its own directory; the skill carries that session's context forward i are in now, so you can pick the work up in a different repository or on a different branch. The skill is [a single markdown file](https://github.com/devcodes9/agsearch/blob/main/skills/agsearch/SKILL.md). -Read it before you install it, and edit your copy freely: upgrades leave an edited skill alone -and tell you it is stale. +Read it before installing. If you would rather not add a marketplace, copy it instead: + +```sh +mkdir -p ~/.claude/skills && cp -r skills/agsearch ~/.claude/skills/ +``` + +Either way it needs the `agsearch` binary, which the installation section above covers. ### Interactive keys diff --git a/agsearch b/agsearch index 4606e10..711bb57 100755 --- a/agsearch +++ b/agsearch @@ -17,7 +17,6 @@ Usage: agsearch --thinking # also index assistant thinking blocks agsearch --reindex # force a full rebuild of the cache agsearch --version # print the installed version and exit - agsearch --install-skill # let Claude Code search these sessions itself agsearch _preview # (internal) fzf preview `-n` prints one entry per session, led by its session id, and drops colour when it is @@ -25,10 +24,9 @@ not writing to a terminal — so a script or a coding agent can search, then rea with `agsearch read `. Piped, ids shorten to a unique prefix, the columns lose their padding and `read` caps its output; a terminal sees none of that. -`--install-skill` writes a Claude Code skill to ~/.claude/skills/agsearch/. With it, -Claude searches your past sessions on its own when you refer to earlier work, instead -of answering that it has no record of the conversation. The curl installer does this -for you; brew and uv users run it once. +Claude can drive that loop itself. `/plugin marketplace add devcodes9/agsearch` then +`/plugin install agsearch@agsearch` installs a skill that searches these sessions when +you refer to earlier work, instead of answering that it has no record of it. In the TUI the right pane previews the matched session, auto-scrolled to your match (marked ▶) with a "match N of M" header. Enter resumes the session (and copies your @@ -1711,8 +1709,6 @@ def main(argv): render_transcript(argv[1] if len(argv) > 1 else "", argv[2] if len(argv) > 2 else "0", " ".join(argv[3:]), color=True) return 0 - if argv and argv[0] == "--install-skill": - return install_skill(force="--force" in argv[1:]) if argv and argv[0] == "read": if len(argv) < 2 or not argv[1].strip(): print("usage: agsearch read [query]", file=sys.stderr) @@ -1775,140 +1771,6 @@ def main(argv): return run_fzf(lines, query, thinking=thinking, no_resume=no_resume, fuzzy=fuzzy) -SKILL_DIR = os.path.join(HOME, ".claude", "skills", "agsearch") - -# The skill text ships inside this script because that is the only copy every install channel -# carries: brew and the curl installer put one file on your PATH, and a wheel has no data dir -# worth the packaging. `skills/agsearch/SKILL.md` in the repo is the readable copy, and -# tests/test_skill.py fails if the two drift. Regenerate with tools/sync-skill.py. -SKILL_MD = """\ ---- -name: agsearch -description: Search past Claude Code and Codex sessions saved on this machine. Use when the user refers to earlier work ("what did we decide about X", "we hit this error before"), asks to continue or hand off work started in another session, or when you are about to say you have no record of a conversation that happened before this one. ---- - -# agsearch - -Every Claude Code and Codex session on this machine is a transcript on disk. `agsearch` -searches what was said inside them and prints ranked hits. You do not remember those -sessions. The transcripts do. - -## Build the query from content words - -Pass the nouns, error strings and identifiers. Question words match hundreds of sessions -and outweigh the two or three words that identify one. - - user: what did we decide about the webhook retry backoff? - you: agsearch -n "webhook retry backoff" - -Two to four content words is the target. On a 247-query benchmark, content words alone put -the right session first 49% of the time; the same words left inside the question that carried -them scored 10% to 22%. - -Pass the user's own vocabulary. Inflections and typos are handled, so `migration` finds -`migrate` and a misspelling still ranks. - -## Read the results - -Each hit is one session: id, date, agent, matched/total terms, project, title, then the line -that matched underneath. - -The top hit is usually the right session, not always. Read the matched line before trusting -it, and name the session you are answering from so the user can check you. - -**Too many hits.** Add a content word. Narrowing beats paging: the output caps and reports how -many it withheld. - -**No hits.** Drop to the single most distinctive word, usually an error string, a library name -or an identifier. If that finds nothing, stop and say the conversation is not on this machine. -That is a real answer, and a more useful one than a guess. - -Reach for the transcripts through `agsearch` rather than reading them directly. They are JSONL -with tool calls, diffs and base64 attachments interleaved, so grepping them returns matches -from machine noise rather than from anything anyone said. - -## Open a session - - agsearch read - -The id is the first field of a result row. Any unambiguous prefix works. Output is capped for -you: you get the opening turns and the closing turns, which is what tells you the goal and the -outcome. - -To find something specific inside a long session, pass the terms as well: - - agsearch read "retry backoff" - -The turns that matched are marked and are kept ahead of the ending when the cap applies. -`--full` prints everything when you need the middle too. - -## Handoff - -When the user asks to continue work from an earlier session: - -1. Find the session with the search above. -2. `agsearch read `. -3. Report the goal, what was already done, and where it stopped. -4. Continue the work. - -Step 3 is done when you can state all three from the transcript. If any of them is a guess, -read again with the terms you are missing, or with `--full`. - -Where the work left commits or a pull request, read those too: the transcript carries the -reasoning and the discarded options, and git carries what actually shipped. Neither is the -whole story on its own. - -This is not `claude --resume`. Resume moves you back into the old session in its own -directory. Handoff brings that context forward into the session you are in now. - -## Scope a search - - agsearch -n "..." --here # sessions from this directory's project only - agsearch -n "..." --project myapp # sessions whose path matches myapp - -Use these when a query returns the right topic from the wrong repository. - -## What this cannot do - -Matching is lexical, not semantic. A session that discussed an idea in different words will -not surface, so a search returning nothing is weak evidence that a conversation never -happened. Everything runs locally against files already on disk, and nothing leaves the -machine. -""" - - -def install_skill(force=False): - """Write the skill where Claude Code looks for it. - - Idempotent, and it keeps a file you edited: a skill is a document people tune, and - silently reverting someone's edit on an upgrade is worse than telling them it is stale. - """ - path = os.path.join(SKILL_DIR, "SKILL.md") - if os.path.isfile(path): - try: - current = open(path, errors="replace").read() - except OSError: - current = None - if current == SKILL_MD: - print(f"already installed: {path}") - return 0 - if not force: - print(f"{path} exists and differs from this version's skill.\n" - f"Keeping your copy. Replace it with: agsearch --install-skill --force", - file=sys.stderr) - return 1 - try: - os.makedirs(SKILL_DIR, exist_ok=True) - with open(path, "w") as fh: - fh.write(SKILL_MD) - except OSError as e: - print(f"could not write {path}: {e}", file=sys.stderr) - return 1 - print(f"installed: {path}") - print("Start a new Claude Code session to pick it up.") - return 0 - - def _entry(): """Console-script entry point. diff --git a/install.sh b/install.sh index 950116d..9657c63 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,6 @@ # AGSEARCH_VERSION=v0.1.0 sh install.sh # a specific release # AGSEARCH_VERSION=main sh install.sh # unreleased tip, for testing # PREFIX=/usr/local/bin sh install.sh # a different install location -# AGSEARCH_SKILL=0 sh install.sh # skip the Claude Code skill set -eu REPO="devcodes9/agsearch" @@ -78,28 +77,4 @@ if ! command -v fzf >/dev/null 2>&1; then echo " (agsearch -n \"query\" works without fzf)" fi -# The skill is what lets a coding agent search these transcripts itself, so the one-line -# install should deliver it too. Taken from the checkout when there is one, fetched otherwise: -# this script never executes the binary it just downloaded, and a pinned older release has no -# --install-skill to call. -if [ "${AGSEARCH_SKILL:-1}" != "0" ]; then - SKILL_REF="${AGSEARCH_VERSION:-main}" - SKILL_DIR="$HOME/.claude/skills/agsearch" - SKILL_URL="https://raw.githubusercontent.com/$REPO/$SKILL_REF/skills/agsearch/SKILL.md" - if [ -f "$SKILL_DIR/SKILL.md" ]; then - echo "note: $SKILL_DIR/SKILL.md exists — leaving it alone." - echo " to replace it: agsearch --install-skill --force" - elif { [ -f "./skills/agsearch/SKILL.md" ] && [ -z "${AGSEARCH_VERSION:-}" ] && - cp "./skills/agsearch/SKILL.md" "$TARGET.skill"; } || - { curl -fsSL "$SKILL_URL" -o "$TARGET.skill" 2>/dev/null && - head -n 1 "$TARGET.skill" | grep -q '^---$'; }; then - mkdir -p "$SKILL_DIR" - mv "$TARGET.skill" "$SKILL_DIR/SKILL.md" - echo "installed skill: $SKILL_DIR/SKILL.md (start a new Claude Code session to pick it up)" - else - rm -f "$TARGET.skill" - echo "note: could not fetch the Claude Code skill. Install it later: agsearch --install-skill" - fi -fi - echo "done. run: agsearch" diff --git a/tests/test_skill.py b/tests/test_skill.py index 26c7499..c4930c7 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -1,45 +1,37 @@ -"""The skill is documentation an agent executes, so a stale line is a bug, not a typo. +"""The skill is documentation an agent executes, so a stale line in it is a bug. -Two copies exist by necessity: the file people read in the repo, and the literal inside the -script, which is the only copy brew, uv and the curl installer carry. These tests pin them -together, and pin the skill to the CLI it describes. +It ships as a Claude Code plugin: the marketplace manifest points at this repo, and the +skill sits where Claude Code expects to find it. These tests pin the manifests to the file +they promise, and pin the skill to the CLI it teaches. """ -import io -import os +import json import pathlib import re -import tempfile import unittest -from contextlib import redirect_stdout, redirect_stderr from load_agsearch import load_agsearch ROOT = pathlib.Path(__file__).resolve().parents[1] SKILL = ROOT / "skills" / "agsearch" / "SKILL.md" +MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json" +PLUGIN = ROOT / ".claude-plugin" / "plugin.json" def frontmatter(text): m = re.match(r"^---\n(.*?)\n---\n", text, re.S) - if not m: - return {} out = {} - for line in m.group(1).splitlines(): + for line in (m.group(1).splitlines() if m else []): if ": " in line: k, v = line.split(": ", 1) out[k.strip()] = v.strip() return out -class SkillFileTests(unittest.TestCase): +class SkillTests(unittest.TestCase): def setUp(self): - self.ag = load_agsearch() self.text = SKILL.read_text() - - def test_the_shipped_copy_matches_the_repo_copy(self): - """Anyone who installed by brew, uv or curl gets the literal, never the repo file.""" - self.assertEqual(self.ag.SKILL_MD, self.text, - "SKILL.md and SKILL_MD have drifted. Run: python3 tools/sync-skill.py") + self.script = (ROOT / "agsearch").read_text() def test_frontmatter_names_the_skill(self): self.assertEqual(frontmatter(self.text).get("name"), "agsearch") @@ -51,56 +43,36 @@ def test_the_description_says_when_to_fire(self): self.assertGreater(len(desc), 80, desc) def test_every_flag_the_skill_teaches_exists_in_the_cli(self): - """A skill that names a removed flag sends the agent to a dead command.""" - script = (ROOT / "agsearch").read_text() + """A skill naming a removed flag sends the agent to a dead command.""" for flag in sorted(set(re.findall(r"agsearch [^\n]*?(--[a-z][a-z-]+)", self.text))): - self.assertIn(f'"{flag}"', script, f"the skill teaches {flag}, the CLI has no such flag") + self.assertIn(f'"{flag}"', self.script, + f"the skill teaches {flag}, the CLI has no such flag") def test_every_subcommand_the_skill_teaches_exists(self): for sub in sorted(set(re.findall(r"agsearch (read|-n) ", self.text))): - self.assertIn(f'"{sub}"', (ROOT / "agsearch").read_text()) + self.assertIn(f'"{sub}"', self.script) -class InstallSkillTests(unittest.TestCase): - def setUp(self): - self.ag = load_agsearch() - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.ag.SKILL_DIR = os.path.join(self.tmp.name, ".claude", "skills", "agsearch") - self.path = os.path.join(self.ag.SKILL_DIR, "SKILL.md") - - def install(self, force=False): - out, err = io.StringIO(), io.StringIO() - with redirect_stdout(out), redirect_stderr(err): - code = self.ag.install_skill(force=force) - return code, out.getvalue() + err.getvalue() - - def test_it_writes_the_skill_where_claude_code_looks(self): - code, _out = self.install() - self.assertEqual(code, 0) - self.assertEqual(open(self.path).read(), self.ag.SKILL_MD) - - def test_running_it_twice_changes_nothing(self): - self.install() - code, out = self.install() - self.assertEqual(code, 0) - self.assertIn("already installed", out) - - def test_an_edited_skill_survives(self): - """People tune skills. Reverting someone's edit on an upgrade is worse than stale.""" - self.install() - open(self.path, "w").write("mine\n") - code, out = self.install() - self.assertEqual(code, 1) - self.assertEqual(open(self.path).read(), "mine\n") - self.assertIn("--force", out) - - def test_force_replaces_an_edited_skill(self): - self.install() - open(self.path, "w").write("mine\n") - code, _out = self.install(force=True) - self.assertEqual(code, 0) - self.assertEqual(open(self.path).read(), self.ag.SKILL_MD) +class PluginManifestTests(unittest.TestCase): + """`/plugin install` fails silently-ish on a manifest that points at nothing.""" + + def test_the_marketplace_lists_this_plugin(self): + m = json.loads(MARKETPLACE.read_text()) + names = [p["name"] for p in m["plugins"]] + self.assertIn(json.loads(PLUGIN.read_text())["name"], names) + + def test_every_plugin_source_exists(self): + for p in json.loads(MARKETPLACE.read_text())["plugins"]: + self.assertTrue((ROOT / p["source"]).is_dir(), p["source"]) + + def test_the_source_actually_contains_the_skill(self): + for p in json.loads(MARKETPLACE.read_text())["plugins"]: + found = list((ROOT / p["source"]).glob("skills/*/SKILL.md")) + self.assertTrue(found, f"{p['source']} has no skills/*/SKILL.md") + + def test_the_plugin_version_is_a_version(self): + """Users only get updates when this changes, so a missing one freezes them.""" + self.assertRegex(json.loads(PLUGIN.read_text())["version"], r"^\d+\.\d+\.\d+$") if __name__ == "__main__": diff --git a/tools/sync-skill.py b/tools/sync-skill.py deleted file mode 100644 index 0320b38..0000000 --- a/tools/sync-skill.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -"""Copy skills/agsearch/SKILL.md into the SKILL_MD literal in the agsearch script. - -The repo file is the one people read and edit. The literal is the copy that reaches anyone who -installed through brew, uv or the curl script, none of which put a data file on disk. -tests/test_skill.py fails when the two disagree; this is what fixes it. - - python3 tools/sync-skill.py -""" -import pathlib -import re -import sys - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "agsearch" -SKILL = ROOT / "skills" / "agsearch" / "SKILL.md" - -BLOCK = re.compile(r'^SKILL_MD = """\\\n.*?"""$', re.S | re.M) - - -def main(): - text = SKILL.read_text() - for bad in ('"""', "\\"): - if bad in text: - sys.exit(f"SKILL.md contains {bad!r}, which cannot go in the literal unescaped") - script = SCRIPT.read_text() - if not BLOCK.search(script): - sys.exit("could not find the SKILL_MD literal in the agsearch script") - SCRIPT.write_text(BLOCK.sub('SKILL_MD = """\\\n' + text + '"""', script, count=1)) - print(f"synced {SKILL.relative_to(ROOT)} -> SKILL_MD") - - -if __name__ == "__main__": - main() From 926f6456ad62f30a2a0506c44179d57db3922d9d Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 12:52:21 +0530 Subject: [PATCH 5/6] Tell the agent what to do when agsearch is not installed The plugin is a markdown file that installs on its own, so someone can hold the skill without ever having installed the CLI it drives. Every command then fails with command-not-found and the skill said nothing about it, which leaves the agent to retry or to go reading transcripts by hand. Names the install commands once and says to stop rather than work around it. --- skills/agsearch/SKILL.md | 10 ++++++++++ tests/test_skill.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index 8c1e60f..28a5f32 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -9,6 +9,16 @@ Every Claude Code and Codex session on this machine is a transcript on disk. `ag searches what was said inside them and prints ranked hits. You do not remember those sessions. The transcripts do. +## Before the first search + +This skill drives the `agsearch` command. If it is not on the PATH, say so once and stop +searching: + + agsearch is not installed. Install it with `brew install devcodes9/tap/agsearch`, + or run a single search with `uvx agsearch -n "..."`. + +Retrying, or reading the transcripts some other way, wastes turns and gets worse answers. + ## Build the query from content words Pass the nouns, error strings and identifiers. Question words match hundreds of sessions diff --git a/tests/test_skill.py b/tests/test_skill.py index c4930c7..5028f69 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -48,6 +48,11 @@ def test_every_flag_the_skill_teaches_exists_in_the_cli(self): self.assertIn(f'"{flag}"', self.script, f"the skill teaches {flag}, the CLI has no such flag") + def test_the_skill_says_what_to_do_without_the_binary(self): + """The plugin installs on its own, so a reader can have the skill and not the CLI.""" + self.assertIn("not installed", self.text) + self.assertIn("brew install", self.text) + def test_every_subcommand_the_skill_teaches_exists(self): for sub in sorted(set(re.findall(r"agsearch (read|-n) ", self.text))): self.assertIn(f'"{sub}"', self.script) From 5d2f05dce0146b51b96e08e16943352f73df2ea0 Mon Sep 17 00:00:00 2001 From: Dev Dalia Date: Sat, 5 Sep 2026 12:56:14 +0530 Subject: [PATCH 6/6] Point the skill at the copy the plugin already ships The plugin sets source to the repo root, so installing it also puts the agsearch script in the plugin cache, executable and working. The skill was telling the agent to give up when the PATH copy was missing, while a usable binary sat next to the skill file. Now: PATH first, then $CLAUDE_PLUGIN_ROOT/agsearch, and only then say it is missing. $CLAUDE_PLUGIN_ROOT is how other plugins reach their bundled files, so this is the existing convention rather than a new one. Checked how 123 locally installed skills handle a shelled-out dependency: none carry "if it is not installed" prose, and plugin.json has no dependency field to declare one with. Bundling and pointing at it is what the format actually supports. --- skills/agsearch/SKILL.md | 13 ++++++------- tests/test_skill.py | 7 +++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/skills/agsearch/SKILL.md b/skills/agsearch/SKILL.md index 28a5f32..15ddea0 100644 --- a/skills/agsearch/SKILL.md +++ b/skills/agsearch/SKILL.md @@ -9,15 +9,14 @@ Every Claude Code and Codex session on this machine is a transcript on disk. `ag searches what was said inside them and prints ranked hits. You do not remember those sessions. The transcripts do. -## Before the first search +## The command -This skill drives the `agsearch` command. If it is not on the PATH, say so once and stop -searching: +Use `agsearch` from the PATH. When it is not there, this plugin ships a copy at +`"$CLAUDE_PLUGIN_ROOT/agsearch"`. Same program either way. Resolve it once and use that for +every command below. - agsearch is not installed. Install it with `brew install devcodes9/tap/agsearch`, - or run a single search with `uvx agsearch -n "..."`. - -Retrying, or reading the transcripts some other way, wastes turns and gets worse answers. +If neither exists, say so and name `brew install devcodes9/tap/agsearch`, rather than +retrying or reading the transcripts some other way. ## Build the query from content words diff --git a/tests/test_skill.py b/tests/test_skill.py index 5028f69..dd76e83 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -49,8 +49,11 @@ def test_every_flag_the_skill_teaches_exists_in_the_cli(self): f"the skill teaches {flag}, the CLI has no such flag") def test_the_skill_says_what_to_do_without_the_binary(self): - """The plugin installs on its own, so a reader can have the skill and not the CLI.""" - self.assertIn("not installed", self.text) + """The plugin installs on its own, so a reader can hold the skill without the CLI. + + It ships the binary too, so the answer is to use that rather than to give up. + """ + self.assertIn("CLAUDE_PLUGIN_ROOT", self.text) self.assertIn("brew install", self.text) def test_every_subcommand_the_skill_teaches_exists(self):