diff --git a/docs/changes/setup-ux/tasks.md b/docs/changes/setup-ux/tasks.md index b182805..bf2a1d6 100644 --- a/docs/changes/setup-ux/tasks.md +++ b/docs/changes/setup-ux/tasks.md @@ -13,14 +13,14 @@ - [x] 2.2 Add `rune provider status` with the six lifecycle states - [x] 2.3 Add `rune provider explain` with evidence, state, and fix command - [x] 2.4 Move setup, context, status, doctor, and drift to the shared registry -- [ ] 2.5 Add the config-reference drift check to CI +- [x] 2.5 Add the config-reference drift check to CI ## 3. Safe mutation and onboarding - [ ] 3.1 Add the syntax-preserving config editor with the managed override fallback - [x] 3.2 Add scoped `rune config reset` with backup, verification, and atomic write - [ ] 3.3 Protect modified installed skills from silent replacement -- [ ] 3.4 Extend `rune setup` with plan, approval, apply, verification, and the versioned record +- [x] 3.4 Extend `rune setup` with plan, approval, apply, verification, and the versioned record - [x] 3.5 Add the first-run nudge in the dispatch path, independent of the `tui` feature ## 4. Verification diff --git a/docs/config-reference.json b/docs/config-reference.json new file mode 100644 index 0000000..c68024c --- /dev/null +++ b/docs/config-reference.json @@ -0,0 +1,914 @@ +{ + "source": [ + { + "default": { + "prefixes": null + }, + "key": "adr", + "type": "object" + }, + { + "default": null, + "key": "adr.prefixes", + "type": "string | array | null" + }, + { + "default": { + "settings_files": [ + "settings.json", + "config.json", + "config.toml", + "config.yaml", + "config.yml", + "opencode.json" + ] + }, + "key": "dashboard", + "type": "object" + }, + { + "default": [ + "settings.json", + "config.json", + "config.toml", + "config.yaml", + "config.yml", + "opencode.json" + ], + "key": "dashboard.settings_files", + "type": "array" + }, + { + "default": { + "agentskills": { + "aliases": [ + "agents" + ], + "assembly": [ + "kebab-case-skills", + "strip-links" + ], + "deploy": null, + "effort": null, + "enabled": false, + "keep_fields": { + "agents": [ + "name", + "description" + ], + "rules": [], + "skills": [ + "name", + "description", + "license", + "compatibility", + "metadata", + "allowed-tools" + ] + }, + "model": null, + "models": null, + "plugin": null, + "target": ".agents" + }, + "claude": { + "aliases": [ + "claudecode" + ], + "assembly": null, + "deploy": null, + "effort": null, + "enabled": true, + "keep_fields": { + "agents": [ + "name", + "description", + "model", + "allowedTools" + ], + "rules": [], + "skills": [ + "name", + "description", + "version", + "when_to_use", + "argument-hint", + "arguments", + "allowed-tools", + "disallowed-tools", + "disable-model-invocation", + "user-invocable", + "model", + "effort", + "context", + "agent", + "paths", + "shell" + ] + }, + "model": "claude-opus-4-6", + "models": { + "fast": [ + "sonnet" + ], + "light": [ + "haiku" + ], + "strong": [ + "opus" + ] + }, + "plugin": null, + "target": ".claude" + }, + "codex": { + "aliases": [ + "codexcli" + ], + "assembly": [ + "agents-to-toml", + "strip-links" + ], + "deploy": [ + "rulesync" + ], + "effort": { + "fast": "low", + "light": "low", + "strong": "medium" + }, + "enabled": true, + "keep_fields": { + "agents": [ + "name", + "description", + "model", + "effort" + ], + "rules": [], + "skills": [ + "name", + "description", + "version" + ] + }, + "model": "gpt-5.5", + "models": { + "fast": [ + "gpt-5.4" + ], + "light": [ + "gpt-5.3-codex" + ], + "strong": [ + "gpt-5.5" + ] + }, + "plugin": null, + "target": ".codex" + }, + "gemini": { + "aliases": [ + "geminicli" + ], + "assembly": [ + "kebab-case-agents", + "remap-tools", + "strip-links" + ], + "deploy": [ + "rulesync" + ], + "effort": null, + "enabled": true, + "keep_fields": { + "agents": [ + "name", + "description" + ], + "rules": [], + "skills": [ + "name", + "description", + "version" + ] + }, + "model": "gemini-2.5-pro", + "models": { + "fast": [ + "gemini-2.5-flash" + ], + "light": [ + "gemini-2.0-flash" + ], + "strong": [ + "gemini-2.5-pro" + ] + }, + "plugin": null, + "target": ".gemini" + }, + "opencode": { + "aliases": null, + "assembly": [ + "kebab-case-agents", + "strip-links" + ], + "deploy": [ + "rulesync" + ], + "effort": null, + "enabled": true, + "keep_fields": { + "agents": [ + "name", + "description" + ], + "rules": [], + "skills": [ + "name", + "description", + "version" + ] + }, + "model": "claude-opus-4-6", + "models": { + "fast": [ + "claude-sonnet-4-6" + ], + "light": [ + "claude-sonnet-4-6" + ], + "strong": [ + "claude-opus-4-6" + ] + }, + "plugin": null, + "target": ".opencode" + } + }, + "key": "providers", + "type": "object" + }, + { + "default": null, + "key": "providers.*", + "type": "object" + }, + { + "default": null, + "key": "providers.*.aliases", + "type": "array | null" + }, + { + "default": null, + "key": "providers.*.assembly", + "type": "array | null" + }, + { + "default": null, + "key": "providers.*.deploy", + "type": "array | null" + }, + { + "default": null, + "key": "providers.*.effort", + "type": "object | null" + }, + { + "default": null, + "key": "providers.*.effort.*", + "type": "string" + }, + { + "default": true, + "key": "providers.*.enabled", + "type": "boolean" + }, + { + "default": null, + "key": "providers.*.keep_fields", + "type": "object | null" + }, + { + "default": null, + "key": "providers.*.keep_fields.*", + "type": "array" + }, + { + "default": null, + "key": "providers.*.model", + "type": "string | null" + }, + { + "default": null, + "key": "providers.*.models", + "type": "object | null" + }, + { + "default": null, + "key": "providers.*.models.*", + "type": "array" + }, + { + "default": null, + "key": "providers.*.plugin", + "type": "string | null" + }, + { + "default": null, + "key": "providers.*.target", + "type": "string | object" + }, + { + "default": null, + "key": "providers.*.target.agents", + "type": "string | null" + }, + { + "default": null, + "key": "providers.*.target.default", + "type": "string" + }, + { + "default": null, + "key": "providers.*.target.rules", + "type": "string | null" + }, + { + "default": null, + "key": "providers.*.target.skills", + "type": "string | null" + }, + { + "default": { + "root": null + }, + "key": "spec", + "type": "object" + }, + { + "default": null, + "key": "spec.root", + "type": "string | array | null" + }, + { + "default": { + "exclude": [ + "templates/*" + ] + }, + "key": "validate", + "type": "object" + }, + { + "default": [ + "templates/*" + ], + "key": "validate.exclude", + "type": "string | array | null" + } + ], + "user": [ + { + "default": [], + "key": "bench", + "type": "array" + }, + { + "default": null, + "key": "deck", + "type": "string | null" + }, + { + "default": "~/.env", + "key": "env", + "type": "string | null" + }, + { + "default": [], + "key": "extensions", + "type": "array" + }, + { + "default": { + "default_with": [], + "middleware": { + "cliproxy": { + "command": "", + "host": "127.0.0.1", + "port": 8317 + }, + "docker": { + "args": [], + "image": "ghcr.io/runedeck/rune-coding-tool:latest" + }, + "otel": { + "endpoint": "http://127.0.0.1:4318", + "service_name": "rune-launch" + }, + "presidio": { + "base_url": "http://127.0.0.1:47822", + "host": "127.0.0.1", + "port": 47822 + }, + "pxpipe": { + "base_url": "http://127.0.0.1:47821", + "command": "pxpipe", + "host": "127.0.0.1", + "log_path": "~/.pxpipe/proxy.log", + "port": 47821 + }, + "squid": { + "http_proxy": "http://127.0.0.1:3128", + "https_proxy": "http://127.0.0.1:3128" + } + }, + "models": {}, + "profiles": { + "claude": { + "grok": { + "args": [], + "env": { + "ANTHROPIC_AUTH_TOKEN": { + "from_env": "CLIPROXY_API_KEY" + }, + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_SMALL_FAST_MODEL": "grok-composer-2.5-fast" + }, + "model": "grok", + "with": [ + "cliproxy" + ] + }, + "sol": { + "args": [], + "env": { + "ANTHROPIC_AUTH_TOKEN": { + "from_env": "CLIPROXY_API_KEY" + }, + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.6-luna" + }, + "model": "sol", + "with": [ + "cliproxy" + ] + } + } + }, + "tools": {} + }, + "key": "launch", + "type": "object" + }, + { + "default": [], + "key": "launch.default-with", + "type": "array" + }, + { + "default": [], + "key": "launch.default_with", + "type": "array" + }, + { + "default": { + "cliproxy": { + "command": "", + "host": "127.0.0.1", + "port": 8317 + }, + "docker": { + "args": [], + "image": "ghcr.io/runedeck/rune-coding-tool:latest" + }, + "otel": { + "endpoint": "http://127.0.0.1:4318", + "service_name": "rune-launch" + }, + "presidio": { + "base_url": "http://127.0.0.1:47822", + "host": "127.0.0.1", + "port": 47822 + }, + "pxpipe": { + "base_url": "http://127.0.0.1:47821", + "command": "pxpipe", + "host": "127.0.0.1", + "log_path": "~/.pxpipe/proxy.log", + "port": 47821 + }, + "squid": { + "http_proxy": "http://127.0.0.1:3128", + "https_proxy": "http://127.0.0.1:3128" + } + }, + "key": "launch.middleware", + "type": "object" + }, + { + "default": { + "command": "", + "host": "127.0.0.1", + "port": 8317 + }, + "key": "launch.middleware.cliproxy", + "type": "object" + }, + { + "default": "", + "key": "launch.middleware.cliproxy.command", + "type": "string" + }, + { + "default": "127.0.0.1", + "key": "launch.middleware.cliproxy.host", + "type": "string" + }, + { + "default": 8317, + "key": "launch.middleware.cliproxy.port", + "type": "integer" + }, + { + "default": { + "args": [], + "image": "ghcr.io/runedeck/rune-coding-tool:latest" + }, + "key": "launch.middleware.docker", + "type": "object" + }, + { + "default": [], + "key": "launch.middleware.docker.args", + "type": "array" + }, + { + "default": "ghcr.io/runedeck/rune-coding-tool:latest", + "key": "launch.middleware.docker.image", + "type": "string" + }, + { + "default": { + "endpoint": "http://127.0.0.1:4318", + "service_name": "rune-launch" + }, + "key": "launch.middleware.otel", + "type": "object" + }, + { + "default": "http://127.0.0.1:4318", + "key": "launch.middleware.otel.endpoint", + "type": "string" + }, + { + "default": "rune-launch", + "key": "launch.middleware.otel.service_name", + "type": "string" + }, + { + "default": { + "base_url": "http://127.0.0.1:47822", + "host": "127.0.0.1", + "port": 47822 + }, + "key": "launch.middleware.presidio", + "type": "object" + }, + { + "default": "http://127.0.0.1:47822", + "key": "launch.middleware.presidio.base_url", + "type": "string" + }, + { + "default": "127.0.0.1", + "key": "launch.middleware.presidio.host", + "type": "string" + }, + { + "default": 47822, + "key": "launch.middleware.presidio.port", + "type": "integer" + }, + { + "default": { + "base_url": "http://127.0.0.1:47821", + "command": "pxpipe", + "host": "127.0.0.1", + "log_path": "~/.pxpipe/proxy.log", + "port": 47821 + }, + "key": "launch.middleware.pxpipe", + "type": "object" + }, + { + "default": "http://127.0.0.1:47821", + "key": "launch.middleware.pxpipe.base_url", + "type": "string" + }, + { + "default": "pxpipe", + "key": "launch.middleware.pxpipe.command", + "type": "string" + }, + { + "default": "127.0.0.1", + "key": "launch.middleware.pxpipe.host", + "type": "string" + }, + { + "default": "~/.pxpipe/proxy.log", + "key": "launch.middleware.pxpipe.log_path", + "type": "string" + }, + { + "default": 47821, + "key": "launch.middleware.pxpipe.port", + "type": "integer" + }, + { + "default": { + "http_proxy": "http://127.0.0.1:3128", + "https_proxy": "http://127.0.0.1:3128" + }, + "key": "launch.middleware.squid", + "type": "object" + }, + { + "default": "http://127.0.0.1:3128", + "key": "launch.middleware.squid.http_proxy", + "type": "string" + }, + { + "default": "http://127.0.0.1:3128", + "key": "launch.middleware.squid.https_proxy", + "type": "string" + }, + { + "default": {}, + "key": "launch.models", + "type": "object" + }, + { + "default": null, + "key": "launch.models.*", + "type": "object" + }, + { + "default": null, + "key": "launch.models.*.compact", + "type": "integer | null" + }, + { + "default": null, + "key": "launch.models.*.context", + "type": "integer" + }, + { + "default": null, + "key": "launch.models.*.id", + "type": "string" + }, + { + "default": { + "claude": { + "grok": { + "args": [], + "env": { + "ANTHROPIC_AUTH_TOKEN": { + "from_env": "CLIPROXY_API_KEY" + }, + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_SMALL_FAST_MODEL": "grok-composer-2.5-fast" + }, + "model": "grok", + "with": [ + "cliproxy" + ] + }, + "sol": { + "args": [], + "env": { + "ANTHROPIC_AUTH_TOKEN": { + "from_env": "CLIPROXY_API_KEY" + }, + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.6-luna" + }, + "model": "sol", + "with": [ + "cliproxy" + ] + } + } + }, + "key": "launch.profiles", + "type": "object" + }, + { + "default": null, + "key": "launch.profiles.*", + "type": "object" + }, + { + "default": null, + "key": "launch.profiles.*.*", + "type": "object" + }, + { + "default": [], + "key": "launch.profiles.*.*.args", + "type": "array" + }, + { + "default": {}, + "key": "launch.profiles.*.*.env", + "type": "object" + }, + { + "default": null, + "key": "launch.profiles.*.*.env.*", + "type": "string | object" + }, + { + "default": null, + "key": "launch.profiles.*.*.env.*.from_env", + "type": "string" + }, + { + "default": null, + "key": "launch.profiles.*.*.model", + "type": "string | null" + }, + { + "default": [], + "key": "launch.profiles.*.*.with", + "type": "array" + }, + { + "default": {}, + "key": "launch.tools", + "type": "object" + }, + { + "default": null, + "key": "launch.tools.*", + "type": "object" + }, + { + "default": null, + "key": "launch.tools.*.base-url-env", + "type": "string | null" + }, + { + "default": null, + "key": "launch.tools.*.base_url_env", + "type": "string | null" + }, + { + "default": null, + "key": "launch.tools.*.binary", + "type": "string | null" + }, + { + "default": { + "archive": "~/Agents/archive", + "artifacts": null, + "developer": null, + "domain": null, + "githooks": null, + "lore": null, + "mount": null, + "owner": null, + "quests": null, + "skeleton": null, + "targets": "~/Agents", + "vault": null, + "work": null + }, + "key": "ontology", + "type": "object" + }, + { + "default": "~/Agents/archive", + "key": "ontology.archive", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.artifacts", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.developer", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.domain", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.githooks", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.lore", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.mount", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.owner", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.quests", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.skeleton", + "type": "string | null" + }, + { + "default": "~/Agents", + "key": "ontology.targets", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.vault", + "type": "string | null" + }, + { + "default": null, + "key": "ontology.work", + "type": "string | null" + }, + { + "default": null, + "key": "setup", + "type": "object | null" + }, + { + "default": null, + "key": "setup.completed", + "type": "array" + }, + { + "default": null, + "key": "setup.version", + "type": "integer" + }, + { + "default": null, + "key": "theme", + "type": "object | null" + }, + { + "default": false, + "key": "theme.auto_switch", + "type": "boolean" + }, + { + "default": {}, + "key": "theme.custom", + "type": "object" + }, + { + "default": null, + "key": "theme.custom.*", + "type": "string" + }, + { + "default": null, + "key": "theme.dark_name", + "type": "string | null" + }, + { + "default": null, + "key": "theme.light_name", + "type": "string | null" + }, + { + "default": null, + "key": "theme.name", + "type": "string | null" + }, + { + "default": {}, + "key": "watch", + "type": "object" + } + ] +} diff --git a/src/cli/completion.rs b/src/cli/completion.rs index c29e17a..45eac5a 100644 --- a/src/cli/completion.rs +++ b/src/cli/completion.rs @@ -4,7 +4,7 @@ use clap::CommandFactory as _; use rune::error::{Error, ErrorKind}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum Shell { @@ -17,7 +17,7 @@ pub enum Shell { } impl Shell { - fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { Self::Bash => "bash", Self::Zsh => "zsh", @@ -28,7 +28,7 @@ impl Shell { } } - fn from_environment() -> Option { + pub(crate) fn from_environment() -> Option { let shell_path = std::env::var("SHELL").ok()?; match std::path::Path::new(&shell_path).file_name()?.to_str()? { "bash" => Some(Self::Bash), @@ -95,39 +95,104 @@ pub fn print(shell: Shell) -> i32 { } } -pub fn install(shell: Option, json: bool) -> Result { - let shell = shell.or_else(Shell::from_environment).ok_or_else(|| { - Error::new( - ErrorKind::Config, - "cannot detect the shell from $SHELL; pass one: rune completion install zsh", - ) - })?; - let destination = install_path(shell)?; - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent).map_err(|error| { +pub(crate) struct InstallPlan { + shell: Shell, + destination: PathBuf, + content: String, + cache_removals: Vec, +} + +impl InstallPlan { + pub(crate) fn shell_name(&self) -> &'static str { + self.shell.name() + } + + pub(crate) fn destination(&self) -> &Path { + &self.destination + } + + pub(crate) fn cache_removals(&self) -> &[PathBuf] { + &self.cache_removals + } + + pub(crate) fn apply(&self) -> Result { + if let Some(parent) = self.destination.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot create {}: {error}", parent.display()), + ) + .with_code("completion.directory_create_failed") + .with_fix_command(format!("rune completion install {}", self.shell.name())) + })?; + } + std::fs::write(&self.destination, &self.content).map_err(|error| { Error::new( ErrorKind::Io, - format!("cannot create {}: {error}", parent.display()), + format!("cannot write {}: {error}", self.destination.display()), ) + .with_code("completion.write_failed") + .with_fix_command(format!("rune completion install {}", self.shell.name())) })?; + for path in &self.cache_removals { + std::fs::remove_file(path).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot remove completion cache {}: {error}", path.display()), + ) + .with_code("completion.cache_remove_failed") + .with_fix_command(format!("rune completion install {}", self.shell.name())) + })?; + } + Ok(self.cache_removals.len()) } - std::fs::write(&destination, script(shell)).map_err(|error| { + + pub(crate) fn is_current(&self) -> Result { + match std::fs::read_to_string(&self.destination) { + Ok(content) => Ok(content == self.content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(Error::new( + ErrorKind::Io, + format!("cannot read {}: {error}", self.destination.display()), + ) + .with_code("completion.verify_failed") + .with_fix_command(format!("rune completion install {}", self.shell.name()))), + } + } +} + +pub(crate) fn plan_install(shell: Option) -> Result { + let shell = shell.or_else(Shell::from_environment).ok_or_else(|| { Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", destination.display()), + ErrorKind::Config, + "Rune cannot detect the shell from $SHELL. Use: rune completion install zsh", ) + .with_code("completion.shell_unknown") + .with_fix_command("rune completion install zsh") })?; - let cleared_caches = if shell == Shell::Zsh { - invalidate_zsh_completion_cache(&zsh_dump_directory()?) + let destination = install_path(shell)?; + let cache_removals = if shell == Shell::Zsh { + completion_cache_paths(&zsh_dump_directory()?)? } else { - 0 + Vec::new() }; + Ok(InstallPlan { + shell, + destination, + content: script(shell), + cache_removals, + }) +} + +pub fn install(shell: Option, json: bool) -> Result { + let plan = plan_install(shell)?; + let cleared_caches = plan.apply()?; if json { println!( "{}", serde_json::json!({ - "shell": shell.name(), - "installed": destination, + "shell": plan.shell.name(), + "installed": plan.destination, "cleared_caches": cleared_caches, }) ); @@ -135,17 +200,21 @@ pub fn install(shell: Option, json: bool) -> Result { } println!( "installed {} completions → {}", - shell.name(), - destination.display() + plan.shell.name(), + plan.destination.display() ); - if let Some(followup) = post_install_hint(shell, &destination) { + if let Some(followup) = post_install_hint(plan.shell, &plan.destination) { println!("{followup}"); } Ok(0) } fn home() -> Result { - dirs::home_dir().ok_or_else(|| Error::new(ErrorKind::Config, "cannot resolve home directory")) + dirs::home_dir().ok_or_else(|| { + Error::new(ErrorKind::Config, "cannot resolve home directory") + .with_code("completion.home_unavailable") + .with_fix_command("printenv HOME") + }) } /// Standard per-shell completion locations: zsh prefers Homebrew's @@ -168,17 +237,21 @@ fn install_path(shell: Shell) -> Result { Shell::Nushell => { let data_dir = dirs::data_dir().ok_or_else(|| { Error::new(ErrorKind::Config, "cannot resolve the user data directory") + .with_code("completion.data_directory_unavailable") + .with_fix_command("printenv HOME") })?; Ok(data_dir.join("nushell/vendor/autoload/rune.nu")) } Shell::Powershell | Shell::Elvish => Err(Error::new( ErrorKind::Config, format!( - "no standard install location for {}; use: rune completion print {} > ", + "Rune has no standard install location for {}. Use: rune completion print {} > ", shell.name(), shell.name() ), - )), + ) + .with_code("completion.install_path_unavailable") + .with_fix_command(format!("rune completion print {}", shell.name()))), } } @@ -198,29 +271,61 @@ fn zsh_dump_directory() -> Result { /// itself produces are touched: `.zcompdump`, its compiled `.zwc` twin, and /// the oh-my-zsh `.zcompdump--` variants (which end in a /// digit) — never other dotfiles that merely share the prefix. -fn invalidate_zsh_completion_cache(dump_directory: &std::path::Path) -> usize { +fn completion_cache_paths(dump_directory: &Path) -> Result, Error> { let entries = match std::fs::read_dir(dump_directory) { Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => { - eprintln!( - "warning: cannot scan {} for completion caches: {error}", - dump_directory.display() - ); - return 0; + return Err(Error::new( + ErrorKind::Io, + format!( + "cannot scan {} for completion caches: {error}", + dump_directory.display() + ), + ) + .with_code("completion.cache_scan_failed") + .with_fix_command("rune completion install zsh")); } }; - let mut cleared = 0; - for entry in entries.flatten() { + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + Error::new( + ErrorKind::Io, + format!( + "cannot scan {} for completion caches: {error}", + dump_directory.display() + ), + ) + .with_code("completion.cache_scan_failed") + .with_fix_command("rune completion install zsh") + })?; let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; - if !is_compinit_dump_name(name) { - continue; + if is_compinit_dump_name(name) { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +#[cfg(test)] +fn invalidate_zsh_completion_cache(dump_directory: &Path) -> usize { + let paths = match completion_cache_paths(dump_directory) { + Ok(paths) => paths, + Err(error) => { + eprintln!("warning: {}", error.message()); + return 0; } - match std::fs::remove_file(entry.path()) { + }; + let mut cleared = 0; + for path in paths { + match std::fs::remove_file(&path) { Ok(()) => cleared += 1, Err(error) => eprintln!( "warning: cannot remove completion cache {}: {error}", - entry.path().display() + path.display() ), } } @@ -256,11 +361,9 @@ fn post_install_hint(shell: Shell, destination: &std::path::Path) -> Option Some("completion cache cleared; restart the shell to rebuild it".to_string()), + Shell::Zsh => Some("Completion cache cleared. Restart the shell.".to_string()), Shell::Fish | Shell::Nushell => Some("restart the shell to load".to_string()), - Shell::Bash => { - Some("requires the bash-completion package; restart the shell to load".to_string()) - } + Shell::Bash => Some("Install the bash-completion package. Restart the shell.".to_string()), Shell::Powershell | Shell::Elvish => None, } } diff --git a/src/cli/config/recovery.rs b/src/cli/config/recovery.rs index e8c201f..f101f4c 100644 --- a/src/cli/config/recovery.rs +++ b/src/cli/config/recovery.rs @@ -219,6 +219,14 @@ pub fn defaults(scope: FileScope, json: bool) -> Result { } pub fn reference() -> Result { + println!("{}", reference_document()?); + Ok(0) +} + +/// The complete config reference as one pretty JSON document. The committed +/// copy at `docs/config-reference.json` is compared against this output by +/// a test, so schema drift fails the suite. +pub fn reference_document() -> Result { let user_defaults = ontology::installed_defaults(); let source_defaults = SourceConfig::installed_defaults().map_err(|error| { Error::config(format!("cannot load the source defaults: {error}")) @@ -228,13 +236,11 @@ pub fn reference() -> Result { let user = reference_entries::(&user_defaults)?; let source = reference_entries::(&source_defaults)?; let document = serde_json::json!({ "user": user, "source": source }); - let rendered = serde_json::to_string_pretty(&document).map_err(|error| { + serde_json::to_string_pretty(&document).map_err(|error| { Error::config(format!("cannot serialize the config reference: {error}")) .with_code("config.reference_serialize") .with_fix_command("rune config defaults --scope user") - })?; - println!("{rendered}"); - Ok(0) + }) } /// Remove one dotted key from the scoped config file after writing a diff --git a/src/cli/launch/tests.rs b/src/cli/launch/tests.rs index 9ac0ec1..8916129 100644 --- a/src/cli/launch/tests.rs +++ b/src/cli/launch/tests.rs @@ -12,6 +12,7 @@ fn context(root: &Path, launch: Launch, extensions: Vec) -> LaunchConte env: None, ontology: ontology::ResolvedOntology::default(), extensions, + setup: None, launch, bench: Vec::new(), @@ -250,6 +251,7 @@ fn resolved_launch_preserves_interactive_arguments_and_dry_run() { env: None, ontology: ontology::ResolvedOntology::default(), extensions: Vec::new(), + setup: None, launch: Launch::default(), bench: Vec::new(), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5738902..0156b45 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -53,6 +53,7 @@ pub(crate) mod watchlist; #[cfg(test)] mod tests; pub(crate) mod theme; +mod update_check; use clap::{Parser, Subcommand}; use rune::error::{Error, ErrorKind}; @@ -283,11 +284,25 @@ enum Command { /// Print an agent-ready brief of the resolved working context Context, + /// Check for a newer release (read-only) + Update { + /// Only report; rune never replaces its own binary. + #[arg(long)] + check: bool, + }, /// Guided first-run configuration Setup { - /// Accept all defaults without prompting (for CI and scripting). - #[arg(long)] + /// Accept all defaults and apply the plan without a prompt. + #[arg(long, hide = true, conflicts_with_all = ["plan", "yes"])] defaults: bool, + + /// Print the full plan and make no changes. + #[arg(long, conflicts_with = "yes")] + plan: bool, + + /// Apply detected defaults after Rune prints the plan. + #[arg(long, conflicts_with = "plan")] + yes: bool, }, /// Bind the target (working repo) that rune commands operate on @@ -988,9 +1003,10 @@ enum SkillAction { #[arg(long = "ref", value_name = "SHA")] reference: Option, }, - /// Write the agent skill into a harness skills directory + /// Write the agent skill into every enabled harness Install { - /// Skills directory to install into. Defaults to ~/.claude/skills. + /// Base directory that holds the provider trees. Defaults to the + /// home directory. #[arg(long, value_name = "DIR")] dir: Option, }, @@ -1344,9 +1360,37 @@ pub fn run() -> i32 { Command::Context => { return exit_code(context::execute(args.json, args.no_color), args.json); } - Command::Setup { defaults } => { + Command::Update { check } => { + if !check { + let error = rune::error::Error::new( + rune::error::ErrorKind::Config, + "rune update performs no self-replacement; package managers own updates", + ) + .with_code("update.check_only") + .with_fix_command("rune update --check"); + print_error(&error, args.json); + return 2; + } + return exit_code(update_check::check(args.json), args.json); + } + Command::Setup { + defaults, + plan, + yes, + } => { + let mode = if yes || defaults { + setup::Mode::ApplyDefaults + } else if plan { + setup::Mode::PlanOnly + } else { + setup::Mode::Interactive + }; return exit_code( - setup::execute(defaults, args.json, args.no_color), + setup::execute(setup::Options { + mode, + json: args.json, + no_color: args.no_color, + }), args.json, ); } @@ -1855,7 +1899,7 @@ fn flow_help(help: &mut String) { help_command( help, "setup", - "[--defaults]", + "[--plan | --yes]", "Guided first-run configuration", ); help_command( @@ -2045,6 +2089,12 @@ fn deck_help(help: &mut String) { fn plumbing_help(help: &mut String) { help.push_str("\n Plumbing:\n"); + help_command( + help, + "update", + "--check", + "Check for a newer release (read-only)", + ); help_command( help, "assemble", diff --git a/src/cli/ontology.rs b/src/cli/ontology.rs index 46273de..e35182d 100644 --- a/src/cli/ontology.rs +++ b/src/cli/ontology.rs @@ -52,7 +52,7 @@ const ONTOLOGY_KEYS: [&str; 12] = [ ]; pub fn set(key: &str, value: &str, json: bool) -> Result { - let config_path = persist(key, value)?; + let config_path = persist_structured(key, value)?; if json { println!( "{}", @@ -68,7 +68,7 @@ const SCALAR_KEYS: [&str; 2] = ["deck", "env"]; fn unsupported_key(key: &str) -> String { format!( - "Rune does not recognize config key '{key}'. Use one of these keys: {}, bench, {}", + "Rune does not support config key '{key}'. Use one of these keys: {}, bench, {}", SCALAR_KEYS.join(", "), ONTOLOGY_KEYS.join(", ") ) @@ -94,6 +94,36 @@ fn supported_key(key: &str) -> bool { /// `bench` holds a list of workspace checkouts: set appends (first entry is /// the primary), unset removes the whole list. pub fn persist(key: &str, value: &str) -> Result { + persist_structured(key, value) +} + +pub fn persist_setup(record: &rune::ontology::SetupRecord) -> Result { + let config_path = ontology::config_dir()?.join("config.yaml"); + let mut document = + read_config_document(&config_path).map_err(|error| setup_record_error(&error))?; + let mapping = document.as_mapping_mut().ok_or_else(|| { + invalid_config_error(format!( + "{} must contain a YAML mapping", + config_path.display() + )) + })?; + let value = serde_yaml::to_value(record).map_err(|error| { + Error::config(format!("cannot serialize the setup record: {error}")) + .with_code("setup.record_invalid") + .with_fix_command("rune setup --yes") + })?; + mapping.insert(serde_yaml::Value::from("setup"), value); + write_config_document(&config_path, &document).map_err(|error| setup_record_error(&error))?; + Ok(config_path) +} + +fn setup_record_error(error: &Error) -> Error { + Error::new(error.kind(), error.message().to_string()) + .with_code("setup.record_write_failed") + .with_fix_command("rune setup --yes") +} + +fn persist_structured(key: &str, value: &str) -> Result { let nested = ONTOLOGY_KEYS.contains(&key); if !supported_key(key) { return Err(unknown_key_error(unsupported_key(key))); @@ -112,13 +142,15 @@ pub fn persist(key: &str, value: &str) -> Result { pub fn get(key: &str, json: bool) -> Result { if !supported_key(key) { - return Err(unknown_key_error(unsupported_key(key))); + return Err(unknown_key_error(format!( + "Rune does not recognize config key '{key}'." + ))); } let config = ontology::load()?; let field = ontology::fields(&config) .into_iter() .find(|field| field.key == key) - .ok_or_else(|| unknown_key_error(unsupported_key(key)))?; + .ok_or_else(|| unknown_key_error(format!("Rune does not recognize config key '{key}'.")))?; if json { let output = serde_json::to_string_pretty(&field) .map_err(|error| Error::config(format!("cannot serialize config field: {error}")))?; diff --git a/src/cli/provider_cmd.rs b/src/cli/provider_cmd.rs index 54b0fef..95bfdb4 100644 --- a/src/cli/provider_cmd.rs +++ b/src/cli/provider_cmd.rs @@ -7,7 +7,7 @@ use rune::provider::detection::{ CONFIG_SOURCE, DeploymentState, DetectionEvidence, ProviderDetection, RecommendedAction, }; use serde::Serialize; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, clap::Subcommand)] pub enum ProviderAction { @@ -290,9 +290,57 @@ fn set_enabled(name: &str, enabled: bool, json: bool) -> Result { } fn set_enabled_at(root: &Path, name: &str, enabled: bool, json: bool) -> Result { + let plan = plan_enabled_at(root, &[(name.to_string(), enabled)])?; + plan.apply()?; + + if json { + println!( + "{}", + serde_json::json!({ "provider": name, "enabled": enabled }) + ); + } else { + let sheet = crate::cli::style::Sheet::detect(false); + let state = if enabled { "enabled" } else { "disabled" }; + println!("{}", sheet.ok(&format!("{name} {state} in ./config.yaml"))); + } + Ok(0) +} + +pub(crate) struct EnablementPlan { + path: PathBuf, + rendered: String, + root: PathBuf, +} + +impl EnablementPlan { + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn apply(&self) -> Result<(), Error> { + crate::cli::config::write_atomic(&self.path, &self.rendered).map_err(|error| { + Error::new(error.kind(), error.message()) + .with_code("provider.config_write_failed") + .with_fix_command(format!( + "ls -ld -- {}", + crate::cli::shell_quote( + &crate::cli::resolved_path(&self.root).to_string_lossy() + ) + )) + }) + } +} + +pub(crate) fn plan_enabled_at( + root: &Path, + toggles: &[(String, bool)], +) -> Result { let merged = crate::cli::config::load_merged_config(root)?; let providers = crate::cli::config::load_providers(&merged)?; - if !providers.contains_key(name) { + if let Some((name, _)) = toggles + .iter() + .find(|(name, _)| !providers.contains_key(name)) + { let mut known: Vec<&String> = providers.keys().collect(); known.sort(); return Err(Error::new( @@ -338,21 +386,23 @@ fn set_enabled_at(root: &Path, name: &str, enabled: bool, json: bool) -> Result< .with_code("provider.config_invalid") .with_fix_command(config_check_command(root))); }; - let entry = provider_map - .entry(serde_yaml::Value::from(name)) - .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); - let Some(entry_map) = entry.as_mapping_mut() else { - return Err(Error::new( - ErrorKind::Config, - format!("config.yaml providers.{name}: is not a map"), - ) - .with_code("provider.config_invalid") - .with_fix_command(config_check_command(root))); - }; - entry_map.insert( - serde_yaml::Value::from("enabled"), - serde_yaml::Value::from(enabled), - ); + for (name, enabled) in toggles { + let entry = provider_map + .entry(serde_yaml::Value::from(name.as_str())) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let Some(entry_map) = entry.as_mapping_mut() else { + return Err(Error::new( + ErrorKind::Config, + format!("config.yaml providers.{name}: is not a map"), + ) + .with_code("provider.config_invalid") + .with_fix_command(config_check_command(root))); + }; + entry_map.insert( + serde_yaml::Value::from("enabled"), + serde_yaml::Value::from(*enabled), + ); + } let mut rendered = serde_yaml::to_string(&document).map_err(|error| { Error::new( @@ -365,26 +415,11 @@ fn set_enabled_at(root: &Path, name: &str, enabled: bool, json: bool) -> Result< if !rendered.ends_with('\n') { rendered.push('\n'); } - crate::cli::config::write_atomic(&config_path, &rendered).map_err(|error| { - Error::new(error.kind(), error.message()) - .with_code("provider.config_write_failed") - .with_fix_command(format!( - "ls -ld -- {}", - crate::cli::shell_quote(&crate::cli::resolved_path(root).to_string_lossy()) - )) - })?; - - if json { - println!( - "{}", - serde_json::json!({ "provider": name, "enabled": enabled }) - ); - } else { - let sheet = crate::cli::style::Sheet::detect(false); - let state = if enabled { "enabled" } else { "disabled" }; - println!("{}", sheet.ok(&format!("{name} {state} in ./config.yaml"))); - } - Ok(0) + Ok(EnablementPlan { + path: config_path, + rendered, + root: root.to_path_buf(), + }) } fn config_check_command(root: &Path) -> String { diff --git a/src/cli/setup.rs b/src/cli/setup.rs index ab5749c..c8b1de8 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -1,66 +1,132 @@ -//! Guided first-run configuration: discover a deck, persist it to the user -//! config, and point at the follow-up steps (completions, target binding). +//! Guided first-run configuration with one reviewed write plan. use rune::error::{Error, ErrorKind}; +use serde::Serialize; use std::io::{BufRead as _, Write as _}; use std::path::{Path, PathBuf}; -pub fn execute(defaults: bool, json: bool, no_color: bool) -> Result { - // JSON consumers need a machine-parseable stream, so prompts (which write - // to stdout) are disabled and every choice falls back to its default. - let defaults = defaults || json; - let config = rune::ontology::load()?; - let mut actions: Vec = Vec::new(); +const SETUP_RECORD_VERSION: u32 = 1; - let detected_providers = detect_providers()?; - if detected_providers.is_empty() { - actions.push("no providers detected".to_string()); - } else { - actions.push(format!( - "providers detected: {}", - detected_providers.join(", ") - )); - } +#[derive(Serialize)] +struct PlannedWrite { + step: String, + path: PathBuf, +} - let deck = if let Some(deck) = config.deck { - actions.push(format!("deck already configured: {}", deck.value)); - Some(PathBuf::from(deck.value)) - } else { - configure_deck(defaults, &mut actions)? - }; +#[derive(Serialize)] +struct PlannedRemoval { + step: String, + path: PathBuf, +} - if let Some(quest) = crate::cli::target::bound_target() { - actions.push(format!("target bound: {}", quest.display())); - } else { - actions.push( - "no target bound; bind a working repo with rune target ".to_string(), - ); +#[derive(Serialize)] +struct ProviderToggle { + provider: String, + enabled: bool, +} + +struct SetupPlan { + source_root: PathBuf, + config_path: PathBuf, + deck: Option, + write_deck: bool, + provider_toggles: Vec, + provider_edit: Option, + completion: Option, + skill: Option, + completed: Vec, + writes: Vec, + removals: Vec, + notes: Vec, +} + +#[derive(Serialize)] +struct PlanDocument<'a> { + version: u32, + completed_steps: &'a [String], + writes: &'a [PlannedWrite], + removals: &'a [PlannedRemoval], + provider_toggles: &'a [ProviderToggle], + notes: &'a [String], +} + +#[derive(Serialize)] +struct VerificationRow { + check: String, + passed: bool, + detail: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Mode { + Interactive, + PlanOnly, + ApplyDefaults, +} + +#[derive(Clone, Copy)] +pub(crate) struct Options { + pub(crate) mode: Mode, + pub(crate) json: bool, + pub(crate) no_color: bool, +} + +impl Options { + fn automatic(self) -> bool { + self.mode != Mode::Interactive || self.json } - if json { - println!( - "{}", - serde_json::json!({ "deck": deck, "actions": actions }) - ); + fn reads_as_plan_only(self) -> bool { + self.mode == Mode::PlanOnly || (self.json && self.mode == Mode::Interactive) + } +} + +pub fn execute(options: Options) -> Result { + let Options { + mode, + json, + no_color, + } = options; + let plan = build_plan(options.automatic())?; + print_plan(&plan, json, no_color)?; + flush()?; + + if options.reads_as_plan_only() { return Ok(0); } - let sheet = crate::cli::style::Sheet::detect(no_color); - println!("{}", sheet.heading("Setup")); - for action in &actions { - if action.starts_with("no ") || action.contains("left unconfigured") { - println!("{}", sheet.warn(action)); - } else { - println!("{}", sheet.ok(action)); - } + if mode == Mode::Interactive && !confirm_apply()? { + return Ok(0); + } + + apply_plan(&plan, json, no_color)?; + let verification = verify_plan(&plan); + print_verification(&verification, json, no_color)?; + if verification.iter().any(|row| !row.passed) { + return Err(Error::new( + ErrorKind::Validate, + "Setup verification failed. Rune did not write the setup record.", + ) + .with_code("setup.verification_failed") + .with_fix_command("rune setup --yes")); + } + + let record = rune::ontology::SetupRecord { + version: SETUP_RECORD_VERSION, + completed: plan.completed.clone(), + }; + let record_path = crate::cli::ontology::persist_setup(&record) + .map_err(|error| setup_error(&error, "setup.record_write_failed", "rune setup --yes"))?; + print_applied(json, no_color, "wrote setup record", &record_path); + + if !json { + let sheet = crate::cli::style::Sheet::detect(no_color); + println!("\n{}", sheet.heading("Next")); + println!("{}", sheet.row("stage", "rune add && rune install")); } - println!("\n{}", sheet.heading("Next")); - println!("{}", sheet.row("completions", "rune completion install")); - println!("{}", sheet.row("agent skill", "rune skill install")); - println!("{}", sheet.row("stage", "rune add && rune install")); Ok(0) } -fn detect_providers() -> Result, Error> { +fn build_plan(automatic: bool) -> Result { let source_root = std::env::current_dir().map_err(|error| { Error::new( ErrorKind::Io, @@ -70,61 +136,511 @@ fn detect_providers() -> Result, Error> { .with_fix_command("pwd") })?; let home = dirs::home_dir().ok_or_else(|| { - Error::new( - ErrorKind::Config, - "Rune cannot resolve the home directory.".to_string(), + Error::new(ErrorKind::Config, "Rune cannot resolve the home directory.") + .with_code("setup.home_unavailable") + .with_fix_command("printenv HOME") + })?; + let config = rune::ontology::load().map_err(|error| { + setup_error( + &error, + "setup.config_unavailable", + "rune config check --scope user", ) - .with_code("setup.home_unavailable") - .with_fix_command("printenv HOME") })?; - crate::cli::config::detect_registered_providers(&source_root, &home).map(|providers| { - providers - .into_iter() - .filter(rune::provider::detection::ProviderDetection::is_detected) - .map(|provider| provider.provider) - .collect() - }) + let config_path = rune::ontology::config_dir() + .map_err(|error| setup_error(&error, "setup.config_unavailable", "printenv HOME"))? + .join("config.yaml"); + let mut notes = Vec::new(); + let (deck, write_deck) = select_deck(&config, automatic, &mut notes)?; + + let (provider_toggles, provider_edit) = + select_provider_plan(&source_root, &home, automatic, &mut notes)?; + let completion = select_completion_plan(automatic, &mut notes)?; + let selection = provider_toggles + .iter() + .map(|toggle| (toggle.provider.clone(), toggle.enabled)) + .collect::>(); + let skill = select_skill_plan(automatic, &selection, &mut notes)?; + + if let Some(target) = crate::cli::target::bound_target() { + notes.push(format!("target bound: {}", target.display())); + } else { + notes.push("no target bound. Use rune target after setup.".to_string()); + } + + let mut plan = SetupPlan { + source_root, + config_path, + deck, + write_deck, + provider_toggles, + provider_edit, + completion, + skill, + completed: Vec::new(), + writes: Vec::new(), + removals: Vec::new(), + notes, + }; + plan.add_actions(); + Ok(plan) } -fn configure_deck(defaults: bool, actions: &mut Vec) -> Result, Error> { +fn select_provider_plan( + source_root: &Path, + home: &Path, + automatic: bool, + notes: &mut Vec, +) -> Result< + ( + Vec, + Option, + ), + Error, +> { + let detections = + crate::cli::config::detect_registered_providers(source_root, home).map_err(|error| { + setup_error( + &error, + "setup.provider_detection_failed", + "rune provider status", + ) + })?; + let detected = detections + .iter() + .filter(|detection| detection.is_detected()) + .map(|detection| detection.provider.clone()) + .collect::>(); + if detected.is_empty() { + notes.push("no providers detected".to_string()); + } else { + notes.push(format!("providers detected: {}", detected.join(", "))); + } + let prompt = if detected.is_empty() { + "use the detected provider set with all providers disabled?".to_string() + } else { + format!("use the detected provider set ({})?", detected.join(", ")) + }; + if !automatic && !confirm_selection(&prompt)? { + notes.push("provider selection skipped".to_string()); + return Ok((Vec::new(), None)); + } + + let toggles = detections + .into_iter() + .map(|detection| { + let enabled = detection.is_detected(); + ProviderToggle { + provider: detection.provider, + enabled, + } + }) + .collect::>(); + let values = toggles + .iter() + .map(|toggle| (toggle.provider.clone(), toggle.enabled)) + .collect::>(); + let edit = + crate::cli::provider_cmd::plan_enabled_at(source_root, &values).map_err(|error| { + setup_error( + &error, + "setup.provider_plan_failed", + "rune config check --scope source", + ) + })?; + Ok((toggles, Some(edit))) +} + +fn select_completion_plan( + automatic: bool, + notes: &mut Vec, +) -> Result, Error> { + let Some(shell) = crate::cli::completion::Shell::from_environment() else { + notes.push("supported shell not detected. Rune skipped shell completion.".to_string()); + return Ok(None); + }; + if !automatic && !confirm_selection(&format!("install {} shell completions?", shell.name()))? { + notes.push("shell completion install skipped".to_string()); + return Ok(None); + } + crate::cli::completion::plan_install(Some(shell)) + .map(Some) + .map_err(|error| { + setup_error( + &error, + "setup.completion_plan_failed", + &format!("rune completion install {}", shell.name()), + ) + }) +} + +fn select_skill_plan( + automatic: bool, + selection: &[(String, bool)], + notes: &mut Vec, +) -> Result, Error> { + if !automatic && !confirm_selection("install the Rune agent skill?")? { + notes.push("agent skill install skipped".to_string()); + return Ok(None); + } + crate::cli::skill::plan_install_with(None, selection) + .map(Some) + .map_err(|error| setup_error(&error, "setup.skill_plan_failed", "rune skill install")) +} + +impl SetupPlan { + fn add_actions(&mut self) { + if self.deck.is_some() { + self.completed.push("deck".to_string()); + } + if self.write_deck { + self.writes.push(PlannedWrite { + step: "set deck".to_string(), + path: self.config_path.clone(), + }); + } + if let Some(edit) = &self.provider_edit { + self.completed.push("providers".to_string()); + self.writes.push(PlannedWrite { + step: "set provider selection".to_string(), + path: edit.path().to_path_buf(), + }); + } + if let Some(completion) = &self.completion { + self.completed.push("shell_completion".to_string()); + self.writes.push(PlannedWrite { + step: format!("install {} completion", completion.shell_name()), + path: completion.destination().to_path_buf(), + }); + self.removals.extend( + completion + .cache_removals() + .iter() + .map(|path| PlannedRemoval { + step: "remove stale completion cache".to_string(), + path: path.clone(), + }), + ); + } + if let Some(skill) = &self.skill { + self.completed.push("agent_skill".to_string()); + for path in skill.destinations() { + self.writes.push(PlannedWrite { + step: "install agent skill".to_string(), + path, + }); + } + } + self.writes.push(PlannedWrite { + step: "write verified setup record".to_string(), + path: self.config_path.clone(), + }); + } +} + +fn select_deck( + config: &rune::ontology::ResolvedConfig, + automatic: bool, + notes: &mut Vec, +) -> Result<(Option, bool), Error> { + if let Some(deck) = &config.deck { + notes.push(format!("deck already configured: {}", deck.value)); + return Ok((Some(PathBuf::from(&deck.value)), false)); + } let candidates = discover_decks(); let chosen = match candidates.as_slice() { [] => { - actions.push( - "no deck found under ~/Developer; set one with rune config set deck " + notes.push( + "no deck found under ~/Developer. Use rune config set deck ." .to_string(), ); - return Ok(None); + None + } + [only] if automatic || confirm_selection(&format!("use deck {}?", only.display()))? => { + Some(only.clone()) } - [only] => { - if defaults || confirm(&format!("use deck {}?", only.display()))? { - Some(only.clone()) + [_] => None, + many if automatic => { + notes.push(format!( + "several decks found. Use rune config set deck : {}", + display_list(many) + )); + None + } + many => choose(many)?, + }; + if let Some(deck) = &chosen { + notes.push(format!("deck selected: {}", deck.display())); + } else { + notes.push("deck left unconfigured".to_string()); + } + let write_deck = chosen.is_some(); + Ok((chosen, write_deck)) +} + +fn print_plan(plan: &SetupPlan, json: bool, no_color: bool) -> Result<(), Error> { + if json { + let document = PlanDocument { + version: SETUP_RECORD_VERSION, + completed_steps: &plan.completed, + writes: &plan.writes, + removals: &plan.removals, + provider_toggles: &plan.provider_toggles, + notes: &plan.notes, + }; + let rendered = serde_json::to_string(&document).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("Rune cannot serialize the setup plan: {error}"), + ) + .with_code("setup.plan_output_failed") + .with_fix_command("rune setup --plan") + })?; + println!("{rendered}"); + return Ok(()); + } + + let sheet = crate::cli::style::Sheet::detect(no_color); + println!("{}", sheet.heading("Setup plan")); + for write in &plan.writes { + println!( + "{}", + sheet.row( + "write", + &format!("{} ({})", write.path.display(), write.step) + ) + ); + } + for removal in &plan.removals { + println!( + "{}", + sheet.row( + "remove", + &format!("{} ({})", removal.path.display(), removal.step) + ) + ); + } + println!("\n{}", sheet.heading("Provider selection")); + if plan.provider_toggles.is_empty() { + println!("{}", sheet.none()); + } else { + for toggle in &plan.provider_toggles { + let state = if toggle.enabled { + "enabled" } else { - None + "disabled" + }; + println!("{}", sheet.row(&toggle.provider, state)); + } + } + println!("\n{}", sheet.heading("Notes")); + for note in &plan.notes { + println!("{}", sheet.warn(note)); + } + Ok(()) +} + +fn apply_plan(plan: &SetupPlan, json: bool, no_color: bool) -> Result<(), Error> { + if !json { + let sheet = crate::cli::style::Sheet::detect(no_color); + println!("\n{}", sheet.heading("Apply")); + } + if plan.write_deck { + let deck = plan.deck.as_ref().ok_or_else(|| { + Error::new(ErrorKind::Config, "The setup plan has no selected deck.") + .with_code("setup.plan_invalid") + .with_fix_command("rune setup --plan") + })?; + let deck_text = deck.to_string_lossy(); + let path = crate::cli::ontology::persist("deck", &deck_text) + .map_err(|error| setup_error(&error, "setup.deck_write_failed", "rune setup --yes"))?; + print_applied(json, no_color, "wrote deck", &path); + } + if let Some(edit) = &plan.provider_edit { + edit.apply().map_err(|error| { + setup_error(&error, "setup.provider_write_failed", "rune setup --yes") + })?; + print_applied(json, no_color, "wrote provider selection", edit.path()); + if !json { + let sheet = crate::cli::style::Sheet::detect(no_color); + for toggle in &plan.provider_toggles { + let state = if toggle.enabled { + "enabled" + } else { + "disabled" + }; + println!("{}", sheet.ok(&format!("{} {state}", toggle.provider))); } } - many => { - if defaults { - actions.push(format!( - "several decks found; pick one with rune config set deck : {}", - display_list(many) - )); - return Ok(None); + } + if let Some(completion) = &plan.completion { + completion.apply().map_err(|error| { + setup_error( + &error, + "setup.completion_write_failed", + &format!("rune completion install {}", completion.shell_name()), + ) + })?; + print_applied( + json, + no_color, + "wrote shell completion", + completion.destination(), + ); + for path in completion.cache_removals() { + print_applied(json, no_color, "removed completion cache", path); + } + } + if let Some(skill) = &plan.skill { + let written = skill.apply().map_err(|error| { + setup_error(&error, "setup.skill_write_failed", "rune skill install") + })?; + for (path, status) in written { + let step = if status.starts_with("kept") { + "kept agent skill" + } else { + "wrote agent skill" + }; + print_applied(json, no_color, step, &path); + } + } + Ok(()) +} + +fn verify_plan(plan: &SetupPlan) -> Vec { + let mut rows = Vec::new(); + match rune::ontology::load() { + Ok(_) => rows.push(VerificationRow { + check: "user config parses".to_string(), + passed: true, + detail: plan.config_path.display().to_string(), + }), + Err(error) => rows.push(VerificationRow { + check: "user config parses".to_string(), + passed: false, + detail: error.message().to_string(), + }), + } + + if !plan.provider_toggles.is_empty() { + match crate::cli::config::load_merged_config(&plan.source_root) + .and_then(|config| crate::cli::config::load_providers(&config)) + { + Ok(providers) => { + for toggle in &plan.provider_toggles { + let actual = providers + .get(&toggle.provider) + .map(|provider| provider.enabled); + let passed = actual == Some(toggle.enabled); + let expected = if toggle.enabled { + "enabled" + } else { + "disabled" + }; + let detail = match actual { + Some(actual) if actual == toggle.enabled => expected.to_string(), + Some(true) => format!("expected {expected}, found enabled"), + Some(false) => format!("expected {expected}, found disabled"), + None => format!("expected {expected}, provider missing"), + }; + rows.push(VerificationRow { + check: format!("provider {}", toggle.provider), + passed, + detail, + }); + } } - choose(many)? + Err(error) => rows.push(VerificationRow { + check: "provider configuration".to_string(), + passed: false, + detail: error.message().to_string(), + }), } - }; - let Some(deck) = chosen else { - actions.push("deck left unconfigured".to_string()); - return Ok(None); - }; - let deck_text = deck.to_string_lossy(); - crate::cli::ontology::persist("deck", &deck_text)?; - actions.push(format!("deck configured: {deck_text}")); - Ok(Some(deck)) + } + if let Some(completion) = &plan.completion { + rows.push(match completion.is_current() { + Ok(passed) => VerificationRow { + check: "shell completion is current".to_string(), + passed, + detail: completion.destination().display().to_string(), + }, + Err(error) => VerificationRow { + check: "shell completion is current".to_string(), + passed: false, + detail: error.message().to_string(), + }, + }); + } + if let Some(skill) = &plan.skill { + rows.push(match skill.is_current() { + Ok((passed, detail)) => VerificationRow { + check: "agent skill is current".to_string(), + passed, + detail, + }, + Err(error) => VerificationRow { + check: "agent skill is current".to_string(), + passed: false, + detail: error.message().to_string(), + }, + }); + } + rows +} + +fn print_verification(rows: &[VerificationRow], json: bool, no_color: bool) -> Result<(), Error> { + if json { + let rendered = serde_json::to_string(&serde_json::json!({ + "verification": rows, + })) + .map_err(|error| { + Error::new( + ErrorKind::Io, + format!("Rune cannot serialize setup verification: {error}"), + ) + .with_code("setup.verification_output_failed") + .with_fix_command("rune setup --yes") + })?; + println!("{rendered}"); + return Ok(()); + } + let sheet = crate::cli::style::Sheet::detect(no_color); + println!("\n{}", sheet.heading("Verification")); + for row in rows { + let text = format!("{}: {}", row.check, row.detail); + if row.passed { + println!("{}", sheet.ok(&text)); + } else { + println!("{}", sheet.fail(&text)); + } + } + Ok(()) +} + +fn print_applied(json: bool, no_color: bool, action: &str, path: &Path) { + if json { + println!( + "{}", + serde_json::json!({ + "applied": { + "action": action, + "path": path, + } + }) + ); + } else { + let sheet = crate::cli::style::Sheet::detect(no_color); + println!("{}", sheet.ok(&format!("{action}: {}", path.display()))); + } } -/// Scan two levels under ~/Developer for directories carrying a `deck.yaml`. +fn setup_error(error: &Error, code: &'static str, fix_command: &str) -> Error { + Error::new(error.kind(), error.message().to_string()) + .with_code(code) + .with_fix_command(fix_command) +} + +/// Scan two levels under ~/Developer for directories with deck.yaml. fn discover_decks() -> Vec { let Some(home) = dirs::home_dir() else { return Vec::new(); @@ -177,11 +693,9 @@ fn display_list(paths: &[PathBuf]) -> String { .join(", ") } -fn confirm(prompt: &str) -> Result { +fn confirm_selection(prompt: &str) -> Result { print!("{prompt} [Y/n] "); flush()?; - // EOF (closed or piped-out stdin) is not consent: only an actual empty - // line or an explicit yes confirms. let Some(answer) = read_line()? else { println!(); return Ok(false); @@ -192,6 +706,16 @@ fn confirm(prompt: &str) -> Result { )) } +fn confirm_apply() -> Result { + print!("Apply this plan? [y/N] "); + flush()?; + let Some(answer) = read_line()? else { + println!(); + return Ok(false); + }; + Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")) +} + fn choose(candidates: &[PathBuf]) -> Result, Error> { println!("decks found:"); for (index, candidate) in candidates.iter().enumerate() { @@ -224,9 +748,14 @@ fn choose(candidates: &[PathBuf]) -> Result, Error> { } fn flush() -> Result<(), Error> { - std::io::stdout() - .flush() - .map_err(|error| Error::new(ErrorKind::Io, format!("cannot flush stdout: {error}"))) + std::io::stdout().flush().map_err(|error| { + Error::new( + ErrorKind::Io, + format!("Rune cannot flush setup output: {error}"), + ) + .with_code("setup.output_failed") + .with_fix_command("rune setup --yes") + }) } fn read_line() -> Result, Error> { @@ -234,6 +763,13 @@ fn read_line() -> Result, Error> { let bytes = std::io::stdin() .lock() .read_line(&mut line) - .map_err(|error| Error::new(ErrorKind::Io, format!("cannot read stdin: {error}")))?; + .map_err(|error| { + Error::new( + ErrorKind::Io, + format!("Rune cannot read setup input: {error}"), + ) + .with_code("setup.input_failed") + .with_fix_command("rune setup --yes") + })?; Ok((bytes > 0).then_some(line)) } diff --git a/src/cli/skill.rs b/src/cli/skill.rs index bad588d..e83bfd6 100644 --- a/src/cli/skill.rs +++ b/src/cli/skill.rs @@ -4,7 +4,7 @@ use rune::error::{Error, ErrorKind}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const SKILL_TEMPLATE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -42,46 +42,214 @@ pub fn install(directory: Option<&str>, json: bool) -> Result { None => dirs::home_dir() .ok_or_else(|| Error::new(ErrorKind::Config, "cannot resolve home directory"))?, }; - let base = root.join(".claude/skills"); + let content = rendered(); + let mut reports = Vec::new(); + for (provider, target) in enabled_skill_targets(&root)? { + let base = root.join(&target).join("skills"); + let report = install_into(&base, &content)?; + reports.push((provider, report)); + } + if json { + let rows: Vec = reports + .iter() + .map(|(provider, report)| { + serde_json::json!({ + "provider": provider, + "installed": report.path.display().to_string(), + "status": report.status, + }) + }) + .collect(); + println!("{}", serde_json::json!({ "skills": rows })); + return Ok(0); + } + for (provider, report) in &reports { + println!("{provider}: {} → {}", report.status, report.path.display()); + } + println!("agents pick the skill up on their next session"); + Ok(0) +} + +struct InstallReport { + path: PathBuf, + status: &'static str, +} + +/// Write the skill into one skills directory. A user-modified file stays +/// protected: the write happens only when the target is absent or carries +/// rune's own previous content shape (the rune frontmatter name). +fn install_into(base: &Path, content: &str) -> Result { let destination = base.join("rune"); let skill_path = destination.join("SKILL.md"); - fs::create_dir_all(&base).map_err(|error| { + fs::create_dir_all(base).map_err(|error| { Error::new( ErrorKind::Io, format!("cannot create {}: {error}", base.display()), ) })?; - rune::services::confine::confine_for_write(&base, &skill_path) + rune::services::confine::confine_for_write(base, &skill_path) .map_err(|message| Error::new(ErrorKind::Config, message))?; - let content = rendered(); let previous = fs::read_to_string(&skill_path).ok(); - let verb = match &previous { + let status = match &previous { None => "installed", - Some(existing) if existing == &content => "unchanged", - Some(_) => "updated (previous content replaced)", + Some(existing) if existing == content => "unchanged", + // Rune owns the file only when it matches a shipped rendering up to + // the version line. Any other difference is user work and stays. + Some(existing) if strip_version_line(existing) == strip_version_line(content) => "updated", + Some(_) => { + return Ok(InstallReport { + path: skill_path, + status: "kept (modified by the user; remove the file to reinstall)", + }); + } }; - fs::create_dir_all(&destination).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot create {}: {error}", destination.display()), - ) - })?; - fs::write(&skill_path, content).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", skill_path.display()), - ) - })?; - if json { - println!( - "{}", - serde_json::json!({ "installed": skill_path, "status": verb }) - ); - } else { - println!("{verb} agent skill → {}", skill_path.display()); - println!("agents pick it up on their next session"); + if status != "unchanged" { + fs::create_dir_all(&destination).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot create {}: {error}", destination.display()), + ) + })?; + fs::write(&skill_path, content).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot write {}: {error}", skill_path.display()), + ) + })?; } - Ok(0) + Ok(InstallReport { + path: skill_path, + status, + }) +} + +/// One frozen plan: the rune skill rendered once, targeted at every +/// enabled provider's skills directory under the home base. The wizard +/// prints each destination before any write. +pub(crate) struct InstallPlan { + targets: Vec<(String, PathBuf)>, + content: String, +} + +impl InstallPlan { + pub(crate) fn destinations(&self) -> Vec { + self.targets + .iter() + .map(|(_, base)| base.join("rune/SKILL.md")) + .collect() + } + + pub(crate) fn apply(&self) -> Result, Error> { + let mut written = Vec::new(); + for (_, base) in &self.targets { + let report = install_into(base, &self.content)?; + written.push((report.path, report.status)); + } + Ok(written) + } + + /// Passed when every target is current or user-modified (protected). + /// A missing or rune-owned-but-outdated file fails the check. + pub(crate) fn is_current(&self) -> Result<(bool, String), Error> { + use std::fmt::Write as _; + let mut current = 0usize; + let mut kept = 0usize; + let mut failing = Vec::new(); + for (provider, base) in &self.targets { + let path = base.join("rune/SKILL.md"); + match std::fs::read_to_string(&path) { + Ok(existing) if existing == self.content => current += 1, + Ok(existing) + if strip_version_line(&existing) != strip_version_line(&self.content) => + { + kept += 1; + } + Ok(_) => failing.push(provider.clone()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + failing.push(provider.clone()); + } + Err(error) => { + return Err(Error::new( + ErrorKind::Io, + format!("cannot read {}: {error}", path.display()), + ) + .with_code("skill.verify_failed") + .with_fix_command("rune skill install")); + } + } + } + let mut detail = format!("{current} current"); + if kept > 0 { + let _ = write!(detail, ", {kept} kept (user-modified)"); + } + if !failing.is_empty() { + let _ = write!(detail, ", failing: {}", failing.join(", ")); + } + Ok((failing.is_empty(), detail)) + } +} + +/// Plan a skill install where `overrides` replaces the enabled flag of the +/// named providers. Setup passes its planned provider selection here, so a +/// provider enabled in the same run gets the skill and a disabled one does +/// not, before the selection is written. +pub(crate) fn plan_install_with( + directory: Option<&str>, + overrides: &[(String, bool)], +) -> Result { + let root = match directory { + Some(directory) => PathBuf::from(directory), + None => dirs::home_dir().ok_or_else(|| { + Error::new(ErrorKind::Config, "cannot resolve home directory") + .with_code("skill.home_unavailable") + .with_fix_command("printenv HOME") + })?, + }; + let targets = skill_targets_with(&root, overrides)? + .into_iter() + .map(|(provider, target)| (provider, root.join(target).join("skills"))) + .collect(); + Ok(InstallPlan { + targets, + content: rendered(), + }) +} + +/// The skill body with the frontmatter `version:` line removed, so a +/// pristine install from another release still counts as rune-owned. +fn strip_version_line(content: &str) -> String { + content + .lines() + .filter(|line| !line.trim_start().starts_with("version:")) + .collect::>() + .join("\n") +} + +/// The enabled providers and their default target roots, for skill installs +/// under a home base. +fn enabled_skill_targets(root: &Path) -> Result, Error> { + skill_targets_with(root, &[]) +} + +fn skill_targets_with( + root: &Path, + overrides: &[(String, bool)], +) -> Result, Error> { + let merged = crate::cli::config::load_merged_config(root)?; + let providers = crate::cli::config::load_providers(&merged)?; + let enabled = |name: &String, config: &rune::provider::ProviderConfig| { + overrides + .iter() + .find(|(provider, _)| provider == name) + .map_or(config.enabled, |(_, enabled)| *enabled) + }; + let mut targets: Vec<(String, String)> = providers + .iter() + .filter(|(name, config)| enabled(name, config)) + .map(|(name, config)| (name.clone(), config.default_target().to_string())) + .collect(); + targets.sort(); + Ok(targets) } #[cfg(test)] diff --git a/src/cli/update_check.rs b/src/cli/update_check.rs new file mode 100644 index 0000000..facc330 --- /dev/null +++ b/src/cli/update_check.rs @@ -0,0 +1,83 @@ +//! Read-only release check: compare this binary's version against the +//! latest published GitHub release and print the package-manager command +//! that performs the update. Nothing here writes or replaces files. + +use rune::error::{Error, ErrorKind}; + +const RELEASES_URL: &str = "https://api.github.com/repos/runedeck/cli/releases/latest"; +/// The repair hint diagnoses the feed instead of retrying the command that +/// just failed. +const DIAGNOSE_FEED_COMMAND: &str = + "curl -sI https://api.github.com/repos/runedeck/cli/releases/latest"; + +pub fn check(json: bool) -> Result { + let current = env!("CARGO_PKG_VERSION"); + let latest = latest_release_tag()?; + let latest_version = latest.trim_start_matches('v'); + let up_to_date = latest_version == current; + if json { + println!( + "{}", + serde_json::json!({ + "current": current, + "latest": latest_version, + "up_to_date": up_to_date, + "update_command": "brew upgrade rune", + }) + ); + return Ok(i32::from(!up_to_date)); + } + let sheet = crate::cli::style::Sheet::detect(false); + println!("{}", sheet.row("current", current)); + println!("{}", sheet.row("latest", latest_version)); + if up_to_date { + println!("{}", sheet.ok("rune is up to date")); + } else { + println!("{}", sheet.warn("a newer release exists")); + println!("{}", sheet.row("update", "brew upgrade rune")); + } + Ok(i32::from(!up_to_date)) +} + +fn latest_release_tag() -> Result { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(10))) + .build() + .into(); + let response = agent + .get(RELEASES_URL) + .header("User-Agent", "rune-cli") + .call() + .map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot reach the release feed: {error}"), + ) + .with_code("update.feed_unreachable") + .with_fix_command(DIAGNOSE_FEED_COMMAND) + })?; + let text = response.into_body().read_to_string().map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot read the release feed: {error}"), + ) + .with_code("update.feed_unreachable") + .with_fix_command("rune update --check") + })?; + let body: serde_json::Value = serde_json::from_str(&text).map_err(|error| { + Error::new( + ErrorKind::Parse, + format!("cannot parse the release feed: {error}"), + ) + .with_code("update.feed_invalid") + .with_fix_command("rune update --check") + })?; + body["tag_name"] + .as_str() + .map(str::to_string) + .ok_or_else(|| { + Error::new(ErrorKind::Parse, "the release feed carries no tag name") + .with_code("update.feed_invalid") + .with_fix_command("rune update --check") + }) +} diff --git a/src/ontology.rs b/src/ontology.rs index bfd3554..ce97d79 100644 --- a/src/ontology.rs +++ b/src/ontology.rs @@ -21,9 +21,17 @@ pub struct Config { /// primary (registry, default results); every entry contributes its /// suites, and a suite's outputs stay in the checkout that owns it. pub bench: Vec, + pub setup: Option, pub theme: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SetupRecord { + pub version: u32, + pub completed: Vec, +} + /// Terminal theme selection: a named palette, an optional light and dark /// pair that follows the host appearance, and single-token overrides. #[derive( @@ -348,6 +356,7 @@ pub struct ResolvedConfig { pub env: Option, pub ontology: ResolvedOntology, pub extensions: Vec, + pub setup: Option, #[serde(skip)] pub launch: Launch, #[serde(skip)] @@ -741,6 +750,7 @@ fn resolve_config(config: &Config, env: &dyn Fn(&str) -> Option) -> Reso env: env_file, ontology, extensions, + setup: config.setup.clone(), launch: resolve_launch(&config.launch), bench: config.bench.clone(), theme: config.theme.clone(), diff --git a/src/ontology/tests.rs b/src/ontology/tests.rs index d936bb6..fc3a9d7 100644 --- a/src/ontology/tests.rs +++ b/src/ontology/tests.rs @@ -115,6 +115,21 @@ fn deck_is_unset_without_env_or_config() { assert!(resolve_config(&Config::default(), &no_env).deck.is_none()); } +#[test] +fn setup_record_parses_and_resolves() { + let directory = tempfile::tempdir().expect("tempdir"); + std::fs::write( + directory.path().join("config.yaml"), + "setup:\n version: 1\n completed:\n - deck\n - providers\n", + ) + .expect("write config"); + + let resolved = load_from_dir_with_env(directory.path(), &no_env).expect("load config"); + let record = resolved.setup.expect("setup record"); + assert_eq!(record.version, 1); + assert_eq!(record.completed, ["deck", "providers"]); +} + #[test] fn config_beats_default() { let config = Config { diff --git a/tests/cli.rs b/tests/cli.rs index 8a84516..2fba15f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -333,11 +333,14 @@ fn structured_error_json_has_the_stable_shape() { assert_eq!(output.status.code(), Some(2)); assert!(output.stderr.is_empty()); let error: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(error["code"], "config.unknown_key"); - assert_eq!(error["fix_command"], "rune config"); - let message = error["message"].as_str().unwrap(); - assert!(message.starts_with("Rune does not recognize config key 'definitely-unknown'.")); - assert!(message.contains("Use one of these keys:")); + assert_eq!( + error, + serde_json::json!({ + "code": "config.unknown_key", + "message": "Rune does not recognize config key 'definitely-unknown'.", + "fix_command": "rune config", + }) + ); } #[test] @@ -405,6 +408,178 @@ fn setup_invalid_selection_names_the_retry_command() { .stderr(predicate::str::contains("fix: rune setup")); } +fn create_setup_deck(home: &std::path::Path) -> std::path::PathBuf { + let deck = home.join("Developer/demo"); + std::fs::create_dir_all(&deck).unwrap(); + std::fs::write(deck.join("deck.yaml"), "name: demo\n").unwrap(); + deck +} + +#[test] +fn setup_plan_json_writes_nothing() { + let home = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let deck = create_setup_deck(home.path()); + std::fs::create_dir(home.path().join(".agents")).unwrap(); + + let output = rune() + .current_dir(source.path()) + .env("HOME", home.path()) + .env("PATH", "") + .env("SHELL", "/bin/bash") + .args(["setup", "--plan", "--json"]) + .output() + .unwrap(); + + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + let plan: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(plan["version"], 1); + assert!(plan["writes"].as_array().is_some_and(|writes| { + writes + .iter() + .any(|write| write["step"] == "write verified setup record") + })); + assert!( + plan["provider_toggles"] + .as_array() + .unwrap() + .iter() + .any(|toggle| toggle["provider"] == "agentskills" && toggle["enabled"] == true) + ); + assert!(deck.join("deck.yaml").is_file()); + assert!(!home.path().join(".config/rune/config.yaml").exists()); + assert!(!source.path().join("config.yaml").exists()); + assert!( + !home + .path() + .join(".local/share/bash-completion/completions/rune") + .exists() + ); + assert!(!home.path().join(".claude").exists()); +} + +#[test] +fn setup_yes_applies_detected_defaults() { + let home = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let deck = create_setup_deck(home.path()); + std::fs::create_dir(home.path().join(".agents")).unwrap(); + + rune() + .current_dir(source.path()) + .env("HOME", home.path()) + .env("PATH", "") + .env("SHELL", "/bin/bash") + .args(["setup", "--yes"]) + .assert() + .success() + .stdout(predicate::str::contains("Verification")) + .stdout(predicate::str::contains("wrote setup record")); + + let user_config: serde_yaml::Value = serde_yaml::from_str( + &std::fs::read_to_string(home.path().join(".config/rune/config.yaml")).unwrap(), + ) + .unwrap(); + assert_eq!(user_config["deck"].as_str(), deck.to_str()); + assert_eq!(user_config["setup"]["version"].as_u64(), Some(1)); + let completed = user_config["setup"]["completed"].as_sequence().unwrap(); + for step in ["deck", "providers", "shell_completion", "agent_skill"] { + assert!( + completed.iter().any(|value| value.as_str() == Some(step)), + "{step}" + ); + } + + let source_config: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(source.path().join("config.yaml")).unwrap()) + .unwrap(); + assert_eq!( + source_config["providers"]["agentskills"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + source_config["providers"]["codex"]["enabled"].as_bool(), + Some(false) + ); + assert!( + home.path() + .join(".local/share/bash-completion/completions/rune") + .is_file() + ); + // The skill follows the planned selection: agentskills was detected, + // claude was not, so only the detected provider gets it. + assert!(home.path().join(".agents/skills/rune/SKILL.md").is_file()); + assert!(!home.path().join(".claude/skills/rune/SKILL.md").exists()); +} + +#[cfg(unix)] +#[test] +fn setup_verification_failure_writes_no_record() { + let home = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let config_path = home.path().join(".config/rune/config.yaml"); + let completion_path = home + .path() + .join(".local/share/bash-completion/completions/rune"); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(completion_path.parent().unwrap()).unwrap(); + std::fs::write(&config_path, "deck: /tmp/deck\n").unwrap(); + std::fs::hard_link(&config_path, &completion_path).unwrap(); + + rune() + .current_dir(source.path()) + .env("HOME", home.path()) + .env("PATH", "") + .env("SHELL", "/bin/bash") + .args(["setup", "--yes"]) + .assert() + .code(2) + .stdout(predicate::str::contains("Verification")) + .stdout(predicate::str::contains("user config parses")) + .stderr(predicate::str::contains("fix: rune setup --yes")); + + let content = std::fs::read_to_string(config_path).unwrap(); + assert!(!content.contains("setup:")); +} + +#[test] +fn setup_prints_every_write_before_apply() { + let home = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + create_setup_deck(home.path()); + std::fs::create_dir(home.path().join(".agents")).unwrap(); + + let output = rune() + .current_dir(source.path()) + .env("HOME", home.path()) + .env("PATH", "") + .env("SHELL", "/bin/bash") + .args(["setup", "--yes"]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let apply = stdout.find(" Apply").unwrap(); + let paths = [ + home.path().join(".config/rune/config.yaml"), + source.path().join("config.yaml"), + home.path() + .join(".local/share/bash-completion/completions/rune"), + home.path().join(".agents/skills/rune/SKILL.md"), + ]; + for path in paths { + let path = path.display().to_string(); + let first = stdout.find(&path).unwrap(); + let last = stdout.rfind(&path).unwrap(); + assert!(first < apply, "{path}"); + assert!(last > apply, "{path}"); + } + for provider in ["agentskills", "claude", "codex", "gemini", "opencode"] { + assert!(stdout[..apply].contains(provider), "{provider}"); + } +} + #[test] fn strict_install_error_uses_the_resolved_review_command() { let root = tempfile::tempdir().unwrap(); diff --git a/tests/config_reference.rs b/tests/config_reference.rs new file mode 100644 index 0000000..e9645b3 --- /dev/null +++ b/tests/config_reference.rs @@ -0,0 +1,20 @@ +//! The committed config reference must match the binary's output, so a +//! config-struct change without a regenerated reference fails here. +//! Regenerate with: `rune config reference > docs/config-reference.json`. + +#[test] +fn committed_reference_matches_the_binary() { + let committed = include_str!("../docs/config-reference.json"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_rune")) + .args(["config", "reference"]) + .env("HOME", env!("CARGO_TARGET_TMPDIR")) + .output() + .expect("run rune config reference"); + assert!(output.status.success(), "{output:?}"); + let live = String::from_utf8(output.stdout).expect("reference is UTF-8"); + assert_eq!( + committed.trim_end(), + live.trim_end(), + "docs/config-reference.json drifted; regenerate it with `rune config reference`" + ); +} diff --git a/tests/fixtures/root-help.txt b/tests/fixtures/root-help.txt index 8c389b3..7e8cd5d 100644 --- a/tests/fixtures/root-help.txt +++ b/tests/fixtures/root-help.txt @@ -2,7 +2,7 @@ {VERSION} ({COMMIT}) built {TIME} Flow: - setup [--defaults] Guided first-run configuration + setup [--plan | --yes] Guided first-run configuration init [--with ] | --module Scaffold a project from a skeleton, or a deck module target [SLUG_OR_PATH|-] [--list] Bind or show the working repository add Add runes to the consumer manifest @@ -39,6 +39,7 @@ watch Manage monitored rune locations Plumbing: + update --check Check for a newer release (read-only) assemble [--source ] Assemble rune content into build/ deploy [--source ] [--target ] Deploy assembled runes copy --source --target Copy runes without transforms