From bdc1396c825243e46e2391638a130feb20d457fa Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Tue, 29 Sep 2026 13:49:57 +0100 Subject: [PATCH 1/2] feat: add quest init and uninstall for repository setup Automate skill stub installation, quest root seeding, scratch gitignore, and agent reference lines with idempotent init/uninstall commands. Co-authored-by: Cursor --- README.md | 6 +- docs/getting-started.md | 47 +++--- quest/m0/README.md | 1 - quest/m0/init.md | 24 --- quest/m0/release-proof.md | 1 - quest/m0/setup.md | 1 - src/lib.rs | 1 + src/main.rs | 18 +++ src/setup.rs | 319 ++++++++++++++++++++++++++++++++++++++ tests/init.rs | 93 +++++++++++ 10 files changed, 459 insertions(+), 52 deletions(-) delete mode 100644 quest/m0/init.md create mode 100644 src/setup.rs create mode 100644 tests/init.rs diff --git a/README.md b/README.md index 19964c8..49adb2b 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, and merge -skills are here. Automatic setup, issue export, and release binaries are still +**Early days:** validation, readiness, init/uninstall, and the planning, +execution, and merge skills are here. Issue export and release binaries are still being built. ## A quest is just a file @@ -113,7 +113,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 48724f0..f6795f8 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: @@ -78,7 +75,13 @@ still need checking; `quest ready` does not look for them. ## Update or remove it -Upgrade by changing the pin; the stubs stay as they are. Issue export and -`quest uninstall` are not implemented yet. If you stop using Quest, preserve any -unfinished plans before removing the stubs, the pin, and the reference line. -Completed plans remain in Git history. +Upgrade by changing the pin; the stubs stay as they are. Before leaving Quest, +export unfinished plans with the export skill, 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; completed and +unfinished plans remain in Git history. diff --git a/quest/m0/README.md b/quest/m0/README.md index 22a6296..6d4003f 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -25,7 +25,6 @@ ownership-manifest design: ## Required - [Release binaries](/quest/m0/releases.md) - tagged releases publish macOS and Linux binaries that mise and a shell installer can fetch -- [Init and uninstall](/quest/m0/init.md) - `quest init` sets a repository up with stubs and a root reference; `quest uninstall` reverses it - [Export skill](/quest/m0/export.md) - turn active quests back into GitHub issues before leaving Quest - [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 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 021402c..b4160a8 100644 --- a/quest/m0/release-proof.md +++ b/quest/m0/release-proof.md @@ -17,6 +17,5 @@ user owned was lost. Then v0.1.0 is tagged and published. ## Required - [Release binaries](/quest/m0/releases.md) - installs a real release -- [Init and uninstall](/quest/m0/init.md) - the lifecycle under test - [Export skill](/quest/m0/export.md) - removal exports first - [Setup guide](/quest/m0/setup.md) - the documented entry point diff --git a/quest/m0/setup.md b/quest/m0/setup.md index abee9ae..86099dc 100644 --- a/quest/m0/setup.md +++ b/quest/m0/setup.md @@ -21,4 +21,3 @@ root instructions. The same file covers removal. ## Required - [Release binaries](/quest/m0/releases.md) - the guide installs a release -- [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..e4ddfa5 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,319 @@ +//! `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::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/"; + +const QUEST_ROOT_README: &str = "\ +# Quests + +## Goal + +What this project is working toward. +"; + +/// Install Quest stubs and markers under `root`. Returns paths changed. +pub fn init(root: &Path) -> Result> { + let mut changes = Vec::new(); + let skills_dir = ensure_skills_layout(root, &mut changes)?; + install_stubs(&skills_dir, &mut changes)?; + if write_quest_readme(root)? { + changes.push(root.join("quest/README.md")); + } + if append_gitignore_scratch(root)? { + changes.push(root.join(".gitignore")); + } + if append_reference_line(root)? { + changes.push(instructions_path(root)); + } + Ok(changes) +} + +/// Remove Quest-owned files under `root`. Returns paths changed. +pub fn uninstall(root: &Path) -> Result> { + let mut changes = Vec::new(); + if let Some(skills_dir) = skills_root(root)? { + remove_stubs(&skills_dir, &mut changes)?; + clean_skills_layout(root, &mut changes)?; + } + if remove_gitignore_scratch(root)? { + changes.push(root.join(".gitignore")); + } + changes.extend(remove_reference_lines(root)?); + Ok(changes) +} + +fn skills_root(root: &Path) -> Result> { + let claude = root.join(".claude/skills"); + let agents = root.join(".agents/skills"); + let claude_meta = meta(&claude); + let agents_meta = meta(&agents); + match (claude_meta, agents_meta) { + (None, None) => Ok(None), + (Some(kind), None) if kind.is_dir() => Ok(Some(claude)), + (None, Some(kind)) if kind.is_dir() => Ok(Some(agents)), + (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_symlink() => Ok(Some(claude)), + (Some(claude_kind), Some(agents_kind)) if agents_kind.is_dir() && claude_kind.is_symlink() => Ok(Some(agents)), + (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_dir() => { + bail!( + "both .claude/skills and .agents/skills exist as directories; \ + remove one or symlink them before running `quest init`" + ); + } + _ => Ok(None), + } +} + +fn meta(path: &Path) -> Option { + std::fs::symlink_metadata(path).ok().map(|m| m.file_type()) +} + +fn ensure_skills_layout(root: &Path, changes: &mut Vec) -> Result { + let claude = root.join(".claude/skills"); + let agents = root.join(".agents/skills"); + let claude_meta = meta(&claude); + let agents_meta = meta(&agents); + + match (claude_meta, agents_meta) { + (None, None) => { + std::fs::create_dir_all(&claude).context("create .claude/skills")?; + changes.push(claude.clone()); + link_dir(&agents, &claude, changes)?; + Ok(claude) + } + (Some(kind), None) if kind.is_dir() => { + link_dir(&agents, &claude, changes)?; + Ok(claude) + } + (None, Some(kind)) if kind.is_dir() => { + link_dir(&claude, &agents, changes)?; + Ok(agents) + } + (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_symlink() => Ok(claude), + (Some(claude_kind), Some(agents_kind)) if agents_kind.is_dir() && claude_kind.is_symlink() => Ok(agents), + (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_dir() => { + bail!( + "both .claude/skills and .agents/skills exist as directories; \ + remove one or symlink them before running `quest init`" + ); + } + _ => bail!("unexpected .claude/skills or .agents/skills layout"), + } +} + +fn link_dir(link: &Path, target: &Path, changes: &mut Vec) -> Result<()> { + if link.exists() { + return Ok(()); + } + let parent = link.parent().context("skills parent")?; + std::fs::create_dir_all(parent).context("create agent config dir")?; + let rel = relative_skills_link(parent, target); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&rel, link).with_context(|| format!("symlink {}", link.display()))?; + } + #[cfg(not(unix))] + { + bail!("directory symlinks for skills require a Unix platform"); + } + changes.push(link.to_path_buf()); + Ok(()) +} + +/// Relative path from `link_parent` to `skills_dir`, for the two layouts init creates. +fn relative_skills_link(link_parent: &Path, skills_dir: &Path) -> PathBuf { + if link_parent.ends_with(".agents") && skills_dir.ends_with(".claude/skills") { + PathBuf::from("../.claude/skills") + } else if link_parent.ends_with(".claude") && skills_dir.ends_with(".agents/skills") { + PathBuf::from("../.agents/skills") + } else { + PathBuf::from(skills_dir) + } +} + +fn install_stubs(skills_dir: &Path, changes: &mut Vec) -> Result<()> { + for skill in skills::all() { + let dir = skills_dir.join(skill.installed()); + let path = dir.join("SKILL.md"); + if path.is_file() { + let existing = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?; + if existing == skill.stub() { + continue; + } + bail!( + "{} is not a Quest stub; remove or rename it before running `quest init`", + path.display() + ); + } else if dir.exists() { + bail!( + "{} exists without SKILL.md; remove or rename it before running `quest init`", + dir.display() + ); + } + std::fs::create_dir_all(&dir).context("create skill dir")?; + std::fs::write(&path, skill.stub()).with_context(|| path.display().to_string())?; + changes.push(path); + } + Ok(()) +} + +fn write_quest_readme(root: &Path) -> Result { + let path = root.join("quest/README.md"); + if path.exists() { + return Ok(false); + } + std::fs::create_dir_all(path.parent().unwrap()).context("create quest/")?; + std::fs::write(&path, QUEST_ROOT_README).context("write quest/README.md")?; + Ok(true) +} + +fn append_gitignore_scratch(root: &Path) -> Result { + let path = root.join(".gitignore"); + if path.is_file() { + let content = std::fs::read_to_string(&path).context("read .gitignore")?; + if content.lines().any(|line| line == SCRATCH_IGNORE) { + return Ok(false); + } + let mut out = content; + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(SCRATCH_IGNORE); + out.push('\n'); + std::fs::write(&path, out).context("update .gitignore")?; + } else { + std::fs::write(&path, format!("{SCRATCH_IGNORE}\n")).context("write .gitignore")?; + } + Ok(true) +} + +fn instructions_path(root: &Path) -> PathBuf { + if root.join("AGENTS.md").is_file() { + root.join("AGENTS.md") + } else if root.join("CLAUDE.md").is_file() { + root.join("CLAUDE.md") + } else { + root.join("AGENTS.md") + } +} + +fn append_reference_line(root: &Path) -> Result { + let path = instructions_path(root); + if path.is_file() { + let content = std::fs::read_to_string(&path).context("read agent instructions")?; + if content.lines().any(|line| line == REFERENCE_LINE) { + return Ok(false); + } + let mut out = content; + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(REFERENCE_LINE); + out.push('\n'); + std::fs::write(&path, out).context("update agent instructions")?; + } else { + std::fs::write(&path, format!("{REFERENCE_LINE}\n")).context("write agent instructions")?; + } + Ok(true) +} + +fn remove_stubs(skills_dir: &Path, changes: &mut Vec) -> Result<()> { + for skill in skills::all() { + let path = skills_dir.join(skill.installed()).join("SKILL.md"); + if !path.is_file() { + continue; + } + let existing = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?; + if existing != skill.stub() { + continue; + } + std::fs::remove_file(&path).with_context(|| path.display().to_string())?; + changes.push(path); + let dir = skills_dir.join(skill.installed()); + if dir.read_dir()?.next().is_none() { + std::fs::remove_dir(&dir).with_context(|| dir.display().to_string())?; + changes.push(dir); + } + } + Ok(()) +} + +fn clean_skills_layout(root: &Path, changes: &mut Vec) -> Result<()> { + let claude = root.join(".claude/skills"); + let agents = root.join(".agents/skills"); + for dir in [&claude, &agents] { + if dir.is_dir() && dir.read_dir()?.next().is_none() { + std::fs::remove_dir(dir).with_context(|| dir.display().to_string())?; + changes.push(dir.clone()); + } + } + for link in [&agents, &claude] { + if link.is_symlink() { + let target = link.parent().unwrap().join(std::fs::read_link(link)?); + if !target.exists() { + std::fs::remove_file(link).with_context(|| link.display().to_string())?; + changes.push(link.clone()); + } + } + } + Ok(()) +} + +fn remove_gitignore_scratch(root: &Path) -> Result { + let path = root.join(".gitignore"); + if !path.is_file() { + return Ok(false); + } + let content = std::fs::read_to_string(&path).context("read .gitignore")?; + let filtered: Vec<_> = content.lines().filter(|line| *line != SCRATCH_IGNORE).collect(); + if filtered.len() == content.lines().count() { + return Ok(false); + } + if filtered.is_empty() { + std::fs::remove_file(&path).context("remove .gitignore")?; + } else { + let mut out = filtered.join("\n"); + out.push('\n'); + std::fs::write(&path, out).context("update .gitignore")?; + } + Ok(true) +} + +fn remove_reference_lines(root: &Path) -> Result> { + let mut changes = Vec::new(); + for name in ["AGENTS.md", "CLAUDE.md"] { + let path = root.join(name); + if !path.is_file() { + continue; + } + let content = std::fs::read_to_string(&path).context("read agent instructions")?; + let filtered: Vec<_> = content.lines().filter(|line| *line != REFERENCE_LINE).collect(); + if filtered.len() == content.lines().count() { + continue; + } + let mut out = filtered.join("\n"); + if content.ends_with('\n') { + out.push('\n'); + } + if out.trim().is_empty() { + std::fs::remove_file(&path).context("remove agent instructions")?; + } else { + std::fs::write(&path, out).context("update agent instructions")?; + } + changes.push(path); + } + Ok(changes) +} diff --git a/tests/init.rs b/tests/init.rs new file mode 100644 index 0000000..b4a3794 --- /dev/null +++ b/tests/init.rs @@ -0,0 +1,93 @@ +//! 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!(agents.contains("User rule.")); + assert!(!agents.contains(quest::setup::REFERENCE_LINE)); + assert!(dir.path().join("quest/m0/plan.md").is_file()); + assert!(!dir.path().join(".claude/skills/quest-start/SKILL.md").exists()); +} + +#[test] +fn uninstall_is_idempotent() { + let dir = repo(); + init(dir.path()); + uninstall(dir.path()); + assert!(uninstall(dir.path()).is_empty()); +} From f5b3c76799cefc56349154b7a8bd9d82c283cd91 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:28:17 -0700 Subject: [PATCH 2/2] refactor(setup): simplify init/uninstall and fix the .agents layout Share one layout decision and line helpers between init and uninstall, report paths relative to the root, and remove empty skill directories. Uninstall no longer fails when .claude/skills links to a real .agents/skills, and a round trip restores user files exactly. Docs no longer point at the unimplemented export skill. Co-Authored-By: Claude Opus 5.5 --- docs/getting-started.md | 8 +- src/setup.rs | 399 ++++++++++++++++------------------------ tests/init.rs | 30 ++- 3 files changed, 186 insertions(+), 251 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index f6795f8..5b65394 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -75,13 +75,13 @@ still need checking; `quest ready` does not look for them. ## Update or remove it -Upgrade by changing the pin; the stubs stay as they are. Before leaving Quest, -export unfinished plans with the export skill, then run: +Upgrade by changing the pin; the stubs stay as they are. Issue export is not +implemented yet, so preserve any unfinished plans before leaving Quest, 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; completed and -unfinished plans remain in Git history. +`/.scratch/` ignore entry. It never deletes your quest tree, and completed plans +remain in Git history. diff --git a/src/setup.rs b/src/setup.rs index e4ddfa5..6d7a5a1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -3,6 +3,7 @@ //! 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}; @@ -14,6 +15,10 @@ pub const REFERENCE_LINE: &str = "When work mentions a quest, run `quest guide` 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 @@ -22,298 +27,204 @@ const QUEST_ROOT_README: &str = "\ What this project is working toward. "; -/// Install Quest stubs and markers under `root`. Returns paths changed. +/// 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_dir = ensure_skills_layout(root, &mut changes)?; - install_stubs(&skills_dir, &mut changes)?; - if write_quest_readme(root)? { - changes.push(root.join("quest/README.md")); - } - if append_gitignore_scratch(root)? { - changes.push(root.join(".gitignore")); - } - if append_reference_line(root)? { - changes.push(instructions_path(root)); - } - Ok(changes) -} - -/// Remove Quest-owned files under `root`. Returns paths changed. -pub fn uninstall(root: &Path) -> Result> { - let mut changes = Vec::new(); - if let Some(skills_dir) = skills_root(root)? { - remove_stubs(&skills_dir, &mut changes)?; - clean_skills_layout(root, &mut changes)?; - } - if remove_gitignore_scratch(root)? { - changes.push(root.join(".gitignore")); - } - changes.extend(remove_reference_lines(root)?); - Ok(changes) -} - -fn skills_root(root: &Path) -> Result> { - let claude = root.join(".claude/skills"); - let agents = root.join(".agents/skills"); - let claude_meta = meta(&claude); - let agents_meta = meta(&agents); - match (claude_meta, agents_meta) { - (None, None) => Ok(None), - (Some(kind), None) if kind.is_dir() => Ok(Some(claude)), - (None, Some(kind)) if kind.is_dir() => Ok(Some(agents)), - (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_symlink() => Ok(Some(claude)), - (Some(claude_kind), Some(agents_kind)) if agents_kind.is_dir() && claude_kind.is_symlink() => Ok(Some(agents)), - (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_dir() => { - bail!( - "both .claude/skills and .agents/skills exist as directories; \ - remove one or symlink them before running `quest init`" - ); - } - _ => Ok(None), - } -} - -fn meta(path: &Path) -> Option { - std::fs::symlink_metadata(path).ok().map(|m| m.file_type()) -} - -fn ensure_skills_layout(root: &Path, changes: &mut Vec) -> Result { - let claude = root.join(".claude/skills"); - let agents = root.join(".agents/skills"); - let claude_meta = meta(&claude); - let agents_meta = meta(&agents); - - match (claude_meta, agents_meta) { - (None, None) => { - std::fs::create_dir_all(&claude).context("create .claude/skills")?; - changes.push(claude.clone()); - link_dir(&agents, &claude, changes)?; - Ok(claude) - } - (Some(kind), None) if kind.is_dir() => { - link_dir(&agents, &claude, changes)?; - Ok(claude) - } - (None, Some(kind)) if kind.is_dir() => { - link_dir(&claude, &agents, changes)?; - Ok(agents) - } - (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_symlink() => Ok(claude), - (Some(claude_kind), Some(agents_kind)) if agents_kind.is_dir() && claude_kind.is_symlink() => Ok(agents), - (Some(claude_kind), Some(agents_kind)) if claude_kind.is_dir() && agents_kind.is_dir() => { - bail!( - "both .claude/skills and .agents/skills exist as directories; \ - remove one or symlink them before running `quest init`" - ); - } - _ => bail!("unexpected .claude/skills or .agents/skills layout"), - } -} - -fn link_dir(link: &Path, target: &Path, changes: &mut Vec) -> Result<()> { - if link.exists() { - return Ok(()); - } - let parent = link.parent().context("skills parent")?; - std::fs::create_dir_all(parent).context("create agent config dir")?; - let rel = relative_skills_link(parent, target); - #[cfg(unix)] - { - std::os::unix::fs::symlink(&rel, link).with_context(|| format!("symlink {}", link.display()))?; - } - #[cfg(not(unix))] - { - bail!("directory symlinks for skills require a Unix platform"); - } - changes.push(link.to_path_buf()); - Ok(()) -} - -/// Relative path from `link_parent` to `skills_dir`, for the two layouts init creates. -fn relative_skills_link(link_parent: &Path, skills_dir: &Path) -> PathBuf { - if link_parent.ends_with(".agents") && skills_dir.ends_with(".claude/skills") { - PathBuf::from("../.claude/skills") - } else if link_parent.ends_with(".claude") && skills_dir.ends_with(".agents/skills") { - PathBuf::from("../.agents/skills") - } else { - PathBuf::from(skills_dir) - } -} - -fn install_stubs(skills_dir: &Path, changes: &mut Vec) -> Result<()> { + let skills = link_skill_dirs(root, &mut changes)?; for skill in skills::all() { - let dir = skills_dir.join(skill.installed()); + let dir = Path::new(skills).join(skill.installed()); let path = dir.join("SKILL.md"); - if path.is_file() { - let existing = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?; - if existing == skill.stub() { + 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 dir.exists() { + } else if root.join(&dir).exists() { bail!( "{} exists without SKILL.md; remove or rename it before running `quest init`", dir.display() ); } - std::fs::create_dir_all(&dir).context("create skill dir")?; - std::fs::write(&path, skill.stub()).with_context(|| path.display().to_string())?; + fs::create_dir_all(root.join(&dir)).with_context(|| dir.display().to_string())?; + write(root, &path, &skill.stub())?; changes.push(path); } - Ok(()) -} -fn write_quest_readme(root: &Path) -> Result { - let path = root.join("quest/README.md"); - if path.exists() { - return Ok(false); + 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()); } - std::fs::create_dir_all(path.parent().unwrap()).context("create quest/")?; - std::fs::write(&path, QUEST_ROOT_README).context("write quest/README.md")?; - Ok(true) -} -fn append_gitignore_scratch(root: &Path) -> Result { - let path = root.join(".gitignore"); - if path.is_file() { - let content = std::fs::read_to_string(&path).context("read .gitignore")?; - if content.lines().any(|line| line == SCRATCH_IGNORE) { - return Ok(false); - } - let mut out = content; - if !out.ends_with('\n') { - out.push('\n'); - } - out.push_str(SCRATCH_IGNORE); - out.push('\n'); - std::fs::write(&path, out).context("update .gitignore")?; - } else { - std::fs::write(&path, format!("{SCRATCH_IGNORE}\n")).context("write .gitignore")?; + let ignore = Path::new(".gitignore"); + if append_line(root, ignore, SCRATCH_IGNORE, false)? { + changes.push(ignore.into()); } - Ok(true) -} -fn instructions_path(root: &Path) -> PathBuf { - if root.join("AGENTS.md").is_file() { - root.join("AGENTS.md") - } else if root.join("CLAUDE.md").is_file() { - root.join("CLAUDE.md") - } else { - root.join("AGENTS.md") + 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) } -fn append_reference_line(root: &Path) -> Result { - let path = instructions_path(root); - if path.is_file() { - let content = std::fs::read_to_string(&path).context("read agent instructions")?; - if content.lines().any(|line| line == REFERENCE_LINE) { - return Ok(false); +/// 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))?; } - let mut out = content; - if !out.is_empty() && !out.ends_with('\n') { - out.push('\n'); + 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()); } - if !out.is_empty() { - out.push('\n'); + let parent = link.parent().context(skills)?; + if is_real_dir(parent) { + remove_if_empty(parent)?; } - out.push_str(REFERENCE_LINE); - out.push('\n'); - std::fs::write(&path, out).context("update agent instructions")?; - } else { - std::fs::write(&path, format!("{REFERENCE_LINE}\n")).context("write agent instructions")?; } - Ok(true) + + 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) } -fn remove_stubs(skills_dir: &Path, changes: &mut Vec) -> Result<()> { - for skill in skills::all() { - let path = skills_dir.join(skill.installed()).join("SKILL.md"); - if !path.is_file() { - continue; +/// 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]) } - let existing = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?; - if existing != skill.stub() { - continue; + (claude, Some(agents)) if agents.is_dir() && claude.is_none_or(|kind| kind.is_symlink()) => { + (SKILL_DIRS[1], SKILL_DIRS[0]) } - std::fs::remove_file(&path).with_context(|| path.display().to_string())?; - changes.push(path); - let dir = skills_dir.join(skill.installed()); - if dir.read_dir()?.next().is_none() { - std::fs::remove_dir(&dir).with_context(|| dir.display().to_string())?; - changes.push(dir); + (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 clean_skills_layout(root: &Path, changes: &mut Vec) -> Result<()> { - let claude = root.join(".claude/skills"); - let agents = root.join(".agents/skills"); - for dir in [&claude, &agents] { - if dir.is_dir() && dir.read_dir()?.next().is_none() { - std::fs::remove_dir(dir).with_context(|| dir.display().to_string())?; - changes.push(dir.clone()); - } +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); } - for link in [&agents, &claude] { - if link.is_symlink() { - let target = link.parent().unwrap().join(std::fs::read_link(link)?); - if !target.exists() { - std::fs::remove_file(link).with_context(|| link.display().to_string())?; - changes.push(link.clone()); - } + if !content.is_empty() { + if !content.ends_with('\n') { + content.push('\n'); + } + if paragraph { + content.push('\n'); } } - Ok(()) + content.push_str(line); + content.push('\n'); + write(root, path, &content)?; + Ok(true) } -fn remove_gitignore_scratch(root: &Path) -> Result { - let path = root.join(".gitignore"); - if !path.is_file() { +/// 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 = std::fs::read_to_string(&path).context("read .gitignore")?; - let filtered: Vec<_> = content.lines().filter(|line| *line != SCRATCH_IGNORE).collect(); - if filtered.len() == content.lines().count() { + let content = read(root, path)?; + if !content.lines().any(|existing| existing == line) { return Ok(false); } - if filtered.is_empty() { - std::fs::remove_file(&path).context("remove .gitignore")?; + 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 { - let mut out = filtered.join("\n"); - out.push('\n'); - std::fs::write(&path, out).context("update .gitignore")?; + write(root, path, &format!("{kept}\n"))?; } Ok(true) } - -fn remove_reference_lines(root: &Path) -> Result> { - let mut changes = Vec::new(); - for name in ["AGENTS.md", "CLAUDE.md"] { - let path = root.join(name); - if !path.is_file() { - continue; - } - let content = std::fs::read_to_string(&path).context("read agent instructions")?; - let filtered: Vec<_> = content.lines().filter(|line| *line != REFERENCE_LINE).collect(); - if filtered.len() == content.lines().count() { - continue; - } - let mut out = filtered.join("\n"); - if content.ends_with('\n') { - out.push('\n'); - } - if out.trim().is_empty() { - std::fs::remove_file(&path).context("remove agent instructions")?; - } else { - std::fs::write(&path, out).context("update agent instructions")?; - } - changes.push(path); - } - Ok(changes) -} diff --git a/tests/init.rs b/tests/init.rs index b4a3794..bd1b7fd 100644 --- a/tests/init.rs +++ b/tests/init.rs @@ -78,10 +78,34 @@ fn uninstall_round_trip_preserves_user_content() { uninstall(dir.path()); let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); - assert!(agents.contains("User rule.")); - assert!(!agents.contains(quest::setup::REFERENCE_LINE)); + assert_eq!(agents, "# Repo\n\nUser rule.\n"); assert!(dir.path().join("quest/m0/plan.md").is_file()); - assert!(!dir.path().join(".claude/skills/quest-start/SKILL.md").exists()); + 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]