diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79b68569..6a67c366 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1099,6 +1099,62 @@ Write template: Success: "✓ Initialized rtk for LLM integration" ``` +### Multi-Target Init Lifecycle (`src/init.rs`) + +The init subsystem in `src/init.rs` now manages two integration surfaces from one entry point: + +- Claude Code artifacts live under `~/.claude/` and still use the hook + `settings.json` patch flow. +- opencode artifacts are split by responsibility: plugin scope is selectable (`~/.config/opencode/plugins/rtk-rewrite.ts` or `.opencode/plugins/rtk-rewrite.ts`), while RTK guidance always lives in the global `~/.config/opencode/AGENTS.md` file. + +Core lifecycle entry points: + +```rust +enum Commands { + Uninstall, + Init { + uninstall: bool, + }, +} + +// src/main.rs +Commands::Uninstall => init::uninstall(false, cli.verbose)?; +Commands::Init { uninstall: true, .. } => { + eprintln!("Deprecated: `rtk init --uninstall` is a compatibility alias. Use `rtk uninstall` instead."); + init::uninstall(global, cli.verbose)?; +} + +fn run_opencode_target(global: bool, verbose: u8) -> Result; +pub fn uninstall(global: bool, verbose: u8) -> Result<()>; +pub fn show_config() -> Result<()>; +fn resolve_opencode_agents_path() -> Result; +fn resolve_opencode_plugin_path(global: bool) -> Result; +``` + +#### CLI routing and shared uninstall path + +- `src/main.rs` exposes `Commands::Uninstall` as the canonical top-level teardown surface, so reviewers can map the shipped CLI help directly to the removal workflow. +- `Commands::Uninstall` routes straight into `init::uninstall()`, which keeps Claude cleanup, opencode plugin cleanup, and RTK `AGENTS.md` marker removal in one shared implementation. +- `Commands::Init { uninstall: true }` is preserved only as a deprecated compatibility alias; it prints migration guidance and then calls the same `init::uninstall()` helper, so the legacy flag does not fork behavior. + +#### opencode setup flow + +`run_opencode_target()` installs the selected plugin file and then updates `~/.config/opencode/AGENTS.md` with a fixed RTK marker block. The two operations are reported back as one `SetupTargetOutcome`, which keeps the final setup summary aligned with the actual filesystem lifecycle. + +#### Plugin scope vs AGENTS scope + +- Plugin scope is user-selectable because opencode can load RTK from either the global plugin directory or the project-local `.opencode/plugins/` directory. +- AGENTS scope is always global because opencode loads `~/.config/opencode/AGENTS.md` as the shared instruction surface for every session. + +This separation is intentional in `src/init.rs`: local plugin installs never create a local `AGENTS.md`, and global AGENTS guidance is preserved even when the plugin itself is project-scoped. + +#### Show and uninstall lifecycle + +- `show_config()` inspects both supported opencode plugin paths and prints each installed location instead of assuming a single global plugin slot. +- `show_config()` treats `AGENTS.md` as configured only when the RTK marker block is present, not when the file merely exists. +- `rtk uninstall` and the deprecated `rtk init --uninstall` alias both call `init::uninstall()`, so the command surface changed without changing cleanup semantics. +- `uninstall()` removes RTK-managed opencode plugins from every supported plugin scope, then rewrites `~/.config/opencode/AGENTS.md` to remove only the RTK block. +- If the RTK block was the only AGENTS content, uninstall writes back an empty `AGENTS.md` file rather than deleting the file outright. + --- ## Module Development Pattern diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e5fc7a..2d617e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features * **hooks:** `exclude_commands` config — exclude specific commands from auto-rewrite ([#243](https://github.com/rtk-ai/rtk/issues/243)) +* **init:** extend the opencode lifecycle so `rtk init` can install and report the full reviewer-facing integration surface + - installs the rewrite plugin in the selected opencode plugin scope and adds the RTK guidance block to `~/.config/opencode/AGENTS.md` + - `rtk init --show` reports marker-aware `AGENTS.md` status plus any configured global/local opencode plugin paths + - `rtk uninstall` is the canonical removal command and strips only the RTK-managed plugin copies plus the RTK block from `AGENTS.md` + - `rtk init --uninstall` remains available as a deprecated compatibility alias that routes into the same uninstall implementation ### Bug Fixes @@ -337,7 +342,7 @@ breakage, but future rule additions won't take effect until they migrate. - Idempotent: detects existing hook, skips modification if present - `rtk init --show` now displays settings.json status - **Uninstall command** for complete RTK removal - - `rtk init -g --uninstall` removes hook, RTK.md, CLAUDE.md reference, and settings.json entry + - historical init-based uninstall compatibility flow removes the hook, RTK.md, CLAUDE.md reference, and settings.json entry - Restores clean state for fresh installation or testing - **Improved error handling** with detailed context messages - All error messages now include file paths and actionable hints diff --git a/README.md b/README.md index 927d2d80..5c5a0e27 100644 --- a/README.md +++ b/README.md @@ -271,13 +271,30 @@ The most effective way to use rtk. The hook transparently intercepts Bash comman ### Setup ```bash -rtk init -g # Install hook + RTK.md (recommended) +rtk init -g # Install Claude hook + RTK.md, and opencode support when selected rtk init -g --auto-patch # Non-interactive (CI/CD) rtk init -g --hook-only # Hook only, no RTK.md rtk init --show # Verify installation ``` -After install, **restart Claude Code**. +`rtk init` now starts by asking which setup target to configure: `Claude`, `opencode`, or `both`. + +- `Claude` keeps the existing Claude Code lifecycle: `~/.claude/hooks/rtk-rewrite.sh`, `~/.claude/RTK.md`, and the `settings.json` hook entry. +- `opencode` installs the rewrite plugin at `~/.config/opencode/plugins/rtk-rewrite.ts` for global setup, or `.opencode/plugins/rtk-rewrite.ts` when you choose a local plugin during project init. +- opencode setup also appends an RTK-managed guidance block to the global `~/.config/opencode/AGENTS.md`, so opencode sessions keep the RTK behavior guidance even when the plugin lives in a project-local `.opencode/plugins/` directory. + +After install, restart Claude Code or opencode before testing rewritten commands. + +### Inspect and Remove Installed Files + +```bash +rtk init --show # Print Claude hook status, opencode plugin path(s), and AGENTS.md status +rtk uninstall # Remove Claude artifacts, opencode plugin copies, and the RTK AGENTS.md block +``` + +- `rtk init --show` reports the active opencode lifecycle exactly as installed: global plugin, local plugin, and whether the RTK marker block is present in `~/.config/opencode/AGENTS.md`. +- `rtk uninstall` is the canonical teardown command. It removes RTK-managed opencode plugin files from both supported plugin scopes and strips only the RTK section from `~/.config/opencode/AGENTS.md`; it does not delete unrelated user content from that file. +- `rtk init --uninstall` still works as a deprecated compatibility alias for the same shared uninstall path, but it is no longer the primary reviewer-facing lifecycle command. ### Commands Rewritten @@ -338,7 +355,8 @@ FAILED: 2/15 tests ### Uninstall ```bash -rtk init -g --uninstall # Remove hook, RTK.md, settings.json entry +rtk uninstall # Canonical RTK teardown command for Claude + opencode artifacts +rtk init --uninstall # Deprecated compatibility alias for rtk uninstall cargo uninstall rtk # Remove binary brew uninstall rtk # If installed via Homebrew ``` diff --git a/hooks/rtk-rewrite.ts b/hooks/rtk-rewrite.ts new file mode 100644 index 00000000..bd890bec --- /dev/null +++ b/hooks/rtk-rewrite.ts @@ -0,0 +1,208 @@ +import type { Plugin } from "@opencode-ai/plugin" +import { spawnSync } from "node:child_process" +import { appendFileSync, existsSync, mkdirSync } from "node:fs" +import { delimiter, dirname, join } from "node:path" + +const RTK_CANDIDATES = [ + `${process.env.HOME ?? ""}/.cargo/bin/rtk`, + "/usr/local/bin/rtk", + "/opt/homebrew/bin/rtk", +] + +const DEBUG_ENABLED = process.env.RTK_OPENCODE_DEBUG === "1" +const DEBUG_FILE = process.env.RTK_OPENCODE_DEBUG_FILE || join(process.env.TMPDIR || "/tmp", "rtk-opencode-debug.log") + +type CommandAccessor = { + field: string + get: () => string + set: (value: string) => void +} + +function writeDebug(event: string, details: Record = {}): void { + if (!DEBUG_ENABLED) { + return + } + + try { + mkdirSync(dirname(DEBUG_FILE), { recursive: true }) + appendFileSync( + DEBUG_FILE, + JSON.stringify({ + event, + ...details, + ts: new Date().toISOString(), + }) + "\n", + "utf8", + ) + } catch { + return + } +} + +function uniqueNonEmpty(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value && value.length > 0)))] +} + +function getRtkCandidates(context: { directory: string; worktree: string }): string[] { + const pathCandidates = (process.env.PATH || "") + .split(delimiter) + .filter((segment) => segment.length > 0) + .map((segment) => join(segment, "rtk")) + + return uniqueNonEmpty([ + ...RTK_CANDIDATES, + join(context.directory, "target", "debug", "rtk"), + join(context.directory, "target", "release", "rtk"), + join(context.worktree, "target", "debug", "rtk"), + join(context.worktree, "target", "release", "rtk"), + ...pathCandidates, + ]) +} + +function findRtk(context: { directory: string; worktree: string }): string | null { + for (const candidate of getRtkCandidates(context)) { + if (candidate && existsSync(candidate)) { + writeDebug("rtk-candidate", { candidate }) + return candidate + } + } + + writeDebug("rtk-candidate", { candidate: null }) + return null +} + +function getCommandAccessor(output: { args?: Record }): CommandAccessor | null { + if (typeof output.args?.command === "string" && output.args.command.length > 0) { + return { + field: "output.args.command", + get: () => output.args!.command as string, + set: (value: string) => { + output.args!.command = value + }, + } + } + + if (typeof output.args?.cmd === "string" && output.args.cmd.length > 0) { + return { + field: "output.args.cmd", + get: () => output.args!.cmd as string, + set: (value: string) => { + output.args!.cmd = value + }, + } + } + + const nestedArgvCommand = output.args?.argv?.command + if ( + typeof output.args?.argv === "object" && + output.args?.argv !== null && + typeof nestedArgvCommand === "string" && + nestedArgvCommand.length > 0 + ) { + return { + field: "output.args.argv.command", + get: () => (output.args!.argv as { command: string }).command, + set: (value: string) => { + ;(output.args!.argv as { command: string }).command = value + }, + } + } + + const nestedBashCommand = output.args?.bash?.command + if ( + typeof output.args?.bash === "object" && + output.args?.bash !== null && + typeof nestedBashCommand === "string" && + nestedBashCommand.length > 0 + ) { + return { + field: "output.args.bash.command", + get: () => (output.args!.bash as { command: string }).command, + set: (value: string) => { + ;(output.args!.bash as { command: string }).command = value + }, + } + } + + return null +} + +function setCommandValue(accessor: CommandAccessor, value: string): boolean { + if (!value) { + return false + } + + accessor.set(value) + return true +} + +export const RtkRewritePlugin: Plugin = async (context) => { + writeDebug("plugin-loaded", { directory: context.directory, worktree: context.worktree }) + + return { + "tool.execute.before": async (input, output) => { + writeDebug("incoming-tool", { tool: input.tool }) + + if (input.tool === "bash") { + } else { + return + } + + const accessor = getCommandAccessor(output) + if (!accessor) { + writeDebug("command-field", { field: "unsupported-command-shape" }) + return + } + + writeDebug("command-field", { field: accessor.field }) + + const original = accessor.get() + if (!original) { + writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "empty-command" }) + return + } + + const rtkBin = findRtk(context) + if (!rtkBin) { + writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "rtk-missing" }) + return + } + + try { + const result = spawnSync(rtkBin, ["rewrite", original], { + encoding: "utf8", + timeout: 5000, + }) + + if (result.error || result.signal || result.status !== 0) { + writeDebug("rewrite-result", { + outcome: "rewrite-error", + reason: result.error?.message || result.signal || String(result.status), + }) + return + } + + const rewritten = result.stdout.trimEnd() + if (!rewritten || rewritten === original) { + writeDebug("rewrite-result", { outcome: "rewrite-noop", reason: "unchanged" }) + return + } + + if (!setCommandValue(accessor, rewritten)) { + writeDebug("rewrite-result", { outcome: "rewrite-error", reason: "set-failed" }) + return + } + + writeDebug("rewrite-result", { outcome: "rewritten", field: accessor.field }) + } catch (error) { + writeDebug("rewrite-result", { + outcome: "rewrite-error", + reason: error instanceof Error ? error.message : "unknown", + }) + return + } + }, + } +} + +export default RtkRewritePlugin diff --git a/src/init.rs b/src/init.rs index 3ff2622b..ee35071f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -9,9 +9,20 @@ use crate::integrity; // Embedded hook script (guards before set -euo pipefail) const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); +#[cfg(unix)] +const OPENCODE_PLUGIN: &str = include_str!("../hooks/rtk-rewrite.ts"); + // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); +#[cfg(unix)] +const RTK_OPENCODE_SECTION: &str = r#" +## RTK opencode guidance + +- Bash commands may be transparently rewritten through RTK for compact output. +- Use `rtk proxy ` when you need raw passthrough behavior. +"#; + /// Control flow for settings.json patching #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatchMode { @@ -29,6 +40,223 @@ pub enum PatchResult { Skipped, // --no-patch flag used } +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpencodeInstallScope { + Global, + Local, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SetupTarget { + Claude, + Opencode, + Both, +} + +#[cfg(unix)] +impl SetupTarget { + fn includes_claude(self) -> bool { + matches!(self, Self::Claude | Self::Both) + } + + fn includes_opencode(self) -> bool { + matches!(self, Self::Opencode | Self::Both) + } +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SetupTargetSelection { + Selected(SetupTarget), + SkippedChoiceRequired, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SetupTargetStatus { + Processed, + AlreadyConfigured, + Skipped, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SetupTargetOutcome { + status: SetupTargetStatus, + detail: &'static str, + paths: Vec, +} + +#[cfg(unix)] +impl SetupTargetOutcome { + fn processed() -> Self { + Self { + status: SetupTargetStatus::Processed, + detail: "configured", + paths: Vec::new(), + } + } + + fn already_configured() -> Self { + Self { + status: SetupTargetStatus::AlreadyConfigured, + detail: "already configured", + paths: Vec::new(), + } + } + + fn skipped() -> Self { + Self { + status: SetupTargetStatus::Skipped, + detail: "skipped", + paths: Vec::new(), + } + } + + fn with_paths(mut self, paths: Vec) -> Self { + self.paths = paths; + self + } + + fn with_detail(mut self, detail: &'static str) -> Self { + self.detail = detail; + self + } +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FinalSetupTargetSummary { + name: &'static str, + status: SetupTargetStatus, + detail: &'static str, + paths: Vec, + processed: bool, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FinalSetupSummary { + selected_target: SetupTarget, + outcomes: Vec, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShowConfigOpencodeStatus { + global_root: PathBuf, + plugins: Vec<(SetupTargetStatus, PathBuf)>, + agents: Option<(SetupTargetStatus, PathBuf)>, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SetupExecutionSummary { + sections: Vec<&'static str>, + claude: SetupTargetOutcome, + opencode: SetupTargetOutcome, +} + +#[cfg(unix)] +impl Default for SetupExecutionSummary { + fn default() -> Self { + Self { + sections: Vec::new(), + claude: SetupTargetOutcome::skipped(), + opencode: SetupTargetOutcome::skipped(), + } + } +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InitMode { + Default, + ClaudeMd, + HookOnly, +} + +#[cfg(unix)] +pub(crate) fn resolve_init_mode(claude_md: bool, hook_only: bool) -> InitMode { + match (claude_md, hook_only) { + (true, _) => InitMode::ClaudeMd, + (false, true) => InitMode::HookOnly, + (false, false) => InitMode::Default, + } +} + +#[cfg(unix)] +impl OpencodeInstallScope { + fn is_global(self) -> bool { + matches!(self, Self::Global) + } + + fn label(self) -> &'static str { + match self { + Self::Global => "global", + Self::Local => "local", + } + } + + fn other(self) -> Self { + match self { + Self::Global => Self::Local, + Self::Local => Self::Global, + } + } +} + +#[cfg(unix)] +impl SetupTarget { + fn label(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Opencode => "opencode", + Self::Both => "both", + } + } +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExistingOpencodeInstall { + scope: OpencodeInstallScope, + path: PathBuf, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +enum OpencodeInstallStatus { + Installed { + scope: OpencodeInstallScope, + path: PathBuf, + other_existing: Option, + }, + AlreadyInstalled { + scope: OpencodeInstallScope, + path: PathBuf, + other_existing: Option, + }, + SkippedChoiceRequired, +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +enum OpencodeAgentsInstallStatus { + Installed { path: PathBuf }, + AlreadyConfigured { path: PathBuf }, + Malformed { path: PathBuf }, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpencodeInstallTargetSelection { + Selected(OpencodeInstallScope), + SkippedChoiceRequired, +} + // Legacy full instructions for backward compatibility (--claude-md mode) const RTK_INSTRUCTIONS: &str = r##" # RTK (Rust Token Killer) - Token-Optimized Commands @@ -173,12 +401,21 @@ pub fn run( patch_mode: PatchMode, verbose: u8, ) -> Result<()> { - // Mode selection + #[cfg(unix)] + match resolve_init_mode(claude_md, hook_only) { + InitMode::ClaudeMd => run_claude_md_mode(global, verbose)?, + InitMode::HookOnly => run_hook_only_mode(global, patch_mode, verbose)?, + InitMode::Default => run_default_mode(global, patch_mode, verbose)?, + } + + #[cfg(not(unix))] match (claude_md, hook_only) { - (true, _) => run_claude_md_mode(global, verbose), - (false, true) => run_hook_only_mode(global, patch_mode, verbose), - (false, false) => run_default_mode(global, patch_mode, verbose), + (true, _) => run_claude_md_mode(global, verbose)?, + (false, true) => run_hook_only_mode(global, patch_mode, verbose)?, + (false, false) => run_default_mode(global, patch_mode, verbose)?, } + + Ok(()) } /// Prepare hook directory and return paths (hook_dir, hook_path) @@ -321,165 +558,962 @@ fn prompt_user_consent(settings_path: &Path) -> Result { Ok(response == "y" || response == "yes") } -/// Print manual instructions for settings.json patching -fn print_manual_instructions(hook_path: &Path) { - println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); - println!(" {{"); - println!(" \"hooks\": {{ \"PreToolUse\": [{{"); - println!(" \"matcher\": \"Bash\","); - println!(" \"hooks\": [{{ \"type\": \"command\","); - println!(" \"command\": \"{}\"", hook_path.display()); - println!(" }}]"); - println!(" }}]}}"); - println!(" }}"); - println!("\n Then restart Claude Code. Test with: git status\n"); +#[cfg(unix)] +fn detect_opencode_with(config_dir: Option<&Path>, has_binary: F) -> bool +where + F: FnOnce() -> bool, +{ + let has_config_dir = config_dir + .map(|dir| dir.join("opencode").exists()) + .unwrap_or(false); + + if has_config_dir { + return true; + } + + has_binary() } -/// Remove RTK hook entry from settings.json -/// Returns true if hook was found and removed -fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { - let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PreToolUse")) { - Some(pre_tool_use) => pre_tool_use, - None => return false, - }; +#[cfg(unix)] +fn prompt_setup_target() -> Result { + use std::io::{self, BufRead, IsTerminal}; - let pre_tool_use_array = match hooks.as_array_mut() { - Some(arr) => arr, - None => return false, - }; + eprintln!("\nChoose setup target: [Claude/opencode/both] "); - // Find and remove RTK entry - let original_len = pre_tool_use_array.len(); - pre_tool_use_array.retain(|entry| { - if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { - for hook in hooks_array { - if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - if command.contains("rtk-rewrite.sh") { - return false; // Remove this entry - } - } + if !io::stdin().is_terminal() { + eprintln!("(non-interactive mode, explicit Claude/opencode/both choice required)"); + return Ok(SetupTargetSelection::SkippedChoiceRequired); + } + + let stdin = io::stdin(); + let mut handle = stdin.lock(); + + loop { + let mut line = String::new(); + handle + .read_line(&mut line) + .context("Failed to read setup target")?; + + match resolve_setup_target(Some(&line), true) { + SetupTargetSelection::Selected(target) => { + return Ok(SetupTargetSelection::Selected(target)); + } + SetupTargetSelection::SkippedChoiceRequired => { + eprintln!("Please answer Claude, opencode, or both."); } } - true // Keep this entry - }); - - pre_tool_use_array.len() < original_len + } } -/// Remove RTK hook from settings.json file -/// Backs up before modification, returns true if hook was found and removed -fn remove_hook_from_settings(verbose: u8) -> Result { - let claude_dir = resolve_claude_dir()?; - let settings_path = claude_dir.join("settings.json"); - - if !settings_path.exists() { - if verbose > 0 { - eprintln!("settings.json not found, nothing to remove"); +#[cfg(unix)] +pub(crate) fn resolve_setup_target( + response: Option<&str>, + interactive: bool, +) -> SetupTargetSelection { + if !interactive { + return SetupTargetSelection::SkippedChoiceRequired; + } + + match response.map(|value| value.trim().to_ascii_lowercase()) { + Some(choice) if choice == "claude" => SetupTargetSelection::Selected(SetupTarget::Claude), + Some(choice) if choice == "opencode" => { + SetupTargetSelection::Selected(SetupTarget::Opencode) } - return Ok(false); + Some(choice) if choice == "both" => SetupTargetSelection::Selected(SetupTarget::Both), + _ => SetupTargetSelection::SkippedChoiceRequired, } +} - let content = fs::read_to_string(&settings_path) - .with_context(|| format!("Failed to read {}", settings_path.display()))?; +#[cfg(unix)] +pub(crate) fn run_setup_target_with( + target: SetupTarget, + mut run_claude: F, + mut run_opencode: G, +) -> std::result::Result +where + F: FnMut() -> std::result::Result, + G: FnMut() -> std::result::Result, +{ + let mut summary = SetupExecutionSummary::default(); - if content.trim().is_empty() { - return Ok(false); + if target.includes_claude() { + summary.sections.push("Claude setup"); + summary.claude = run_claude()?; } - let mut root: serde_json::Value = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; + if target.includes_opencode() { + summary.sections.push("opencode setup"); + summary.opencode = run_opencode()?; + } - let removed = remove_hook_from_json(&mut root); + Ok(summary) +} - if removed { - // Backup original - let backup_path = settings_path.with_extension("json.bak"); - fs::copy(&settings_path, &backup_path) - .with_context(|| format!("Failed to backup to {}", backup_path.display()))?; +#[cfg(unix)] +fn resolve_opencode_install_target( + global_init: bool, + response: Option<&str>, + interactive: bool, +) -> OpencodeInstallTargetSelection { + if global_init { + return OpencodeInstallTargetSelection::Selected(OpencodeInstallScope::Global); + } - // Atomic write - let serialized = - serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; - atomic_write(&settings_path, &serialized)?; + if !interactive { + return OpencodeInstallTargetSelection::SkippedChoiceRequired; + } - if verbose > 0 { - eprintln!("Removed RTK hook from settings.json"); + match response.map(|value| value.trim().to_ascii_lowercase()) { + Some(choice) if choice == "global" => { + OpencodeInstallTargetSelection::Selected(OpencodeInstallScope::Global) + } + Some(choice) if choice == "local" => { + OpencodeInstallTargetSelection::Selected(OpencodeInstallScope::Local) } + _ => OpencodeInstallTargetSelection::SkippedChoiceRequired, } - - Ok(removed) } -/// Full uninstall: remove hook, RTK.md, @RTK.md reference, settings.json entry -pub fn uninstall(global: bool, verbose: u8) -> Result<()> { - if !global { - anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); - } - - let claude_dir = resolve_claude_dir()?; - let mut removed = Vec::new(); +#[cfg(unix)] +fn prompt_opencode_install_target(global_init: bool) -> Result { + use std::io::{self, BufRead, IsTerminal}; - // 1. Remove hook file - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); - if hook_path.exists() { - fs::remove_file(&hook_path) - .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; - removed.push(format!("Hook: {}", hook_path.display())); + if global_init { + return Ok(OpencodeInstallTargetSelection::Selected( + OpencodeInstallScope::Global, + )); } - // 1b. Remove integrity hash file - if integrity::remove_hash(&hook_path)? { - removed.push("Integrity hash: removed".to_string()); - } + eprintln!("\nWhere do you want to install opencode plugin? [global/local] "); - // 2. Remove RTK.md - let rtk_md_path = claude_dir.join("RTK.md"); - if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; - removed.push(format!("RTK.md: {}", rtk_md_path.display())); + if !io::stdin().is_terminal() { + eprintln!("(non-interactive mode, explicit global/local choice required)"); + return Ok(OpencodeInstallTargetSelection::SkippedChoiceRequired); } - // 3. Remove @RTK.md reference from CLAUDE.md - let claude_md_path = claude_dir.join("CLAUDE.md"); - if claude_md_path.exists() { - let content = fs::read_to_string(&claude_md_path) - .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; - - if content.contains("@RTK.md") { - let new_content = content - .lines() - .filter(|line| !line.trim().starts_with("@RTK.md")) - .collect::>() - .join("\n"); + let stdin = io::stdin(); + let mut handle = stdin.lock(); - // Clean up double blanks - let cleaned = clean_double_blanks(&new_content); + loop { + let mut line = String::new(); + handle + .read_line(&mut line) + .context("Failed to read opencode install target")?; - fs::write(&claude_md_path, cleaned).with_context(|| { - format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) - })?; - removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + match resolve_opencode_install_target(false, Some(&line), true) { + OpencodeInstallTargetSelection::Selected(scope) => { + return Ok(OpencodeInstallTargetSelection::Selected(scope)); + } + OpencodeInstallTargetSelection::SkippedChoiceRequired => { + eprintln!("Please answer global or local."); + } } } +} - // 4. Remove hook entry from settings.json - if remove_hook_from_settings(verbose)? { - removed.push("settings.json: removed RTK hook entry".to_string()); +#[cfg(unix)] +fn resolve_opencode_plugin_path(global: bool) -> Result { + if global { + let config_dir = resolve_official_opencode_config_dir()?; + Ok(resolve_opencode_plugin_path_from_config_dir( + &config_dir, + true, + )) + } else { + Ok(std::env::current_dir() + .context("Cannot determine current directory")? + .join(".opencode") + .join("plugins") + .join("rtk-rewrite.ts")) } +} - // Report results - if removed.is_empty() { - println!("RTK was not installed (nothing to remove)"); - } else { - println!("RTK uninstalled:"); - for item in removed { - println!(" - {}", item); - } - println!("\nRestart Claude Code to apply changes."); +#[cfg(unix)] +fn resolve_official_opencode_config_dir() -> Result { + let config_dir = dirs::config_dir(); + let home_dir = dirs::home_dir(); + resolve_official_opencode_config_dir_with(config_dir.as_deref(), home_dir.as_deref()) +} + +#[cfg(unix)] +fn resolve_official_opencode_config_dir_with( + config_dir: Option<&Path>, + home_dir: Option<&Path>, +) -> Result { + if let Some(home) = home_dir { + return Ok(home.join(".config")); } - Ok(()) + config_dir + .map(Path::to_path_buf) + .context("Cannot determine config directory") +} + +#[cfg(unix)] +fn resolve_opencode_global_root() -> Result { + let config_dir = resolve_official_opencode_config_dir()?; + Ok(resolve_opencode_global_root_at(&config_dir)) +} + +#[cfg(unix)] +fn resolve_opencode_global_root_at(config_dir: &Path) -> PathBuf { + config_dir.join("opencode") +} + +#[cfg(unix)] +fn resolve_opencode_plugin_path_from_config_dir(config_dir: &Path, global: bool) -> PathBuf { + if global { + resolve_opencode_global_root_at(config_dir) + .join("plugins") + .join("rtk-rewrite.ts") + } else { + config_dir + .join(".opencode") + .join("plugins") + .join("rtk-rewrite.ts") + } +} + +#[cfg(unix)] +fn resolve_opencode_agents_path() -> Result { + Ok(resolve_opencode_global_root()?.join("AGENTS.md")) +} + +#[cfg(unix)] +fn resolve_opencode_agents_path_from_config_dir(config_dir: &Path) -> PathBuf { + resolve_opencode_global_root_at(config_dir).join("AGENTS.md") +} + +#[cfg(unix)] +fn resolve_opencode_plugin_path_at(root: &Path, global: bool) -> PathBuf { + if global { + root.join("config") + .join("opencode") + .join("plugins") + .join("rtk-rewrite.ts") + } else { + root.join(".opencode") + .join("plugins") + .join("rtk-rewrite.ts") + } +} + +#[cfg(unix)] +fn resolve_opencode_plugin_path_for_scope(scope: OpencodeInstallScope) -> Result { + resolve_opencode_plugin_path(scope.is_global()) +} + +#[cfg(unix)] +fn resolve_opencode_plugin_path_at_for_scope(root: &Path, scope: OpencodeInstallScope) -> PathBuf { + resolve_opencode_plugin_path_at(root, scope.is_global()) +} + +#[cfg(unix)] +fn install_opencode_plugin(global: bool, verbose: u8) -> Result { + let scope = if global { + OpencodeInstallScope::Global + } else { + OpencodeInstallScope::Local + }; + + Ok(matches!( + install_opencode_plugin_with_status(scope, verbose)?, + OpencodeInstallStatus::Installed { .. } + )) +} + +#[cfg(unix)] +fn install_opencode_plugin_at(root: &Path, global: bool, verbose: u8) -> Result { + let scope = if global { + OpencodeInstallScope::Global + } else { + OpencodeInstallScope::Local + }; + + Ok(matches!( + install_opencode_plugin_with_status_at(root, scope, verbose)?, + OpencodeInstallStatus::Installed { .. } + )) +} + +#[cfg(unix)] +fn install_opencode_plugin_with_status( + scope: OpencodeInstallScope, + verbose: u8, +) -> Result { + let plugin_path = resolve_opencode_plugin_path_for_scope(scope)?; + let other_path = resolve_opencode_plugin_path_for_scope(scope.other())?; + install_opencode_plugin_file_with_status(&plugin_path, &other_path, scope, verbose) +} + +#[cfg(unix)] +fn install_opencode_plugin_with_status_at( + root: &Path, + scope: OpencodeInstallScope, + verbose: u8, +) -> Result { + let plugin_path = resolve_opencode_plugin_path_at_for_scope(root, scope); + let other_path = resolve_opencode_plugin_path_at_for_scope(root, scope.other()); + install_opencode_plugin_file_with_status(&plugin_path, &other_path, scope, verbose) +} + +#[cfg(unix)] +fn install_opencode_plugin_file_with_status( + path: &Path, + other_path: &Path, + scope: OpencodeInstallScope, + verbose: u8, +) -> Result { + let other_existing = other_path.exists().then(|| ExistingOpencodeInstall { + scope: scope.other(), + path: other_path.to_path_buf(), + }); + + let parent = path.parent().with_context(|| { + format!( + "Cannot install opencode plugin at {}: missing parent directory", + path.display() + ) + })?; + + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create plugin directory: {}", parent.display()))?; + + if path.exists() { + if verbose > 0 { + eprintln!("opencode plugin already installed: {}", path.display()); + } + return Ok(OpencodeInstallStatus::AlreadyInstalled { + scope, + path: path.to_path_buf(), + other_existing, + }); + } + + fs::write(path, OPENCODE_PLUGIN) + .with_context(|| format!("Failed to write opencode plugin: {}", path.display()))?; + + if verbose > 0 { + eprintln!("Created opencode plugin: {}", path.display()); + } + + Ok(OpencodeInstallStatus::Installed { + scope, + path: path.to_path_buf(), + other_existing, + }) +} + +#[cfg(unix)] +fn format_opencode_install_status(status: &OpencodeInstallStatus) -> String { + let mut lines = Vec::new(); + + match status { + OpencodeInstallStatus::Installed { + scope, + path, + other_existing, + } => { + lines.push(format!(" opencode plugin installed: {}", path.display())); + lines.push(format!(" Scope: {}", scope.label())); + lines.push(format!( + " Active plugin path to refresh/recheck: {}", + path.display() + )); + + if let Some(other) = other_existing { + lines.push(format!( + " Note: {} already installed: {}", + other.scope.label(), + other.path.display() + )); + lines.push( + " Note: duplicate global/local installs can double-load; keep only the intended path before rechecking." + .to_string(), + ); + } + + lines.push( + " opencode bash/tool execution now routes through `rtk rewrite`.".to_string(), + ); + lines.push( + " RTK lookup keeps the standard absolute-path fallbacks and also checks `/target/debug/rtk`, `/target/release/rtk`, and PATH for source-built installs.".to_string(), + ); + lines.push( + " If the active file is stale, delete that exact path and rerun `rtk init` for the same scope before restarting opencode.".to_string(), + ); + lines.push( + " Verify the refreshed plugin path exists, then run `git status` in opencode." + .to_string(), + ); + } + OpencodeInstallStatus::AlreadyInstalled { + path, + other_existing, + .. + } => { + lines.push(format!(" already installed: {}", path.display())); + lines.push(format!( + " Active plugin path to refresh/recheck: {}", + path.display() + )); + + if let Some(other) = other_existing { + lines.push(format!( + " Note: {} already installed: {}", + other.scope.label(), + other.path.display() + )); + lines.push( + " Note: duplicate global/local installs can double-load; remove the non-target copy before rechecking." + .to_string(), + ); + } + + lines.push( + " Delete the exact stale plugin path above before rerunning `rtk init` if you need to refresh the asset." + .to_string(), + ); + lines.push( + " RTK lookup uses the standard absolute-path fallbacks plus `/target/debug/rtk`, `/target/release/rtk`, and PATH when you refresh the plugin." + .to_string(), + ); + lines.push(" Run `rtk uninstall` to remove opencode support.".to_string()); + } + OpencodeInstallStatus::SkippedChoiceRequired => { + lines.push( + " Skipped plugin install: local init needs an explicit global/local choice." + .to_string(), + ); + lines.push( + " Re-run `rtk init` interactively to choose where to install the plugin." + .to_string(), + ); + } + } + + lines.join("\n") +} + +#[cfg(unix)] +fn install_opencode_agents_with_status(verbose: u8) -> Result { + let config_dir = resolve_official_opencode_config_dir()?; + install_opencode_agents_with_status_at(&config_dir, verbose) +} + +#[cfg(unix)] +fn install_opencode_agents_with_status_at( + config_dir: &Path, + verbose: u8, +) -> Result { + let agents_path = resolve_opencode_agents_path_from_config_dir(config_dir); + let parent = agents_path.parent().with_context(|| { + format!( + "Cannot install opencode AGENTS.md at {}: missing parent directory", + agents_path.display() + ) + })?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create opencode directory: {}", parent.display()))?; + + let existing = if agents_path.exists() { + fs::read_to_string(&agents_path) + .with_context(|| format!("Failed to read {}", agents_path.display()))? + } else { + String::new() + }; + + let (new_content, action) = upsert_opencode_agents_section(&existing); + match action { + OpencodeAgentsSectionUpsert::Added | OpencodeAgentsSectionUpsert::Updated => { + fs::write(&agents_path, new_content) + .with_context(|| format!("Failed to write {}", agents_path.display()))?; + if verbose > 0 { + eprintln!("Configured opencode AGENTS.md: {}", agents_path.display()); + } + Ok(OpencodeAgentsInstallStatus::Installed { path: agents_path }) + } + OpencodeAgentsSectionUpsert::Unchanged => { + if verbose > 0 { + eprintln!( + "opencode AGENTS.md already configured: {}", + agents_path.display() + ); + } + Ok(OpencodeAgentsInstallStatus::AlreadyConfigured { path: agents_path }) + } + OpencodeAgentsSectionUpsert::Malformed => { + if verbose > 0 { + eprintln!( + "Skipped opencode AGENTS.md update due to malformed RTK markers: {}", + agents_path.display() + ); + } + Ok(OpencodeAgentsInstallStatus::Malformed { path: agents_path }) + } + } +} + +#[cfg(unix)] +fn format_opencode_agents_install_status(status: &OpencodeAgentsInstallStatus) -> String { + match status { + OpencodeAgentsInstallStatus::Installed { path } => { + format!(" AGENTS.md configured: {}", path.display()) + } + OpencodeAgentsInstallStatus::AlreadyConfigured { path } => { + format!(" AGENTS.md already configured: {}", path.display()) + } + OpencodeAgentsInstallStatus::Malformed { path } => format!( + " AGENTS.md skipped: malformed RTK markers in {}\n Fix the RTK block markers manually, then rerun `rtk init`.", + path.display() + ), + } +} + +#[cfg(unix)] +fn has_opencode_agents_section(content: &str) -> bool { + let start_marker = ""; + let end_marker = ""; + + content + .find(start_marker) + .and_then(|start| content[start..].find(end_marker).map(|end| (start, end))) + .is_some() +} + +#[cfg(unix)] +fn detect_opencode_agents_status(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + Ok(has_opencode_agents_section(&content) + .then_some((SetupTargetStatus::AlreadyConfigured, path.to_path_buf()))) +} + +#[cfg(unix)] +fn collect_show_config_opencode_status_at(config_dir: &Path) -> Result { + let global_root = resolve_opencode_global_root_at(config_dir); + let global_plugin = resolve_opencode_plugin_path_from_config_dir(config_dir, true); + let local_plugin = resolve_opencode_plugin_path_from_config_dir(config_dir, false); + let agents_path = resolve_opencode_agents_path_from_config_dir(config_dir); + let mut plugins = Vec::new(); + + if global_plugin.exists() { + plugins.push((SetupTargetStatus::AlreadyConfigured, global_plugin)); + } + + if local_plugin.exists() { + plugins.push((SetupTargetStatus::AlreadyConfigured, local_plugin)); + } + + Ok(ShowConfigOpencodeStatus { + global_root, + plugins, + agents: detect_opencode_agents_status(&agents_path)?, + }) +} + +#[cfg(unix)] +fn collect_show_config_opencode_status() -> Result { + let config_dir = resolve_official_opencode_config_dir()?; + collect_show_config_opencode_status_at(&config_dir) +} + +#[cfg(unix)] +fn build_opencode_target_outcome( + plugin_status: &OpencodeInstallStatus, + agents_status: &OpencodeAgentsInstallStatus, +) -> SetupTargetOutcome { + let mut paths = match plugin_status { + OpencodeInstallStatus::Installed { path, .. } + | OpencodeInstallStatus::AlreadyInstalled { path, .. } => vec![path.clone()], + OpencodeInstallStatus::SkippedChoiceRequired => Vec::new(), + }; + + let agents_path = match agents_status { + OpencodeAgentsInstallStatus::Installed { path } + | OpencodeAgentsInstallStatus::AlreadyConfigured { path } + | OpencodeAgentsInstallStatus::Malformed { path } => path.clone(), + }; + paths.push(agents_path); + + match (plugin_status, agents_status) { + (_, OpencodeAgentsInstallStatus::Malformed { .. }) => SetupTargetOutcome::skipped() + .with_detail("AGENTS.md has malformed RTK markers; fix the file and rerun") + .with_paths(paths), + ( + OpencodeInstallStatus::AlreadyInstalled { .. }, + OpencodeAgentsInstallStatus::AlreadyConfigured { .. }, + ) => SetupTargetOutcome::already_configured().with_paths(paths), + _ => SetupTargetOutcome::processed().with_paths(paths), + } +} + +#[cfg(unix)] +fn run_opencode_target_at( + config_dir: &Path, + scope: OpencodeInstallScope, + verbose: u8, +) -> Result { + let plugin_path = resolve_opencode_plugin_path_from_config_dir(config_dir, scope.is_global()); + let other_path = + resolve_opencode_plugin_path_from_config_dir(config_dir, scope.other().is_global()); + let plugin_status = + install_opencode_plugin_file_with_status(&plugin_path, &other_path, scope, verbose)?; + let agents_status = install_opencode_agents_with_status_at(config_dir, verbose)?; + + Ok(build_opencode_target_outcome( + &plugin_status, + &agents_status, + )) +} + +#[cfg(unix)] +fn build_final_setup_summary( + selected_target: SetupTarget, + execution: &SetupExecutionSummary, +) -> FinalSetupSummary { + let outcomes = [ + ( + "Claude", + selected_target.includes_claude(), + &execution.claude, + "not selected", + ), + ( + "opencode", + selected_target.includes_opencode(), + &execution.opencode, + "not selected", + ), + ] + .into_iter() + .map( + |(name, processed, outcome, skipped_detail)| FinalSetupTargetSummary { + name, + status: if processed { + outcome.status + } else { + SetupTargetStatus::Skipped + }, + detail: if processed { + outcome.detail + } else { + skipped_detail + }, + paths: if processed { + outcome.paths.clone() + } else { + Vec::new() + }, + processed, + }, + ) + .collect(); + + FinalSetupSummary { + selected_target, + outcomes, + } +} + +#[cfg(unix)] +fn format_final_setup_summary(summary: &FinalSetupSummary) -> String { + let mut lines = vec![ + "Final setup summary".to_string(), + format!("Selected target: {}", summary.selected_target.label()), + ]; + + for outcome in &summary.outcomes { + let status_line = if outcome.processed { + format!("- {}: {}", outcome.name, outcome.detail) + } else { + format!("- {}: {} (not processed)", outcome.name, outcome.detail) + }; + lines.push(status_line); + + for path in &outcome.paths { + lines.push(format!(" path: {}", path.display())); + } + } + + lines.join("\n") +} + +#[cfg(unix)] +fn format_show_config_opencode_status(status: &ShowConfigOpencodeStatus) -> String { + let mut lines = vec![format!( + "opencode (global): {}", + status.global_root.display() + )]; + + if status.plugins.is_empty() { + lines.push(" plugin: not configured".to_string()); + } else { + for (entry_status, path) in &status.plugins { + let scope = if path.starts_with(&status.global_root) { + "global" + } else { + "local" + }; + lines.push(format!( + " plugin[{scope}]: {} ({})", + format_target_status(*entry_status), + path.display() + )); + } + } + + match &status.agents { + Some((entry_status, path)) => lines.push(format!( + " AGENTS.md: {} ({})", + format_target_status(*entry_status), + path.display() + )), + None => lines.push(format!( + " AGENTS.md: not configured ({})", + status.global_root.join("AGENTS.md").display() + )), + } + + lines.join("\n") +} + +#[cfg(unix)] +fn format_target_status(status: SetupTargetStatus) -> &'static str { + match status { + SetupTargetStatus::Processed => "configured", + SetupTargetStatus::AlreadyConfigured => "already configured", + SetupTargetStatus::Skipped => "skipped", + } +} + +/// Print manual instructions for settings.json patching +fn print_manual_instructions(hook_path: &Path) { + println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); + println!(" {{"); + println!(" \"hooks\": {{ \"PreToolUse\": [{{"); + println!(" \"matcher\": \"Bash\","); + println!(" \"hooks\": [{{ \"type\": \"command\","); + println!(" \"command\": \"{}\"", hook_path.display()); + println!(" }}]"); + println!(" }}]}}"); + println!(" }}"); + println!("\n Then restart Claude Code. Test with: git status\n"); +} + +/// Remove RTK hook entry from settings.json +/// Returns true if hook was found and removed +fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { + let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PreToolUse")) { + Some(pre_tool_use) => pre_tool_use, + None => return false, + }; + + let pre_tool_use_array = match hooks.as_array_mut() { + Some(arr) => arr, + None => return false, + }; + + // Find and remove RTK entry + let original_len = pre_tool_use_array.len(); + pre_tool_use_array.retain(|entry| { + if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { + for hook in hooks_array { + if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { + if command.contains("rtk-rewrite.sh") { + return false; // Remove this entry + } + } + } + } + true // Keep this entry + }); + + pre_tool_use_array.len() < original_len +} + +/// Remove RTK hook from settings.json file +/// Backs up before modification, returns true if hook was found and removed +fn remove_hook_from_settings(verbose: u8) -> Result { + let claude_dir = resolve_claude_dir()?; + let settings_path = claude_dir.join("settings.json"); + + if !settings_path.exists() { + if verbose > 0 { + eprintln!("settings.json not found, nothing to remove"); + } + return Ok(false); + } + + let content = fs::read_to_string(&settings_path) + .with_context(|| format!("Failed to read {}", settings_path.display()))?; + + if content.trim().is_empty() { + return Ok(false); + } + + let mut root: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; + + let removed = remove_hook_from_json(&mut root); + + if removed { + // Backup original + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(&settings_path, &backup_path) + .with_context(|| format!("Failed to backup to {}", backup_path.display()))?; + + // Atomic write + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; + atomic_write(&settings_path, &serialized)?; + + if verbose > 0 { + eprintln!("Removed RTK hook from settings.json"); + } + } + + Ok(removed) +} + +#[cfg(unix)] +fn uninstall_opencode_artifacts(verbose: u8, removed: &mut Vec) -> Result<()> { + let config_dir = resolve_official_opencode_config_dir()?; + uninstall_opencode_artifacts_at(&config_dir, removed, verbose) +} + +#[cfg(unix)] +fn uninstall_opencode_artifacts_at( + config_dir: &Path, + removed: &mut Vec, + verbose: u8, +) -> Result<()> { + for (scope, path) in [ + ( + OpencodeInstallScope::Global, + resolve_opencode_plugin_path_from_config_dir(config_dir, true), + ), + ( + OpencodeInstallScope::Local, + resolve_opencode_plugin_path_from_config_dir(config_dir, false), + ), + ] { + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove opencode plugin: {}", path.display()))?; + removed.push(format!( + "opencode plugin ({}): {}", + scope.label(), + path.display() + )); + } + } + + let agents_path = resolve_opencode_agents_path_from_config_dir(config_dir); + if !agents_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&agents_path) + .with_context(|| format!("Failed to read {}", agents_path.display()))?; + let (new_content, action) = remove_opencode_agents_section(&content); + + match action { + OpencodeAgentsSectionRemove::Removed => { + fs::write(&agents_path, new_content) + .with_context(|| format!("Failed to write {}", agents_path.display()))?; + removed.push(format!( + "AGENTS.md: removed RTK section ({})", + agents_path.display() + )); + } + OpencodeAgentsSectionRemove::Malformed => { + if verbose > 0 { + eprintln!( + "Skipped opencode AGENTS.md uninstall due to malformed RTK markers: {}", + agents_path.display() + ); + } + } + OpencodeAgentsSectionRemove::Unchanged => {} + } + + Ok(()) +} + +/// Full uninstall: remove hook, RTK.md, @RTK.md reference, settings.json entry +pub fn uninstall(_global: bool, verbose: u8) -> Result<()> { + let claude_dir = resolve_claude_dir()?; + let mut removed = Vec::new(); + + // 1. Remove hook file + let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); + if hook_path.exists() { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?; + removed.push(format!("Hook: {}", hook_path.display())); + } + + // 1b. Remove integrity hash file + if integrity::remove_hash(&hook_path)? { + removed.push("Integrity hash: removed".to_string()); + } + + // 2. Remove RTK.md + let rtk_md_path = claude_dir.join("RTK.md"); + if rtk_md_path.exists() { + fs::remove_file(&rtk_md_path) + .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + removed.push(format!("RTK.md: {}", rtk_md_path.display())); + } + + // 3. Remove @RTK.md reference from CLAUDE.md + let claude_md_path = claude_dir.join("CLAUDE.md"); + if claude_md_path.exists() { + let content = fs::read_to_string(&claude_md_path) + .with_context(|| format!("Failed to read CLAUDE.md: {}", claude_md_path.display()))?; + + if content.contains("@RTK.md") { + let new_content = content + .lines() + .filter(|line| !line.trim().starts_with("@RTK.md")) + .collect::>() + .join("\n"); + + // Clean up double blanks + let cleaned = clean_double_blanks(&new_content); + + fs::write(&claude_md_path, cleaned).with_context(|| { + format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) + })?; + removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + } + } + + // 4. Remove hook entry from settings.json + if remove_hook_from_settings(verbose)? { + removed.push("settings.json: removed RTK hook entry".to_string()); + } + + #[cfg(unix)] + uninstall_opencode_artifacts(verbose, &mut removed)?; + + // Report results + if removed.is_empty() { + println!("RTK was not installed (nothing to remove)"); + } else { + println!("RTK uninstalled:"); + for item in removed { + println!(" - {}", item); + } + println!("\nRestart Claude Code to apply changes."); + } + + Ok(()) } /// Orchestrator: patch settings.json with RTK hook @@ -574,7 +1608,6 @@ fn clean_double_blanks(content: &str) -> String { if line.trim().is_empty() { // Count consecutive blank lines let mut blank_count = 0; - let start = i; while i < lines.len() && lines[i].trim().is_empty() { blank_count += 1; i += 1; @@ -654,6 +1687,29 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { }) } +#[cfg(unix)] +fn run_opencode_target(global: bool, verbose: u8) -> Result { + let target = prompt_opencode_install_target(global)?; + let scope = match target { + OpencodeInstallTargetSelection::Selected(scope) => scope, + OpencodeInstallTargetSelection::SkippedChoiceRequired => { + println!(" Skipped opencode setup: local init needs an explicit global/local choice."); + return Ok(SetupTargetOutcome::skipped()); + } + }; + + let plugin_status = install_opencode_plugin_with_status(scope, verbose)?; + let agents_status = install_opencode_agents_with_status(verbose)?; + + println!("{}", format_opencode_install_status(&plugin_status)); + println!("{}", format_opencode_agents_install_status(&agents_status)); + + Ok(build_opencode_target_outcome( + &plugin_status, + &agents_status, + )) +} + /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] fn run_default_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> { @@ -664,27 +1720,24 @@ fn run_default_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Resu } #[cfg(unix)] -fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { +fn run_claude_target( + global: bool, + patch_mode: PatchMode, + verbose: u8, +) -> Result { if !global { - // Local init: unchanged behavior (full injection into ./CLAUDE.md) - return run_claude_md_mode(false, verbose); + return run_claude_md_mode_with_status(false, verbose); } let claude_dir = resolve_claude_dir()?; let rtk_md_path = claude_dir.join("RTK.md"); let claude_md_path = claude_dir.join("CLAUDE.md"); - // 1. Prepare hook directory and install hook let (_hook_dir, hook_path) = prepare_hook_paths()?; let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - - // 2. Write RTK.md - write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; - - // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) + let rtk_md_changed = write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; let migrated = patch_claude_md(&claude_md_path, verbose)?; - // 4. Print success message let hook_status = if hook_changed { "installed/updated" } else { @@ -700,24 +1753,60 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result< println!(" replaced with @RTK.md (10 lines)"); } - // 5. Patch settings.json let patch_result = patch_settings_json(&hook_path, patch_mode, verbose)?; - - // Report result match patch_result { - PatchResult::Patched => { - // Already printed by patch_settings_json - } + PatchResult::Patched => {} PatchResult::AlreadyPresent => { println!("\n settings.json: hook already present"); println!(" Restart Claude Code. Test with: git status"); } - PatchResult::Declined | PatchResult::Skipped => { - // Manual instructions already printed by patch_settings_json - } + PatchResult::Declined | PatchResult::Skipped => {} } - println!(); // Final newline + let outcome_paths = vec![hook_path, rtk_md_path, claude_md_path]; + + let status = if !hook_changed + && !rtk_md_changed + && !migrated + && matches!(patch_result, PatchResult::AlreadyPresent) + { + SetupTargetOutcome::already_configured().with_paths(outcome_paths) + } else { + SetupTargetOutcome::processed().with_paths(outcome_paths) + }; + + Ok(status) +} + +#[cfg(unix)] +fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> { + let target = prompt_setup_target()?; + let selected_target = match target { + SetupTargetSelection::Selected(target) => target, + SetupTargetSelection::SkippedChoiceRequired => { + println!("\nSkipped init: default mode needs an explicit Claude/opencode/both choice."); + return Ok(()); + } + }; + + let summary = run_setup_target_with( + selected_target, + || { + println!("\nClaude setup"); + run_claude_target(global, patch_mode, verbose) + }, + || { + println!("\nopencode setup"); + run_opencode_target(global, verbose) + }, + )?; + + println!( + "{}", + format_final_setup_summary(&build_final_setup_summary(selected_target, &summary)) + ); + + println!(); Ok(()) } @@ -775,6 +1864,10 @@ fn run_hook_only_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Resul /// Legacy mode: full 137-line injection into CLAUDE.md fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { + run_claude_md_mode_with_status(global, verbose).map(|_| ()) +} + +fn run_claude_md_mode_with_status(global: bool, verbose: u8) -> Result { let path = if global { resolve_claude_dir()?.join("CLAUDE.md") } else { @@ -810,7 +1903,7 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { "✅ {} already contains up-to-date rtk instructions", path.display() ); - return Ok(()); + return Ok(SetupTargetOutcome::already_configured()); } RtkBlockUpsert::Malformed => { eprintln!( @@ -832,7 +1925,7 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { } else { eprintln!(" rtk init --claude-md"); } - return Ok(()); + return Ok(SetupTargetOutcome::skipped()); } } } else { @@ -846,7 +1939,7 @@ fn run_claude_md_mode(global: bool, verbose: u8) -> Result<()> { println!(" Claude Code will use rtk in this project"); } - Ok(()) + Ok(SetupTargetOutcome::processed()) } // --- upsert_rtk_block: idempotent RTK block management --- @@ -863,6 +1956,23 @@ enum RtkBlockUpsert { Malformed, } +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpencodeAgentsSectionUpsert { + Added, + Updated, + Unchanged, + Malformed, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpencodeAgentsSectionRemove { + Removed, + Unchanged, + Malformed, +} + /// Insert or replace the RTK instructions block in `content`. /// /// Returns `(new_content, action)` describing what happened. @@ -893,23 +2003,95 @@ fn upsert_rtk_block(content: &str, block: &str) -> (String, RtkBlockUpsert) { (false, false) => format!("{before}\n\n{desired_block}\n\n{after}"), }; - return (result, RtkBlockUpsert::Updated); + return (result, RtkBlockUpsert::Updated); + } + + // Opening marker without closing marker — malformed + return (content.to_string(), RtkBlockUpsert::Malformed); + } + + // No existing block — append + let trimmed = content.trim(); + if trimmed.is_empty() { + (block.to_string(), RtkBlockUpsert::Added) + } else { + ( + format!("{trimmed}\n\n{}", block.trim()), + RtkBlockUpsert::Added, + ) + } +} + +#[cfg(unix)] +fn upsert_opencode_agents_section(content: &str) -> (String, OpencodeAgentsSectionUpsert) { + let start_marker = ""; + let end_marker = ""; + + if let Some(start) = content.find(start_marker) { + if let Some(relative_end) = content[start..].find(end_marker) { + let end = start + relative_end; + let end_pos = end + end_marker.len(); + let current_block = content[start..end_pos].trim(); + let desired_block = RTK_OPENCODE_SECTION.trim(); + + if current_block == desired_block { + return (content.to_string(), OpencodeAgentsSectionUpsert::Unchanged); + } + + let before = content[..start].trim_end(); + let after = content[end_pos..].trim_start(); + let result = match (before.is_empty(), after.is_empty()) { + (true, true) => desired_block.to_string(), + (true, false) => format!("{desired_block}\n\n{after}"), + (false, true) => format!("{before}\n\n{desired_block}"), + (false, false) => format!("{before}\n\n{desired_block}\n\n{after}"), + }; + + return (result, OpencodeAgentsSectionUpsert::Updated); + } + + return (content.to_string(), OpencodeAgentsSectionUpsert::Malformed); + } + + let trimmed = content.trim(); + if trimmed.is_empty() { + ( + RTK_OPENCODE_SECTION.to_string(), + OpencodeAgentsSectionUpsert::Added, + ) + } else { + ( + format!("{trimmed}\n\n{}", RTK_OPENCODE_SECTION.trim()), + OpencodeAgentsSectionUpsert::Added, + ) + } +} + +#[cfg(unix)] +fn remove_opencode_agents_section(content: &str) -> (String, OpencodeAgentsSectionRemove) { + let start_marker = ""; + let end_marker = ""; + + if let Some(start) = content.find(start_marker) { + if let Some(relative_end) = content[start..].find(end_marker) { + let end = start + relative_end; + let end_pos = end + end_marker.len(); + let before = content[..start].trim_end(); + let after = content[end_pos..].trim_start(); + let result = match (before.is_empty(), after.is_empty()) { + (true, true) => String::new(), + (true, false) => after.to_string(), + (false, true) => before.to_string(), + (false, false) => format!("{before}\n\n{after}"), + }; + + return (result, OpencodeAgentsSectionRemove::Removed); } - // Opening marker without closing marker — malformed - return (content.to_string(), RtkBlockUpsert::Malformed); + return (content.to_string(), OpencodeAgentsSectionRemove::Malformed); } - // No existing block — append - let trimmed = content.trim(); - if trimmed.is_empty() { - (block.to_string(), RtkBlockUpsert::Added) - } else { - ( - format!("{trimmed}\n\n{}", block.trim()), - RtkBlockUpsert::Added, - ) - } + (content.to_string(), OpencodeAgentsSectionRemove::Unchanged) } /// Patch CLAUDE.md: add @RTK.md, migrate if old block exists @@ -1070,126 +2252,617 @@ pub fn show_config() -> Result<()> { println!("⚪ RTK.md: not found"); } - // Check hook integrity - match integrity::verify_hook_at(&hook_path) { - Ok(integrity::IntegrityStatus::Verified) => { - println!("✅ Integrity: hook hash verified"); - } - Ok(integrity::IntegrityStatus::Tampered { .. }) => { - println!("❌ Integrity: hook modified outside rtk init (run: rtk verify)"); - } - Ok(integrity::IntegrityStatus::NoBaseline) => { - println!("⚠️ Integrity: no baseline hash (run: rtk init -g to establish)"); - } - Ok(integrity::IntegrityStatus::NotInstalled) - | Ok(integrity::IntegrityStatus::OrphanedHash) => { - // Don't show integrity line if hook isn't installed - } - Err(_) => { - println!("⚠️ Integrity: check failed"); - } + // Check hook integrity + match integrity::verify_hook_at(&hook_path) { + Ok(integrity::IntegrityStatus::Verified) => { + println!("✅ Integrity: hook hash verified"); + } + Ok(integrity::IntegrityStatus::Tampered { .. }) => { + println!("❌ Integrity: hook modified outside rtk init (run: rtk verify)"); + } + Ok(integrity::IntegrityStatus::NoBaseline) => { + println!("⚠️ Integrity: no baseline hash (run: rtk init -g to establish)"); + } + Ok(integrity::IntegrityStatus::NotInstalled) + | Ok(integrity::IntegrityStatus::OrphanedHash) => { + // Don't show integrity line if hook isn't installed + } + Err(_) => { + println!("⚠️ Integrity: check failed"); + } + } + + // Check global CLAUDE.md + if global_claude_md.exists() { + let content = fs::read_to_string(&global_claude_md)?; + if content.contains("@RTK.md") { + println!("✅ Global (~/.claude/CLAUDE.md): @RTK.md reference"); + } else if content.contains("").count(), 1); + assert_eq!(content.matches("").count(), 1); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_upsert_appends_without_overwriting_user_content() { + let input = "# User instructions\n\nKeep this note."; + + let (content, action) = upsert_opencode_agents_section(input); + + assert_eq!(action, OpencodeAgentsSectionUpsert::Added); + assert!(content.starts_with(input)); + assert!(content.contains("Keep this note.")); + assert!(content.contains(RTK_OPENCODE_SECTION)); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_upsert_is_idempotent_when_section_is_current() { + let input = format!("# User instructions\n\n{}\n", RTK_OPENCODE_SECTION); + + let (content, action) = upsert_opencode_agents_section(&input); + + assert_eq!(action, OpencodeAgentsSectionUpsert::Unchanged); + assert_eq!(content, input); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_upsert_detects_malformed_markers_without_rewriting() { + let input = "# User instructions\n\n\npartial section"; + + let (content, action) = upsert_opencode_agents_section(input); + + assert_eq!(action, OpencodeAgentsSectionUpsert::Malformed); + assert_eq!(content, input); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_remove_preserves_surrounding_user_content() { + let input = format!( + "# User instructions\n\n{}\n\n## Local rules\nDo not remove.", + RTK_OPENCODE_SECTION + ); + + let (content, action) = remove_opencode_agents_section(&input); + + assert_eq!(action, OpencodeAgentsSectionRemove::Removed); + assert!(!content.contains("rtk-opencode-start")); + assert!(content.contains("# User instructions")); + assert!(content.contains("## Local rules")); + assert!(content.contains("Do not remove.")); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_setup_creates_global_agents_file_and_reports_processed() { + let temp = TempDir::new().unwrap(); + + let outcome = run_opencode_target_at(temp.path(), OpencodeInstallScope::Global, 0).unwrap(); + let agents_path = resolve_opencode_global_root_at(temp.path()).join("AGENTS.md"); + let plugin_path = resolve_opencode_plugin_path_from_config_dir(temp.path(), true); + + assert_eq!(outcome.status, SetupTargetStatus::Processed); + assert!(outcome.paths.contains(&agents_path)); + assert!(outcome.paths.contains(&plugin_path)); + assert_eq!( + fs::read_to_string(agents_path).unwrap(), + RTK_OPENCODE_SECTION + ); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_setup_preserves_existing_content_and_reports_already_configured() { + let temp = TempDir::new().unwrap(); + let agents_path = resolve_opencode_global_root_at(temp.path()).join("AGENTS.md"); + let plugin_path = resolve_opencode_plugin_path_from_config_dir(temp.path(), true); + let existing = format!("# User instructions\n\n{}\n", RTK_OPENCODE_SECTION); + + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write(&agents_path, &existing).unwrap(); + fs::create_dir_all(plugin_path.parent().unwrap()).unwrap(); + fs::write(&plugin_path, OPENCODE_PLUGIN).unwrap(); + + let outcome = run_opencode_target_at(temp.path(), OpencodeInstallScope::Global, 0).unwrap(); + + assert_eq!(outcome.status, SetupTargetStatus::AlreadyConfigured); + assert!(outcome.paths.contains(&agents_path)); + assert!(outcome.paths.contains(&plugin_path)); + assert_eq!(fs::read_to_string(agents_path).unwrap(), existing); + } + + #[test] + #[cfg(unix)] + fn test_opencode_agents_setup_skips_malformed_agents_file_without_overwriting() { + let temp = TempDir::new().unwrap(); + let agents_path = resolve_opencode_global_root_at(temp.path()).join("AGENTS.md"); + let malformed = "# User instructions\n\n\npartial section"; + + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write(&agents_path, malformed).unwrap(); + + let outcome = run_opencode_target_at(temp.path(), OpencodeInstallScope::Global, 0).unwrap(); + + assert_eq!(outcome.status, SetupTargetStatus::Skipped); + assert!(outcome.detail.contains("AGENTS.md")); + assert_eq!(fs::read_to_string(agents_path).unwrap(), malformed); + } + + #[test] + #[cfg(unix)] + fn test_show_config_opencode_reports_agents_section_presence_from_markers() { + let temp = TempDir::new().unwrap(); + let global_root = resolve_opencode_global_root_at(temp.path()); + let agents_path = global_root.join("AGENTS.md"); + + fs::create_dir_all(global_root.join("plugins")).unwrap(); + fs::write(global_root.join("plugins/rtk-rewrite.ts"), OPENCODE_PLUGIN).unwrap(); + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write(&agents_path, "# User instructions only\n").unwrap(); + + let without_markers = collect_show_config_opencode_status_at(temp.path()).unwrap(); + assert!(without_markers.agents.is_none()); + + fs::write( + &agents_path, + format!("# User instructions\n\n{}\n", RTK_OPENCODE_SECTION), + ) + .unwrap(); + + let with_markers = collect_show_config_opencode_status_at(temp.path()).unwrap(); + assert_eq!( + with_markers.agents, + Some((SetupTargetStatus::AlreadyConfigured, agents_path.clone())) + ); + } + + #[test] + #[cfg(unix)] + fn test_uninstall_opencode_removes_plugin_files_for_all_scopes() { + let temp = TempDir::new().unwrap(); + let global_plugin = resolve_opencode_plugin_path_from_config_dir(temp.path(), true); + let local_plugin = resolve_opencode_plugin_path_from_config_dir(temp.path(), false); + let mut removed: Vec = Vec::new(); + + fs::create_dir_all(global_plugin.parent().unwrap()).unwrap(); + fs::create_dir_all(local_plugin.parent().unwrap()).unwrap(); + fs::write(&global_plugin, OPENCODE_PLUGIN).unwrap(); + fs::write(&local_plugin, OPENCODE_PLUGIN).unwrap(); + + uninstall_opencode_artifacts_at(temp.path(), &mut removed, 0).unwrap(); + + assert!(!global_plugin.exists()); + assert!(!local_plugin.exists()); + assert!(removed + .iter() + .any(|item| item.contains(&global_plugin.display().to_string()))); + assert!(removed + .iter() + .any(|item| item.contains(&local_plugin.display().to_string()))); + } + + #[test] + #[cfg(unix)] + fn test_uninstall_opencode_removes_only_rtk_agents_section() { + let temp = TempDir::new().unwrap(); + let agents_path = resolve_opencode_agents_path_from_config_dir(temp.path()); + let mut removed: Vec = Vec::new(); + let existing = format!( + "# User instructions\n\n{}\n\n## Keep\nThis stays.\n", + RTK_OPENCODE_SECTION + ); + + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write(&agents_path, existing).unwrap(); + + uninstall_opencode_artifacts_at(temp.path(), &mut removed, 0).unwrap(); + + let content = fs::read_to_string(&agents_path).unwrap(); + assert!(!content.contains("rtk-opencode-start")); + assert!(content.contains("# User instructions")); + assert!(content.contains("## Keep")); + assert!(content.contains("This stays.")); + assert!(agents_path.exists()); + assert!(removed + .iter() + .any(|item| item.contains("AGENTS.md: removed RTK section"))); + } + + #[test] + #[cfg(unix)] + fn test_uninstall_opencode_preserves_empty_agents_file() { + let temp = TempDir::new().unwrap(); + let agents_path = resolve_opencode_agents_path_from_config_dir(temp.path()); + let mut removed: Vec = Vec::new(); + + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write(&agents_path, RTK_OPENCODE_SECTION).unwrap(); + + uninstall_opencode_artifacts_at(temp.path(), &mut removed, 0).unwrap(); + + assert!(agents_path.exists()); + assert_eq!(fs::read_to_string(&agents_path).unwrap(), ""); + assert!(removed + .iter() + .any(|item| item.contains("AGENTS.md: removed RTK section"))); + } + + #[test] + #[cfg(unix)] + fn test_show_config_opencode_reports_actual_plugin_locations_for_global_and_local_scopes() { + let temp = TempDir::new().unwrap(); + let global_plugin = resolve_opencode_plugin_path_from_config_dir(temp.path(), true); + let local_plugin = resolve_opencode_plugin_path_from_config_dir(temp.path(), false); + + fs::create_dir_all(global_plugin.parent().unwrap()).unwrap(); + fs::create_dir_all(local_plugin.parent().unwrap()).unwrap(); + fs::write(&global_plugin, OPENCODE_PLUGIN).unwrap(); + fs::write(&local_plugin, OPENCODE_PLUGIN).unwrap(); + + let status = collect_show_config_opencode_status_at(temp.path()).unwrap(); + let rendered = format_show_config_opencode_status(&status); + + assert_eq!( + status.plugins, + vec![ + (SetupTargetStatus::AlreadyConfigured, global_plugin.clone()), + (SetupTargetStatus::AlreadyConfigured, local_plugin.clone()), + ] + ); + assert!(rendered.contains(&global_plugin.display().to_string())); + assert!(rendered.contains(&local_plugin.display().to_string())); + assert!(rendered.contains("plugin[global]: already configured")); + assert!(rendered.contains("plugin[local]: already configured")); + } + + #[test] + #[cfg(unix)] + fn test_show_config_opencode_reports_agents_marker_status_without_plugin_false_positive() { + let temp = TempDir::new().unwrap(); + let agents_path = resolve_opencode_agents_path_from_config_dir(temp.path()); + + fs::create_dir_all(agents_path.parent().unwrap()).unwrap(); + fs::write( + &agents_path, + format!("# User instructions\n\n{}\n", RTK_OPENCODE_SECTION), + ) + .unwrap(); + + let status = collect_show_config_opencode_status_at(temp.path()).unwrap(); + let rendered = format_show_config_opencode_status(&status); + + assert!(status.plugins.is_empty()); + assert_eq!( + status.agents, + Some((SetupTargetStatus::AlreadyConfigured, agents_path.clone())) + ); + assert!(rendered.contains("plugin: not configured")); + assert!(rendered.contains(&agents_path.display().to_string())); + assert!(rendered.contains("AGENTS.md: already configured")); + } + #[test] fn test_init_is_idempotent() { let temp = TempDir::new().unwrap(); @@ -1489,11 +3434,6 @@ More notes let parsed: serde_json::Value = serde_json::from_str(original).unwrap(); let serialized = serde_json::to_string(&parsed).unwrap(); - // Keys should appear in same order - let original_keys: Vec<&str> = original.split("\"").filter(|s| s.contains(":")).collect(); - let serialized_keys: Vec<&str> = - serialized.split("\"").filter(|s| s.contains(":")).collect(); - // Just check that keys exist (preserve_order doesn't guarantee exact order in nested objects) assert!(serialized.contains("\"env\"")); assert!(serialized.contains("\"permissions\"")); diff --git a/src/main.rs b/src/main.rs index 7a1c0159..7df75fcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -331,11 +331,14 @@ enum Commands { #[arg(long = "no-patch", group = "patch")] no_patch: bool, - /// Remove all RTK artifacts (hook, RTK.md, CLAUDE.md reference, settings.json entry) + /// Deprecated compatibility alias for `rtk uninstall` #[arg(long)] uninstall: bool, }, + /// Remove RTK-managed Claude and opencode artifacts + Uninstall, + /// Download with compact output (strips progress bars) Wget { /// URL to download @@ -942,12 +945,16 @@ const RTK_META_COMMANDS: &[&str] = &[ "discover", "learn", "init", + "uninstall", "config", "proxy", "hook-audit", "cc-economics", ]; +const LEGACY_UNINSTALL_NOTICE: &str = + "Deprecated: `rtk init --uninstall` is a compatibility alias. Use `rtk uninstall` instead."; + fn run_fallback(parse_error: clap::Error) -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); @@ -1417,6 +1424,7 @@ fn main() -> Result<()> { if show { init::show_config()?; } else if uninstall { + eprintln!("{LEGACY_UNINSTALL_NOTICE}"); init::uninstall(global, cli.verbose)?; } else { let patch_mode = if auto_patch { @@ -1430,6 +1438,10 @@ fn main() -> Result<()> { } } + Commands::Uninstall => { + init::uninstall(false, cli.verbose)?; + } + Commands::Wget { url, stdout, args } => { if stdout { wget_cmd::run_stdout(&url, &args, cli.verbose)?; @@ -1935,7 +1947,48 @@ fn is_operational_command(cmd: &Commands) -> bool { #[cfg(test)] mod tests { use super::*; - use clap::Parser; + use clap::{CommandFactory, Parser}; + + #[test] + fn test_uninstall_command_parses_as_top_level_variant() { + let cli = Cli::try_parse_from(["rtk", "uninstall"]).unwrap(); + + match cli.command { + Commands::Uninstall => {} + _ => panic!("Expected top-level uninstall command"), + } + } + + #[test] + fn test_uninstall_legacy_init_alias_still_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--uninstall"]).unwrap(); + + match cli.command { + Commands::Init { uninstall, .. } => assert!(uninstall), + _ => panic!("Expected init uninstall compatibility alias"), + } + } + + #[test] + fn test_uninstall_help_promotes_top_level_command() { + let mut command = Cli::command(); + let help = command.render_long_help().to_string(); + + assert!(help.contains("uninstall")); + assert!(help.contains("Remove RTK-managed Claude and opencode artifacts")); + } + + #[test] + fn test_uninstall_init_help_marks_legacy_alias_as_deprecated() { + let mut init_command = Cli::command().find_subcommand_mut("init").unwrap().clone(); + let help = init_command + .render_long_help() + .to_string() + .to_ascii_lowercase(); + + assert!(help.contains("deprecated")); + assert!(help.contains("rtk uninstall")); + } #[test] fn test_git_commit_single_message() {