From a675be1391b1f7fc981bbc8d518216d668d44454 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 08:38:13 -0700 Subject: [PATCH] feat(init): recognize the reference line by a Quests: prefix Co-Authored-By: Claude Opus 5.5 --- docs/getting-started.md | 3 ++- src/setup.rs | 39 +++++++++++++++++++++++---------------- tests/init.rs | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 89949ff..bf89756 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,7 +28,8 @@ This writes a Quest stub for each shipped skill under `.claude/skills/`, links `.agents/skills/` to the same tree when needed, adds `/.scratch/` to `.gitignore`, creates `quest/README.md` when missing, and appends one line to your root `AGENTS.md` or `CLAUDE.md` telling agents to run `quest guide` when -work mentions a quest. +work mentions a quest. That line starts with `Quests: `; reword the rest freely, +since init and uninstall recognize it by that prefix. Init refuses to overwrite an existing same-named skill that is not a Quest stub. Run it again safely: it only prints paths it changed. diff --git a/src/setup.rs b/src/setup.rs index 6d7a5a1..1b7e700 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -10,8 +10,12 @@ use anyhow::{Context, Result, bail}; use crate::skills; +/// Marks Quest's line in the root agent instructions, so a repository can +/// reword the rest of it without init adding another. +const REFERENCE_MARKER: &str = "Quests: "; + /// Appended to the root agent instructions when Quest is installed. -pub const REFERENCE_LINE: &str = "When work mentions a quest, run `quest guide` and follow it."; +pub const REFERENCE_LINE: &str = "Quests: when work mentions a quest, run `quest guide` and follow it."; const SCRATCH_IGNORE: &str = "/.scratch/"; @@ -62,7 +66,7 @@ pub fn init(root: &Path) -> Result> { } let ignore = Path::new(".gitignore"); - if append_line(root, ignore, SCRATCH_IGNORE, false)? { + if append_line(root, ignore, SCRATCH_IGNORE, SCRATCH_IGNORE, false)? { changes.push(ignore.into()); } @@ -70,7 +74,7 @@ pub fn init(root: &Path) -> Result> { .into_iter() .find(|name| root.join(name).is_file()) .unwrap_or("AGENTS.md"); - if append_line(root, Path::new(instructions), REFERENCE_LINE, true)? { + if append_line(root, Path::new(instructions), REFERENCE_MARKER, REFERENCE_LINE, true)? { changes.push(instructions.into()); } Ok(changes) @@ -109,12 +113,12 @@ pub fn uninstall(root: &Path) -> Result> { } } - for (path, line) in [ + for (path, marker) in [ (".gitignore", SCRATCH_IGNORE), - ("AGENTS.md", REFERENCE_LINE), - ("CLAUDE.md", REFERENCE_LINE), + ("AGENTS.md", REFERENCE_MARKER), + ("CLAUDE.md", REFERENCE_MARKER), ] { - if remove_line(root, Path::new(path), line)? { + if remove_line(root, Path::new(path), marker)? { changes.push(path.into()); } } @@ -183,15 +187,15 @@ fn write(root: &Path, path: &Path, content: &str) -> Result<()> { fs::write(root.join(path), content).with_context(|| path.display().to_string()) } -/// Append `line` unless the file already has it, creating the file if missing. -/// `paragraph` separates it from existing content with a blank line. -fn append_line(root: &Path, path: &Path, line: &str, paragraph: bool) -> Result { +/// Append `line` unless a line already starts with `marker`, creating the file +/// if missing. `paragraph` separates it from existing content with a blank line. +fn append_line(root: &Path, path: &Path, marker: &str, line: &str, paragraph: bool) -> Result { let mut content = if root.join(path).is_file() { read(root, path)? } else { String::new() }; - if content.lines().any(|existing| existing == line) { + if content.lines().any(|existing| existing.starts_with(marker)) { return Ok(false); } if !content.is_empty() { @@ -208,17 +212,20 @@ fn append_line(root: &Path, path: &Path, line: &str, paragraph: bool) -> Result< Ok(true) } -/// Remove every copy of `line` and the blank lines it leaves at the end, -/// deleting the file if nothing else remains. -fn remove_line(root: &Path, path: &Path, line: &str) -> Result { +/// Remove every line starting with `marker` and the blank lines it leaves at +/// the end, deleting the file if nothing else remains. +fn remove_line(root: &Path, path: &Path, marker: &str) -> Result { if !root.join(path).is_file() { return Ok(false); } let content = read(root, path)?; - if !content.lines().any(|existing| existing == line) { + if !content.lines().any(|existing| existing.starts_with(marker)) { return Ok(false); } - let kept: Vec<_> = content.lines().filter(|existing| *existing != line).collect(); + let kept: Vec<_> = content + .lines() + .filter(|existing| !existing.starts_with(marker)) + .collect(); let kept = kept.join("\n"); let kept = kept.trim_end(); if kept.is_empty() { diff --git a/tests/init.rs b/tests/init.rs index bd1b7fd..bab95a6 100644 --- a/tests/init.rs +++ b/tests/init.rs @@ -115,3 +115,17 @@ fn uninstall_is_idempotent() { uninstall(dir.path()); assert!(uninstall(dir.path()).is_empty()); } + +#[test] +fn init_keeps_a_reworded_reference_line() { + let dir = repo(); + let custom = "Quests: run `quest guide` first; skills live upstream."; + std::fs::write(dir.path().join("AGENTS.md"), format!("# Repo\n\n{custom}\n")).unwrap(); + init(dir.path()); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert_eq!(agents, format!("# Repo\n\n{custom}\n")); + + uninstall(dir.path()); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert_eq!(agents, "# Repo\n"); +}