diff --git a/README.md b/README.md index 894cef3..554aa44 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ and Codex plan the work and pick it up. It grew out of [MoQ](https://github.com/moq-dev/moq). This is the standalone version, being built for use in other repositories. -**Early days:** validation, readiness, and the planning, execution, merge, and -export skills are here. Automatic setup and release binaries are still being +**Early days:** validation, readiness, init/uninstall, and the planning, +execution, merge, and export skills are here. Release binaries are still being built. ## A quest is just a file @@ -114,7 +114,7 @@ Use `/quest-plan` in Claude Code or `$quest-plan` in Codex. Skills coordinate within your agent session. Starting work stops at a PR; merging is a separate invocation. -To use Quest in your own repository, follow the [manual setup](docs/getting-started.md). +To use Quest in your own repository, follow the [getting started guide](docs/getting-started.md) (`quest init`). `quest guide` prints the format and workflow. ## Where this is going diff --git a/docs/getting-started.md b/docs/getting-started.md index 723b314..89949ff 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,8 +1,7 @@ # Use Quest in your repository -Setup is manual for now; `quest init` will automate it. The binary carries the -skills and the quest guide, so your repository only pins a version and installs -small stubs that call it. +The binary carries the skills and the quest guide, so your repository only pins a +version and installs small stubs that call it. ## Install and pin the binary @@ -17,24 +16,22 @@ manager your repository already uses: The package requires Rust 1.91 or newer. -## Add the stubs and the reference line +## Initialize Quest From your repository root: ```sh -for skill in $(quest skill | cut -d' ' -f1); do - mkdir -p .claude/skills/quest-$skill - quest skill --stub $skill > .claude/skills/quest-$skill/SKILL.md -done -echo /.scratch/ >> .gitignore +quest init ``` -Check for existing skills with the same names first. Codex reads skills from -`.agents/skills/`; a directory symlink to `.claude/skills` shares one copy. +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. -Add one line to your root `AGENTS.md` or `CLAUDE.md`: when work mentions a quest, -run `quest guide` and follow it. Keep repository-specific rules there too; the -stubs never change between versions, so upgrading is only a new pin. +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. Claude Code's direct `AGENTS.md` support starts at v2.1.277 and depends on the session configuration. An existing project or ancestor `CLAUDE.md` can take @@ -43,7 +40,7 @@ if the shared instructions do not load. ## Start a roadmap -Create `quest/README.md`: +If `quest init` created an empty root, edit `quest/README.md`: ```markdown # Quests @@ -53,9 +50,9 @@ Create `quest/README.md`: What this project is working toward. ``` -An empty root is valid. Invoke `/quest-plan` in Claude Code or `$quest-plan` in -Codex with an outcome you want to work toward. The skill helps settle the scope, -then creates milestones, quests, and dependencies. See the +Invoke `/quest-plan` in Claude Code or `$quest-plan` in Codex with an outcome you +want to work toward. The skill helps settle the scope, then creates milestones, +quests, and dependencies. See the [CSV export example](../examples/export/quest/README.md) for a populated tree. From your repository root, validate the result and look for ready work: @@ -80,5 +77,12 @@ still need checking; `quest ready` does not look for them. Upgrade by changing the pin; the stubs stay as they are. Before you remove Quest, invoke `/quest-export` (or `$quest-export` in Codex) so active quests become -GitHub issues; `quest uninstall` is not implemented yet. Then remove the stubs, -the pin, and the reference line. Completed plans remain in Git history. +GitHub issues, then run: + +```sh +quest uninstall +``` + +Uninstall removes only Quest stubs that still match, the reference line, and the +`/.scratch/` ignore entry. It never deletes your quest tree, and completed plans +remain in Git history. Then remove the pin. diff --git a/quest/m0/README.md b/quest/m0/README.md index eb0bb61..5b4fe3a 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -24,7 +24,6 @@ ownership-manifest design: ## Required -- [Init and uninstall](/quest/m0/init.md) - `quest init` sets a repository up with stubs and a root reference; `quest uninstall` reverses it - [Setup guide](/quest/m0/setup.md) - one line pasted into an agent installs, pins, and initializes Quest, or removes it - [Release proof](/quest/m0/release-proof.md) - a fresh repository completes the whole lifecycle in CI, then v0.1.0 is tagged - [Launch material](/quest/m0/launch.md) - README, quickstart, demo, comparison, and launch-post drafts diff --git a/quest/m0/init.md b/quest/m0/init.md deleted file mode 100644 index 521b710..0000000 --- a/quest/m0/init.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] Add init and uninstall - -## Goal - -`quest init` sets up a repository for Quest and `quest uninstall` reverses it, -touching only what init owns. Both are idempotent and print what they changed. - -## Plan - -- init writes: a skill stub per shipped skill in `.claude/skills/` and - `.agents/skills/` (a directory symlink between them where one already - exists), a `quest/README.md` root if none exists, `/.scratch/` in - `.gitignore`, and one reference line appended to the root `AGENTS.md` (or - `CLAUDE.md` when that is the file present) telling agents to run - `quest guide` when work mentions a quest. -- It refuses to overwrite a same-named skill that is not a Quest stub, rather - than guessing. -- uninstall removes stubs that still match byte-for-byte, the reference line, - and the ignore entry. It never deletes the quest tree: the export skill and - git history are how plans leave. -- There is no `upgrade` command: stubs are version-independent, so upgrading is - bumping the version in mise or the flake. -- `quest skill --stub ` already renders each stub; init writes those. -- Replace the manual steps in `docs/getting-started.md` with init. diff --git a/quest/m0/release-proof.md b/quest/m0/release-proof.md index d99d2ce..76b87b3 100644 --- a/quest/m0/release-proof.md +++ b/quest/m0/release-proof.md @@ -16,5 +16,4 @@ user owned was lost. Then v0.1.0 is tagged and published. ## Required -- [Init and uninstall](/quest/m0/init.md) - the lifecycle under test - [Setup guide](/quest/m0/setup.md) - the documented entry point diff --git a/quest/m0/setup.md b/quest/m0/setup.md index 378fca3..0937d9f 100644 --- a/quest/m0/setup.md +++ b/quest/m0/setup.md @@ -17,7 +17,3 @@ root instructions. The same file covers removal. installer with the user's consent. - Removal: run the export skill, `quest uninstall`, then drop the pin. - Written for an agent to execute and a human to audit; keep it short. - -## Required - -- [Init and uninstall](/quest/m0/init.md) - the guide runs them diff --git a/src/lib.rs b/src/lib.rs index 96ca8ce..5edd12d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod branch; pub mod doc; pub mod ready; pub mod rules; +pub mod setup; pub mod skills; use std::path::{Path, PathBuf}; diff --git a/src/main.rs b/src/main.rs index 720bb53..27e6178 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,12 @@ enum Command { #[arg(long, requires = "name")] stub: bool, }, + + /// Install skill stubs, a quest root, and agent pointers for this repository. + Init, + + /// Remove Quest stubs and markers installed by `quest init`. + Uninstall, } fn main() -> Result { @@ -145,5 +151,17 @@ fn main() -> Result { } Ok(ExitCode::SUCCESS) } + Command::Init => { + for path in quest::setup::init(&cli.root)? { + println!("{}", path.display()); + } + Ok(ExitCode::SUCCESS) + } + Command::Uninstall => { + for path in quest::setup::uninstall(&cli.root)? { + println!("{}", path.display()); + } + Ok(ExitCode::SUCCESS) + } } } diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..6d7a5a1 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,230 @@ +//! `quest init` and `quest uninstall`: install skill stubs and reverse it. +//! +//! Init touches only stubs, one reference line, `/.scratch/` in `.gitignore`, and +//! an empty `quest/README.md` when missing. Uninstall never deletes the quest tree. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::skills; + +/// 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."; + +const SCRATCH_IGNORE: &str = "/.scratch/"; + +/// The skill directories Claude Code and Codex read. One holds the stubs and +/// the other links to it. +const SKILL_DIRS: [&str; 2] = [".claude/skills", ".agents/skills"]; + +const QUEST_ROOT_README: &str = "\ +# Quests + +## Goal + +What this project is working toward. +"; + +/// Install Quest stubs and markers under `root`. Returns the paths it changed, +/// relative to `root`. +pub fn init(root: &Path) -> Result> { + let mut changes = Vec::new(); + let skills = link_skill_dirs(root, &mut changes)?; + for skill in skills::all() { + let dir = Path::new(skills).join(skill.installed()); + let path = dir.join("SKILL.md"); + if root.join(&path).is_file() { + if read(root, &path)? == skill.stub() { + continue; + } + bail!( + "{} is not a Quest stub; remove or rename it before running `quest init`", + path.display() + ); + } else if root.join(&dir).exists() { + bail!( + "{} exists without SKILL.md; remove or rename it before running `quest init`", + dir.display() + ); + } + fs::create_dir_all(root.join(&dir)).with_context(|| dir.display().to_string())?; + write(root, &path, &skill.stub())?; + changes.push(path); + } + + let readme = Path::new("quest/README.md"); + if !root.join(readme).exists() { + fs::create_dir_all(root.join("quest")).context("quest")?; + write(root, readme, QUEST_ROOT_README)?; + changes.push(readme.into()); + } + + let ignore = Path::new(".gitignore"); + if append_line(root, 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)? { + changes.push(instructions.into()); + } + Ok(changes) +} + +/// Remove Quest stubs and markers under `root`. Returns the paths it changed, +/// relative to `root`. +pub fn uninstall(root: &Path) -> Result> { + let mut changes = Vec::new(); + for skills in SKILL_DIRS { + if !is_real_dir(&root.join(skills)) { + continue; + } + for skill in skills::all() { + let dir = Path::new(skills).join(skill.installed()); + let path = dir.join("SKILL.md"); + if !root.join(&path).is_file() || read(root, &path)? != skill.stub() { + continue; + } + fs::remove_file(root.join(&path)).with_context(|| path.display().to_string())?; + changes.push(path); + remove_if_empty(&root.join(dir))?; + } + remove_if_empty(&root.join(skills))?; + } + // A link whose target just went away was the one init made. + for skills in SKILL_DIRS { + let link = root.join(skills); + if link.is_symlink() && !link.exists() { + fs::remove_file(&link).with_context(|| skills.to_string())?; + changes.push(skills.into()); + } + let parent = link.parent().context(skills)?; + if is_real_dir(parent) { + remove_if_empty(parent)?; + } + } + + for (path, line) in [ + (".gitignore", SCRATCH_IGNORE), + ("AGENTS.md", REFERENCE_LINE), + ("CLAUDE.md", REFERENCE_LINE), + ] { + if remove_line(root, Path::new(path), line)? { + changes.push(path.into()); + } + } + Ok(changes) +} + +/// Make one skill directory real and link the other to it, creating +/// `.claude/skills` when neither exists. Returns the real one. +fn link_skill_dirs(root: &Path, changes: &mut Vec) -> Result<&'static str> { + let [claude, agents] = SKILL_DIRS.map(|dir| fs::symlink_metadata(root.join(dir)).ok().map(|meta| meta.file_type())); + let (real, link) = match (claude, agents) { + (Some(claude), Some(agents)) if claude.is_dir() && agents.is_dir() => bail!( + "both .claude/skills and .agents/skills are directories; \ + merge them and link one to the other before running `quest init`" + ), + (Some(claude), agents) if claude.is_dir() && agents.is_none_or(|kind| kind.is_symlink()) => { + (SKILL_DIRS[0], SKILL_DIRS[1]) + } + (claude, Some(agents)) if agents.is_dir() && claude.is_none_or(|kind| kind.is_symlink()) => { + (SKILL_DIRS[1], SKILL_DIRS[0]) + } + (None, None) => { + fs::create_dir_all(root.join(SKILL_DIRS[0])).context(SKILL_DIRS[0])?; + (SKILL_DIRS[0], SKILL_DIRS[1]) + } + _ => bail!("unexpected .claude/skills or .agents/skills layout; link one directory to the other"), + }; + + let link_path = root.join(link); + if !link_path.is_symlink() { + fs::create_dir_all(link_path.parent().context(link)?).context(link)?; + symlink(&Path::new("..").join(real), &link_path).context(link)?; + changes.push(link.into()); + } + Ok(real) +} + +#[cfg(unix)] +fn symlink(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(not(unix))] +fn symlink(_target: &Path, _link: &Path) -> std::io::Result<()> { + Err(std::io::Error::other( + "linking skill directories requires a Unix platform", + )) +} + +fn is_real_dir(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|meta| meta.is_dir()) +} + +fn remove_if_empty(dir: &Path) -> Result<()> { + if dir.read_dir()?.next().is_none() { + fs::remove_dir(dir).with_context(|| dir.display().to_string())?; + } + Ok(()) +} + +fn read(root: &Path, path: &Path) -> Result { + fs::read_to_string(root.join(path)).with_context(|| path.display().to_string()) +} + +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 { + let mut content = if root.join(path).is_file() { + read(root, path)? + } else { + String::new() + }; + if content.lines().any(|existing| existing == line) { + return Ok(false); + } + if !content.is_empty() { + if !content.ends_with('\n') { + content.push('\n'); + } + if paragraph { + content.push('\n'); + } + } + content.push_str(line); + content.push('\n'); + write(root, path, &content)?; + 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 { + if !root.join(path).is_file() { + return Ok(false); + } + let content = read(root, path)?; + if !content.lines().any(|existing| existing == line) { + return Ok(false); + } + let kept: Vec<_> = content.lines().filter(|existing| *existing != line).collect(); + let kept = kept.join("\n"); + let kept = kept.trim_end(); + if kept.is_empty() { + fs::remove_file(root.join(path)).with_context(|| path.display().to_string())?; + } else { + write(root, path, &format!("{kept}\n"))?; + } + Ok(true) +} diff --git a/tests/init.rs b/tests/init.rs new file mode 100644 index 0000000..bd1b7fd --- /dev/null +++ b/tests/init.rs @@ -0,0 +1,117 @@ +//! Integration tests for `quest init` and `quest uninstall` in temporary repositories. + +use std::path::Path; + +use tempfile::TempDir; + +fn repo() -> TempDir { + TempDir::new().expect("tempdir") +} + +fn init(root: &Path) -> Vec { + quest::setup::init(root).expect("init") +} + +fn uninstall(root: &Path) -> Vec { + quest::setup::uninstall(root).expect("uninstall") +} + +#[test] +fn init_creates_layout_and_is_idempotent() { + let dir = repo(); + let first = init(dir.path()); + assert!(!first.is_empty()); + assert!(dir.path().join(".claude/skills/quest-start/SKILL.md").is_file()); + assert!(dir.path().join(".agents/skills/quest-start/SKILL.md").is_file()); + assert!(dir.path().join("quest/README.md").is_file()); + assert!(dir.path().join(".gitignore").is_file()); + assert!(dir.path().join("AGENTS.md").is_file()); + let ignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(ignore.lines().any(|line| line == "/.scratch/")); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(agents.contains(quest::setup::REFERENCE_LINE)); + + let second = init(dir.path()); + assert!(second.is_empty(), "second init changed: {second:?}"); +} + +#[test] +fn init_refuses_non_stub_skill() { + let dir = repo(); + let skill_dir = dir.path().join(".claude/skills/quest-start"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# My workflow\n").unwrap(); + let err = quest::setup::init(dir.path()).unwrap_err(); + assert!(err.to_string().contains("not a Quest stub")); +} + +#[test] +fn init_appends_agents_not_claude_when_both_exist() { + let dir = repo(); + std::fs::write(dir.path().join("AGENTS.md"), "# Repo\n\nKeep this.\n").unwrap(); + std::fs::write(dir.path().join("CLAUDE.md"), "# Claude only\n").unwrap(); + init(dir.path()); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + let claude = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); + assert!(agents.contains("Keep this.")); + assert!(agents.contains(quest::setup::REFERENCE_LINE)); + assert!(!claude.contains(quest::setup::REFERENCE_LINE)); +} + +#[test] +fn init_uses_claude_when_only_claude_exists() { + let dir = repo(); + std::fs::write(dir.path().join("CLAUDE.md"), "# Claude\n").unwrap(); + init(dir.path()); + let claude = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); + assert!(claude.contains(quest::setup::REFERENCE_LINE)); + assert!(!dir.path().join("AGENTS.md").exists()); +} + +#[test] +fn uninstall_round_trip_preserves_user_content() { + let dir = repo(); + std::fs::write(dir.path().join("AGENTS.md"), "# Repo\n\nUser rule.\n").unwrap(); + std::fs::create_dir_all(dir.path().join("quest/m0")).unwrap(); + std::fs::write(dir.path().join("quest/m0/plan.md"), "# [S] Keep\n\n## Goal\n\nStay.\n").unwrap(); + init(dir.path()); + uninstall(dir.path()); + + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert_eq!(agents, "# Repo\n\nUser rule.\n"); + assert!(dir.path().join("quest/m0/plan.md").is_file()); + assert!(!dir.path().join(".gitignore").exists()); + assert!(!dir.path().join(".claude").exists()); + assert!(!dir.path().join(".agents").exists()); +} + +#[test] +fn round_trip_with_codex_skills_directory() { + let dir = repo(); + std::fs::create_dir_all(dir.path().join(".agents/skills")).unwrap(); + init(dir.path()); + assert!(dir.path().join(".agents/skills/quest-start/SKILL.md").is_file()); + assert!(dir.path().join(".claude/skills/quest-start/SKILL.md").is_file()); + assert!(dir.path().join(".claude/skills").is_symlink()); + + uninstall(dir.path()); + assert!(!dir.path().join(".agents/skills").exists()); + assert!(!dir.path().join(".claude/skills").is_symlink()); +} + +#[test] +fn init_refuses_two_skill_directories() { + let dir = repo(); + std::fs::create_dir_all(dir.path().join(".claude/skills")).unwrap(); + std::fs::create_dir_all(dir.path().join(".agents/skills")).unwrap(); + let err = quest::setup::init(dir.path()).unwrap_err(); + assert!(err.to_string().contains("both")); +} + +#[test] +fn uninstall_is_idempotent() { + let dir = repo(); + init(dir.path()); + uninstall(dir.path()); + assert!(uninstall(dir.path()).is_empty()); +}