Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 23 additions & 16 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/";

Expand Down Expand Up @@ -62,15 +66,15 @@ pub fn init(root: &Path) -> Result<Vec<PathBuf>> {
}

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());
}

let instructions = ["AGENTS.md", "CLAUDE.md"]
.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)
Expand Down Expand Up @@ -109,12 +113,12 @@ pub fn uninstall(root: &Path) -> Result<Vec<PathBuf>> {
}
}

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());
}
}
Expand Down Expand Up @@ -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<bool> {
/// 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<bool> {
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() {
Expand All @@ -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<bool> {
/// 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<bool> {
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() {
Expand Down
14 changes: 14 additions & 0 deletions tests/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading